{"record":{"id":"d2ae4a87a404077d","repo":"signalapp/Signal-Server","slug":"receipt-serial-is-already-redeemed","errorCode":null,"errorMessage":"receipt serial is already redeemed","messagePattern":"receipt serial is already redeemed","errorType":"exception","errorClass":"BackupBadReceiptException","httpStatus":400,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java","lineNumber":271,"sourceCode":"    final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());\n    if (clock.instant().isAfter(receiptExpiration)) {\n      throw new BackupBadReceiptException(\"receipt is already expired\");\n    }\n\n    final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();\n\n    if (BackupLevelUtil.fromReceiptLevel(receiptLevel) != BackupLevel.PAID) {\n      throw new BackupInvalidArgumentException(\"server does not recognize the requested receipt level\");\n    }\n\n    if (account.getBackupCredentialRequest(BackupCredentialType.MEDIA).isEmpty()) {\n      throw new BackupMissingIdCommitmentException();\n    }\n\n    boolean receiptAllowed = redeemedReceiptsManager\n        .put(receiptSerial, receiptExpiration, receiptLevel, account.getAccountIdentifier());\n    if (!receiptAllowed) {\n      throw new BackupBadReceiptException(\"receipt serial is already redeemed\");\n    }\n    extendBackupVoucher(account, new Account.BackupVoucher(receiptLevel, receiptExpiration));\n  }\n\n  /**\n   * Extend the duration of the backup voucher on an account.\n   *\n   * @param account The account to update\n   * @param backupVoucher The backup voucher to apply to this account\n   */\n  public void extendBackupVoucher(final Account account, final Account.BackupVoucher backupVoucher) {\n    accountsManager.update(account, a -> {\n      // Receipt credential expirations must be day aligned. Make sure any manually set backupVoucher is also day\n      // aligned\n      final Account.BackupVoucher newPayment = new Account.BackupVoucher(\n          backupVoucher.receiptLevel(),\n          backupVoucher.expiration().truncatedTo(ChronoUnit.DAYS));\n      final Account.BackupVoucher existingPayment = a.getBackupVoucher();","sourceCodeStart":253,"sourceCodeEnd":289,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java#L253-L289","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nretryOnTimeout(() -> redeemReceipt(serial, presentation)); // replays -> 'already redeemed'\n// after\nif (!clientState.isSerialRedeemed(serial)) {\n  redeemReceipt(serial, presentation);\n  clientState.markSerialRedeemed(serial);\n}","handlingStrategy":"validation","validationCode":"if (redeemedSerials.contains(serial)) {\n  return existingVoucher; // already redeemed, do not resend\n}","typeGuard":null,"tryCatchPattern":"try { redeemReceipt(serial, presentation); }\ncatch (BackupBadReceiptException e) {\n  if (e.getMessage().contains(\"already redeemed\")) { loadExistingVoucher(); } else { throw e; }\n}","preventionTips":["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"],"tags":["zk-receipt","replay-protection","backup-auth"],"backgroundTag":"receipt-already-redeemed","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"}