signalapp/Signal-Server · error · BadRequestException

cannot use app store purchases for one-time donations

Error message

cannot use app store purchases for one-time donations

What it means

One-time donation boost receipts can only be issued for purchases made through a supported payment processor. Google Play Billing and Apple App Store purchases are handled by separate subscription flows, so when a boost receipt credential request arrives with one of those processors the controller rejects it with a 400 BadRequest before ever looking up the payment. This prevents double-claiming app-store purchases through the one-time donation path.

Solutions

  1. Use the processor matching the actual purchase channel: STRIPE (paymentIntentId) or BRAINTREE for one-time donations
  2. For app store / play billing purchases, use the store-specific donation endpoints (e.g. Google Play Billing or App Store donation receipt endpoints) instead of the boost endpoint
  3. Verify the paymentIntentId was created with the same processor being claimed

Example fix

// before
{ "processor": "APPLE_APP_STORE", "paymentIntentId": "..." } // POST /v1/donations
// after
{ "processor": "STRIPE", "paymentIntentId": "pi_..." } // POST /v1/donations
Defensive patterns

Strategy: validation

Validate before calling

if (request.processor == Processor.GOOGLE_PLAY_BILLING || request.processor == Processor.APPLE_APP_STORE) {
  throw new IllegalArgumentException("use store-specific donation endpoints for " + request.processor);
}

Type guard

boolean isSupportedBoostProcessor(Processor p) { return p == Processor.STRIPE || p == Processor.BRAINTREE; }

Prevention

When it happens

Trigger: A client calls POST /v1/donations (createBoostReceiptCredentials) with a BoostReceiptCredentialRequest whose 'processor' field is GOOGLE_PLAY_BILLING or APPLE_APP_STORE and a paymentIntentId from that store.

Common situations: Mobile clients that made an in-app purchase and then mistakenly reuse the generic boost donation endpoint; SDK or API version mismatches where the client uses the wrong processor enum for a store purchase; hand-crafted API calls that default to the wrong processor.

Understand the failure class

Background: UnsupportedOperationException and "is not supported" errors: when a library deliberately refuses a call — this error's family across 30 libraries.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java:356

  @POST
  @Path("/receipt_credentials")
  @Consumes(MediaType.APPLICATION_JSON)
  @Produces(MediaType.APPLICATION_JSON)
  @ManagedAsync
  public Response createBoostReceiptCredentials(
      @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
      @NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws IOException {

    if (authenticatedAccount.isPresent()) {
      throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
    }

    final Optional<PaymentDetails> maybePaymentDetails = (switch (request.processor) {
      case STRIPE -> stripeManager.claimOneTimePurchase(request.paymentIntentId);
      case BRAINTREE -> braintreeManager.claimOneTimePurchase(request.paymentIntentId);
      case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations");
      case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations");
    });

    if (maybePaymentDetails.isEmpty()) {
      throw new WebApplicationException(Response.Status.NOT_FOUND);
    }
    final PaymentDetails paymentDetails = maybePaymentDetails.get();
    if (paymentDetails.status() == PaymentStatus.PROCESSING) {
      return Response.noContent().build();
    }
    if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
      throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED)
          .entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build());
    }

    // The payment was successful, try to issue the receipt credential

    final OneTimeDonationUtil.DonationLevelDetails levelDetails;
    try {

View on GitHub (pinned to 100ab61c82)