Offline Entitlements
Starting with v13.18.0, the plugin ships CdvPurchase.OfflineEntitlements, a helper class that answers "is this product owned?" when the device is offline or has just restarted without connectivity.
It persists a subset of each VerifiedPurchase to device storage on every verified event, reloads it at app launch, and exposes isOwned() against that persisted cache. store.owned() itself is unchanged — it still checks the in-memory verified and local receipts. OfflineEntitlements adds a parallel check, so you can combine both:
const offline = new CdvPurchase.OfflineEntitlements(store, {
gracePeriodMs: 30 * 24 * 60 * 60 * 1000, // 30 days
onExpiredOffline: 'readonly',
detectClockRollback: true,
});
await offline.ready();
function isUserPremium() {
return store.owned('premium') || offline.isOwned('premium');
}
Surviving a Validator or Store Outage
With a validator configured, store.verifiedReceipts is kept in memory only. A validation error mid-session leaves it alone (see the transient flag on unverified), but an app restarted while validation fails starts with nothing owned. The plugin doesn't persist anything by default, so use OfflineEntitlements for this case if you accept the trade-off below. store.owned() never grants a purchase past its expiryDate. offline.isOwned() is looser: it adds gracePeriodMs, and with the default onExpiredOffline: 'readonly' it keeps granting access after the grace period while the device stays offline. Set onExpiredOffline: 'deny' if access must end with the grace period.
Security Boundary: Phase 1 Is an Unsigned Cache
Phase 1 persists entitlement data with no signature, no JWT, and no cryptography. The cache is a convenience for UI gating while offline, not proof of purchase. A user with access to the device's storage can tamper with it. Do not use OfflineEntitlements as the sole gate for high-value content or server-side entitlements — always confirm with receipt validation (store.owned() / your validator) once connectivity is back.
Retrieving Entitlement Details
Use find() to retrieve the persisted entitlement for a product — expiry date, renewal intent, last renewal date, etc. — without digging into local storage by hand:
const entitlement = offline.find('premium');
if (entitlement?.expiryDate) {
const daysLeft = Math.ceil((entitlement.expiryDate - Date.now()) / 86400000);
showRenewalBanner(daysLeft);
}
Options
| Option | Description |
|---|---|
storage | Pluggable OfflineStorageAdapter (defaults to a localStorage wrapper). For long-offline deployments (weeks without connectivity), pass a file-based or secure-storage adapter — localStorage can be evicted by the WebView under storage pressure. |
gracePeriodMs | Grace window after a subscription's expiryDate during which it is still considered owned (default 30 days). |
onExpiredOffline | Behavior when the grace period has elapsed and the device is still offline: 'readonly' keeps granting access (default), 'deny' revokes it. |
detectClockRollback | If true, deny access when the persisted lastSeenTimestamp is in the future relative to now — clock tampering (default false). |
Events
Subscribe with offline.onEvent(event => ...) to be notified of state transitions:
| Event | When It Fires |
|---|---|
grace | A subscription is past expiryDate but still within the grace period |
readonly | Grace period elapsed while offline, access kept (onExpiredOffline: 'readonly') |
clock_rollback | Clock tampering detected (persisted timestamp in the future) |
entitlement_missing | No persisted entitlement for a queried product |
expired | An entitlement expired and access was revoked |
Events are deduplicated per productId.
Clearing Persisted Data
offline.clear() removes all persisted entitlements — call it on user logout.
Platform Compatibility
OfflineEntitlements is a TypeScript-only helper with no native code. The StoreKit 2, Braintree, and Apple Pay companion plugins are unaffected and require no version bump.