---
title: "Client callbacks — Purchases for Laravel | Roundly"
description: "Verify what a client sends up — an Apple receipt, a Google token, a Stripe session id — and tell bad input from a store or credentials failure."
url: https://roundly-consulting.com/open-source/docs/purchases-for-laravel/client-callbacks
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Purchases for Laravel](https://roundly-consulting.com/open-source/docs/purchases-for-laravel.md)

# 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:

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/purchases-for-laravel.md)
