{"record":{"id":"8908153994aaf120","repo":"signalapp/Signal-Server","slug":"cannot-use-app-store-purchases-for-one-time-donati","errorCode":null,"errorMessage":"cannot use app store purchases for one-time donations","messagePattern":"cannot use app store purchases for one-time donations","errorType":"http","errorClass":"BadRequestException","httpStatus":400,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java","lineNumber":356,"sourceCode":"  @POST\n  @Path(\"/receipt_credentials\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public Response createBoostReceiptCredentials(\n      @Auth final Optional<AuthenticatedDevice> authenticatedAccount,\n      @NotNull @Valid final CreateBoostReceiptCredentialsRequest request,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws IOException {\n\n    if (authenticatedAccount.isPresent()) {\n      throw new ForbiddenException(\"must not use authenticated connection for one-time donation operations\");\n    }\n\n    final Optional<PaymentDetails> maybePaymentDetails = (switch (request.processor) {\n      case STRIPE -> stripeManager.claimOneTimePurchase(request.paymentIntentId);\n      case BRAINTREE -> braintreeManager.claimOneTimePurchase(request.paymentIntentId);\n      case GOOGLE_PLAY_BILLING -> throw new BadRequestException(\"cannot use play billing for one-time donations\");\n      case APPLE_APP_STORE -> throw new BadRequestException(\"cannot use app store purchases for one-time donations\");\n    });\n\n    if (maybePaymentDetails.isEmpty()) {\n      throw new WebApplicationException(Response.Status.NOT_FOUND);\n    }\n    final PaymentDetails paymentDetails = maybePaymentDetails.get();\n    if (paymentDetails.status() == PaymentStatus.PROCESSING) {\n      return Response.noContent().build();\n    }\n    if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {\n      throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED)\n          .entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build());\n    }\n\n    // The payment was successful, try to issue the receipt credential\n\n    final OneTimeDonationUtil.DonationLevelDetails levelDetails;\n    try {","sourceCodeStart":338,"sourceCodeEnd":374,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java#L338-L374","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\n{ \"processor\": \"APPLE_APP_STORE\", \"paymentIntentId\": \"...\" } // POST /v1/donations\n// after\n{ \"processor\": \"STRIPE\", \"paymentIntentId\": \"pi_...\" } // POST /v1/donations","handlingStrategy":"validation","validationCode":"if (request.processor == Processor.GOOGLE_PLAY_BILLING || request.processor == Processor.APPLE_APP_STORE) {\n  throw new IllegalArgumentException(\"use store-specific donation endpoints for \" + request.processor);\n}","typeGuard":"boolean isSupportedBoostProcessor(Processor p) { return p == Processor.STRIPE || p == Processor.BRAINTREE; }","tryCatchPattern":null,"preventionTips":["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"],"tags":["bad-request","donations","payment-processor","unsupported-operation"],"backgroundTag":"unsupported-operation","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"}