A modern PHP library for validating in-app purchases from the Apple App Store (including legacy iTunes), Google Play and Amazon Appstore. Supports both production and sandbox environments with detailed response parsing.
Using Laravel? Use aporat/laravel-appstore-purchases instead. It is a Laravel package built on top of this library, and adds Laravel events for App Store and Google Play server notifications, config-based setup and container bindings.
- ✅ Apple App Store Server API (v2) support
- ✅ Apple StoreKit 2 signed transaction (
jwsRepresentation) verification, offline - ✅ Apple iTunes Legacy API support (deprecated by Apple, still available here)
- ✅ Google Play Developer API (Android Publisher v3) support: subscriptions, one-time products, voided purchases
- ✅ Google Play Real-time Developer Notifications parsing (Pub/Sub envelope included)
- ✅ Amazon Appstore receipt validation
- ✅ App Store Server Notifications v1 & v2 parsing
- ✅ Strong typing (PHP 8.4+), enums, and modern error handling
- ✅ PSR-3 compatible logging support
- ✅ Built-in test suite with 100% coverage
- PHP >= 8.4
composer require aporat/store-receipt-validatoruse ReceiptValidator\AppleAppStore\ReceiptUtility;
use ReceiptValidator\AppleAppStore\Validator as AppleValidator;
use ReceiptValidator\Environment;
// Credentials
$signingKey = file_get_contents($root . '/examples/SubscriptionKey_RA9DAYVX3X.p8');
$keyId = 'RA9DAYVX3X';
$issuerId = 'xxxxxx-xxxx-xxxx-xxxx-xxxxxxx';
$bundleId = 'com.myapp';
$receiptBase64Data = '...'; // your app receipt here
// 🔑 Tip: Apple's Server API does not accept the full app receipt.
// Use ReceiptUtility to extract the latest transaction ID.
$transactionId = ReceiptUtility::extractTransactionIdFromAppReceipt($receiptBase64Data);
$validator = new AppleValidator(
signingKey: $signingKey,
keyId: $keyId,
issuerId: $issuerId,
bundleId: $bundleId,
environment: Environment::PRODUCTION
);
try {
$response = $validator->getTransactionHistory($transactionId);
} catch (ValidationException $e) {
if ($e->getCode() === APIError::INVALID_TRANSACTION_ID->value) {
echo "Invalid Transaction ID: {$e->getMessage()}" . PHP_EOL;
} else {
echo "Validation failed: {$e->getMessage()}" . PHP_EOL;
}
exit(1);
} catch (Exception $e) {
echo 'Error validating transaction: ' . $e->getMessage() . PHP_EOL;
exit(1);
}
echo 'Validation successful.' . PHP_EOL;
echo 'Bundle ID: ' . $response->getBundleId() . PHP_EOL;
echo 'App Apple ID: ' . $response->getAppAppleId() . PHP_EOL;
foreach ($response->getTransactions() as $transaction) {
echo 'Product ID: ' . $transaction->getProductId() . PHP_EOL;
echo 'Transaction ID: ' . $transaction->getTransactionId() . PHP_EOL;
if ($transaction->getPurchaseDate() !== null) {
echo 'Purchase Date: ' . $transaction->getPurchaseDate()->toIso8601String() . PHP_EOL;
}
}ℹ️
Validator::validate()is deprecated as of v9. UsegetTransactionHistory()for paginated history, orgetTransactionInfo()for a single signed transaction.
The AppleAppStore\Validator now covers Apple's full API surface:
| Area | Methods |
|---|---|
| Transactions | getTransactionHistory(), getTransactionInfo(), getAppTransactionInfo(), finishTransaction(), setAppAccountToken(), sendConsumptionInformation() |
| Order / refunds | lookUpOrderId(), getRefundHistory() |
| Subscriptions | getAllSubscriptionStatuses(), extendSubscriptionRenewalDate(), extendSubscriptionRenewalDatesForAllActiveSubscribers(), getStatusOfSubscriptionRenewalDateExtensions() |
| Notifications | requestTestNotification(), getTestNotificationStatus(), getNotificationHistory() |
sendConsumptionInformation() targets Apple's v2 endpoint and requires a delivery status:
use ReceiptValidator\AppleAppStore\ConsumptionRequest;
use ReceiptValidator\AppleAppStore\DeliveryStatus;
use ReceiptValidator\AppleAppStore\RefundPreference;
$request = new ConsumptionRequest(
customerConsented: true,
sampleContentProvided: false,
deliveryStatus: DeliveryStatus::DELIVERED,
);
$request->refundPreference = RefundPreference::GRANT_PRORATED;
$request->setConsumptionPercent(40); // sent as 40000 milliunits
$validator->sendConsumptionInformation($transactionId, $request);Pass a ConsumptionRequestV1 instead to call the deprecated v1 endpoint.
getAllSubscriptionStatuses() accepts an optional status filter, so Apple returns
only subscriptions in any of the given states:
use ReceiptValidator\AppleAppStore\SubscriptionStatus;
$statuses = $validator->getAllSubscriptionStatuses($transactionId, [
SubscriptionStatus::Active,
SubscriptionStatus::InBillingGracePeriod,
]);Each returns a typed response object (Transaction, AppTransaction, SubscriptionStatusResponse, RefundHistoryResponse, NotificationHistoryResponse, …). See the App Store Server API docs for endpoint semantics.
StoreKit 2 apps no longer send an app receipt. Each VerificationResult carries a
signed transaction (JWS) in jwsRepresentation, which your app can send to your server.
Verify it offline — checks the signature, the Apple certificate chain, and that the payload's bundle ID and environment match the validator:
use ReceiptValidator\AppleAppStore\Validator as AppleValidator;
use ReceiptValidator\Environment;
use ReceiptValidator\Exceptions\ValidationException;
$validator = new AppleValidator(
signingKey: $signingKey,
keyId: $keyId,
issuerId: $issuerId,
bundleId: 'com.myapp',
environment: Environment::PRODUCTION
);
try {
$transaction = $validator->verifySignedTransaction($jwsRepresentation);
} catch (ValidationException $e) {
// Bad signature, untrusted chain, or bundle/environment mismatch
exit(1);
}
echo $transaction->getProductId() . PHP_EOL;
echo $transaction->getTransactionId() . PHP_EOL;verifySignedRenewalInfo() and verifySignedAppTransaction() do the same for a
subscription's renewal info and for AppTransaction.shared.
A signed transaction reflects the purchase at the time it was signed. To see its current state (for example, a later refund), look it up with the App Store Server API:
$current = $validator->getTransactionInfo($transaction->getTransactionId());Some StoreKit features need a signature from your server before the app can use them. The signature creators take the same In-App Purchase key you use for the App Store Server API.
use ReceiptValidator\AppleAppStore\Signature\PromotionalOfferV2SignatureCreator;
use ReceiptValidator\AppleAppStore\Signature\IntroductoryOfferEligibilitySignatureCreator;
use ReceiptValidator\AppleAppStore\Signature\AdvancedCommerceInAppSignatureCreator;
use ReceiptValidator\AppleAppStore\Signature\PromotionalOfferSignatureCreator;
// StoreKit 2 promotional offer (JWS). transactionId is optional but recommended.
$creator = new PromotionalOfferV2SignatureCreator($signingKey, $keyId, $issuerId, 'com.myapp');
$jws = $creator->createSignature('com.myapp.monthly', 'SPRING_PROMO', $transactionId);
// Introductory offer eligibility (JWS)
$creator = new IntroductoryOfferEligibilitySignatureCreator($signingKey, $keyId, $issuerId, 'com.myapp');
$jws = $creator->createSignature('com.myapp.monthly', allowIntroductoryOffer: true, transactionId: $transactionId);
// Advanced Commerce API in-app request (JWS)
$creator = new AdvancedCommerceInAppSignatureCreator($signingKey, $keyId, $issuerId, 'com.myapp');
$jws = $creator->createSignature($advancedCommerceRequest);
// StoreKit 1 promotional offer (Base64 ECDSA signature)
$creator = new PromotionalOfferSignatureCreator($signingKey, $keyId, 'com.myapp');
$signature = $creator->createSignature('com.myapp.monthly', 'SPRING_PROMO', $appAccountToken, $nonce, $timestampMs);Return the result to your app, which passes it to the matching StoreKit API. See Generating JWS to sign App Store requests and Generating a signature for promotional offers.
use ReceiptValidator\Environment;
use ReceiptValidator\iTunes\Validator as iTunesValidator;
$validator = new ITunesValidator($sharedSecret, Environment::PRODUCTION);
try {
$response = $validator->setReceiptData('BASE64_RECEIPT')->validate();
} catch (Exception $e) {
echo 'Error: ' . $e->getMessage() . PHP_EOL;
echo $e->getTraceAsString() . PHP_EOL;
exit;
}
echo 'Bundle ID: ' . $response->getBundleId() . PHP_EOL;
echo 'Original Purchase Date: ' . $response->getOriginalPurchaseDate()?->toIso8601String() . PHP_EOL;
foreach ($response->getTransactions() as $tx) {
echo 'Product ID: ' . $tx->getProductId() . PHP_EOL;
echo 'Transaction ID: ' . $tx->getTransactionId() . PHP_EOL;
echo 'Original Transaction ID: ' . ($tx->getOriginalTransactionId() ?? 'N/A') . PHP_EOL;
if ($tx->getPurchaseDate() !== null) {
echo 'Purchase Date: ' . $tx->getPurchaseDate()?->toIso8601String() . PHP_EOL;
}
if ($tx->getExpiresDate() !== null) {
echo 'Expires Date: ' . $tx->getExpiresDate()?->toIso8601String() . PHP_EOL;
}
}
foreach ($response->getLatestReceiptInfo() as $tx) {
echo 'Latest — Product ID: ' . $tx->getProductId() . PHP_EOL;
echo 'Latest — Transaction ID: ' . $tx->getTransactionId() . PHP_EOL;
if ($tx->getPurchaseDate() !== null) {
echo 'Latest — Purchase Date: ' . $tx->getPurchaseDate()?->toIso8601String() . PHP_EOL;
}
if ($tx->getExpiresDate() !== null) {
echo 'Latest — Expires Date: ' . $tx->getExpiresDate()?->toIso8601String() . PHP_EOL;
}
}Authentication uses a Google Cloud service account that has been granted access to your app in the Play Console ("Users and permissions" → invite the service account email with View financial data / Manage orders). Download its JSON key and pass the contents to the validator. Tokens are minted with the OAuth 2.0 JWT bearer flow and cached in memory; no extra Google SDK is required.
use ReceiptValidator\Environment;
use ReceiptValidator\Exceptions\ValidationException;
use ReceiptValidator\GooglePlay\Validator as GooglePlayValidator;
$validator = new GooglePlayValidator(
packageName: 'com.example.app',
credentials: file_get_contents('/path/to/service-account.json'),
);
try {
// The purchase token from BillingClient's Purchase.getPurchaseToken()
$purchase = $validator->getSubscriptionPurchaseV2($purchaseToken);
} catch (ValidationException $e) {
echo 'Validation failed: ' . $e->getMessage() . PHP_EOL;
exit(1);
}
echo 'State: ' . $purchase->getSubscriptionState()->name . PHP_EOL;
echo 'Entitled: ' . ($purchase->isEntitled() ? 'yes' : 'no') . PHP_EOL;
echo 'Expires: ' . $purchase->getExpiryTime()?->toIso8601String() . PHP_EOL;
echo 'Test purchase: ' . ($purchase->isTestPurchase() ? 'yes' : 'no') . PHP_EOL; // Environment::SANDBOX
echo 'Obfuscated account ID: ' . $purchase->getObfuscatedExternalAccountId() . PHP_EOL;
foreach ($purchase->getLineItems() as $item) {
echo 'Product ID: ' . $item->getProductId() . PHP_EOL;
echo 'Base plan: ' . $item->getBasePlanId() . PHP_EOL;
echo 'Order ID: ' . $item->getLatestSuccessfulOrderId() . PHP_EOL;
echo 'Auto-renewing: ' . ($item->isAutoRenewEnabled() ? 'yes' : 'no') . PHP_EOL;
}ℹ️ Google has no sandbox endpoint. Licence-tester purchases come back from the production API with a
testPurchasemarker, which the response exposes asisTestPurchase()andEnvironment::SANDBOX.
Non-2xx responses throw GooglePlay\APIException (a ValidationException) carrying the HTTP status, Google's reason string and the matching APIError, so you can retry or branch without parsing the message:
use ReceiptValidator\GooglePlay\APIError;
use ReceiptValidator\GooglePlay\APIException;
try {
$purchase = $validator->getSubscriptionPurchaseV2($purchaseToken);
} catch (APIException $e) {
if ($e->isRetryable()) { // 429, 5xx, quota or backend errors
// schedule a retry
} elseif ($e->getError() === APIError::PURCHASE_TOKEN_NO_LONGER_VALID) {
// the token was superseded; drop it
}
}| Area | Methods |
|---|---|
| Subscriptions | getSubscriptionPurchaseV2(), acknowledgeSubscription(), cancelSubscription(), deferSubscription(), revokeSubscription() |
| One-time products | getProductPurchaseV2(), getProductPurchase(), acknowledgeProduct(), consumeProduct() |
| Orders | getOrder(), getOrders(), refundOrder(), reviewRefund() |
| Refunds | getVoidedPurchases() |
use ReceiptValidator\GooglePlay\RevocationContext;
use ReceiptValidator\GooglePlay\SubscriptionCancellationType;
use ReceiptValidator\GooglePlay\VoidedPurchasesParams;
use ReceiptValidator\GooglePlay\VoidedPurchaseType;
// One-time products: the v2 lookup needs only the token and returns one line item per product
$product = $validator->getProductPurchaseV2($purchaseToken);
if ($product->isPurchased() && !$product->isAcknowledged()) {
foreach ($product->getLineItems() as $item) {
echo $item->getProductId() . ' x' . $item->getQuantity() . PHP_EOL;
$validator->acknowledgeProduct($item->getProductId(), $purchaseToken);
}
}
// Stop the next renewal without a refund; access continues until the period ends
$validator->cancelSubscription($purchaseToken, SubscriptionCancellationType::USER_REQUESTED_STOP_RENEWALS);
// Extend a subscription by a week (etag comes from the latest getSubscriptionPurchaseV2() call)
$deferred = $validator->deferSubscription($purchaseToken, $purchase->getEtag(), 7 * 24 * 3600);
echo 'New expiry: ' . $deferred->getExpiryTime()?->toIso8601String() . PHP_EOL;
// End access now and refund the unused part of the period
$validator->revokeSubscription($purchaseToken, RevocationContext::proratedRefund());
// Orders are the financial record: what was charged, tax, buyer country, service period, refund state
$order = $validator->getOrder($purchase->getLatestLineItem()?->getLatestSuccessfulOrderId());
echo 'Charged: ' . $order->getTotal() . ' (tax ' . $order->getTax() . ')' . PHP_EOL; // "10.89 USD (tax 0.9 USD)"
echo 'Period: ' . $order->getLineItems()[0]->getServicePeriodEndTime()?->toDateString() . PHP_EOL;
if ($order->isRefunded() && $order->isChargeback()) {
// remove access
}
// Google's recommendation when a purchase fails your own validation: refund and revoke
$validator->refundOrder($order->getOrderId(), revoke: true);
$voided = $validator->getVoidedPurchases(new VoidedPurchasesParams(type: VoidedPurchaseType::INCLUDE_SUBSCRIPTIONS));
foreach ($voided->getVoidedPurchases() as $refund) {
echo $refund->getOrderId() . ' voided at ' . $refund->getVoidedTime()?->toIso8601String() . PHP_EOL;
}If you already use google/auth (or want to share a token cache), implement GooglePlay\JWT\AccessTokenProvider or wrap a callable:
use Google\Auth\Credentials\ServiceAccountCredentials;
use ReceiptValidator\GooglePlay\JWT\CallbackAccessTokenProvider;
$credentials = new ServiceAccountCredentials(
'https://www.googleapis.com/auth/androidpublisher',
'/path/to/service-account.json'
);
$validator = new GooglePlayValidator('com.example.app');
$validator->setAccessTokenProvider(
new CallbackAccessTokenProvider(fn () => $credentials->fetchAuthToken()['access_token'])
);use ReceiptValidator\Amazon\APIError;
use ReceiptValidator\Amazon\Validator as AmazonValidator;
use ReceiptValidator\Environment;
use ReceiptValidator\Exceptions\ValidationException;
// The shared secret from the Developer Console's Shared Key page.
$validator = new AmazonValidator('SHARED_SECRET', Environment::PRODUCTION);
try {
// receiptId from PurchaseResponse.getReceipt().getReceiptId(),
// userId from PurchaseResponse.getUserData().getUserId()
$response = $validator->validate($receiptId, $userId);
} catch (ValidationException $e) {
$error = APIError::fromException($e); // null for connection failures
if ($error?->isCanceledReceipt()) {
// HTTP 410: the receipt was valid once. Revoke what it granted.
} elseif ($error?->isRetryable()) {
// HTTP 429 or 500: back off and try again later.
}
echo 'Validation failed: ' . $e->getMessage() . PHP_EOL;
exit(1);
}
echo 'Product: ' . $response->getProductId() . PHP_EOL;
echo 'Type: ' . $response->getProductType()?->name . PHP_EOL; // CONSUMABLE, ENTITLED or SUBSCRIPTION
echo 'Entitled: ' . ($response->isEntitled() ? 'yes' : 'no') . PHP_EOL;
echo 'Expires: ' . $response->getExpiresAt()?->toIso8601String() . PHP_EOL; // subscriptions only
echo 'Country: ' . $response->getCountryCode() . PHP_EOL;
$transaction = $response->getTransaction();
if ($transaction?->isSubscription()) {
echo 'Auto-renewing: ' . ($transaction->isAutoRenewing() ? 'yes' : 'no') . PHP_EOL;
echo 'Free trial: ' . ($transaction->isInFreeTrial() ? 'yes' : 'no') . PHP_EOL;
echo 'Grace period: ' . ($transaction->isInGracePeriod() ? 'yes' : 'no') . PHP_EOL;
echo 'Quick Subscribe: ' . ($transaction->isQuickSubscribe() ? 'yes' : 'no') . PHP_EOL;
echo 'Cancel reason: ' . $transaction->getCancelReason()?->name . PHP_EOL;
foreach ($transaction->getPromotions() as $promotion) {
echo 'Promotion: ' . $promotion->getType()?->name . ' (' . $promotion->getStatus()?->name . ')' . PHP_EOL;
}
}A rejected receipt throws a ValidationException whose code is the HTTP status Amazon returned, and APIError maps the documented ones: 400 invalid receipt, 410 receipt no longer valid, 429 throttled, 496 invalid shared secret, 497 invalid user ID and 500 internal error.
ℹ️ The RVS sandbox (
Environment::SANDBOX) accepts any non-empty shared secret and appends_termtotermSku, which production does not. Test receipts come from Amazon's App Tester and carrytestTransaction: true.
All validators support PSR-3 compatible logging via setLogger(). By default, a NullLogger is used so no output is produced unless you inject a logger.
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('receipt-validator');
$logger->pushHandler(new StreamHandler('php://stdout'));
$validator = new AppleValidator($signingKey, $keyId, $issuerId, $bundleId);
$validator->setLogger($logger);The method returns $this for fluent chaining:
$response = $validator
->setLogger($logger)
->getTransactionHistory($transactionId);| Level | Events |
|---|---|
DEBUG |
Outgoing API request details (environment, URI, parameters) |
INFO |
Successful responses; environment retries (e.g. production → sandbox) |
WARNING |
API error responses, unexpected HTTP status codes |
ERROR |
Network/connection failures |
Verify the notification through your Validator so that, in addition to Apple's
signature, the notification is checked to belong to your app and environment.
In production, also pass your app's numeric Apple ID so it is checked too:
use ReceiptValidator\AppleAppStore\Validator as AppleValidator;
use ReceiptValidator\Environment;
use ReceiptValidator\Exceptions\ValidationException;
$validator = new AppleValidator(
signingKey: $signingKey,
keyId: $keyId,
issuerId: $issuerId,
bundleId: 'com.myapp',
environment: Environment::PRODUCTION,
appAppleId: 1234567890, // from App Store Connect; Apple omits it from sandbox payloads
);
public function subscriptions(Request $request): JsonResponse {
try {
$notification = $validator->verifyNotification($request->all());
echo 'Type: ' . $notification->getNotificationType()->value . PHP_EOL;
echo 'Subtype: ' . ($notification->getSubtype()?->value ?? 'N/A') . PHP_EOL;
$tx = $notification->getTransaction();
if ($tx !== null) {
echo 'Transaction ID: ' . $tx->getTransactionId() . PHP_EOL;
}
$renewalInfo = $notification->getRenewalInfo();
if ($renewalInfo !== null) {
echo 'Auto-Renew Product ID: ' . $renewalInfo->getAutoRenewProductId() . PHP_EOL;
}
} catch (ValidationException $e) {
echo 'Invalid notification: ' . $e->getMessage() . PHP_EOL;
}
}Constructing new ServerNotification($request->all()) directly still works and
verifies Apple's signature only; compare getBundleId() and getEnvironment()
yourself in that case.
use ReceiptValidator\iTunes\ServerNotification;
use ReceiptValidator\Exceptions\ValidationException;
public function subscriptions(Request $request): JsonResponse {
$sharedSecret = 'your_shared_secret';
try {
$notification = new ServerNotification($request->all(), $sharedSecret);
echo 'Type: ' . $notification->getNotificationType()->value . PHP_EOL;
echo 'Bundle ID: ' . $notification->getBundleId() . PHP_EOL;
$transactions = $notification->getLatestReceipt()->getTransactions();
foreach ($transactions as $tx) {
echo 'Transaction ID: ' . $tx->getTransactionId() . PHP_EOL;
}
} catch (ValidationException $e) {
echo 'Invalid notification: ' . $e->getMessage() . PHP_EOL;
}
}Play publishes notifications to a Cloud Pub/Sub topic; a push subscription POSTs them to your endpoint wrapped in a Pub/Sub envelope. ServerNotification::fromPubSubMessage() unwraps the envelope and decodes the notification.
Unlike Apple's notifications, the payload is not signed and carries no purchase data: it only tells you which purchase token changed. Always re-read the purchase from the API before changing entitlement, and authenticate the push itself (Pub/Sub's OIDC bearer token) at the HTTP layer.
use ReceiptValidator\Exceptions\ValidationException;
use ReceiptValidator\GooglePlay\RefundPreference;
use ReceiptValidator\GooglePlay\ReviewRefundRequest;
use ReceiptValidator\GooglePlay\ServerNotification;
use ReceiptValidator\GooglePlay\SubscriptionNotificationType;
public function googlePlay(Request $request): JsonResponse {
try {
$notification = ServerNotification::fromPubSubMessage($request->all());
} catch (ValidationException $e) {
// Undecodable messages will never succeed: acknowledge them so Pub/Sub stops retrying.
return response()->json(['status' => 'ignored']);
}
if ($notification->isTestNotification()) {
return response()->json(['status' => 'test']);
}
if ($sub = $notification->getSubscriptionNotification()) {
echo 'Type: ' . $sub->getNotificationType()->name . PHP_EOL;
echo 'Product: ' . $sub->getSubscriptionId() . PHP_EOL;
$purchase = $validator->getSubscriptionPurchaseV2($sub->getPurchaseToken());
if ($sub->getNotificationType()->revokesEntitlement() || !$purchase->isEntitled()) {
// remove access
}
}
if ($voided = $notification->getVoidedPurchaseNotification()) {
echo 'Refunded order: ' . $voided->getOrderId() . PHP_EOL;
}
if ($review = $notification->getPendingRefundReviewNotification()) {
// A chargeback awaiting your decision. Tell Google whether to approve it.
$validator->reviewRefund($review->getOrderId(), new ReviewRefundRequest(
$review->getPendingRefundToken(),
RefundPreference::DECLINE,
sampleContentProvided: true,
)->withConsumptionPercent(80));
}
return response()->json(['status' => 'handled']);
}composer test # Run tests with PHPUnit
composer lint # Run code style checks with PHP_CodeSniffer
composer analyze # Run static analysis with PHPStanContributions are welcome!
To get started:
- Fork this repo
- Create a feature branch
- Submit a pull request
Found a bug or want a new feature? Open an issue
Notable changes in each release are listed in the CHANGELOG.
Apache-2.0 License. See LICENSE.