vipertecpro / mobile-entitlements
Verified App Store Server Notifications V2 and Google Play RTDN webhooks with a per-user entitlement table for Laravel.
Package info
github.com/vipertecpro/mobile-entitlements
pkg:composer/vipertecpro/mobile-entitlements
Requires
- php: ^8.3
- ext-json: *
- ext-openssl: *
- firebase/php-jwt: ^7.0
- laravel/framework: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0|^5.0
- pestphp/pest-plugin-laravel: ^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Receive and verify App Store Server Notifications V2 and Google Play Real-time Developer Notifications, and keep a per-user entitlement table your Laravel backend can trust. Works with any mobile client: Flutter, React Native, Swift, Kotlin or NativePHP.
if ($request->user()->hasEntitlement('pro')) { // ... }
Why
Your app knows what the user bought, but your API should not take the app's word for it. This package lets the stores tell your server directly and checks every message:
- Apple notifications are JWS documents. The package checks the
x5ccertificate chain against the bundled Apple Root CA - G3, the Apple OIDs on the leaf and intermediate certificates, the ES256 signature, your bundle id and the environment. - Google notifications arrive through Pub/Sub push. The package checks Google's OIDC token (issuer, audience, service account), then reads the real state from the Play Developer API. The notification itself is only a hint.
- Your app links a purchase to a user through an authenticated
/syncendpoint. The server verifies the token with the store and uses the store's product id, never the client's. - Every notification is stored once (idempotent on Apple
notificationUUIDand Pub/SubmessageId), and every change fires an event.
Install
composer require vipertecpro/mobile-entitlements php artisan vendor:publish --tag=mobile-entitlements-config php artisan vendor:publish --tag=mobile-entitlements-migrations php artisan migrate
Requires PHP 8.3+ and Laravel 12 or 13.
UUID or ULID user ids. The entitlements.user_id column is an unsigned big integer, matching
Laravel's default users.id. If your users use UUIDs or ULIDs, edit the published migration before
running it and change that column to $table->uuid('user_id')->nullable()->index(); or
$table->ulid('user_id')->nullable()->index();.
Add the trait to your user model:
use Vipertecpro\MobileEntitlements\Concerns\HasEntitlements; class User extends Authenticatable { use HasEntitlements; }
Map entitlement keys to store product ids in config/mobile-entitlements.php:
'entitlements' => [ 'pro' => ['com.example.pro.monthly', 'com.example.pro.yearly', 'pro_monthly', 'pro_yearly'], 'lifetime' => ['com.example.lifetime', 'lifetime_unlock'], ], // Google Play does not say whether a one-time product is a consumable, so list them here. 'consumables' => ['coins_100'],
Routes
The package registers these routes under the route_prefix (default mobile-entitlements):
| Method | URI | Middleware | Purpose |
|---|---|---|---|
| POST | /mobile-entitlements/apple |
middleware.webhooks (default api) |
App Store Server Notifications V2 |
| POST | /mobile-entitlements/google |
middleware.webhooks (default api) |
Pub/Sub push for Play RTDN |
| POST | /mobile-entitlements/sync |
middleware.sync (default api, auth:sanctum) + 60 requests/minute per user |
Link a purchase to the signed-in user |
| POST | /mobile-entitlements/promo-signature |
same as /sync |
Sign an App Store promotional offer (off by default) |
| GET | /mobile-entitlements/summary |
middleware.summary (default api, auth:sanctum, can:viewMobileEntitlementsSummary) |
Subscriber and sales counts as JSON |
The webhook routes are limited to webhook_rate_limit requests per minute per IP (default 120).
They use the api group, so they are not subject to CSRF checks. If you move them
into the web group, exclude them from CSRF verification. To register the routes yourself, set
register_routes to false and load routes/mobile-entitlements.php from the package.
/sync uses Laravel Sanctum by default. If you use another guard, change middleware.sync, for
example ['api', 'auth:api'].
Apple setup
-
In App Store Connect, open your app, then App Information → App Store Server Notifications.
-
Set the Production Server URL to
https://your-app.com/mobile-entitlements/appleand choose Version 2. -
Set the Sandbox Server URL to the same URL on a staging server (or the same server with
accept_sandboxon while you test). -
Configure:
MOBILE_ENTITLEMENTS_APPLE_BUNDLE_ID=com.example.app MOBILE_ENTITLEMENTS_APPLE_ENVIRONMENT=production MOBILE_ENTITLEMENTS_APPLE_ACCEPT_SANDBOX=false
-
Optional, recommended: create an In-App Purchase key under Users and Access → Integrations to use the App Store Server API. With it,
/syncand the reconcile command ask Apple for the current subscription status (grace period, billing retry, renewal) instead of relying on the transaction the app sent.MOBILE_ENTITLEMENTS_APPLE_VERIFY_WITH_SERVER_API=true MOBILE_ENTITLEMENTS_APPLE_ISSUER_ID=57246542-96fe-1a63-e053-0824d011072a MOBILE_ENTITLEMENTS_APPLE_KEY_ID=2X9R4HXF34 MOBILE_ENTITLEMENTS_APPLE_PRIVATE_KEY=/path/to/SubscriptionKey_2X9R4HXF34.p8
You can test delivery with Apple's Request a Test Notification endpoint; it arrives as a
TESTnotification.
Google setup
-
In Google Cloud, create a service account and a JSON key. In Play Console, invite the service account under Users and permissions with permission to view financial data and manage orders and subscriptions.
-
Create a Pub/Sub topic, for example
play-rtdn, and grantgoogle-play-developer-notifications@system.gserviceaccount.comthe Pub/Sub Publisher role on it. -
Create a push subscription on the topic:
- Endpoint URL:
https://your-app.com/mobile-entitlements/google - Enable authentication, choose a service account for the push, and set the audience (use the endpoint URL).
- Endpoint URL:
-
In Play Console, open Monetize with Play → Monetization setup, enter the topic name (
projects/your-project/topics/play-rtdn) under Real-time developer notifications and send a test notification. -
Configure:
MOBILE_ENTITLEMENTS_GOOGLE_PACKAGE_NAME=com.example.app MOBILE_ENTITLEMENTS_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json MOBILE_ENTITLEMENTS_GOOGLE_PUSH_AUDIENCE=https://your-app.com/mobile-entitlements/google MOBILE_ENTITLEMENTS_GOOGLE_PUSH_SERVICE_ACCOUNT_EMAIL=pubsub-push@your-project.iam.gserviceaccount.com
Purchases by licence testers (Google reports them as test purchases) are recorded in
store_transactions with a note but grant nothing, and /sync rejects them, unless
google.accept_test_purchases (MOBILE_ENTITLEMENTS_GOOGLE_ACCEPT_TEST_PURCHASES) is true. Turn
it on only on servers used for testing.
If the Play Developer API is down, the endpoint answers 503 so Pub/Sub retries. Tokens Google no
longer knows are recorded and acknowledged so they are not retried forever.
Linking purchases to users
A notification can arrive before your server knows which user made the purchase. Those rows are
stored with user_id null and attached later:
- On
/sync. After a purchase or restore, the app posts the store token to/syncwith the user's API token. The server verifies it with the store, attaches the entitlement to the user, and attaches any earlier anonymous rows of the same subscription. - By account token (optional). If the app passes the user's UUID as Apple
appAccountTokenor GoogleobfuscatedAccountId, setapp_account_token_columnto the users column that holds it. Webhook rows are then attached immediately, and/syncrefuses purchases stamped for another user.
A purchase that is already linked to another user is refused with 409.
With the NativePHP plugin
Paywalls & Purchases for NativePHP does this in one call:
Purchases::syncWithServer() posts the latest purchase to /sync and caches the granted keys.
Point it at this package:
PURCHASES_SERVER_URL=https://your-app.com/mobile-entitlements/sync
From any other client
Send the StoreKit 2 jwsRepresentation (Apple) or the purchase token (Google):
curl -X POST https://your-app.com/mobile-entitlements/sync \ -H "Authorization: Bearer $USER_API_TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"store": "appStore", "token": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlF..."}'
{
"granted": ["pro"],
"entitlements": [
{
"id": 1,
"store": "appStore",
"product_id": "com.example.pro.monthly",
"type": "subscription",
"keys": ["pro"],
"is_active": true,
"expires_at": "2026-11-09T10:00:00+00:00",
"will_renew": true,
"in_grace_period": false,
"is_trial": false,
"environment": "production"
}
]
}
Body fields: store (appStore or googlePlay), token, and optionally productId,
appAccountToken, platform. For Google one-time products, include productId: it is only used
to look the purchase up, and Google rejects a token sent with the wrong product. Responses:
422 when the store does not confirm the purchase, 409 when it belongs to another user, 503
when the store is unreachable, 429 above the rate limit.
Checking access
$user->hasEntitlement('pro'); // bool $user->entitlement('pro'); // ?Entitlement, the active one with the latest expiry $user->entitlements; // every row $user->activeEntitlements()->get(); // rows that grant access right now Route::middleware(['auth:sanctum', 'entitled:pro'])->group(function () { // 402 JSON when the user lacks the entitlement }); Route::middleware('entitled:pro,lifetime')->get(...); // any of several keys
Set middleware.redirect_to to send browser requests to a pricing page instead of a 402.
An entitlement grants access while it is active, not revoked, and either has no expiry
(non-consumables), has not expired, or is in a billing grace period. During Apple's grace period,
expires_at is the end of the grace period.
The facade offers the same checks plus verification:
use Vipertecpro\MobileEntitlements\Enums\Store; use Vipertecpro\MobileEntitlements\Facades\MobileEntitlements; MobileEntitlements::verify(Store::AppStore, $jws); // VerifiedPurchase, nothing stored MobileEntitlements::grantFromToken($user, Store::GooglePlay, $token); MobileEntitlements::reconcile($user);
Events
| Event | When |
|---|---|
EntitlementGranted($entitlement) |
A row starts granting access (new purchase, resubscribe, recovery). |
EntitlementChanged($entitlement, $cause) |
Anything else changed (renewal, auto-renew toggled, linked to a user, superseded by an upgrade). |
EntitlementRevoked($entitlement, $cause) |
A row stops granting access (expiry, refund, revoke, account hold, pause). |
StoreNotificationReceived($store, $type, $payload) |
Every verified notification before it is applied, for logging. |
$cause is the lower-case notification type (did_renew, refund, subscription_on_hold, ...),
or sync, reconcile, linked, superseded, voided_purchase. Events are dispatched after the
database transaction commits.
Reconciling
Webhooks get lost. Schedule the reconcile command to re-ask the stores about rows that have expired while still marked active, or that have not been refreshed recently:
// routes/console.php Schedule::command('mobile-entitlements:reconcile')->hourly();
php artisan mobile-entitlements:reconcile --stale=24h php artisan mobile-entitlements:reconcile --user=42
Google rows are refreshed from the Play Developer API. Apple rows are refreshed from the App Store
Server API when verify_with_server_api is on; otherwise their stored expiry is applied.
Revenue report
Counts from the entitlements table, by store:
php artisan mobile-entitlements:report # last 30 days, as a table
php artisan mobile-entitlements:report --days=7 --json
| Metric | Meaning |
|---|---|
active_subscribers |
Subscriptions that grant access now (grace period included) |
in_trial |
Active subscriptions in a free trial |
new_subscriptions |
Subscriptions first seen in the period |
churned |
Subscriptions without access now that expired or were revoked in the period |
refunds |
Apple refunds and Google voided purchases in the period, any product type |
one_time_unlocks_sold |
Non-consumables first seen in the period |
consumables_sold |
Total quantity of consumables first seen in the period |
Sandbox rows are left out. "First seen" is when the package created the row, from a webhook or
/sync, so purchases made before you installed the package are not counted as new.
The report never invents amounts. To add estimated_mrr, list a monthly amount per subscription
product id, all in one currency:
'prices' => [ 'com.example.pro.monthly' => 9.99, 'com.example.pro.yearly' => 99.99 / 12, 'pro_monthly' => 9.99, ],
estimated_mrr is the sum of those amounts over active subscribers who are not in a trial. It is
before store commission and tax, and ignores price changes and introductory prices. Active
subscribers whose product has no price are counted in unpriced_subscribers.
Summary endpoint
GET /mobile-entitlements/summary?days=30 returns the same JSON, for a dashboard:
{
"period": {"days": 30, "from": "2026-09-08T10:00:00+00:00", "to": "2026-10-08T10:00:00+00:00"},
"stores": {
"appStore": {"active_subscribers": 120, "in_trial": 14, "new_subscriptions": 31, "churned": 9, "refunds": 1, "one_time_unlocks_sold": 4, "consumables_sold": 0},
"googlePlay": {"active_subscribers": 75, "in_trial": 6, "new_subscriptions": 18, "churned": 5, "refunds": 0, "one_time_unlocks_sold": 2, "consumables_sold": 0}
},
"total": {"active_subscribers": 195, "in_trial": 20, "new_subscriptions": 49, "churned": 14, "refunds": 1, "one_time_unlocks_sold": 6, "consumables_sold": 0}
}
It is guarded by the viewMobileEntitlementsSummary Gate. Define it in your app; until you do,
every request gets 403:
// app/Providers/AppServiceProvider.php use Illuminate\Support\Facades\Gate; public function boot(): void { Gate::define('viewMobileEntitlementsSummary', fn ($user): bool => $user->is_admin); }
Change middleware.summary to use another guard or your own middleware.
Promotional offer signatures
StoreKit 2 promotional offers need a JWS signed on your server. The package signs it as described in Apple's Generating JWS to sign App Store requests.
- In App Store Connect open Users and Access → Integrations → In-App Purchase and generate a key. Apple expects an In-App Purchase key here, not an App Store Connect API key.
- Configure it:
MOBILE_ENTITLEMENTS_APPLE_PROMO_OFFERS=true MOBILE_ENTITLEMENTS_APPLE_ISSUER_ID=57246542-96fe-1a63-e053-0824d011072a MOBILE_ENTITLEMENTS_APPLE_PROMO_KEY_ID=2X9R4HXF34 MOBILE_ENTITLEMENTS_APPLE_PROMO_PRIVATE_KEY=/path/to/SubscriptionKey_2X9R4HXF34.p8
If promo_key_id and promo_private_key are empty, the App Store Server API key (key_id,
private_key) is used. That key is also an In-App Purchase key, so one key can do both.
The app asks for a signature (same authentication as /sync):
curl -X POST https://your-app.com/mobile-entitlements/promo-signature \ -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \ -d productId=com.example.pro.monthly -d offerId=winback_50 -d transactionId=2000000000000001
{"signature": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IjJYOVI0SFhGMzQifQ..."}
and passes it to Product.PurchaseOption.promotionalOffer(offerID, compactJWS: signature).
The JWS header is {"alg": "ES256", "kid": "<key id>", "typ": "JWT"} and the claims are iss,
iat, aud: "promotional-offer", bid, a fresh nonce (UUID), productId, offerIdentifier
and, when sent, transactionId (any transaction of the customer, or their appTransactionID;
Apple recommends it). There is no exp: Apple rejects tokens that carry one and enforces the
expiry from iat, so request the signature right before the purchase. Apple's claim set has no
appAccountToken; set it on the purchase with .appAccountToken(...) in the app instead.
Without further checks any signed-in user can get any offer signed. To decide who gets which offer, define this Gate; when it exists, denied requests get 403:
Gate::define('redeemMobileEntitlementsPromoOffer', function ($user, string $productId, string $offerId): bool { return $offerId !== 'winback_50' || $user->entitlements()->where('product_id', $productId)->exists(); });
With promo_offers off (the default) the endpoint answers 403. Without a key it answers 503.
Testing with fakes
MobileEntitlements::fake() replaces store verification with purchases you define. Everything
else (rows, linking, events, the /sync endpoint) runs for real:
use Vipertecpro\MobileEntitlements\Enums\Store; use Vipertecpro\MobileEntitlements\Facades\MobileEntitlements; use Vipertecpro\MobileEntitlements\Support\VerifiedPurchase; $fake = MobileEntitlements::fake([ 'test-token' => VerifiedPurchase::fake([ 'store' => Store::GooglePlay, 'productId' => 'pro_monthly', ]), ]); $this->actingAs($user) ->postJson('/mobile-entitlements/sync', ['store' => 'googlePlay', 'token' => 'test-token']) ->assertOk() ->assertJson(['granted' => ['pro']]); $fake->assertVerified('test-token');
Unknown tokens fail verification, like a real store would.
Security notes
- Never trust client product ids.
/syncstores what the store reports. A client cannot upgrade itself by sending a differentproductId. - Keep the root certificate. The package ships Apple Root CA - G3 and checks its fingerprint in
its own test suite.
apple.root_certificatesexists for tests; do not add other roots in production. - Sandbox. Keep
accept_sandboxoff in production. TestFlight and App Review purchases use the sandbox, so enable it on the server those builds talk to. - Rotate keys. Rotate the App Store Connect key and the Google service-account key from time to
time, and keep them out of version control. Purchase tokens are stored encrypted with your
APP_KEY; keep old keys inAPP_PREVIOUS_KEYSwhen rotating it. - Fail closed. Without
apple.bundle_id,google.package_nameorgoogle.push_audienceconfigured, the matching webhook rejects every request.
Licence
MIT. See LICENSE.
Apple, App Store and StoreKit are trademarks of Apple Inc. Google Play and Google Cloud Pub/Sub are trademarks of Google LLC. NativePHP is a trademark of its respective owner. This package is an independent project and is not affiliated with, endorsed or sponsored by Apple, Google or NativePHP.
The companion NativePHP plugin, Paywalls & Purchases for NativePHP, is sold on vipertecpro.com.