A mobile app should not decide on the device what a user paid for, because the device is the one place the user controls. The reliable pattern is to verify each purchase on your server, record the result in an entitlements table, and keep that table current with the notifications Apple and Google send you. The app then asks your server what the user may do, instead of reading its own local state.
This article covers why on-device checks fail, what the server needs to store, and how to handle the awkward cases: grace periods, refunds, and family sharing.
Why is an on-device entitlement check not enough?
StoreKit on iOS and Google Play Billing on Android both give the app a way to learn what the user bought. Both are fine as a first signal. Neither is fit to be the final authority, for four reasons.
The device can be tampered with. On a jailbroken or rooted device, or with a modified build, a local check such as if hasPremium can be flipped. Anything that gates real cost to you, such as AI calls, storage, or paid content served from your backend, needs a check the user cannot edit.
The device is only one of several clients. Many subscriptions are used on an iPhone, an iPad, a web app, and sometimes a second Android device. If each client works out “is this user premium?” on its own, they will disagree. A server-side record gives all of them one answer.
The device is often offline or stale. A subscription can renew, lapse, or be refunded while the app is not running. The app finds out only the next time it syncs, if at all.
Your backend needs to know too. If a user is on the free plan, your API should refuse the premium endpoint. It cannot do that by trusting a flag the client sends.
None of this means you should skip the local check. Use it to make the UI feel instant. Just treat it as a cache of the server’s answer, never as the source of it.
What does server-side verification look like?
The flow has three steps.
- The user buys a subscription. The store hands the app a signed proof of the transaction.
- The app sends that proof to your server.
- Your server verifies it with the store, then writes or updates the user’s entitlement.
On iOS, StoreKit 2 represents a purchase as a signed transaction. It is a JSON Web Signature, which your server can validate against Apple’s certificate chain, or look up through the App Store Server API. On Android, the app sends a purchase token to your server, which checks it with the Google Play Developer API. The exact calls and field names change between versions, so follow Apple’s StoreKit and App Store Server API documentation and Google’s Play Billing documentation for the current shape.
The point is not which call you use. The point is that the decision “this purchase is real and belongs to this user” is made by your server, after talking to the store, and recorded in your database.
Why are store notifications the source of truth?
Verification at purchase time only tells you the state at that moment. Subscriptions change afterwards: they renew, fail to renew, get cancelled, get refunded, or move into a billing retry period. You find out about these changes in two ways: by polling, or by listening.
Apple offers App Store Server Notifications. You register an endpoint, and Apple posts a signed message when something changes, such as a renewal, an expiry, a refund, or a change of plan. Google offers Real-time developer notifications, delivered through a Google Cloud Pub/Sub topic. Each message tells you that something changed for a purchase token, and you then fetch the current state from the Google Play Developer API.
Design your handler around three rules.
Verify before you trust. Apple’s notifications are signed, and you should validate the signature. For Google, treat the Pub/Sub message as a hint and read the real state from the API.
Be idempotent. Stores retry delivery, and messages can arrive twice or out of order. Store a notification identifier and ignore repeats. When two messages disagree, prefer the one that describes the later state, using the dates in the payload, not the arrival order.
Return quickly. Write the raw event to a table, acknowledge it, and process it in a background job. If your handler is slow or fails, the store will retry, which makes duplicates more likely.
A reconciliation job that periodically re-checks active subscriptions against the store is a sensible backstop. Notifications can be missed during an outage on your side, and the job closes the gap.
How should you design the entitlements table?
Keep two ideas apart: what the user bought, and what the user may do.
A purchase record mirrors the store. An entitlement is your own concept, such as pro, that your code checks. One purchase can grant one entitlement, or several. Two stores can grant the same entitlement to the same user.
Here is a minimal PostgreSQL sketch.
create table store_transactions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users (id),
store text not null check (store in ('apple', 'google')),
original_transaction text not null, -- stable id across renewals
product_id text not null,
status text not null, -- active, grace, retry, expired, refunded, revoked
expires_at timestamptz,
last_event_at timestamptz not null,
unique (store, original_transaction)
);
create table entitlements (
user_id uuid not null references users (id),
entitlement text not null, -- e.g. 'pro'
source_id uuid not null references store_transactions (id),
active_until timestamptz,
primary key (user_id, entitlement, source_id)
);
create table store_events (
id text primary key, -- the store's notification id
received_at timestamptz not null default now(),
payload jsonb not null,
processed_at timestamptz
);
A few choices here are worth explaining.
The original_transaction column is the identifier that stays the same across renewals of one subscription. Using it as the natural key means a renewal updates a row instead of inserting a new one.
The store_events table gives you idempotency for free, because the primary key rejects a replayed notification. It also gives you an audit trail, which you will want the first time a customer writes in to say they paid and were not upgraded.
Keeping entitlements separate from store_transactions lets you grant access for reasons that are not purchases: a support gift, a promotion, a beta tester. Your app code checks entitlements and never needs to know where they came from.
The query your API runs on every protected request should be small and indexed.
select exists (
select 1 from entitlements
where user_id = $1
and entitlement = 'pro'
and (active_until is null or active_until > now())
);
How do you link a purchase to a user?
The store knows an Apple ID or a Google account, not your user. You have to bind them yourself.
On iOS, StoreKit lets you attach an app account token, a UUID you choose, to a purchase. Set it to a value that maps to your user id, and the signed transaction will carry it back to you. Google Play Billing has an equivalent: an obfuscated account identifier you can set at purchase time (see the Play Billing integration guide). Check the current documentation for the exact parameter names.
If the user buys before signing in, you have a harder case. Hold the purchase proof, ask the user to sign in or create an account, then claim the transaction for that account. Decide in advance what happens if the same transaction is claimed by a second account. The usual answer is that the first claim wins and the second is refused, with a clear message and a way to contact support.
What about grace periods and billing retries?
When a renewal payment fails, the subscription does not always end at once. Both stores can keep trying to collect the money, and each offers an optional grace period (Google describes the subscription lifecycle) during which the user keeps access while the retry runs.
Your table should reflect that. Add a grace status, and decide what active_until means in that state. A common choice is to extend access until the end of the grace window reported by the store, and to show the user a gentle prompt to update their payment method. If you ignore this, users with an expired card lose access during a window the store itself says they should keep it, and they churn for a reason that is not their choice.
Let the notifications drive each state change: a notification that billing failed moves the row to retry or grace, and a later renewal notification moves it back to active. Do not compute these transitions from timestamps alone.
How do you handle refunds and revocations?
A refund is a state change like any other, but it is the one most likely to be mishandled, because nothing in the app tells the user it happened.
Apple sends a refund notification when it refunds a transaction. Google reports voided purchases through its notifications and through a dedicated API for voided purchases. In both cases, your handler should mark the transaction refunded, remove or end the entitlement it granted, and record the time.
Think about what “remove” means for your product. If the entitlement unlocked a consumable, such as credits already spent, you may not be able to take it back, and you may choose to let the balance go negative or to block further purchases of credits. Make that rule explicit in code, not an accident of whichever branch runs.
What are the caveats with family sharing?
Family Sharing on Apple lets one purchase benefit several people, and Google Play has its own family features, so check its current documentation for which product types they cover. Either way, the assumption that one transaction maps to one user.
With Apple, a shared transaction carries information that tells you the ownership type: purchased by the account holder or shared through family. Your server should decide whether to grant the entitlement to family members at all, since that is a product decision, then map each member to their own user row. Each member gets their own entitlement row pointing at the same source transaction. When the purchaser leaves the family or cancels, the notifications tell you, and you end every row tied to that source.
If you do not support sharing, say so in your handler: check the ownership type and refuse to grant. Doing nothing is a choice too, and it is rarely the one you intended.
Where can a third-party service help?
Services such as RevenueCat implement much of this flow for you: receipt validation, notification handling, and a unified entitlement view. They are a reasonable way to avoid writing the code above. Whichever route you take, the principle holds. The final yes or no on “can this user do this?” lives on a server you can query, and your own backend should be able to read it without asking the device.
How does this relate to Edict?
Edict is a mobile backend in development, and its planned subscriptions module is designed around the pattern in this article: store purchases verified on the server, entitlements checked by the backend, and the rules declared in configuration. The planned payments and credits module would apply the same verified-transaction approach to credit purchases. Nothing here is available yet, and the design may change.
Two example apps show where this matters. The AI photo and content tools example spends credits and plan allowances, and the AI assistants and companion apps example gates a paid chat feature. For an Expo-specific walkthrough, read backend for an Expo app: auth, subscriptions and push. For related reading, see how to test subscription states before a real purchase and how to give each plan its own AI quota.
Key takeaways
- Treat the device’s view of a purchase as a cache, never as the authority.
- Verify every purchase on your server by talking to Apple or Google, then write the result to your database.
- Use App Store Server Notifications and Google Play Real-time developer notifications to keep state current, and make the handler idempotent.
- Separate store transactions from entitlements, so you can grant access for non-purchase reasons.
- Model grace periods, billing retry, refunds, and family sharing as explicit states, driven by notifications.
- Run a reconciliation job to catch missed events.