+1 (415) 843-4662

In-app purchases and subscriptions in Ionic apps: StoreKit 2, Play Billing and a server that is the source of truth

Subscriptions are the feature most likely to be built twice. The first build usually works in the sandbox, ships, and then starts leaking: entitlements that disappear after a reinstall, users who paid on iOS and see a paywall on Android, refunds that never revoke access, and a support inbox full of "restore purchases doesn't work". This tutorial builds the version that holds up — StoreKit 2 and Google Play Billing under the hood, a single entitlement check in the Ionic app, and a server that is the source of truth.

Examples are Ionic Angular with Capacitor 8; the plugin API and the server logic are identical for React and Vue.

The rule that prevents most of the bugs

The app never decides whether a user is subscribed. The store tells your backend, your backend tells the app. The app's job is to launch the purchase sheet and then read an entitlement flag. Every architecture that skips the backend eventually has to be rebuilt, because refunds, billing retries, grace periods, family sharing and cross-platform access all arrive as server-side events that the device never sees.

1. Products and entitlements

Model entitlements (what the user can do), not products (what they bought). Two products — monthly and annual — plus a Play-only promo plan should all map to one entitlement called pro.

StoreProduct IDEntitlement
App Storepro_monthly, pro_annualpro
Google Playpro (base plans monthly, annual)pro

Create the products in App Store Connect and Play Console first; nothing works in the sandbox until they exist and, on iOS, until you have accepted the paid apps agreement and added a StoreKit configuration file to the Xcode scheme.

2. Install the plugin

We use RevenueCat's Capacitor SDK on most engagements: it wraps StoreKit 2 and Play Billing, does receipt validation, and gives you webhooks — work that is several weeks to build and maintain yourself. If you must avoid a third party, @capacitor-community/in-app-purchases (or a thin custom plugin over StoreKit 2 / Play Billing 7) gives you the same client surface, and you write the validation server described in section 5 yourself.

npm install @revenuecat/purchases-capacitor
npx cap sync

Android needs nothing extra with Play Billing 7+. On iOS add the In-App Purchase capability in Xcode.

3. Configure once, at startup

// src/app/billing/billing.service.ts
import { Injectable, signal } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import {
  Purchases, LOG_LEVEL, type CustomerInfo, type PurchasesPackage,
} from '@revenuecat/purchases-capacitor';

@Injectable({ providedIn: 'root' })
export class BillingService {
  readonly entitlements = signal<Set<string>>(new Set());
  readonly ready = signal(false);

  async init(appUserId: string | null) {
    if (!Capacitor.isNativePlatform()) { this.ready.set(true); return; }

    await Purchases.setLogLevel({ level: LOG_LEVEL.WARN });
    await Purchases.configure({
      apiKey: Capacitor.getPlatform() === 'ios'
        ? environment.rcApiKeyIos
        : environment.rcApiKeyAndroid,
      appUserID: appUserId ?? undefined,   // your own user id, never a device id
    });

    Purchases.addCustomerInfoUpdateListener((info) => this.apply(info));
    const { customerInfo } = await Purchases.getCustomerInfo();
    this.apply(customerInfo);
    this.ready.set(true);
  }

  private apply(info: CustomerInfo) {
    this.entitlements.set(new Set(Object.keys(info.entitlements.active)));
  }

  isActive(id = 'pro') { return this.entitlements().has(id); }
}

Two things to get right here:

  • Use your own user id as appUserID. Anonymous ids mean a user who reinstalls or switches platforms looks like a new customer.
  • Call init after login, and Purchases.logIn()/logOut() on account switches — otherwise entitlements follow the device, not the person.

4. The paywall

Fetch offerings rather than hard-coding prices; the store returns localised price strings that you are required to display as given.

async loadOfferings(): Promise<PurchasesPackage[]> {
  const { current } = (await Purchases.getOfferings()).all
    ? (await Purchases.getOfferings())
    : { current: null } as any;
  return current?.availablePackages ?? [];
}

async buy(pkg: PurchasesPackage) {
  try {
    const { customerInfo } = await Purchases.purchasePackage({ aPackage: pkg });
    this.apply(customerInfo);
    return 'purchased';
  } catch (e: any) {
    if (e?.code === 'PURCHASE_CANCELLED' || e?.userCancelled) return 'cancelled';
    if (e?.code === 'PRODUCT_ALREADY_PURCHASED') { await this.restore(); return 'restored'; }
    throw e;
  }
}

