---
title: "Apple App Store — Purchases for Laravel | Roundly"
description: "Verify signed App Store server notifications, look up transactions via the App Store Server API, and map Apple’s states."
url: https://roundly-consulting.com/open-source/docs/purchases-for-laravel/apple-app-store
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)

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

```php
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:

```bash
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:

```php
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 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. 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](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)
