signalapp/Signal-Server · error · NotAuthorizedException

Receipt already redeemed

Error message

Receipt already redeemed

What it means

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.

Solutions

  1. 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.
  2. Purchase a new receipt and retry registration with the fresh receipt.
  3. Make clients idempotent: persist 'receipt submitted' state before sending and never resend the same presentation after a timeout without first querying registration status.
  4. In tests, use a unique receipt per test run rather than a shared fixture.

Example fix

// before: naive retry on timeout
} catch (IOException e) { register(presentation); } // replays consumed receipt
// after: check redemption/account state before retrying
} catch (IOException e) {
  if (!registrationStatusQueried()) return recoverAccount();
  register(newlyPurchasedPresentation());
}
Defensive patterns

Strategy: try-catch

Validate before calling

// before submitting, confirm this presentation hash was not already submitted
if (submittedReceiptHashes.contains(sha256(presentation.serialize()))) {
  return queryExistingRegistration(); // do not resend
}

Try / catch

try {
  return register(request);
} catch (NotAuthorizedException e) {
  if (e.getMessage().contains("Receipt already redeemed")) {
    return lookupOrCreateAccountViaRecovery(); // first attempt may have succeeded
  }
  throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09). Data as JSON: /api/errors/0c164ab804c76a8d. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java:389

              password,
              signalAgent,
              registrationRequest.accountAttributes().getCapabilities(),
              new DeviceIdentityInfo(registrationRequest.accountAttributes().getRegistrationId(), registrationRequest.deviceActivationRequest()
                  .aciSignedPreKey(), registrationRequest.deviceActivationRequest().aciPqLastResortPreKey()),
              Optional.empty(),
              registrationRequest.accountAttributes().getFetchesMessages(),
              registrationRequest.deviceActivationRequest().apnToken(),
              registrationRequest.deviceActivationRequest().gcmToken()),
          userAgent);

      Metrics.counter(ACCOUNT_CREATED_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
              Tag.of(VERIFICATION_TYPE_TAG_NAME, registrationRequest.verificationType().name())))
          .increment();

      final AccountIdentityResponse accountIdentityResponse = new AccountIdentityResponseBuilder(account).build();
      return new AccountCreationResponse(accountIdentityResponse, false);
    } catch (ReceiptAlreadyRedeemedException _) {
      throw new NotAuthorizedException("Receipt already redeemed");
    }
  }

  private AccountCreationResponse recoverAccount(final UUID accountIdentifier,
      final String password,
      final RegistrationRequest registrationRequest,
      final String userAgent,
      final String signalAgent) throws RegistrationLockFailureException, RateLimitExceededException {

    if (!dynamicConfigurationManager.getConfiguration().getLoginPurchaseConfiguration().enabled()) {
      throw new BadRequestException("login purchases are not enabled");
    }

    if (ArrayUtils.isEmpty(registrationRequest.recoveryPassword())) {
      throw new BadRequestException("Recovery password required for authentication when recovering an account by identifier");
    }

    if (registrationRequest.accountAttributes().recoveryPassword().isEmpty()) {

View on GitHub (pinned to 100ab61c82)