{"record":{"id":"ecc9cf0f13948cc5","repo":"signalapp/Signal-Server","slug":"transactionid-did-not-include-a-paid-subscription","errorCode":null,"errorMessage":"transactionId did not include a paid subscription or the provided transactionId was not an originalTransactionId","messagePattern":"transactionId did not include a paid subscription or the provided transactionId was not an originalTransactionId","errorType":"exception","errorClass":"SubscriptionInvalidArgumentsException","httpStatus":400,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java","lineNumber":211,"sourceCode":"\n  private AppleAppStoreDecodedTransaction lookupAndValidateTransaction(final String originalTransactionId, final Tags errorTags)\n      throws SubscriptionInvalidArgumentsException, RateLimitExceededException, SubscriptionNotFoundException {\n    final StatusResponse statuses = appleAppStoreClient.getAllSubscriptions(originalTransactionId, errorTags);\n    final SubscriptionGroupIdentifierItem item = statuses.getData().stream()\n        .filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst()\n        .orElseThrow(() -> new SubscriptionInvalidArgumentsException(\"transaction did not contain a backup subscription\", null));\n\n    final List<AppleAppStoreDecodedTransaction> txs = item.getLastTransactions().stream()\n        .map(txItem -> appleAppStoreClient.verifySubscription(statuses.getEnvironment(), txItem))\n        .filter(tx -> tx.signedTransaction().getOriginalTransactionId().equals(originalTransactionId))\n        .filter(decoded -> productIdToLevel.containsKey(decoded.transaction().getProductId()))\n        .toList();\n\n    if (txs.isEmpty()) {\n      // Get All Subscriptions only requires that the transaction be some transaction associated with the\n      // subscription. This is too flexible, since we'd like to key on the originalTransactionId in the\n      // SubscriptionManager.\n      throw new SubscriptionInvalidArgumentsException(\"transactionId did not include a paid subscription or the provided transactionId was not an originalTransactionId\", null);\n    }\n\n    if (txs.size() > 1) {\n      logger.warn(\"Multiple matching product transactions found with a sha256(originalTransactionId)={}, only considering first\",\n          sha256(originalTransactionId));\n    }\n    return txs.getFirst();\n  }\n\n  private SubscriptionPrice getSubscriptionPrice(final AppleAppStoreDecodedTransaction tx) {\n    final BigDecimal amount = new BigDecimal(tx.transaction().getPrice()).scaleByPowerOfTen(-3);\n    return new SubscriptionPrice(\n        tx.transaction().getCurrency().toUpperCase(Locale.ROOT),\n        SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction().getCurrency(), amount));\n  }\n\n  private ReceiptLevel getLevel(final JWSTransactionDecodedPayload tx) {\n    final ReceiptLevel level = productIdToLevel.get(tx.getProductId());","sourceCodeStart":193,"sourceCodeEnd":229,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java#L193-L229","documentation":"lookupAndValidateTransaction fetches the subscription history for the given transactionId and filters for paid transactions matching the sha256 of the originalTransactionId. If none match, Signal cannot key the subscription on an originalTransactionId in SubscriptionManager, so it throws SubscriptionInvalidArgumentsException. Get All Transactions returns any transaction tied to the subscription, which is more permissive than Signal requires.","triggerScenarios":"Passing a transactionId that belongs to an unpaid/pending purchase, or an id that is a regular transactionId rather than the originalTransactionId of the subscription, such that the filtered txs list is empty.","commonSituations":"Client sends the transactionId from a receipt instead of originalTransactionId; refund/failed-renewal means no paid transactions exist; testing with sandbox transaction ids against production.","solutions":["Send the subscription's originalTransactionId (stable across renewals), not an individual renewal transactionId.","Confirm the subscription actually had at least one paid transaction (status not in billing-retry/refund).","Ensure sandbox vs production App Store environment matches where the purchase was made (check the JWS payload's environment field).","Decode the client's JWS payload and read originalTransactionId directly before submitting it."],"exampleFix":"// before\napi.lookupAndValidateTransaction(transactionId);\n// after\nString originalTx = decodedJwsPayload.getOriginalTransactionId();\napi.lookupAndValidateTransaction(originalTx);","handlingStrategy":"validation","validationCode":"// send originalTransactionId, extracted from the decoded JWS payload\nString originalTx = decodedJws.getOriginalTransactionId();\nif (originalTx == null || originalTx.isBlank()) { throw new Error(\"missing originalTransactionId\"); }","typeGuard":"function hasOriginalTransactionId(payload) { return typeof payload?.originalTransactionId === 'string' && payload.originalTransactionId.length > 0; }","tryCatchPattern":"try {\n  api.lookupAndValidateTransaction(originalTx);\n} catch (SubscriptionInvalidArgumentsException e) {\n  verifyTransactionHasPaidHistory(); // check sandbox vs prod and paid status\n}","preventionTips":["Always key on originalTransactionId, never a renewal transactionId","Confirm the subscription has at least one paid (non-refunded, non-retry) transaction","Match sandbox/production environment between purchase and lookup"],"tags":["app-store","original-transaction-id","subscription-lookup","argument-validation"],"backgroundTag":"invalid-argument-value","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"}