BackendAdapter
Interface for the backend transport layer. The default HttpBackendAdapter covers HTTP/JSON; implement this interface yourself if your backend uses a different transport (GraphQL, gRPC-web, Firebase Functions, Supabase Edge Functions, etc.).
Interface
interface BackendAdapter<TEntitlement extends EntitlementBase = EntitlementBase> {
verifyApple(req: VerifyAppleRequest): Promise<VerifyResponse<TEntitlement>>;
verifyGoogle(req: VerifyGoogleRequest): Promise<VerifyResponse<TEntitlement>>;
getEntitlements(): Promise<TEntitlement[]>;
restore(req: RestoreRequest): Promise<RestoreResponse<TEntitlement>>;
/** Optional: return the SKU manifest the app should register. */
listProducts?(): Promise<ConfiguredProduct[]>;
/** Optional: verify from the whole transaction, not the extracted fields. */
verifyNative?(tx: NativeTransaction): Promise<VerifyResponse<TEntitlement>>;
/** Optional: the restore counterpart of `verifyNative`. */
restoreNative?(owned: NativeTransaction[]): Promise<RestoreResponse<TEntitlement>>;
}Implementing verifyNative instead of the extracted pair
VerifyAppleRequest and VerifyGoogleRequest carry what a server needs to look a receipt up — a product id and a token. An adapter that answers locally needs more than that: the binding, the subscription state, the expiry. So rather than widen the HTTP contract (and change what goes over the wire), such an adapter implements verifyNative, and iap's router prefers it when present.
restoreNative exists for the same reason, plus one more: building a RestoreRequest requires a packageName on every Google transaction, which an adapter answering locally never reads — so a request it would discard could fail the restore outright.
An adapter is accepted if it has either verifyApple + verifyGoogle + restore, orverifyNative plus one of restore/restoreNative. getEntitlements is always required. So you do not have to stub the extracted pair out to write a local adapter.
The built-in local-verification mode is exactly this shape — see Offline and no-backend use.
Request types
VerifyAppleRequest
interface VerifyAppleRequest {
productId: string;
/** Apple StoreKit transactionId (numeric string) */
transactionId: string;
type: ProductType;
}VerifyGoogleRequest
interface VerifyGoogleRequest {
productId: string;
/** Google Play purchaseToken (long opaque string) */
purchaseToken: string;
/** App package name, e.g. 'com.example.app' */
packageName: string;
type: ProductType;
}RestoreRequest
type RestoreRequestTransaction =
| { platform: 'apple'; productId: string; transactionId: string }
| { platform: 'google'; productId: string; purchaseToken: string; packageName: string };
interface RestoreRequest {
transactions: RestoreRequestTransaction[];
}Response types
VerifyResponse<T>
Used by verifyApple and verifyGoogle.
type VerifyResponse<T extends EntitlementBase = EntitlementBase> =
| {
valid: true;
transaction: { id: string; productId: string; expiresAt?: string | null };
entitlements: T[];
}
| {
valid: false;
error: string; // stable machine-readable code
message?: string; // human-readable detail
};RestoreResponse<T>
Used by restore. Identical to VerifyResponse except the success branch has no required transaction echo — the library never reads it on the restore path.
type RestoreResponse<T extends EntitlementBase = EntitlementBase> =
| { valid: true; entitlements: T[] }
| { valid: false; error: string; message?: string };Backends that include a transaction (or any other extra fields) on either response shape ride through unmodified — the library validates only the named fields above and preserves everything else via Zod .passthrough(). Consumers can cast the resolved value to their own type to read backend-defined extras.
getEntitlements returns the entitlement array directly (no per-transaction verdict).
listProducts() (optional)
When implemented, the library calls this during initialize() if the consumer omitted products from createIAP() config. Lets the backend curate the SKU manifest at runtime.
listProducts?(): Promise<ConfiguredProduct[]>;Returned shape per entry:
type ConfiguredProduct = {
id: string; // store identifier
type: 'subscription' | 'product' | 'consumable';
androidPlanId?: string; // optional; Android multi-plan only
};The library validates the response against the same schema used at config-parse time. Malformed entries throw IAPError(BACKEND_BAD_RESPONSE) and initialize() rejects.
Pre-registration is non-negotiable
Every id MUST already be registered in App Store Connect AND Google Play Console for the platforms you ship on. The backend manifest is a curated subset of pre-registered SKUs, not a registration.
Error semantics
Adapter implementations MUST throw IAPError with the appropriate code on failure. The orchestrator branches on IAPError.recoverable to decide whether to retry or abort.
| Condition | Code | Recoverable? |
|---|---|---|
| Network unreachable / 5xx | BACKEND_UNAVAILABLE | yes |
| Timeout | BACKEND_TIMEOUT | yes |
| 401 / 403 | BACKEND_AUTH_FAILED | no |
| Other 4xx, malformed JSON, schema fail | BACKEND_BAD_RESPONSE | no |
Backend explicitly returned valid: false | Surface as VerifyResponse (don't throw) | n/a |
Don't throw on valid: false
The orchestrator distinguishes "backend rejected the receipt" from "backend was unreachable" — return the valid: false shape rather than throwing. Throw only on transport / parsing failures.
Example: Firebase Functions adapter
import { httpsCallable } from 'firebase/functions';
import { IAPError, IAPErrorCode } from '@nosslabs/iap';
import type {
BackendAdapter,
VerifyAppleRequest,
VerifyGoogleRequest,
RestoreRequest,
VerifyResponse,
EntitlementBase,
} from '@nosslabs/iap';
import type { AppEntitlement } from './services/iap';
export class FirebaseBackendAdapter implements BackendAdapter<AppEntitlement> {
constructor(private readonly functions: Functions) {}
async verifyApple(req: VerifyAppleRequest) {
return this.callFn<VerifyResponse<AppEntitlement>>('iapVerifyApple', req);
}
async verifyGoogle(req: VerifyGoogleRequest) {
return this.callFn<VerifyResponse<AppEntitlement>>('iapVerifyGoogle', req);
}
async getEntitlements(): Promise<AppEntitlement[]> {
const result = await this.callFn<{ entitlements: AppEntitlement[] }>('iapEntitlements', {});
return result.entitlements;
}
async restore(req: RestoreRequest) {
return this.callFn<VerifyResponse<AppEntitlement>>('iapRestore', req);
}
private async callFn<R>(name: string, payload: unknown): Promise<R> {
try {
const fn = httpsCallable<unknown, R>(this.functions, name);
const result = await fn(payload);
return result.data;
} catch (error: any) {
// Firebase wraps errors in HttpsError with .code
if (error.code === 'unauthenticated') {
throw new IAPError({ code: IAPErrorCode.BACKEND_AUTH_FAILED, message: error.message, cause: error });
}
if (error.code === 'unavailable' || error.code === 'deadline-exceeded') {
throw new IAPError({ code: IAPErrorCode.BACKEND_UNAVAILABLE, message: error.message, cause: error });
}
throw new IAPError({ code: IAPErrorCode.BACKEND_BAD_RESPONSE, message: error.message, cause: error });
}
}
}Then in your createIAP config:
const iap = createIAP<AppEntitlement>({
products: [/* ... */],
backend: {
adapter: new FirebaseBackendAdapter(functions),
timeoutMs: 15_000,
retries: 2,
},
});When adapter is provided, baseUrl / endpoints / getAuthHeaders are not used (and may be omitted).
Example: Supabase Edge Functions adapter
Same pattern, different transport:
import { SupabaseClient } from '@supabase/supabase-js';
import { IAPError, IAPErrorCode } from '@nosslabs/iap';
import type { BackendAdapter, /* ... */ } from '@nosslabs/iap';
export class SupabaseBackendAdapter implements BackendAdapter<AppEntitlement> {
constructor(private readonly supabase: SupabaseClient) {}
async verifyApple(req: VerifyAppleRequest) {
const { data, error } = await this.supabase.functions.invoke('iap-verify-apple', { body: req });
if (error) {
throw new IAPError({
code: IAPErrorCode.BACKEND_UNAVAILABLE,
message: error.message,
cause: error,
});
}
return data as VerifyResponse<AppEntitlement>;
}
// ... verifyGoogle, getEntitlements, restore identically
}Built-in: HttpBackendAdapter
The library exports the default HTTP implementation for advanced use:
import { HttpBackendAdapter, HttpClient } from '@nosslabs/iap';You typically don't import these — createIAP constructs them automatically when you pass baseUrl + endpoints. The named export exists so test setups can construct one manually with a mocked HttpClient.
See also
- Backend contract — wire-format reference
- Configuration — how to wire in your adapter
- Errors —
IAPErrorshape your adapter throws