signalapp/Signal-Server · error · SubscriptionInvalidArgumentsException

transactionId did not include a paid subscription or the…

Error message

transactionId did not include a paid subscription or the provided transactionId was not an originalTransactionId

What it means

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.

Solutions

  1. Send the subscription's originalTransactionId (stable across renewals), not an individual renewal transactionId.
  2. Confirm the subscription actually had at least one paid transaction (status not in billing-retry/refund).
  3. Ensure sandbox vs production App Store environment matches where the purchase was made (check the JWS payload's environment field).
  4. Decode the client's JWS payload and read originalTransactionId directly before submitting it.

Example fix

// before
api.lookupAndValidateTransaction(transactionId);
// after
String originalTx = decodedJwsPayload.getOriginalTransactionId();
api.lookupAndValidateTransaction(originalTx);
Defensive patterns

Strategy: validation

Validate before calling

// send originalTransactionId, extracted from the decoded JWS payload
String originalTx = decodedJws.getOriginalTransactionId();
if (originalTx == null || originalTx.isBlank()) { throw new Error("missing originalTransactionId"); }

Type guard

function hasOriginalTransactionId(payload) { return typeof payload?.originalTransactionId === 'string' && payload.originalTransactionId.length > 0; }

Try / catch

try {
  api.lookupAndValidateTransaction(originalTx);
} catch (SubscriptionInvalidArgumentsException e) {
  verifyTransactionHasPaidHistory(); // check sandbox vs prod and paid status
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

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/ecc9cf0f13948cc5. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java:211

  private AppleAppStoreDecodedTransaction lookupAndValidateTransaction(final String originalTransactionId, final Tags errorTags)
      throws SubscriptionInvalidArgumentsException, RateLimitExceededException, SubscriptionNotFoundException {
    final StatusResponse statuses = appleAppStoreClient.getAllSubscriptions(originalTransactionId, errorTags);
    final SubscriptionGroupIdentifierItem item = statuses.getData().stream()
        .filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst()
        .orElseThrow(() -> new SubscriptionInvalidArgumentsException("transaction did not contain a backup subscription", null));

    final List<AppleAppStoreDecodedTransaction> txs = item.getLastTransactions().stream()
        .map(txItem -> appleAppStoreClient.verifySubscription(statuses.getEnvironment(), txItem))
        .filter(tx -> tx.signedTransaction().getOriginalTransactionId().equals(originalTransactionId))
        .filter(decoded -> productIdToLevel.containsKey(decoded.transaction().getProductId()))
        .toList();

    if (txs.isEmpty()) {
      // Get All Subscriptions only requires that the transaction be some transaction associated with the
      // subscription. This is too flexible, since we'd like to key on the originalTransactionId in the
      // SubscriptionManager.
      throw new SubscriptionInvalidArgumentsException("transactionId did not include a paid subscription or the provided transactionId was not an originalTransactionId", null);
    }

    if (txs.size() > 1) {
      logger.warn("Multiple matching product transactions found with a sha256(originalTransactionId)={}, only considering first",
          sha256(originalTransactionId));
    }
    return txs.getFirst();
  }

  private SubscriptionPrice getSubscriptionPrice(final AppleAppStoreDecodedTransaction tx) {
    final BigDecimal amount = new BigDecimal(tx.transaction().getPrice()).scaleByPowerOfTen(-3);
    return new SubscriptionPrice(
        tx.transaction().getCurrency().toUpperCase(Locale.ROOT),
        SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction().getCurrency(), amount));
  }

  private ReceiptLevel getLevel(final JWSTransactionDecodedPayload tx) {
    final ReceiptLevel level = productIdToLevel.get(tx.getProductId());

View on GitHub (pinned to 100ab61c82)