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"
});upgradePolicy | Behavior | Platform |
|---|---|---|
"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
In-App Purchase API Reference
Full API reference including all subscription types and options
Set Up In-App Purchases
Guide for one-time consumable and non-consumable purchases
Handle Apple Server Notifications
Process App Store Server Notifications V2 on your backend
Handle Google Server Notifications
Process Google Play Real-Time Developer Notifications on your backend