signalapp/Signal-Server · error · SubscriptionInvalidArgumentsException

purchase has no line items

Error message

purchase has no line items

What it means

getLineItem expects a ProductPurchaseV2 to contain exactly one product line item; a purchase with a null or empty productLineItem list cannot be mapped to a subscription/product state, so Signal throws SubscriptionInvalidArgumentsException. (If more than one exists it only logs a warning and uses the first.) An empty list means the Play API returned a purchase record without any active line item data.

Solutions

  1. Treat this as an expired/voided purchase: have the user re-subscribe and submit a fresh purchase token.
  2. Check the purchase's acknowledgement/voided state via the Play Developer API; voided purchases legitimately have no line items.
  3. Verify the client sends the purchaseToken for the right product/package (wrong token can yield empty records).
  4. If it is transient, re-query the Play API; occasionally line items appear only after purchase processing completes.

Example fix

// before: trusting a cached/stale token
client.validateToken(stalePurchaseToken);
// after: refresh purchase record first
PurchaseResult p = billingClient.queryPurchasesAsync(product);
if (!p.lineItems.isEmpty()) {
  client.validateToken(p.purchaseToken);
}
Defensive patterns

Strategy: validation

Validate before calling

ProductPurchaseV2 p = playApi.getPurchase(token);
if (p == null || p.getProductLineItem() == null || p.getProductLineItem().isEmpty()) {
  treatAsExpiredOrVoided();
  return;
}

Type guard

function hasLineItems(purchase) { return Array.isArray(purchase?.productLineItem) && purchase.productLineItem.length > 0; }

Try / catch

try {
  api.validateToken(purchaseToken);
} catch (SubscriptionInvalidArgumentsException e) {
  requireNewPurchase(); // line items are gone; purchase is expired/voided
}

Prevention

When it happens

Trigger: Calling purchase/lineItem/expired paths with a purchase token whose ProductPurchaseV2 response from the Google Play Developer API contains no productLineItem entries — typically an expired, voided, or malformed purchase.

Common situations: Purchase was refunded/voided so Play drops the line item; token submitted long after expiry; incorrect package/product configuration making the API return an empty purchase record; Play API version mismatch returning v2 shape differences.

Understand the failure class

Background: EmptyResultError / "no results found": when an API or scraper succeeds but returns zero rows — this error's family across 9 libraries.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java:369

            .flatMap(ConsumptionState::fromString)
            .orElseThrow(() -> new IllegalStateException("Purchase did not contain a consumption state: " + lineItem.getProductOfferDetails()));

        if (consumptionState == ConsumptionState.YET_TO_BE_CONSUMED) {
          // Mark this token as consumed
          executeTokenOperation(publisher ->
              publisher.purchases().products().consume(packageName, productId, purchaseToken));
        }
      }
      return Optional.of(new PaymentDetails(purchaseToken, level, paymentStatus, purchaseTime, null));
    } catch (SubscriptionNotFoundException e) {
      return Optional.empty();
    }
  }

  private ProductLineItem getLineItem(final ProductPurchaseV2 purchase) throws SubscriptionInvalidArgumentsException {
    final List<ProductLineItem> lineItems = purchase.getProductLineItem();
    if (lineItems == null || lineItems.isEmpty()) {
      throw new SubscriptionInvalidArgumentsException("purchase has no line items");
    }
    if (lineItems.size() > 1) {
      logger.warn("{} line items found for purchase {}, expected 1", lineItems.size(), purchase.getOrderId());
    }
    return lineItems.getFirst();
  }

  interface ApiCall<T> {

    AndroidPublisherRequest<T> req(AndroidPublisher publisher) throws IOException;
  }

  /**
   * Asynchronously execute a synchronous API call on a purchaseToken, mapping expected errors to the appropriate
   * {@link SubscriptionException}
   *
   * @param apiCall An API call that operates on a purchaseToken
   * @param <R>     The result of the API call

View on GitHub (pinned to 100ab61c82)