starti.app
How-to Guides

Set Up Subscriptions

Offer auto-renewable subscriptions on iOS and Android using the starti.app SDK

Set Up Subscriptions

Use the SDK to offer auto-renewable subscriptions on iOS and Android. The SDK handles the platform-specific purchase flow and exposes subscription status and promotional offers.

Prerequisites

  • The starti.app SDK is installed and initialized
  • Subscription products are configured in App Store Connect and/or Google Play Console
  • Product IDs match between the store(s) and your code
  • Your app includes a "Restore Purchases" button (required by App Store guidelines)

Get subscription product details

Fetch subscription details including pricing, billing period, and available offers:

const result = await startiapp.InAppPurchase.getSubscriptionProducts([
  "com.example.monthly",
  "com.example.yearly"
]);

if (result.success) {
  result.value.forEach(product => {
    console.log(`${product.name}: ${product.localizedPrice} / ${product.subscriptionPeriod}`);

    if (product.introductoryOffer) {
      console.log(`  Free trial: ${product.introductoryOffer.period}`);
    }
  });
}

Subscribe

const result = await startiapp.InAppPurchase.subscribe("com.example.monthly");

if (result.success) {
  console.log("Transaction ID:", result.value.transactionId);

  // 1. Validate on your backend
  const validated = await fetch("/api/validate-subscription", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      transactionId: result.value.transactionId,
      originalTransactionId: result.value.originalTransactionId,
      productId: result.value.productId
    })
  }).then(r => r.ok);

  // 2. Finish the transaction AFTER backend confirms
  if (validated) {
    await startiapp.InAppPurchase.finishTransaction(result.value.transactionId);
  }
}

Always call finishTransaction() after your backend validates the purchase. If you skip this step, the store will re-deliver the transaction on next app launch and may eventually refund the user automatically.

Handle unfinished transactions

If the app crashes, loses internet, or the user's phone dies after a purchase but before finishTransaction() is called, the transaction stays unfinished. The SDK automatically checks for unfinished transactions on startup and emits an "unfinishedTransaction" event for each one.

Register a listener to handle them:

startiapp.InAppPurchase.addEventListener("unfinishedTransaction", async (event) => {
  const transaction = event.detail;

  const validated = await fetch("/api/validate-subscription", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(transaction)
  }).then(r => r.ok);

  if (validated) {
    await startiapp.InAppPurchase.finishTransaction(transaction.transactionId);
  }
});

This listener can be registered before or after startiapp.initialize() — the SDK buffers unfinished transactions and delivers them when the first listener is added.

On Android, Google Play gives you 3 days to acknowledge (finish) a purchase. After that, the purchase is automatically refunded.

Check subscription status

For a simple yes/no check, use isSubscribed():

if (await startiapp.InAppPurchase.isSubscribed()) {
  unlockPremiumFeatures();
}

// Check for a specific product
const hasYearly = await startiapp.InAppPurchase.isSubscribed("com.example.yearly");

For detailed status (expiry date, renewal info, etc.), use getActiveSubscriptions():

const result = await startiapp.InAppPurchase.getActiveSubscriptions();

if (result.success) {
  const active = result.value.filter(s => s.state === "subscribed");
  if (active.length > 0) {
    // User has an active subscription
    unlockPremiumFeatures();
  }
}

Upgrade or downgrade

Pass upgradePolicy to switch a user between subscription tiers. upgradePolicy is Android only — on iOS, StoreKit 2 handles transitions automatically and ignores this parameter.

const result = await startiapp.InAppPurchase.subscribe("com.example.yearly", {
  upgradePolicy: "immediate"
});
upgradePolicyBehaviorPlatform
"immediate"Charges the user immediately and switches the subscription now. The user receives a prorated credit for the remaining time on their old plan.Android only
"deferred"The current subscription continues until its expiration date, then the new subscription begins.Android only

The SDK auto-detects which subscription to replace. On iOS, StoreKit 2 handles same-group upgrades automatically. On Android, the SDK finds the user's active subscription and upgrades from it.

If your app offers multiple subscription groups (e.g., both an "ad-free" subscription and a separate "premium" subscription), you must pass oldProductId to specify which subscription to replace. Without it, the SDK may pick the wrong one on Android.

