Your app

Capacitor: in-app purchases for iPhone and Android

One TypeScript API for both stores, a free MIT-licensed plugin, and Vend16 checking every sale. About 10 minutes of code, then the store settings.

1Get your public key

Create a free account, verify your email, and the setup steps ask for your app’s name, iOS bundle ID and Android package name. You get a public key (pk_…, it goes in the app) and a secret key (sk_…, only for a server, if you have one). Both are shown once, so copy them.

2Add it to your app

Install the free, open-source plugin. It does StoreKit 2 on iPhone and Play Billing on Android, and its helper does the Vend16 part.

Terminal
npm install github:spruikco/capacitor-iap
npx cap sync

Connect it once, at launch, with your user’s ID:

store.ts
import { SpruikIap } from '@spruik/capacitor-iap';
import { createVend16 } from '@spruik/capacitor-iap/vend16';

// Your PUBLIC key. It is safe to ship inside the app.
export const vend16 = createVend16({
  apiKey: 'pk_your_public_key',
  // Consumables are consumed once recorded, so they can be bought again.
  productTypes: { coins_100: 'consumable', pro_monthly: 'subscription' },
});

export async function startStore(userId: string) {
  // Listen BEFORE initialize: both stores replay unfinished purchases on connect.
  await SpruikIap.addListener('transactionUpdated', (tx) => vend16.handle(tx, userId, SpruikIap));
  await SpruikIap.initialize({ productIds: ['coins_100', 'pro_monthly'] });
}

The buy button:

store.ts
export async function buy(productId: string, userId: string) {
  const { products } = await SpruikIap.getProducts();
  const p = products.find((x) => x.productId === productId);
  const { status, transaction } = await SpruikIap.purchase({ productId });
  if (status !== 'purchased') return; // cancelled or pending: nothing to do yet
  // Records it with Vend16, and only then tells the store it is finished.
  // The price is for your revenue figures (Google does not report it).
  await vend16.handle(transaction, userId, SpruikIap, p && { micros: p.priceMicros, currency: p.currency });
}

Unlock from Vend16’s answer, and add Restore:

store.ts
export async function refreshAccess(userId: string) {
  const { active_product_ids } = await vend16.subscriber(userId);
  return {
    pro: active_product_ids.includes('pro_monthly'),
  };
}

// Apple wants a visible Restore button for subscriptions and non-consumables.
export async function restore(userId: string) {
  const { transactions } = await SpruikIap.restore();
  for (const tx of transactions) await vend16.handle(tx, userId, SpruikIap);
  return refreshAccess(userId);
}

3Keep the money safe

Three rules that protect your money
  1. Tell the store a purchase is finished only after Vend16 answers recorded, duplicate or revoked. If the answer is pending, or Vend16 can’t be reached (a 502 or no network), leave it unfinished. The store hands it back on the next launch and you try again.
  2. If Vend16 answers 422 invalid_receipt, finish it and unlock nothing. The store says it isn’t a real purchase.
  3. Unlock from active_product_ids in GET /v1/subscribers/:appUserId, never from a flag on the phone. Check it at launch and after every purchase.

Use a stable user ID from your own sign-in (or a UUID you keep on the device), never an email address.

4Connect the stores

Your code is done. The stores need to know about your products and about Vend16:

Stuck? Email hello@spruik.co and a real person replies.