Skip to content

Getting started ​

This page walks you from zero to a working sandbox purchase in under 30 minutes. It assumes you already have:

  • A Capacitor 8 app (on Capacitor 7? see the v7 docs; on Capacitor 5, the v5 docs)
  • Products configured in App Store Connect (sandbox testers OK) and/or Google Play Console (license testers OK)
  • A backend you can deploy a few new endpoints to — or none at all, if you verify on the device (or you can implement them as a custom BackendAdapter)
  • An Attesto tenant with API key

If you're missing any of these, the Backend contract and Testing on sandbox pages cover them.

1. Install ​

bash
npm install @nosslabs/iap @capgo/native-purchases
npx cap sync

Both packages' latest tags are the Capacitor 8 line, so no pinning is needed on Capacitor 8.

Still on Capacitor 7?

Install the maintenance line instead: npm install @nosslabs/iap@^7 @capgo/native-purchases@lts-v7, and follow the v7 docs. Everything else on this page is identical — the library API is unchanged between the two lines.

Optional: app-resume listener

If you want the library to automatically refresh entitlements when your app returns from background (the default), also install @capacitor/app:

bash
npm install @capacitor/app
npx cap sync

You can disable this by passing options.refreshOnResume: false when creating the IAP instance.

2. Create the IAP instance ​

typescript
// src/services/iap.ts
import { createIAP } from '@nosslabs/iap';
import type { EntitlementBase } from '@nosslabs/iap';

// Define your entitlement shape (extends the base).
interface AppEntitlement extends EntitlementBase {
  key: string;            // e.g., 'premium', 'remove_ads'
  productId: string;
  expiresAt: string | null;
  // Add app-specific fields the backend returns:
  tier?: 'basic' | 'pro';
}

export const iap = createIAP<AppEntitlement>({
  products: [
    {
      id: 'premium_monthly',
      type: 'subscription',
      androidPlanId: 'monthly-plan', // required for Android subs
    },
    { id: 'remove_ads', type: 'product' },
  ],
  backend: {
    baseUrl: 'https://api.your-app.com',
    endpoints: {
      verifyApple: '/api/iap/verify/apple',
      verifyGoogle: '/api/iap/verify/google',
      entitlements: '/api/iap/entitlements',
      restore: '/api/iap/restore',
    },
    getAuthHeaders: async () => ({
      Authorization: `Bearer ${await getAuthToken()}`,
    }),
  },
});

The factory validates your config with zod and throws IAPError(INVALID_CONFIG) on any structural issue — no surprises at runtime.

3. Initialize after auth is ready ​

typescript
// src/main.ts (or wherever your app boots)
import { iap } from './services/iap';

await iap.initialize();

initialize() does the following:

  1. Loads any cached entitlements from local storage (instant warm cache)
  2. Recovers any unfinished transactions from prior sessions
  3. Wires the app-resume listener (if refreshOnResume)
  4. Schedules a background refresh if cache exceeds TTL
  5. Emits ready

After initialize() returns, all read methods (hasEntitlement, getEntitlements, getEntitlement, getProducts, getStorefront) are safe to call.

Building paywall copy from the store's own data ​

getProducts() returns each subscription's introductory offer, so the paywall can describe it without a hardcoded price:

typescript
const products = await iap.getProducts();
const plan = products.find((p) => p.id === 'premium_monthly');
if (!plan) return; // configured, but the store has not ingested it yet

// Every string here is the store's own, in the store's currency and format.
const offer = plan.introductoryOffer;
let label: string;

if (offer?.payment === 'free-trial' && offer.period) {
  // `period` is an ISO 8601 duration, e.g. "P1W" — and it is nullable, so
  // guard it rather than rendering "null free, then …".
  label = `${formatDuration(offer.period)} free, then ${plan.priceString}`;
} else if (offer) {
  label = `${offer.priceString} to start, then ${plan.priceString}`;
} else {
  label = plan.priceString;
}

Do not promise an offer to a specific user

An offer here means this product has one, not this user will get it. On iOS, introductoryOfferEligible answers that for the introductory offer (true/false, or null when the store did not say); on Android it is always null, and no promotional or win-back offer has an answer on either platform. Where it is null, phrase copy so it is true either way ("7-day free trial for new subscribers"). On Android, also do not render promotionalOffers terms. All of it in full: SubscriptionOffer.

4. Buy something ​

typescript
const result = await iap.purchase({ productId: 'premium_monthly' });

