signalapp/Signal-Server · error · SubscriptionInvalidArgumentsException

purchase was for an unexpected product

Error message

purchase was for an unexpected product

What it means

generateReceipt validates that a successfully paid one-time purchase grants the LOGIN receipt level. If paymentDetails.status() is SUCCEEDED but paymentDetails.level() != ReceiptLevel.LOGIN, the purchase bought a different product (e.g. a donation badge) and cannot be redeemed for login entitlement, so Signal throws SubscriptionInvalidArgumentsException.

Solutions

  1. Ensure the user purchases the correct login product (the one mapped to ReceiptLevel.LOGIN) and send that purchase id.
  2. Use the correct redemption flow for non-login products (donation flow) instead of generateReceipt for login.
  3. Verify server-side product-to-level configuration matches the product ids the client offers.
  4. Handle SubscriptionInvalidArgumentsException client-side by pointing the user to buy the login-specific product.

Example fix

// before: redeeming a donation purchase for login
client.generateReceipt(purchaseIdOfDonationProduct);
// after
if (productLevel == ReceiptLevel.LOGIN) {
  client.generateReceipt(purchaseId);
} else {
  promptPurchaseOfLoginProduct();
}
Defensive patterns

Strategy: validation

Validate before calling

// confirm the claimed purchase maps to the LOGIN level before generating a receipt
if (paymentDetails.level() != ReceiptLevel.LOGIN) { promptPurchaseOfLoginProduct(); return; }

Type guard

function isLoginLevel(details) { return details?.level === 'LOGIN'; }

Try / catch

try {
  api.generateReceipt(paymentProvider, purchaseId, request);
} catch (SubscriptionInvalidArgumentsException e) {
  showWrongProductError(); // purchase was not for the login product
}

Prevention

When it happens

Trigger: Claiming a one-time purchase whose configured product/level maps to a non-LOGIN receipt level (e.g. a donation-tier product) and then requesting a login receipt with it.

Common situations: Client sends the purchase id of a donation/one-time-donation product into the login purchase flow; server product-to-level mapping changed after the purchase; mixing the donations API with the login purchase API.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/LoginPurchaseManager.java:79

      throws RateLimitExceededException, SubscriptionInvalidArgumentsException, IOException, SubscriptionNotFoundException, SubscriptionReceiptRequestedForOpenPaymentException, SubscriptionPaymentRequiredException, SubscriptionReceiptAlreadyRedeemedException, VerificationFailedException {

    final OneTimePaymentProcessor oneTimePaymentProcessor = oneTimePaymentProcessors.get(paymentProvider);
    if (oneTimePaymentProcessor == null) {
      throw new SubscriptionInvalidArgumentsException("unknown payment provider: " + paymentProvider);
    }

    final PaymentDetails paymentDetails = oneTimePaymentProcessor
        .claimOneTimePurchase(purchaseId)
        .orElseThrow(SubscriptionNotFoundException::new);
    if (paymentDetails.status() == PaymentStatus.PROCESSING) {
      throw new SubscriptionReceiptRequestedForOpenPaymentException();
    } else if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
      throw Optional.ofNullable(paymentDetails.chargeFailure())
          .<SubscriptionPaymentRequiredException>map(
              cf -> new SubscriptionChargeFailurePaymentRequiredException(paymentProvider, cf))
          .orElseGet(SubscriptionPaymentRequiredException::new);
    } else if (paymentDetails.level() != ReceiptLevel.LOGIN) {
      throw new SubscriptionInvalidArgumentsException("purchase was for an unexpected product");
    }

    // Calculating the expiration from the creation date works for IAP purchases. However, for other processors, the
    // creation date of the payment intent might be days before the payment actually completed. If we support non-IAP
    // processors we should attempt to get the latest date. see OneTimeDonationController/OneTimeDonationManager
    final Instant expiration = paymentDetails.created().plus(LOGIN_EXPIRATION).truncatedTo(ChronoUnit.DAYS);

    try {
      issuedReceiptsManager.recordOneTimeIssuance(paymentDetails.id(), paymentProvider, receiptCredentialRequest,
          expiration);
    } catch (WriteConflictException _) {
      throw new SubscriptionReceiptAlreadyRedeemedException();
    }

    return zkReceiptOperations.issueReceiptCredential(receiptCredentialRequest, expiration.getEpochSecond(),
        ReceiptLevel.LOGIN.getValue());
  }
}

View on GitHub (pinned to 100ab61c82)