Apple App Store
The Apple provider (id apple) verifies signed App Store server notifications — an ES256 JWS whose x5c certificate chain is pinned to Apple’s published certificates — and decodes the nested signed transaction and renewal info:
use RoundlyConsulting\Purchases\Providers\Apple\Apple;
// Verify a signed App Store server notification (ES256 JWS, native).
$payload = app(Apple::class)->notification($request); // reads the signedPayload field
$payload->type; // NotificationType, e.g. TypeDidRenew
$payload->subType; // ?NotificationSubType (null for REFUND, REVOKE, TEST …)
$payload->transactionInfo?->productId;
$payload->transactionInfo?->originalTransactionId;
$payload->transactionInfo?->expiresDate; // ?Carbon
$payload->renewalInfo; // ?RenewalInfoYour app only
Apple signs every app’s notifications with the same certificate chain, so a genuine signature proves only that Apple sent a notification — not that it concerns your app. notification() (and so result(), handle() and the webhook route) therefore also requires the notification — and every transaction and renewal it carries — to name your PURCHASES_APPLE_BUNDLE_ID and your environment (PURCHASES_APPLE_SANDBOX), and, in production, your PURCHASES_APPLE_APP_APPLE_ID. Anything else throws VerificationException (400 on the route); with no bundle id configured every notification is refused. A transaction looked up through AppStoreServerApi::transaction() is bound the same way:
PURCHASES_APPLE_BUNDLE_ID=com.example.app
PURCHASES_APPLE_APP_APPLE_ID=1234567890
PURCHASES_APPLE_SANDBOX=false # the default; true on a host that receives Sandbox notificationsApp Store Server API
With the api.* credentials set, AppStoreServerApi talks to Apple’s modern API — the preferred path over the deprecated verifyReceipt. transaction() fetches and verifies a signed transaction; requestTestNotification() asks Apple to send a test notification and powers purchases:verify:
use RoundlyConsulting\Purchases\Providers\Apple\AppStoreServerApi;
$api = app(AppStoreServerApi::class);
// Modern App Store Server API (preferred over the deprecated verifyReceipt path).
$transaction = $api->transaction('2000000000000001'); // TransactionInfo, JWS verified
$transaction->productId;
$transaction->purchaseDate; // ?Carbon
$transaction->price; // ?int — milli-units
$transaction->currency; // ?string
$api->requestTestNotification(); // asks Apple to send a test notificationHow Apple notifications map
| Apple notification | Status |
|---|---|
| SUBSCRIBED, DID_RENEW, OFFER_REDEEMED, ONE_TIME_CHARGE, REFUND_REVERSED, RENEWAL_EXTENDED, an immediate UPGRADE | Completed |
| DID_FAIL_TO_RENEW with the GRACE_PERIOD subtype | InGracePeriod |
| DID_FAIL_TO_RENEW without it (billing retry), GRACE_PERIOD_EXPIRED | OnHold |
| EXPIRED | Failed |
| REFUND, REVOKE (refund result) | Refunded |
| Renewal-preference or status change, price increase or change, consumption request, REFUND_DECLINED, TEST and unknown types | Informational — audited, never applied |
A notification with a transaction is keyed on originalTransactionId, carries the product id as its name and the transaction’s expiresDate as endsAt. The price comes from the signed transaction in milli-units, rounded half away from zero to the currency’s minor unit.
Legacy receipts
Apple::callback() still posts the raw request body to Apple’s deprecated verifyReceipt endpoint, with your shared secret from settings.apple.password, and returns a ReceiptResponse — throwing VerificationException when Apple reports the receipt invalid. Prefer the App Store Server API for new code.
Certificate validity and the clock-skew leeway are covered in Signature verification & trust.
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.