aporat/store-receipt-validator
store-receipt-validator
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.
Related Packages
Helps building nice, normalized and easy to consume Laravel REST API.
PHP-first mobile UI package for Android WebView — Alpine.js + Livewire v4 compon...
A Laravel package to create Apple Wallet (old Passbook) compatible tickets.
Laravel receipt validator for Apple iTunes, Google Play