Skip to content

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

typescript
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[]>;
}

Request types

VerifyAppleRequest

typescript
interface VerifyAppleRequest {
  productId: string;
  /** Apple StoreKit transactionId (numeric string) */
  transactionId: string;
  type: ProductType;
}

VerifyGoogleRequest

typescript
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

typescript
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.

typescript
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.

typescript
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.

typescript
listProducts?(): Promise<ConfiguredProduct[]>;

Returned shape per entry:

typescript
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.

ConditionCodeRecoverable?
Network unreachable / 5xxBACKEND_UNAVAILABLEyes
TimeoutBACKEND_TIMEOUTyes
401 / 403BACKEND_AUTH_FAILEDno
Other 4xx, malformed JSON, schema failBACKEND_BAD_RESPONSEno
Backend explicitly returned valid: falseSurface 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

typescript
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:

typescript
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:

typescript
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:

typescript
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

Released under the MIT License. Pairs with Attesto for server-side receipt validation.