{"record":{"id":"0c164ab804c76a8d","repo":"signalapp/Signal-Server","slug":"receipt-already-redeemed","errorCode":null,"errorMessage":"Receipt already redeemed","messagePattern":"Receipt already redeemed","errorType":"http","errorClass":"NotAuthorizedException","httpStatus":401,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java","lineNumber":389,"sourceCode":"              password,\n              signalAgent,\n              registrationRequest.accountAttributes().getCapabilities(),\n              new DeviceIdentityInfo(registrationRequest.accountAttributes().getRegistrationId(), registrationRequest.deviceActivationRequest()\n                  .aciSignedPreKey(), registrationRequest.deviceActivationRequest().aciPqLastResortPreKey()),\n              Optional.empty(),\n              registrationRequest.accountAttributes().getFetchesMessages(),\n              registrationRequest.deviceActivationRequest().apnToken(),\n              registrationRequest.deviceActivationRequest().gcmToken()),\n          userAgent);\n\n      Metrics.counter(ACCOUNT_CREATED_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),\n              Tag.of(VERIFICATION_TYPE_TAG_NAME, registrationRequest.verificationType().name())))\n          .increment();\n\n      final AccountIdentityResponse accountIdentityResponse = new AccountIdentityResponseBuilder(account).build();\n      return new AccountCreationResponse(accountIdentityResponse, false);\n    } catch (ReceiptAlreadyRedeemedException _) {\n      throw new NotAuthorizedException(\"Receipt already redeemed\");\n    }\n  }\n\n  private AccountCreationResponse recoverAccount(final UUID accountIdentifier,\n      final String password,\n      final RegistrationRequest registrationRequest,\n      final String userAgent,\n      final String signalAgent) throws RegistrationLockFailureException, RateLimitExceededException {\n\n    if (!dynamicConfigurationManager.getConfiguration().getLoginPurchaseConfiguration().enabled()) {\n      throw new BadRequestException(\"login purchases are not enabled\");\n    }\n\n    if (ArrayUtils.isEmpty(registrationRequest.recoveryPassword())) {\n      throw new BadRequestException(\"Recovery password required for authentication when recovering an account by identifier\");\n    }\n\n    if (registrationRequest.accountAttributes().recoveryPassword().isEmpty()) {","sourceCodeStart":371,"sourceCodeEnd":407,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java#L371-L407","documentation":"When accounts.create() persists the new login-purchase account it also records the receipt as redeemed; if the same receipt credential presentation has already been redeemed (ReceiptAlreadyRedeemedException), the controller throws a NotAuthorizedException. Receipts are single-use to prevent one purchase from creating multiple accounts.","triggerScenarios":"Submitting the same receipt credential presentation twice to the registration endpoint: client retries after a partial/timeout response, a replayed request, or two registration attempts racing with the identical receipt.","commonSituations":"Network timeout after the server committed the account but before the client saw the response, leading the client to retry; automated tests replaying a fixture receipt; a user restoring app state that retained an already-consumed receipt.","solutions":["Check whether the account was actually created before retrying — the first attempt may have succeeded; use the account recovery flow instead of re-submitting the receipt.","Purchase a new receipt and retry registration with the fresh receipt.","Make clients idempotent: persist 'receipt submitted' state before sending and never resend the same presentation after a timeout without first querying registration status.","In tests, use a unique receipt per test run rather than a shared fixture."],"exampleFix":"// before: naive retry on timeout\n} catch (IOException e) { register(presentation); } // replays consumed receipt\n// after: check redemption/account state before retrying\n} catch (IOException e) {\n  if (!registrationStatusQueried()) return recoverAccount();\n  register(newlyPurchasedPresentation());\n}","handlingStrategy":"try-catch","validationCode":"// before submitting, confirm this presentation hash was not already submitted\nif (submittedReceiptHashes.contains(sha256(presentation.serialize()))) {\n  return queryExistingRegistration(); // do not resend\n}","typeGuard":null,"tryCatchPattern":"try {\n  return register(request);\n} catch (NotAuthorizedException e) {\n  if (e.getMessage().contains(\"Receipt already redeemed\")) {\n    return lookupOrCreateAccountViaRecovery(); // first attempt may have succeeded\n  }\n  throw e;\n}","preventionTips":["Persist 'receipt submitted' state before the network call and check it on retry.","Query registration status after timeouts instead of blindly resending.","Use a fresh receipt per registration attempt in tests."],"tags":["zk-receipt","single-use","registration","replay","not-authorized"],"backgroundTag":"resource-already-exists","analyzedSha":"100ab61c82627582c867d19e1c0561ba2781e927","analyzedAt":"2026-09-09T13:29:47.883Z","contentChangedAt":"2026-09-09T13:29:47.883Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}