Client callbacks
Each provider’s callback() verifies input your app received from a client — an Apple receipt, a Google purchaseToken (plus productId for a one-time product), a Stripe session_id or payment_intent. It is unauthenticated, so call it from your own route; the webhook route takes signed store events only. Failures come in two kinds: a VerificationException means the client’s input is bad, so answer the client; the HTTP client’s RequestException or ConnectionException means the store or your credentials could not answer, so retry or let it fail with a 500:
use RoundlyConsulting\Purchases\Exceptions\VerificationException;
use RoundlyConsulting\Purchases\Facades\Purchases;
try {
$verified = Purchases::provider('stripe')->callback($request);
} catch (VerificationException $e) {
abort(422, $e->getMessage()); // the client's id: answer it
}
// RequestException / ConnectionException: Stripe or the secret key, not the id. Let it 500.What each callback accepts
- Stripe — a session_id (a Checkout Session) wins, else the payment_intent. An id that is present but not a string (session_id[]=x, a number, a boolean) is refused — “Malformed Stripe session id.” / “Malformed Stripe payment intent id.” — whatever the other id holds, so a malformed session_id is never skipped in favour of the payment_intent next to it. A missing, null or empty id counts as not sent; with neither sent, it throws “No Stripe session or payment intent id provided.”
- Google — callback() and callbackResult() need a purchaseToken that is a non-empty string (“Missing or malformed Google purchase token.”). A productId that is present but not a string is refused (“Malformed Google product id.”) rather than read as no product, which would verify a one-time purchase as a subscription; a missing, null or empty productId means a subscription. Both go through product() / subscription(), so a subclass that overrides them sees every callback.
- Apple — the raw request body goes to verifyReceipt as the receipt; on a production configuration a 21007 answer (a sandbox receipt from App Review or TestFlight) is re-verified against the sandbox host. An invalid status throws a VerificationException that names it, such as [21003].
Ids in the API path
Every id a provider puts into a store API path is percent-encoded (rawurlencode), so a client’s session_id of cs_1?expand[]=customer cannot add a query string to a request sent with your secret key, a # cannot cut the path short and ../ cannot walk it to another endpoint. An empty, . or .. id is refused before the store is called — no encoding keeps it one path segment — with “Malformed Stripe id.”, “Malformed Google id.” or “Malformed Apple transaction id.” A valid id such as cs_test_a1B2 is sent unchanged:
session_id=cs_1?expand[]=customer
→ GET /v1/checkout/sessions/cs_1%3Fexpand%5B%5D%3Dcustomer
transaction id ../../v1/notifications/test
→ GET /inApps/v1/transactions/..%2F..%2Fv1%2Fnotifications%2Ftest
purchaseToken ..
→ VerificationException: Malformed Google id. (Google is never called)That covers Stripe’s paymentIntent(), subscription(), session() and invoice(); Google’s product(), subscription(), acknowledgeSubscription() and the subscription read behind an RTDN, plus a configured package_name of . or ..; and AppStoreServerApi::transaction().
What callback() throws
| Exception | When |
|---|---|
VerificationException | The client’s input is bad: a malformed or missing id (above), or one the store answers with a 4xx about it — 400, 404, or 410 for a Google token that is no longer valid: “Stripe rejected the session id.” / “Stripe rejected the payment intent id.”, “Google rejected the purchase token.” / “Google rejected the purchase token or product id.”, “Apple rejected the receipt.” Its getPrevious() is the store’s RequestException. Also an invalid or malformed Apple receipt, a Google purchase that is not purchased or a subscription that has expired, and a missing Stripe secret or Google package_name or service account. |
RequestException | The store answered 401 / 403 (your credentials are refused), 429 (rate limited) or 5xx. For Google also a 4xx whose error reason is applicationNotFound (a wrong package_name), its OAuth token endpoint refusing the service account (such as invalid_grant), and an acknowledgement refused after a good lookup. Fix the configuration, retry, or let it answer 500. |
ConnectionException | The store could not be reached. Retry, or let it answer 500. |
For Google the error reason decides, not the status: a 404 is as often an unknown token as an unknown app. Only applicationNotFound — a package_name Google doesn’t know, or an app with nothing uploaded to a track yet — is read as your setup; any other reason, and a 4xx without Google’s error envelope, stays “Google rejected the purchase token.” purchaseTokenDoesNotMatchPackageName (400) is a rejection too: the client sent another app’s token.
Direct lookups
Called directly, Stripe’s paymentIntent(), subscription(), session() and invoice(), Google’s product() and subscription() and AppStoreServerApi::transaction() refuse a malformed id the same way, but leave every error answer from the store — a 404 for an unknown id included — as the HTTP client’s RequestException. Only callback() (and Google’s callbackResult()) turns a 4xx about the input into a VerificationException.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.