signalapp/Signal-Server · error · BackupBadReceiptException
receipt serial is already redeemed
Error message
receipt serial is already redeemed
What it means
BackupBadReceiptException thrown in redeemReceipt when RedeemedReceiptsManager.put reports the receipt serial has already been redeemed. Receipt serials are single-use: the first redemption records the serial, expiration, level, and account, and any later put with the same serial is refused. This is the server's replay protection for receipt credentials.
Solutions
- Treat this as success-if-already-applied: the original redemption stands; fetch the account's existing backup voucher instead of re-redeeming
- Remove automatic retries around redemption, or make them idempotent by keying on the receipt serial
- If the purchase must apply to another account, redeem the separate receipt serial issued for that purchase/account
- Persist redemption results client-side so the same serial is never resubmitted
Example fix
// before
retryOnTimeout(() -> redeemReceipt(serial, presentation)); // replays -> 'already redeemed'
// after
if (!clientState.isSerialRedeemed(serial)) {
redeemReceipt(serial, presentation);
clientState.markSerialRedeemed(serial);
} Defensive patterns
Strategy: validation
Validate before calling
if (redeemedSerials.contains(serial)) {
return existingVoucher; // already redeemed, do not resend
} Try / catch
try { redeemReceipt(serial, presentation); }
catch (BackupBadReceiptException e) {
if (e.getMessage().contains("already redeemed")) { loadExistingVoucher(); } else { throw e; }
} Prevention
- Persist redemption results keyed by serial and skip resubmission
- Never auto-retry redemption requests on ambiguous failures
- Use a distinct receipt serial per account/purchase
- Make batch redemption jobs idempotent
When it happens
Trigger: Redeeming the same ReceiptCredentialPresentation (same serial) a second time — a retried request after a timeout, re-running a redemption job, or presenting one purchased receipt on a second account.
Common situations: HTTP clients retrying on ambiguous errors after the first request actually succeeded; users restoring the same purchase on a new device/account; scheduled jobs that re-process purchased receipts without deduplication.
Related errors
- receipt is already expired
- server does not recognize the requested receipt level
- Invalid receipt credential presentation
- Receipt credential presentation verification failed
- Receipt is already expired
AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09).
Data as JSON: /api/errors/d2ae4a87a404077d.
Report an issue: GitHub.
Appendix: source
Thrown at service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java:271
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
if (clock.instant().isAfter(receiptExpiration)) {
throw new BackupBadReceiptException("receipt is already expired");
}
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
if (BackupLevelUtil.fromReceiptLevel(receiptLevel) != BackupLevel.PAID) {
throw new BackupInvalidArgumentException("server does not recognize the requested receipt level");
}
if (account.getBackupCredentialRequest(BackupCredentialType.MEDIA).isEmpty()) {
throw new BackupMissingIdCommitmentException();
}
boolean receiptAllowed = redeemedReceiptsManager
.put(receiptSerial, receiptExpiration, receiptLevel, account.getAccountIdentifier());
if (!receiptAllowed) {
throw new BackupBadReceiptException("receipt serial is already redeemed");
}
extendBackupVoucher(account, new Account.BackupVoucher(receiptLevel, receiptExpiration));
}
/**
* Extend the duration of the backup voucher on an account.
*
* @param account The account to update
* @param backupVoucher The backup voucher to apply to this account
*/
public void extendBackupVoucher(final Account account, final Account.BackupVoucher backupVoucher) {
accountsManager.update(account, a -> {
// Receipt credential expirations must be day aligned. Make sure any manually set backupVoucher is also day
// aligned
final Account.BackupVoucher newPayment = new Account.BackupVoucher(
backupVoucher.receiptLevel(),
backupVoucher.expiration().truncatedTo(ChronoUnit.DAYS));
final Account.BackupVoucher existingPayment = a.getBackupVoucher();View on GitHub (pinned to 100ab61c82)