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<VerifyResponse<TEntitlement>>;
}

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 type

VerifyResponse<T>

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

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 '@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:

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 '@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:

typescript
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

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