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
- Use the processor matching the actual purchase channel: STRIPE (paymentIntentId) or BRAINTREE for one-time donations
- 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
- 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
- Map each purchase channel to exactly one API endpoint in client code
- Never send store-billing processors to the generic boost donation endpoint
- Add a client-side enum check before building the request
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
- cannot use play billing for one-time donations
- receipt credential request failed verification
- account does not have a phone number
- Operation requires unauthenticated access
- must not use authenticated connection for anonymous…
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)