NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Purchases for Laravel

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;                         // ?RenewalInfo

Your 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, and so is a request whose signedPayload is missing or not a string. 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 notifications

App 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 notification

transaction() percent-encodes the id into the path and refuses an empty, . or .. id with a VerificationException (“Malformed Apple transaction id.”) before Apple is called. It is a direct lookup, so an error answer from Apple — 400 for an invalid id, 404 for an unknown one, 401 for a refused token — stays the HTTP client’s RequestException.

How Apple notifications map

Apple notificationStatus
SUBSCRIBED, DID_RENEW, OFFER_REDEEMED, ONE_TIME_CHARGE, REFUND_REVERSED, RENEWAL_EXTENDED, an immediate UPGRADECompleted
DID_FAIL_TO_RENEW with the GRACE_PERIOD subtypeInGracePeriod
DID_FAIL_TO_RENEW without it (billing retry), GRACE_PERIOD_EXPIREDOnHold
EXPIREDFailed
REFUND, REVOKE (refund result)Refunded
Renewal-preference or status change, price increase or change, consumption request, REFUND_DECLINED, TEST and unknown typesInformational — 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. On a production configuration a 21007 answer — a sandbox receipt from App Review or TestFlight — is re-verified against the sandbox host, as Apple prescribes. The status decides first: an invalid or missing one throws VerificationException, and a valid response that names no environment takes the one of the host that verified it. An HTTP 4xx about the receipt is a VerificationException too (“Apple rejected the receipt.”), while a 401 / 403, 429 or 5xx stays a RequestException — see Client callbacks. 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 crypto

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