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
- Ensure the user purchases the correct login product (the one mapped to ReceiptLevel.LOGIN) and send that purchase id.
- Use the correct redemption flow for non-login products (donation flow) instead of generateReceipt for login.
- Verify server-side product-to-level configuration matches the product ids the client offers.
- 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
- Verify the purchased product maps to ReceiptLevel.LOGIN server-side before redeeming
- Keep product-to-level configuration consistent across deploys
- Route donation/other-level products through their own redemption flows
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
- transaction is not a consumable one-time purchase
- unknown payment provider:
- server does not recognize the requested receipt level
- Invalid receipt level
- transactionId did not include a paid subscription or the…
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)