aporat / store-receipt-validator
PHP receipt validator for Apple App Store, Google Play and Amazon Appstore
Fund package maintenance!
Requires
- php: ^8.4
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.9|^8.0
- guzzlehttp/psr7: ^2.6 || ^3.0
- lcobucci/clock: ^3.3
- lcobucci/jwt: ^5.5
- nesbot/carbon: ^2.72.6|^3.0
- phpseclib/phpseclib: ^4.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/log: ^3.0
Requires (Dev)
- mockery/mockery: ^1.6
- phpstan/phpstan: ^1.11 || ^2.0
- phpstan/phpstan-phpunit: ^1.4 || ^2.0
- phpunit/phpunit: ^13.0
- squizlabs/php_codesniffer: ^3.10 || ^4.0
Suggests
- codecov/codecov-php: Helps send coverage reports to Codecov.io
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 10.0.0
- 9.0.0
- 8.0.0
- 7.1.0
- 7.0.0
- 6.1.4
- 6.1.2
- 6.1.0
- 6.0.6
- 6.0.4
- 6.0.2
- 6.0.0
- 5.0.1
- 5.0.0
- 4.4.3
- 4.4.2
- 4.4.1
- 4.4.0
- 4.3.0
- 4.2.0
- 4.1.0
- 4.0.3
- 4.0.2
- 4.0.1
- 4.0.0
- 3.5.0
- 3.4.1
- 3.4.0
- 3.3.0
- 3.2.0
- 3.1.0
- 3.0.0
- 2.2.0
- 2.1.0
- 2.0.6
- 2.0.4
- 2.0.2
- 2.0.0
- 1.5.0
- 1.4.1
- 1.4.0
- 1.3.1
- 1.3.0
- 1.2.1
- 1.2.0
- 1.1.1
- 1.1.0
- 1.0.13
- 1.0.12
- 1.0.11
- 1.0.10
- 1.0.9
- 1.0.8
- 1.0.7
- 1.0.6
- 1.0.5
- 1.0.4
- 1.0.2
- 1.0.0
This package is auto-updated.
Last update: 2026-09-08 13:30:08 UTC
README
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.
✨ Features
- ✅ Apple App Store Server API (v2) support
- ✅ 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
📦 Requirements
- PHP >= 8.4
📥 Installation
composer require aporat/store-receipt-validator
🚀 Quick Start
📲 Apple App Store Server API
use 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) { 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.
📚 Other App Store Server API endpoints
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() |
Each returns a typed response object (Transaction, AppTransaction, SubscriptionStatusResponse, RefundHistoryResponse, NotificationHistoryResponse, …). See the App Store Server API docs for endpoint semantics.
🍏 Apple iTunes (Legacy API - Deprecated)
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; } }
🤖 Google Play
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.
Other Google Play endpoints
| Area | Methods |
|---|---|
| Subscriptions | getSubscriptionPurchaseV2(), acknowledgeSubscription(), revokeSubscription() |
| One-time products | getProductPurchase(), acknowledgeProduct(), consumeProduct() |
| Refunds | getVoidedPurchases() |
use ReceiptValidator\GooglePlay\RevocationContext; use ReceiptValidator\GooglePlay\VoidedPurchasesParams; use ReceiptValidator\GooglePlay\VoidedPurchaseType; $product = $validator->getProductPurchase('com.example.coins.100', $purchaseToken); if ($product->isPurchased() && !$product->isAcknowledged()) { $validator->acknowledgeProduct('com.example.coins.100', $purchaseToken); } $validator->revokeSubscription($purchaseToken, RevocationContext::proratedRefund()); $voided = $validator->getVoidedPurchases(new VoidedPurchasesParams(type: VoidedPurchaseType::INCLUDE_SUBSCRIPTIONS)); foreach ($voided->getVoidedPurchases() as $refund) { echo $refund->getOrderId() . ' voided at ' . $refund->getVoidedTime()?->toIso8601String() . PHP_EOL; }
Bringing your own access tokens
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']) );
🛒 Amazon Appstore
use ReceiptValidator\Amazon\Validator; $validator = new Validator(); try { $response = $validator ->setDeveloperSecret('SECRET') ->setReceiptId('RECEIPT_ID') ->setUserId('USER_ID') ->validate(); } catch (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; echo $e->getTraceAsString() . PHP_EOL; exit; } echo 'Receipt is valid.' . PHP_EOL; foreach ($response->getTransactions() as $transaction) { echo 'Product ID: ' . $transaction->getProductId() . PHP_EOL; if ($transaction->getPurchaseDate() !== null) { echo 'Purchase Date: ' . $transaction->getPurchaseDate()->toIso8601String() . PHP_EOL; } }
📋 Logging
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);
Log levels
| 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 |
📬 Apple App Store Server Notifications
🔔 V2 Notifications (App Store Server API)
use ReceiptValidator\AppleAppStore\ServerNotification; use ReceiptValidator\Exceptions\ValidationException; public function subscriptions(Request $request): JsonResponse { try { $notification = new ServerNotification($request->all()); echo 'Type: ' . $notification->getNotificationType()->value . PHP_EOL; echo 'Subtype: ' . ($notification->getSubtype()?->value ?? 'N/A') . PHP_EOL; echo 'Bundle ID: ' . $notification->getBundleId() . 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; } }
🔔 V1 Notifications (iTunes - Deprecated)
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; } }
🤖 Google Play Real-time Developer Notifications
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\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; } return response()->json(['status' => 'handled']); }
🧪 Testing
composer test # Run tests with PHPUnit composer lint # Run code style checks with PHP_CodeSniffer composer analyze # Run static analysis with PHPStan
🙌 Contributing
Contributions 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
📄 License
Apache-2.0 License. See LICENSE.