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
- 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.
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
- 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.
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
- Receipt credential presentation verification failed
- Receipt is already expired
- Invalid receipt credential presentation
- Invalid receipt level
- number does not match session
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)