JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 87
  • Score
    100M100P100Q75769F

Package Exports

  • @biltme/iap
  • @biltme/iap/dist/index.js
  • @biltme/iap/src/index.ts

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@biltme/iap) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

@biltme/iap

Thin Expo / React Native client wrapper for the bilt-billing backend.

@biltme/iap gives a React app a single provider + two hooks that handle the full in-app purchase lifecycle on iOS:

  • Bootstrap the user with the backend (gets an appAccountToken).
  • Fetch App Store product metadata through expo-iap.
  • Drive native purchases and forward receipts to POST /v1/iap/purchases/ingest.
  • Expose a backend-authoritative entitlements map to the UI.
  • Restore purchases via expo-iap + POST /v1/iap/restore.
  • Open the native Manage Subscriptions sheet.
  • Transparently retry ingest calls that failed due to network / transient errors, persisted across app restarts.

Only iOS is wired up today. The provider is safe to mount on Android — it will bootstrap entitlements and skip the store calls.

Contents

Installation

This package is published as a workspace package inside the bilt-billing repo. Consumers install it by path (or through the workspace) and own the native peer dependencies themselves.

Peer dependencies (must live in the host app's node_modules):

  • react >= 18
  • react-native >= 0.72
  • expo-iap >= 3
  • @react-native-async-storage/async-storage >= 2

They are peers because both the host app and @biltme/iap must see the exact same copy. Duplicating react or react-native produces runtime errors like Invalid hook call or Cannot read property 'useRef' of null.

expo-iap requires a custom dev build. Expo Go cannot load it.

Quick start

import React from 'react';
import {BiltIapProvider, useBiltIAP, useEntitlement} from '@biltme/iap';
import type {BiltIapConfig} from '@biltme/iap';

const config: BiltIapConfig = {
  tenantAppId: '11111111-1111-1111-1111-111111111111',
  getAccessToken: async () => auth.getToken(),
  productIds: ['com.example.pro.monthly'],
  onError: (err) =>
    console.warn('[BiltIAP]', {
      code: err.code,
      message: err.message,
      retryable: err.retryable,
      requestId: err.requestId,
      cause: err.cause,
    }),
};

export default function App() {
  return (
    <BiltIapProvider config={config}>
      <PaywallScreen />
    </BiltIapProvider>
  );
}

function PaywallScreen() {
  const {initialized, products, purchaseProduct, restorePurchases} = useBiltIAP();
  const pro = useEntitlement('pro');

  if (!initialized) return null;
  if (pro.active) return <ProFeatures />;

  return (
    <>
      {products.map((p) => (
        <Button
          key={p.productId}
          title={`Buy ${p.title}${p.displayPrice}`}
          onPress={() => purchaseProduct(p.productId)}
        />
      ))}
      <Button title="Restore Purchases" onPress={() => restorePurchases()} />
    </>
  );
}

A complete, runnable example (UI, synthetic purchases, StoreKit, restore, notifications) lives in testapp/App.tsx and testapp/src/BillingScreen.tsx.

Configuration (BiltIapConfig)

Field Type Required Description
backendUrl string no Base URL of the bilt-billing backend. Defaults to https://billing.bilt.me. Trailing slashes are stripped.
tenantAppId string yes Sent as X-Bilt-Tenant-App-Id on every request. It scopes the request to the correct tenant app.
getAccessToken () => Promise<string> yes Returns a signed bearer token for the current authenticated user. The backend derives appUserId from its claims.
productIds string[] yes Apple product IDs to fetch from the store during init. May mix one-time and auto-renewable subscriptions.
onError (err: BiltIapError) => void no Called whenever the provider surfaces an error. Useful for Sentry / Datadog / logging.

getAccessToken is read on every backend call. If your app rotates sessions or refreshes tokens in the background, return the latest access token from this callback.

If you need to point at a local or staging billing backend, override it explicitly:

const config: BiltIapConfig = {
  backendUrl: 'http://127.0.0.1:8099',
  tenantAppId: '11111111-1111-1111-1111-111111111111',
  getAccessToken: async () => auth.getToken(),
  productIds: ['com.example.pro.monthly'],
};

BiltIapProvider

<BiltIapProvider config={config}>{children}</BiltIapProvider>

On mount the provider:

  1. Calls GET /v1/iap/bootstrap to obtain appAccountToken and the initial entitlements map for the user represented by the bearer token.
  2. On iOS, calls ExpoIap.initConnection() and ExpoIap.fetchProducts({skus: productIds, type: 'all'}) so both subscriptions and one-time SKUs load.
  3. Loads the persisted retry queue from AsyncStorage and flushes it.
  4. Subscribes to ExpoIap.purchaseUpdatedListener and ExpoIap.purchaseErrorListener.
  5. Subscribes to AppState "change". On foreground it re-fetches entitlements and flushes the retry queue.

On unmount it removes listeners, clears any pending flush timer, and calls ExpoIap.endConnection() on iOS.

Hooks

useBiltIAP()

Returns BiltIapState & BiltIapApi. Must be called inside a BiltIapProvider — otherwise it throws useBiltIAP must be used inside <BiltIapProvider>.

State:

Field Type Notes
initialized boolean true once bootstrap + store init have both succeeded.
loading boolean true until the initial bootstrap resolves (success or failure).
purchasing boolean true while a purchase flow is in progress.
restoring boolean true while restorePurchases is running.
entitlements EntitlementMap Backend-authoritative map keyed by entitlement code (e.g. "pro").
appAccountToken string | undefined Stable per-user token the backend tags transactions with. Set after bootstrap.
products StoreProduct[] App Store products for config.productIds. Empty on Android or if fetch failed.
lastError BiltIapError | undef Last error the provider surfaced.
pendingRetries number Number of ingest payloads sitting in the offline retry queue.

API methods:

Method Description
purchaseProduct(productId: string): Promise<void> Kicks off the native purchase sheet. Resolves when the request has been dispatched; the actual receipt is processed by the internal listener. Throws if the provider has not initialized or the product is not in products.
restorePurchases(): Promise<void> Calls ExpoIap.restorePurchases(), re-ingests every active purchase it finds, then calls POST /v1/iap/restore to reconcile. Updates entitlements.
refreshEntitlements(): Promise<void> Calls GET /v1/iap/entitlements and updates state. Never throws — errors are piped to onError.
hasEntitlement(code: string): boolean Shortcut for entitlements[code]?.active === true.
openManageSubscriptions(): Promise<void> iOS: opens the native Manage Subscriptions sheet via ExpoIap.deepLinkToSubscriptions. Android: no-op without extra args.
flushRetryQueue(): Promise<void> Force-flush the retry queue now (e.g. when the app regains connectivity).

useEntitlement(code)

Convenience hook for simple gate UIs.

const pro = useEntitlement('pro');

if (pro.loading) return <Spinner />;
if (pro.active) return <ProFeatures />;
return <Paywall />;

Returns:

Field Type
active boolean
status EntitlementStatus | undefined
entitlement BiltEntitlement | undefined
loading boolean (true until initialized)

Entitlements

type EntitlementStatus =
  | 'active'
  | 'grace_period'
  | 'billing_retry'
  | 'expired'
  | 'revoked'
  | 'refunded'
  | 'purchased';

type BiltEntitlement = {
  active: boolean;
  status: EntitlementStatus;
  platform: 'IOS' | 'Android';
  productId?: string;
  currentPlanId?: string;
  expirationDate?: number;   // epoch ms
  isAutoRenewing?: boolean;
  gracePeriod?: boolean;
  billingRetry?: boolean;
  updatedAt?: number;        // epoch ms
};

type EntitlementMap = Record<string, BiltEntitlement>;

Entitlements are always authored by the backend. The client never derives entitlement state from a local purchase — it only forwards receipts and re-reads the map.

Products

type StoreProduct = {
  productId: string;
  title: string;
  description: string;
  displayPrice: string;      // localized, e.g. "$4.99"
  price: number;             // numeric price in `currency`
  currency: string;
  type: 'subscription' | 'one-time';
};

type is inferred from expo-iap's typeIOS: auto-renewable-subscription and non-renewing-subscription are treated as 'subscription', everything else as 'one-time'. purchaseProduct uses this to pick between type: 'subs' and type: 'in-app' when calling ExpoIap.requestPurchase.

Error handling (BiltIapError)

Every error surfaced by the provider is an instance of BiltIapError.

class BiltIapError extends Error {
  readonly code: BiltIapErrorCode;
  readonly retryable: boolean;
  readonly requestId?: string;
}

Codes:

Code Source Meaning
unauthorized backend Caller is not allowed.
invalid-request backend Validation failed.
ownership-mismatch backend Transaction belongs to another appAccountToken.
product-not-configured backend Product id is not mapped in the tenant catalog.
store-unavailable backend Upstream App Store call failed.
internal-error backend Fallback for unexpected backend failures.
billing-user-not-found backend The backend has no record of this user.
notification-invalid backend Apple notification was rejected.
not-initialized client Provider has not finished init / token missing.
purchase-cancelled client User dismissed the purchase sheet.
purchase-pending client Ask-to-buy / deferred purchase.
purchase-failed client Native purchase flow failed.
network-error client fetch rejected. Always retryable: true.
store-not-available client Store connection never came up.
product-not-found client purchaseProduct called with an id not in products.

Only refreshEntitlements swallows its own errors (into lastError / onError). purchaseProduct and restorePurchases both swallow to onError and also rethrow so callers can surface per-action UI.

Offline retry queue

Ingest calls (POST /v1/iap/purchases/ingest) are critical — they turn a real StoreKit receipt into an entitlement. If one fails with a retryable: true error, the payload is enqueued in RetryQueue and persisted to AsyncStorage under the key @biltme/iap:retry-queue.

Retry behavior:

  • Exponential backoff: min(2s * 2^attempts, 5min) with 50–100% jitter.
  • Up to 8 attempts per payload. After that the item is dropped (dead letter) rather than poisoning the queue.
  • Flushed on: enqueue, app foreground, and when flushRetryQueue() is called explicitly.
  • Non-retryable errors (invalid-request, ownership-mismatch, etc.) drop the item immediately instead of retrying.

The queue count is exposed through pendingRetries so the UI can show a banner like:

{pendingRetries > 0 && (
  <Text>{pendingRetries} pending retries in queue</Text>
)}

Lifecycle behavior

  • purchase-updated listener: ingests the receipt, then calls ExpoIap.finishTransaction({purchase}) only if the backend returns finishTransaction: true. Pending (ask-to-buy) purchases are ignored until they resolve — the user does not get access yet.
  • purchase-error listener: user-cancelled is silently dropped; all other codes surface via onError.
  • Foreground refresh: when AppState flips to "active" and the provider has initialized, it re-fetches entitlements and flushes the retry queue. This is how server-side lifecycle events (renew, expire, refund) reach the client without an explicit pull.
  • Restore: calls ExpoIap.restorePurchases() first (so the native receipt refresh happens), then ExpoIap.getAvailablePurchases with onlyIncludeActiveItemsIOS: true, re-ingests each unique transaction, then hits /v1/iap/restore to let the backend reconcile.

Backend endpoints used

All requests are JSON and carry these headers:

  • Content-Type: application/json
  • X-Bilt-Tenant-App-Id: <config.tenantAppId>
  • Authorization: Bearer <await config.getAccessToken()>

The backend uses the bearer token to authenticate the caller and derive the stable appUserId. tenantAppId is tenant routing only.

Method Path When
GET /v1/iap/bootstrap Mount.
GET /v1/iap/entitlements Foreground, refreshEntitlements().
POST /v1/iap/purchases/ingest After every purchase-updated and during restore.
POST /v1/iap/restore restorePurchases().

Responses follow the shape { ok: true, data: T } on success and { ok: false, error: { code, message, retryable?, requestId? } } on failure. The client reads retryable from the body when present and falls back to "HTTP 5xx => retryable" otherwise.

Metro / workspace resolution

When consuming @biltme/iap from a local workspace path inside an Expo app, Metro must resolve react, react-native, and the native peers from the host app's node_modules, not from packages/iap/node_modules. Otherwise you will see:

  • Invalid hook call
  • Cannot read property 'useRef' of null

See testapp/metro.config.js for a working config.

Testapp reference

The testapp workspace exercises every public API of this package:

  • testapp/App.tsx — how to mount BiltIapProvider and wire onError.
  • testapp/src/BillingScreen.tsx — full usage of useBiltIAP, useEntitlement, purchaseProduct, restorePurchases, refreshEntitlements, openManageSubscriptions, and pendingRetries.
  • testapp/README.md — four end-to-end scenarios (smoke test, synthetic purchases, local StoreKit, ASC sandbox on device).