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
entitlementsmap 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
- Quick start
- Configuration (
BiltIapConfig) BiltIapProvider- Hooks
- Entitlements
- Products
- Error handling (
BiltIapError) - Offline retry queue
- Lifecycle behavior
- Backend endpoints used
- Metro / workspace resolution
- Testapp reference
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>= 18react-native>= 0.72expo-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-iaprequires 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:
- Calls
GET /v1/iap/bootstrapto obtainappAccountTokenand the initialentitlementsmap for the user represented by the bearer token. - On iOS, calls
ExpoIap.initConnection()andExpoIap.fetchProducts({skus: productIds, type: 'all'})so both subscriptions and one-time SKUs load. - Loads the persisted retry queue from
AsyncStorageand flushes it. - Subscribes to
ExpoIap.purchaseUpdatedListenerandExpoIap.purchaseErrorListener. - 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
8attempts 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-updatedlistener: ingests the receipt, then callsExpoIap.finishTransaction({purchase})only if the backend returnsfinishTransaction: true. Pending (ask-to-buy) purchases are ignored until they resolve — the user does not get access yet.purchase-errorlistener:user-cancelledis silently dropped; all other codes surface viaonError.- Foreground refresh: when
AppStateflips 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), thenExpoIap.getAvailablePurchaseswithonlyIncludeActiveItemsIOS: true, re-ingests each unique transaction, then hits/v1/iap/restoreto let the backend reconcile.
Backend endpoints used
All requests are JSON and carry these headers:
Content-Type: application/jsonX-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 callCannot 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 mountBiltIapProviderand wireonError.testapp/src/BillingScreen.tsx— full usage ofuseBiltIAP,useEntitlement,purchaseProduct,restorePurchases,refreshEntitlements,openManageSubscriptions, andpendingRetries.testapp/README.md— four end-to-end scenarios (smoke test, synthetic purchases, local StoreKit, ASC sandbox on device).