async restore() {
  const { customerInfo } = await Purchases.restorePurchases();
  this.apply(customerInfo);
}
<ion-content class="ion-padding">
  <ion-list lines="full">
    @for (pkg of packages(); track pkg.identifier) {
      <ion-item button (click)="select(pkg)">
        <ion-label>
          <h2>{{ pkg.product.title }}</h2>
          <p>{{ pkg.product.priceString }} {{ periodLabel(pkg) }}</p>
        </ion-label>
      </ion-item>
    }
  </ion-list>

  <ion-button expand="block" [disabled]="!selected() || busy()" (click)="buy()">
    <ion-spinner *ngIf="busy()" name="crescent"></ion-spinner>
    <span *ngIf="!busy()">Continue</span>
  </ion-button>

  <ion-button expand="block" fill="clear" (click)="restore()">Restore purchases</ion-button>

  <p class="legal">
    Auto-renews until cancelled. Manage or cancel in your store account.
    <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a>
  </p>
</ion-content>

A visible "Restore purchases" button and the auto-renew/terms text are review requirements, not nice-to-haves — missing them is one of the most common rejection reasons for subscription apps. Disable the buy button while a purchase is in flight; double taps on slow networks produce duplicate purchase attempts and confused users.

5. The server is the source of truth

Configure store server notifications (App Store Server Notifications V2 and Play Real-time Developer Notifications) to hit your backend — directly, or via a RevenueCat webhook, which normalises both into one payload.

// POST /webhooks/billing
app.post('/webhooks/billing', verifySignature, async (req, res) => {
  const { event } = req.body;
  const userId = event.app_user_id;

  switch (event.type) {
    case 'INITIAL_PURCHASE':
    case 'RENEWAL':
    case 'UNCANCELLATION':
    case 'PRODUCT_CHANGE':
      await setEntitlement(userId, 'pro', { expiresAt: event.expiration_at_ms });
      break;
    case 'CANCELLATION':          // still entitled until expiry
      await markWillNotRenew(userId, 'pro');
      break;
    case 'EXPIRATION':
    case 'REFUND':
      await revokeEntitlement(userId, 'pro');
      break;
    case 'BILLING_ISSUE':
      await flagGracePeriod(userId, 'pro', event.grace_period_expires_at_ms);
      break;
  }
  res.sendStatus(200);   // ack fast; do slow work in a queue
});

Webhooks are at-least-once and can arrive out of order. Make setEntitlement idempotent and ignore events older than the state you already hold (event_timestamp_ms). Your own API — not the client — then gates premium endpoints; a client-side-only check is trivially bypassed.

6. Guarding routes in Ionic

export const proGuard: CanActivateFn = async () => {
  const billing = inject(BillingService);
  const nav = inject(NavController);
  if (billing.isActive('pro')) return true;
  await nav.navigateForward('/paywall');
  return false;
};

Refresh entitlements on resume, because the user may have subscribed, or cancelled, in the store app while your app was backgrounded:

App.addListener('appStateChange', async ({ isActive }) => {
  if (isActive) await Purchases.getCustomerInfo();  // fires the update listener
});

7. What changed in 2025–2026

  • External purchase links (US). Following the Epic v. Apple injunction, US App Store apps may link out to web checkout without Apple's commission or the old scare-screen. If a meaningful share of your revenue is subscriptions, a web checkout offered alongside IAP is now a real option — but it is region-specific, and the EU has its own external-purchase entitlement with different fees and disclosure rules. Gate the link by storefront, keep IAP available everywhere, and reconcile web purchases into the same entitlement table.
  • StoreKit 2 only. The original StoreKit API is deprecated; make sure your plugin uses StoreKit 2 so you get transaction-level refund and revocation data.
  • Play Billing deadlines. Google retires Billing Library majors on a rolling annual schedule — an app that cannot ship an update for a year will eventually be blocked from Play. Keep the plugin current.
  • Price and tax changes now propagate through priceString automatically; hard-coded prices in your paywall copy will be wrong somewhere.

8. Testing before you ship

  • iOS: a StoreKit configuration file in Xcode for fast local testing, then a Sandbox Apple ID in TestFlight for the real purchase flow. Renewals are accelerated in sandbox (a month is minutes) — use that to test expiry and billing-retry states.
  • Android: licence testers in Play Console plus the internal test track; test cards for approved, declined, and refunded outcomes.
  • Both: reinstall the app and press Restore purchases; switch platforms with the same account; issue a refund and confirm access is revoked within minutes; put the device in airplane mode mid-purchase and confirm the transaction completes on reconnect.

Write these as automated device tests if you can — the Playwright + Maestro setup we use covers the paywall UI, even though the store sheet itself has to be driven by hand.

The short version

Sell entitlements, not products. Configure the SDK with your own user id. Show Restore and the renewal disclosure. Let store webhooks — not the device — write entitlement state, and gate your API on that state. Everything else is paywall design.

We build and audit subscription flows as part of our Ionic development engagements; a typical first implementation is one to two weeks, and a rescue of a leaking one is usually less. Tell us about your app if you want a second pair of eyes before launch.