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<VerifyResponse<TEntitlement>>;
}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 type
VerifyResponse<T>
type VerifyResponse<T extends EntitlementBase = EntitlementBase> =
| {
valid: true;
transaction: VerifiedTransaction;
entitlements: T[];
}
| {
valid: false;
error: string; // stable machine-readable code
message?: string; // human-readable detail
};Return shape is identical across all four methods (getEntitlements returns the entitlement array directly, since there's no per-transaction verdict).
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 '@nossdev/iap';
import type {
BackendAdapter,
VerifyAppleRequest,
VerifyGoogleRequest,
RestoreRequest,
VerifyResponse,
EntitlementBase,
} from '@nossdev/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 '@nossdev/iap';
import type { BackendAdapter, /* ... */ } from '@nossdev/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 '@nossdev/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