Offline and no-backend use
iap's default shape is library → your backend → Attesto, and that is still the shape to reach for. This page is about the two cases where it is not available: an app with no server at all, and an app with a server that is temporarily unreachable.
They are different problems with different answers, so they are separated below.
No backend at all
Set verification instead of backend:
import { createIAP } from '@nosslabs/iap';
const iap = createIAP({
products: [{ id: 'household_yearly', type: 'subscription', androidPlanId: 'yearly' }],
verification: {
mode: 'local',
// iap cannot know what a product entitles anyone to, and there is no
// backend to ask — so you say.
entitlements: (purchase) => [
{ key: 'household', productId: purchase.productId, expiresAt: purchase.expiresAt },
],
// Optional: every purchase must carry this binding, or it is refused.
// A function, so it follows the session — see below.
appUserId: () => session.householdId,
},
});Exactly one of backend or verification is required. Both is a config error — they are two answers to the same question with no rule for which wins — and neither leaves nothing to gate an entitlement on.
What stays the same
All of it, which is the point. Local verification is implemented as a BackendAdapter, so everything above that boundary is untouched:
- The purchase flow, including acknowledge only after verification succeeds. A purchase whose local check fails is never acknowledged — on any of the four paths that settle one (a purchase, a restore, launch recovery, or a transaction arriving on its own) — so the store keeps it and it is retried. That is the same guarantee the backend path gives, and the same protection against Play's 3-day auto-refund.
- The at-least-once write to the unfinished-transaction store before verifying, so an app that dies mid-purchase recovers.
restorePurchases(), the event stream, the entitlement cache,refreshOnResume.
There is no second purchase path to keep in step. refresh() and restorePurchases() both answer "what does this store account own", by asking the store — which is why they keep working with no network at all.
An empty answer from the store is not a revocation
Play resolves its owned-purchases query with an empty array when the query fails — a disconnected billing service, a signed-out account, a non-OK response code — rather than rejecting. So "owns nothing" and "could not tell you" look identical.
Local mode therefore keeps the current entitlements when the store reports nothing owned, rather than treating it as authoritative. A downgrade needs a positive signal: an expiry, or a transaction the store reports as revoked. On iOS a refund is such a signal (Transaction.all keeps revoked transactions, and iap refuses them); on Android a refunded purchase simply disappears, which is indistinguishable from the failure above — so refund detection there needs the server API.
Binding purchases to an account
verification.appUserId is the binding every purchase must carry, matching purchase({ appUserId }). It takes either the id or a function returning it, and the function form is the one to reach for in any app where the signed-in user can change:
verification: {
mode: 'local',
entitlements: entitlementsFor,
// Read at the time of each check.
appUserId: () => session.householdId,
}A fixed string is captured at createIAP() time and cannot track a login change. After a switch it is still the previous user's, so every purchase made afterwards is refused as a mismatch — and the only recovery is to tear the instance down and build a new one.
Returning null is not the same as omitting it
null means "nobody is signed in right now". iap then refuses purchases and restores (BINDING_UNAVAILABLE) rather than falling back to an unguarded check: treating a signed-out session as "this app uses no binding" would accept a purchase bound to someone else, silently, which is the one failure a binding exists to prevent. The purchase stays unacknowledged, so the store keeps it and it is retried once someone is signed in.
A refresh in that state is the exception: it keeps the current entitlements rather than revoking them. A multi-user app restores its session asynchronously, so a refresh can land while the binding is unresolved, and "I cannot tell you" must never read as "you own nothing" — the same asymmetry that governs an empty answer from the store.
Omit appUserId entirely if the app does not bind purchases. A getter that throws is treated exactly like one returning null.
What the device can actually check
Four things, and it is worth knowing it is only four:
- The product is one your app sells (it is in
products). - The purchase carries the expected binding, when you set
verification.appUserId. - On Android, the store reports the purchase as purchased rather than awaiting clearance.
- A subscription has not expired, by the device clock.
How strong the answer is, per platform
Every verified purchase carries an assurance, because the two platforms genuinely differ and an app deciding what to unlock deserves to know which it got:
assurance | What it means | |
|---|---|---|
| iOS | store-signed | StoreKit 2 verified Apple's signature over the transaction in native code before it reached the WebView. The native layer resolves a purchase only for a verified result and rejects anything else. A cryptographic guarantee, made by the platform. |
| Android | store-reported | No cryptographic check is possible on the device. The native layer exposes neither Play's purchase signature nor the original JSON it is computed over, so this means "Play told this device it owns this". The store's word — which is a real answer, and the same one Play's own client APIs give, but not proof. |
What this cannot do
Neither level resists a device that is lying. A rooted device can patch the store client, and the device clock can be set back to extend a subscription. On Android there is additionally no signature to check at all.
So: local mode is right for an app with no server, where the alternative is no verification at all. It is not a substitute for server-side validation where being wrong is expensive — where a single fraudulent unlock costs real money, or where you need refund detection, which needs the store's server API. If you have a backend, use it.
Do not map a consumable to a standing entitlement
A consumable is consumed once verified, which releases it so the user can buy it again — and in Play Billing 8 a consumed purchase is gone from the owned set for good. Local mode re-derives entitlements from that set on every refresh, so an entitlement your mapper returned for a consumable disappears at the next one.
That is the right shape: a consumable's effect — coins added, a hint spent — belongs in your own records, not in something iap re-derives. LocallyVerifiedPurchase.productType is there so your mapper can tell. iOS makes the same point from the other side: its owned set retains finished consumables, so a mapper that grants on every verified purchase would re-grant the same coins forever.
Reusing the checks in your own adapter
If you would rather write your own BackendAdapter — to add your own rules, or to fall back to a server when one is reachable — the same checks are exported:
import { verifyPurchaseOnDevice } from '@nosslabs/iap';
const result = verifyPurchaseOnDevice(transaction, {
productIds: ['household_yearly'],
appUserId: householdBinding,
});
if (result.valid) {
// result.purchase.assurance, .purchaseReference, .expiresAt, .subscription
}Offline with a backend
Nothing to configure: this is how iap already behaves. Worth stating as guarantees, because they are the answers to "what happens on the train".
A failed refresh never downgrades anyone
The only things that take an entitlement away are an authoritative negative and an expiry. A network error, a timeout, a 500, a DNS failure — none of them change the cached entitlements. refresh() throws and the previous state stands.
This is deliberate, and it is the asymmetry to hold on to: wrongly locking out a paying user is a support ticket and a refund, while wrongly serving a lapsed one costs a few hours of access. iap errs towards the second.
A corrupt or unreadable cache is likewise treated as empty rather than fatal — a bad cache never blocks startup.
Expiry is enforced on the device clock
hasEntitlement(), getEntitlement() and getEntitlements() return only active entitlements: ones whose expiresAt has not passed. This needs no network, so a subscription that lapses while the app is backgrounded stops granting access the moment it lapses, not at the next successful refresh.
The device clock is the only clock available offline, and it can be wrong. That is the accepted cost; a backend is the fix if it matters.
A grace window, if you want one
options: {
// Two hours past expiresAt still counts.
entitlementGraceMs: 2 * 60 * 60 * 1000,
}Defaults to 0 — the strict reading. Set it because a renewal is not instantaneous: a subscription that renews at midnight may not have a fresh receipt on the device for minutes or hours, and locking the user out in that window is the worse error.
Store-side grace periods and billing retry can run much longer. On Android those are server-side only — the native layer cannot see them — so a backendless Android app has no way to know it is in one, and this window is the only cushion it has.
Knowing how stale the answer is
const verifiedAt = iap.getEntitlementsVerifiedAt(); // ISO 8601, or nullWhen the entitlements were last confirmed by the verification source. iap deliberately does not revoke on staleness, however old that is — see the asymmetry above — so this is exposed for an app that wants to be stricter than iap is, or simply to show "last checked".
Privacy
The privacy guarantees are mode-independent and live in one place: Safety guarantees § Privacy. In short — no telemetry, no device identifiers, no generated user id, and only three keys stored.
Worth repeating here because it bears on running without a server: in local mode iap makes no network request at all, so there is nothing to opt out of.
Erasing everything
await iap.reset();Wipes the three keys iap owns — the entitlement cache, the unfinished-transaction queue and the pending-purchase marks — and then clears the configured storage namespace, to catch anything an older version wrote. For a "delete everything" control.
If you pass a custom storage adapter
That last step calls clear() on your adapter, so it wipes whatever scope your adapter covers. If you share one adapter between iap and your own app data, give iap its own namespace or its own adapter.
Two things it is not:
- Not
destroy(). That tears the instance down and deliberately keeps the cache, so the next instance sees the user's entitlements immediately.reset()is the opposite: the instance stays usable, and arefresh()repopulates from the verification source. - Not a revocation. The store still owns the purchase, because the user paid for it, and a restore will bring the entitlement back.
reset()erases iap's record, not the store's.
Best-effort on storage: a failure to clear one key is logged and the rest is still cleared.
Two cases where it does not simply wipe:
- A purchase in flight throws
ALREADY_IN_PROGRESS. That purchase would re-populate the state and re-persist the cache moments after the wipe, and leave its unfinished entry behind — so the erase would look like it had silently failed. Await the purchase, then reset. - After
destroy()it does nothing, rather than erroring. A teardown racing a "delete my data" tap is a real sequence, not a programming mistake.