Types
Domain types exported from @nosslabs/iap.
EntitlementBase
interface EntitlementBase {
/** Logical identifier — e.g. 'premium', 'remove_ads', 'pro_features' */
key: string;
/** Native store productId that granted this entitlement */
productId: string;
/** ISO 8601 expiry; null for non-expiring (one-time non-consumable) */
expiresAt: string | null;
}The minimal shape every entitlement must have. Validated at runtime via zod when the backend response arrives.
Extend with your own fields:
interface AppEntitlement extends EntitlementBase {
tier: 'basic' | 'pro';
features?: string[];
trialEndsAt?: string;
}Custom fields are NOT validated — your backend is trusted to return them correctly.
DefaultEntitlement
type DefaultEntitlement = EntitlementBase;Alias used when consumers don't specify a TEntitlement generic. Identical to EntitlementBase.
Product
type ProductType = 'subscription' | 'product' | 'consumable';
interface Product {
id: string;
type: ProductType;
title: string;
description: string;
/** Localized price string, e.g. "$4.99". MUST be rendered as-is. */
priceString: string;
/** Price in micros, BigInt as string to avoid precision loss. */
priceMicros: string;
/** ISO 4217 currency code, e.g. "USD". */
currency: string;
// ----- Subscription detail. null / empty for non-subscriptions. -----
/** Billing period as an ISO 8601 duration, e.g. "P1M". iOS only. */
subscriptionPeriod?: string | null;
/** Subscription group. iOS only. */
subscriptionGroupId?: string | null;
/** The introductory offer, or null when there is none. */
introductoryOffer?: SubscriptionOffer | null;
/** Eligible for the introductory offer? iOS only; null when unknown. */
introductoryOfferEligible?: boolean | null;
/** Promotional and win-back offers. Empty when there are none. */
promotionalOffers?: SubscriptionOffer[];
/** Android: Play's offer token and offer id, for identification only. */
androidOfferToken?: string | null;
androidOfferId?: string | null;
}Returned by iap.getProducts(). The combination of priceMicros + currency gives you precise math without floating-point issues; priceString is what you display.
subscriptionPeriod and subscriptionGroupId are iOS only and null on Android — the native plugin collapses Play's pricing phases before exposing them and never surfaces a base plan's billing period. Where you need the cadence on both platforms, take it from your own product configuration: you chose the base plan's period in Play Console.
SubscriptionOffer
type SubscriptionOfferType = 'introductory' | 'promotional' | 'win-back' | 'unknown';
type SubscriptionOfferPayment =
| 'free-trial' // no charge for the offer period
| 'pay-up-front' // one discounted charge for the whole offer period
| 'pay-as-you-go' // a discounted charge each period, periodCount times
| 'unknown';
interface SubscriptionOffer {
/** The store's id for the offer; null when the store does not name it. */
id: string | null;
type: SubscriptionOfferType;
payment: SubscriptionOfferPayment;
/** Localized, e.g. "$0.00". Render as-is. */
priceString: string;
priceMicros: string;
currency: string;
/** Offer period as an ISO 8601 duration, e.g. "P1W". */
period: string | null;
/** How many periods the offer runs for. 0 when the store did not say. */
periodCount: number;
}Build paywall copy from these fields rather than hardcoding — priceString is the store's own localized string, in the store's currency and format.
Eligibility: iOS answers for the introductory offer, nothing else does
An offer appearing here means this product has one, which is not the same as this user getting it.
Product.introductoryOfferEligible answers it for the introductory offer on iOS, from StoreKit's isEligibleForIntroOffer: true, false, or null when the device cannot say. StoreKit answers per account and per subscription group, so every product in one group reports the same value — taking any introductory offer in a group spends it for all of them.
It is null everywhere else: always on Android, which has no such query on the phone (Play filters offers the account cannot take out of what it returns, which helps but is not an answer); for a product with no introductory offer; and when StoreKit did not say. promotionalOffers has no eligibility signal on either platform. See The native layer.
Treat true as what the paywall should show now, not as a decision made: it is a snapshot from when getProducts() ran, and a refund or a purchase on another device changes it. The store's own purchase sheet applies eligibility at purchase time and remains the authority on what is charged. Where the answer is null, write copy that reads correctly either way — "7-day free trial for new subscribers" rather than "your 7 days free" — or confirm eligibility server-side.
ConfiguredProduct
interface ConfiguredProduct {
id: string;
type: ProductType;
/**
* Optional. Set for Android multi-plan subscriptions to disambiguate which
* base plan to purchase. iOS ignores it. Single-plan Android subscriptions
* and iOS-only configs may omit it.
*/
androidPlanId?: string;
}The shape of a single SKU manifest entry. Consumers either pass an array of these to createIAP({ products: [...] }), or have their backend return them via BackendAdapter.listProducts() — see Backend contract → products.
Storefront
interface Storefront {
/** ISO 3166-1 alpha-2 (normalized), e.g. "US". Falls back to the uppercased
* raw value if an unrecognized code can't be mapped. */
countryCode: string;
/** Raw native value (whitespace-trimmed): alpha-3 on iOS ("USA"), alpha-2 on
* Android ("US"). */
countryCodeRaw: string;
/** Apple-defined storefront identifier (iOS only); undefined on Android. */
storefrontId?: string;
/** Store the value came from. */
platform: 'apple' | 'google';
}Returned by iap.getStorefront(). countryCode is normalized to a single scheme (alpha-2) so you compare one consistent value across platforms; countryCodeRaw preserves the native format. Read it live (don't cache) and treat it as a UX/targeting hint — for compliance or entitlement decisions, trust the server-side signed storefront.
NativeTransaction
type Platform = 'apple' | 'google';
interface NativeTransaction {
platform: Platform;
productId: string;
/**
* Apple: StoreKit 2 transactionId (numeric string) — CHANGES each renewal.
* Google: Play Billing purchaseToken.
*/
token: string;
/**
* The stable reference to the purchase, which survives renewals.
* Apple: originalTransactionId. Google: the purchaseToken (== token).
*/
purchaseReference: string;
/** Google only — required by Attesto's /v1/google/verify */
packageName?: string;
productType: ProductType;
/** The binding the purchase was made with; null when it carried none. */
appUserId?: string | null;
/** Renewal detail for a subscription; null when not reported. */
subscription?: SubscriptionRenewal | null;
/** Plugin-native fields — opaque pass-through */
raw?: unknown;
}The shape the native adapter passes to backend verification. Most consumers never see this directly — it's an internal contract surfaced via BackendAdapter for custom transports.
token vs purchaseReference is the distinction worth internalizing. token is the handle for acknowledging this transaction; on Apple it is a different value after every renewal. purchaseReference is the identity of the purchase itself, so it is what you store if you need to recognise the subscription later — re-minting a token from it months after the purchase, say.
On Google the two are the same value. On Apple, purchaseReference is read from the signed jwsRepresentation payload, because the native plugin does not expose originalTransactionId as a field; it falls back to token when the JWS is absent or unreadable, so it is always set.
SubscriptionRenewal
type SubscriptionState =
| 'subscribed'
| 'expired'
| 'revoked'
| 'in-grace-period'
| 'in-billing-retry'
| 'unknown';
interface SubscriptionRenewal {
state: SubscriptionState | null;
/** false = cancelled but still has access until expiresAt. */
autoRenewing: boolean | null;
/** When the next period begins, ISO 8601. */
renewsAt: string | null;
/** The product it renews into — differs after a pending plan change. */
renewalProductId: string | null;
/** When the current period ends, ISO 8601. */
expiresAt: string | null;
}iap passes these through and does not interpret them. Whether a grace period still grants access is your business rule, and the authoritative answer comes from your backend — iap does not run a subscription state machine (PLAN.md §14).
iOS only, in practice
subscription is null on Android, where the native plugin exposes no subscription state, no renewal date and no auto-renew flag, and always reports cancellation as unknown. On Android this information lives server-side in the Google Play Developer API, so read it from your backend. See The native layer.
VerifiedTransaction
interface VerifiedTransaction<TExtra = Record<string, unknown>> {
id: string;
productId: string;
expiresAt: string | null;
verifiedAt: string;
raw?: TExtra;
}What your backend returns inside a verify* success response. Generic over TExtra so backend-specific extra fields can be typed without any.
Surfaced to consumers via:
purchase-successevent payloadPurchaseResultsuccessvariant
PurchaseOptions
interface PurchaseOptions {
productId: string;
appUserId?: AppUserId;
/** Android one-time products only; refused for a subscription. */
androidOfferToken?: string;
}Argument shape for iap.purchase(opts). Required: productId. Optional: appUserId — see below.
AppUserId
type AppUserId =
| string
| (() => Promise<string>)
| ((ctx: AppUserIdFetcherContext) => Promise<string>);
interface AppUserIdFetcherContext {
authHeaders: Record<string, string>;
}Pre-attach value supplied to iap.purchase({ appUserId }). Travels through StoreKit / Play Billing and surfaces on Attesto's verify response and outbound webhook payload as a top-level appUserId field — lets the integrator's backend join on user identity directly.
Both AppUserId and AppUserIdFetcherContext are exported from the package root, so a separately-defined fetcher can be typed explicitly:
import type { AppUserIdFetcherContext } from '@nosslabs/iap';
const myFetcher = async ({ authHeaders }: AppUserIdFetcherContext) => {
const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders });
return (await r.json()).uuid;
};| Form | Behavior |
|---|---|
string | Validated as an RFC 9562 UUID (any version) then passed to native. Use when the caller already has the UUID (cached locally / app state, or derived deterministically with UUIDv5). |
() => Promise | Zero-arg async fetcher. Invoked once per purchase (no caching by iap — backend owns mint-or-lookup idempotency). Resolved value validated as an RFC 9562 UUID (any version). Wraps any thrown/rejected error as IAPError(APP_USER_ID_FETCH_FAILED, cause: <original>). The fetcher closes over its own auth state. |
(ctx) => Promise | Same semantics as the zero-arg form, plus iap passes ctx.authHeaders populated from backend.getAuthHeaders() (resolved fresh per purchase) so consumers whose UUID-minting endpoint shares auth with their IAP backend can reuse it without redefining a helper. For consumers using a custom BackendAdapter (no getAuthHeaders configured), authHeaders is {}. |
Non-UUID values throw IAPError(INVALID_APP_USER_ID). Apple requires a UUID for appAccountToken; iap enforces the same constraint on Android for cross-platform consistency.
ctx.authHeaders is convenience, not contract. Use it when your UUID-minting endpoint shares auth with your IAP backend; ignore the parameter and close over your own auth state when it doesn't (e.g. a separate identity service with its own auth scheme).
PurchaseResult
type PurchaseResult<T extends EntitlementBase = EntitlementBase> =
| { status: 'success'; productId: string; transaction: VerifiedTransaction; entitlements: T[] }
| { status: 'cancelled'; productId: string }
| { status: 'pending'; productId: string }
| { status: 'verification_failed'; productId: string; error: IAPError }
| { status: 'failed'; productId: string; error: IAPError };The discriminated union returned by iap.purchase(opts). Switch on status to render the right UI without try/catch.
RestoreResult
interface RestoreResult<T extends EntitlementBase = EntitlementBase> {
restored: number;
entitlements: T[];
/** The store's view of every transaction the account owns. */
purchases: StorePurchase[];
}Returned by iap.restorePurchases().
Platform
type Platform = 'apple' | 'google';Native platform identifier. 'apple' covers iOS / iPadOS / macOS Catalyst; 'google' covers Android (Play Billing).
ProductType
type ProductType = 'subscription' | 'product' | 'consumable';'subscription'— auto-renewable subscription. RequiresandroidPlanIdon Android.'product'— non-consumable one-time purchase (Remove Ads, lifetime unlock).'consumable'— consumable one-time purchase (coin packs, hint refills).
AndroidOneTimeOffer
interface AndroidOneTimeOffer {
offerId: string | null;
offerToken: string;
priceString: string;
price: number;
currencyCode: string;
}Reported as Product.androidOneTimeOffers when an Android one-time product has more than one purchase offer — a base price plus, say, a launch discount. Play reports each as its own entry; iap returns one Product per configured id and prices it from the base offer, so this is where the rest live.
offerToken is what purchase({ androidOfferToken }) takes. It is a per-query device token: it belongs to the getProducts() call that returned it and goes stale, so read it fresh rather than persisting one — the store rejects an unknown token and iap surfaces that as INVALID_PURCHASE_OPTIONS.
Absent for a single-offer product, on iOS, and for subscriptions — whose offers are introductoryOffer and promotionalOffers, and which cannot be selected at purchase time at all. See the native-layer page.
LocallyVerifiedPurchase
type PurchaseAssurance = 'store-signed' | 'store-reported';
type LocalVerificationError =
| 'PRODUCT_NOT_CONFIGURED'
| 'BINDING_MISMATCH'
| 'BINDING_UNAVAILABLE'
| 'NOT_PURCHASED'
| 'REVOKED'
| 'SUBSCRIPTION_EXPIRED';
interface LocallyVerifiedPurchase {
productId: string;
platform: Platform;
productType: ProductType;
purchaseReference: string;
appUserId: string | null;
expiresAt: string | null;
subscription: SubscriptionRenewal | null;
assurance: PurchaseAssurance;
}What local verification hands your verification.entitlements mapper. assurance records how strongly the purchase was checked: store-signed on iOS, where StoreKit verified Apple's signature in native code, and store-reported on Android, where no cryptographic check is possible on the device.
BINDING_UNAVAILABLE is the one of these that verifyPurchaseOnDevice never returns: it means the app binds purchases but has no account to bind to right now — a signed-out session, or a verification.appUserId getter that threw — and only the adapter resolving that getter can know. See Binding purchases to an account.
productType is there because it changes what an entitlement means. iOS's owned set retains finished consumables, so a mapper that grants on every verified purchase will re-grant a consumable on every refresh, forever — a consumable's effect belongs in your own records, not in a standing entitlement.
Config types
| Type | Purpose |
|---|---|
IAPConfigInput | What you pass to createIAP (some fields optional, defaults applied) |
IAPConfig | Resolved internal type after parsing (all defaults filled) |
BackendConfigInput / BackendConfig | The backend sub-tree |
StorageConfig | The storage sub-tree |
OptionsConfig | The options sub-tree |
Use IAPConfigInput when typing the object you construct; IAPConfig is rarely needed by consumers.
See also
createIAP— the factory- Errors —
IAPError,IAPErrorCode - Events reference —
EventMapand friends