// Multiple subscription groups — specify which one to replace
const result = await startiapp.InAppPurchase.subscribe("com.example.premium-yearly", {
  oldProductId: "com.example.premium-monthly",
  upgradePolicy: "immediate"
});

Restore purchases

App Store guidelines require a visible "Restore Purchases" button. This syncs with the store and returns all subscriptions (including expired ones), so you can re-validate on your backend:

const result = await startiapp.InAppPurchase.restorePurchases();

if (result.success) {
  const active = result.value.filter(s => s.state === "subscribed");
  console.log(`Restored ${active.length} active subscription(s)`);
  active.forEach(s => {
    console.log(`${s.productId}: ${s.state}`);
  });
}

Manage subscriptions

Open the platform's native subscription management screen where users can cancel or change their subscription:

await startiapp.InAppPurchase.manageSubscriptions();

Handle subscription states

const result = await startiapp.InAppPurchase.getActiveSubscriptions();

if (result.success) {
  for (const sub of result.value) {
    if (sub.state === "subscribed") {
      // Grant access
    } else {
      // sub.state === "expired" — revoke access
    }
  }
}

Promotional offers

Pass offerId to subscribe() to apply a promotional offer. On iOS, you also need a resolveOffer callback that returns a server-generated signature — Apple requires this for promotional offers. On Android, offerId is passed directly as the offer token and resolveOffer is ignored.

const result = await startiapp.InAppPurchase.subscribe("com.example.monthly", {
  offerId: "spring_discount",
  resolveOffer: async (productId, offerId) => {
    return await fetch("/api/sign-offer", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ productId, offerId })
    }).then(r => r.json());
  }
});

Your backend's /api/sign-offer endpoint should generate the signature using your App Store Connect subscription key. It returns:

{
  "signature": "...",
  "nonce": "a1b2c3d4-...",
  "timestamp": "1234567890",
  "keyId": "ABC123"
}

See Apple's Generating a Signature for Promotional Offers for how to set up the signing key. On Android, resolveOffer is never called — you only need the signing endpoint if your app runs on iOS.

For offers without server signing (e.g., introductory offers and free trials), no parameters are needed — the store applies them automatically if the user is eligible.

Complete example

await startiapp.initialize();

// Handle unfinished transactions from previous sessions
startiapp.InAppPurchase.addEventListener("unfinishedTransaction", async (event) => {
  const transaction = event.detail;
  const validated = await fetch("/api/validate-subscription", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(transaction)
  }).then(r => r.ok);

  if (validated) {
    await startiapp.InAppPurchase.finishTransaction(transaction.transactionId);
  }
});

// Load products
const products = await startiapp.InAppPurchase.getSubscriptionProducts([
  "com.example.monthly",
  "com.example.yearly"
]);

if (products.success) {
  products.value.forEach(product => {
    const btn = document.createElement("button");
    btn.textContent = `${product.name} — ${product.localizedPrice}/${product.subscriptionPeriod}`;
    btn.addEventListener("click", async () => {
      const result = await startiapp.InAppPurchase.subscribe(product.productId);
      if (result.success) {
        // 1. Validate on your backend
        const validated = await fetch("/api/validate-subscription", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(result.value)
        }).then(r => r.ok);

        // 2. Finish the transaction after backend confirms
        if (validated) {
          await startiapp.InAppPurchase.finishTransaction(result.value.transactionId);
        }
      }
    });
    document.getElementById("plans").appendChild(btn);
  });
}

// Restore button
document.getElementById("restore-btn").addEventListener("click", async () => {
  const result = await startiapp.InAppPurchase.restorePurchases();
  if (result.success && result.value.length > 0) {
    alert(`Restored ${result.value.length} subscription(s)`);
  } else {
    alert("No subscriptions found");
  }
});

// Manage button
document.getElementById("manage-btn").addEventListener("click", () => {
  startiapp.InAppPurchase.manageSubscriptions();
});

Always validate subscriptions on your backend using the App Store Server API or Google Play Developer API. Do not rely solely on client-side status checks for granting access to premium features.

See also

On this page