switch (result.status) {
  case 'success':
    // Backend has validated. Entitlements are already updated in state.
    console.log('Welcome to premium!');
    break;
  case 'cancelled':
    // User dismissed the native purchase sheet.
    break;
  case 'pending':
    // Android: payment awaiting external clearance (e.g. cash).
    console.log('Payment is processing. We\'ll notify you when it clears.');
    break;
  case 'verification_failed':
    // Backend rejected, transport error, etc. The unfinished entry stays
    // for retry; library will auto-recover on next launch / refresh.
    console.error('Verification failed:', result.error.message);
    break;
  case 'failed':
    // Native error (network, store error, etc.). User can try again.
    console.error('Purchase failed:', result.error.message);
    break;
}

The discriminated union is by design — UI code decides what to show without needing try/catch around every call.

Pre-attaching a user identifier (optional) ​

If your app has a logged-in user at purchase time, pass an appUserId so it travels through StoreKit / Play Billing and reaches your backend on both the verify response and the eventual webhook. Eliminates the verify/webhook race on the backend side — see Attesto's integration guide for the join pattern.

typescript
// (a) Plain string — your app already has the UUID.
await iap.purchase({
  productId: 'premium_monthly',
  appUserId: currentUser.iapUuid,
});

// (b) Async fetcher — your backend mints+saves the UUID per user the first
//     time it's asked, returns the same UUID on later calls. iap calls the
//     fetcher fresh on every purchase; backend owns the idempotency.
await iap.purchase({
  productId: 'premium_monthly',
  appUserId: async () => {
    const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders() });
    return (await r.json()).uuid;
  },
});

// (c) Async fetcher with `ctx` — equivalent to (b) when your UUID endpoint
//     uses the same auth as your IAP backend. iap awaits
//     `backend.getAuthHeaders()` and passes the result as `ctx.authHeaders`,
//     so you don't have to redefine a helper. Convenience only — ignore the
//     parameter and close over your own auth state when your UUID endpoint
//     uses different auth.
await iap.purchase({
  productId: 'premium_monthly',
  appUserId: async ({ authHeaders }) => {
    const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders });
    return (await r.json()).uuid;
  },
});

The supplied value (literal or fetcher-returned) must be an RFC 9562 UUID — any version. iap validates before passing to native; non-UUIDs throw IAPError(INVALID_APP_USER_ID), as do the nil and max UUIDs, which carry no identity. Apple requires a UUID for appAccountToken; we enforce the same on Android for a consistent contract.

Any version is allowed because a deterministic identifier is often the better choice: a UUIDv5 over a stable name (an account id, a household's address) gives the same binding on every device with nothing to store or keep in step. crypto.randomUUID() (v4) is fine when you have somewhere to persist it.

After the purchase, result.purchase.appUserId reads the binding back off the transaction, so you can confirm what a purchase was bound to. It is lower-cased to compare equal to what you sent — StoreKit returns an uppercase UUID string.

For purchases without a logged-in user (guest flows), simply omit appUserId — your backend falls back to mapping by Attesto's subject.key (see the integration guide).

5. Read entitlements anywhere ​

typescript
// Synchronous reads from the in-memory cache
if (iap.hasEntitlement('premium')) {
  showPremiumFeatures();
}

const allEntitlements = iap.getEntitlements();
// → [{ key: 'premium', productId: 'premium_monthly', expiresAt: '...', ... }]

const premium = iap.getEntitlement('premium');
// → AppEntitlement | null

These reads are O(1) on a frozen array. Safe to call inside reactive computeds — they don't trigger network.

6. React to changes ​

typescript
const unsubscribe = iap.on('entitlements-changed', ({ entitlements, previous }) => {
  // Wire to your store / state management.
  store.setEntitlements(entitlements);
});

// Later, when tearing down:
unsubscribe();

See Vue + Quasar recipe and React recipe for full reactive store wiring.

7. Restore on a new device ​

typescript
// Wire to a "Restore Purchases" button.
async function onRestoreClick() {
  try {
    const { restored, entitlements } = await iap.restorePurchases();
    if (restored === 0) {
      alert('No previous purchases found.');
    } else {
      alert(`Restored ${restored} purchase(s).`);
    }
  } catch (error) {
    alert(`Restore failed: ${error.message}`);
  }
}

restorePurchases() calls getOwnedTransactions() natively, batches to your backend's /restore endpoint, and updates entitlements from the consolidated response.

What's next ​

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