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

  1. Treat this as success-if-already-applied: the original redemption stands; fetch the account's existing backup voucher instead of re-redeeming
  2. Remove automatic retries around redemption, or make them idempotent by keying on the receipt serial
  3. If the purchase must apply to another account, redeem the separate receipt serial issued for that purchase/account
  4. 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

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


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)