signalapp/Signal-Server · error · BackupBadReceiptException

receipt is already expired

Error message

receipt is already expired

What it means

BackupBadReceiptException thrown by BackupAuthManager.redeemReceipt when the ZK receipt credential presentation's receiptExpirationTime is before the server's current clock instant. The library treats receipts as strictly time-bounded entitlements; once the encoded expiration has passed the presentation is refused and no backup voucher is extended. It is a client-data problem: the credential itself is well-formed but stale.

Solutions

  1. Purchase/redeem a fresh receipt and use its newly issued ReceiptCredentialPresentation
  2. Before redeeming, decode the presentation and check receiptExpirationTime against current time
  3. Discard persisted receipt credentials after their expiration instead of retrying them
  4. If expiration is imminent but not passed, retry immediately and investigate client clock sync

Example fix

// before
redeemReceipt(presentationFromLastYear); // 400: receipt is already expired
// after
if (Instant.now().isBefore(Instant.ofEpochSecond(presentation.getReceiptExpirationTime()))) {
  redeemReceipt(presentation);
} else {
  purchaseAndRedeemNewReceipt();
}
Defensive patterns

Strategy: validation

Validate before calling

Instant expiration = Instant.ofEpochSecond(presentation.getReceiptExpirationTime());
if (!Instant.now().isBefore(expiration)) {
  throw new IllegalStateException("receipt expired " + expiration + ", purchase a new one");
}

Try / catch

try { redeemReceipt(presentation); }
catch (BackupBadReceiptException e) {
  if (e.getMessage().contains("already expired")) { purchaseNewReceipt(); } else { throw e; }
}

Prevention

When it happens

Trigger: Client calls the redeem-receipt endpoint with a ReceiptCredentialPresentation whose getReceiptExpirationTime() is in the past relative to the server clock; typically a retry of an old redemption or a saved credential reused after its validity window ended.

Common situations: Replaying a receipt that was already redeemed months earlier; restoring backups with an old exported receipt after the paid period lapsed; device clock skew on the client producing credentials near expiration; testing with fixtures generated long ago.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java:255

  /**
   * Redeem a receipt to enable paid backups on the account.
   *
   * @param account                       The account to enable backups on
   * @param receiptCredentialPresentation A ZK receipt presentation proving payment
   */
  public void redeemReceipt(
      final Account account,
      final ReceiptCredentialPresentation receiptCredentialPresentation)
      throws BackupBadReceiptException, BackupInvalidArgumentException, BackupMissingIdCommitmentException {
    try {
      serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
    } catch (VerificationFailedException e) {
      throw new BackupBadReceiptException("receipt credential presentation verification failed");
    }
    final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
    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));

View on GitHub (pinned to 100ab61c82)