In-App Purchase Validation on the Server: iOS and Android

In-app purchase validation means checking every App Store or Google Play purchase with Apple or Google on a server, never trusting the device's word, and then keeping the subscription status current as it renews, lapses or gets refunded. Done only in the app, a jailbroken phone or a patched APK can unlock paid features for free, and a refunded user keeps access. StitchDB does the server side for you from the Pro plan: the app sends each purchase, StitchDB checks it with the store, saves it for the signed-in user, follows store notifications, and exposes entitlements you check in SQL and Row Level Security.

This guide explains what server-side validation has to do, then sets it up for iOS and Android.

#What a purchase backend has to do

A complete backend for subscriptions has more moving parts than "verify a receipt":

  1. Verify each purchase with the store and tie it to your user, not just to a device.
  2. Acknowledge Google Play purchases, or Google refunds them after three days.
  3. Receive store notifications for renewals, cancellations, billing failures, refunds and revocations.
  4. Resolve status: is this user entitled to "pro" right now, given grace periods and billing retries?
  5. Enforce it where the data is, so premium content cannot be fetched by calling the API directly.
  6. Tell your other systems when something changes.

The platforms have also moved. StoreKit 2 transactions arrive signed as JWS, and Apple has marked the older verifyReceipt endpoint as deprecated in favor of the App Store Server API and App Store Server Notifications version 2. A backend written a few years ago may need rework anyway.

#Step 1: connect the stores

Open Studio › Integrations › In-app purchases.

#App Store

  • Enter the app's bundle ID.
  • Add an App Store Connect API key for in-app purchases: the .p8 file, its Key ID and the Issuer ID (Users and Access › Integrations › In-App Purchase). With it, subscription status comes straight from Apple.
  • In App Store Connect › your app › App Information › App Store Server Notifications, set both the Production and the Sandbox URL to the address Studio shows (https://<project-ref>.p.stitchdb.com/purchases/v1/apple) and choose Version 2.

Without the API key, the signed transaction from the app is still checked against Apple's certificates; with it, StitchDB also reads its status from Apple.

#Google Play

  • Enter the package name and upload a Google Cloud service account key (JSON).
  • In Play Console › Users and permissions, invite that service account with View financial data and Manage orders and subscriptions.
  • For notifications, create a Pub/Sub topic, select it under Monetization setup › Real-time developer notifications, and add a push subscription with the endpoint Studio shows. That endpoint contains a key; keep it private.

#Entitlements

List which product IDs grant which entitlement, for example:

Terminal
pro: com.example.pro.monthly, com.example.pro.yearly

Your code and policies then ask "does this user have pro?" and never need to know about individual products, prices or which store sold them. Adding a yearly plan or a promotional product is a Studio change, not a release. Keys are stored encrypted and used only on your project's server.

#Step 2: send purchases from the app

After every purchase, and when the user restores purchases, send it to be checked. The user must be signed in, so the purchase is linked to them.

#iOS with StoreKit 2

JavaScript
// After product.purchase() returns .success(.verified(transaction)),
// and for each transaction in Transaction.currentEntitlements when restoring:
const { purchase, entitlements } = await stitchdb.purchases.sync({
  store: 'apple',
  signed_transaction: transaction.jwsRepresentation,
})

In a native Swift app the Swift SDK takes the verification result directly:

swift
let result = try await product.purchase()
if case .success(let verification) = result { try await stitchdb.purchases.sync(verification) }

if try await stitchdb.purchases.hasEntitlement("pro") { unlockPro() }

Pass appAccountToken set to the user's id when purchasing. Then store notifications can find the user even if the app never sends the purchase, for example when it crashed right after payment.

#Android with Play Billing

JavaScript
// In onPurchasesUpdated, for each purchase in state PURCHASED:
const { entitlements } = await stitchdb.purchases.sync({
  store: 'google',
  purchase_token: purchase.purchaseToken,
  product_id: purchase.products[0], // needed for one-time products
})

StitchDB acknowledges the purchase for you, so the three-day refund rule is handled. Consumables still need to be consumed in the app. In Kotlin:

kotlin
stitchdb.purchases.sync(PurchaseInput.Google(purchaseToken = purchase.purchaseToken, productId = "pro_monthly"))
if (stitchdb.purchases.hasEntitlement("pro")) unlock()

#Over HTTP

Terminal
curl -X POST 'https://<project-ref>.p.stitchdb.com/purchases/v1/sync' \
  -H "apikey: <anon-key>" -H "Authorization: Bearer <user-access-token>" \
  -H 'Content-Type: application/json' \
  -d '{ "store": "google", "purchase_token": "<token>", "product_id": "pro_monthly" }'

Restoring a purchase on another account moves it to that account by default. You can turn this off in Studio, and the request is then refused with 409, which stops one subscription being shared across many accounts.

#Step 3: understand the statuses

StatusMeaningAccess
activePaid and current. A cancelled subscription stays active until the end of the paid period (auto_renew is false)Yes
graceRenewal failed; the store is retrying, and the user keeps access until grace_untilYes
billing_retryRenewal failed without a grace period (Google: on hold)No
paused, pendingPaused by the user (Google), or payment pendingNo
expiredEnded, or replaced by an upgrade or resubscriptionNo
refunded, revokedRefunded, or access removed (Apple Family Sharing)No

An entitlement is active while one of its products has a purchase that is active or in grace. If a store notification is missed, subscriptions past their end date are checked with the store again automatically, so a lost notification does not leave a lapsed user with access forever.

#Step 4: enforce entitlements in the database

The app's hasEntitlement('pro') decides what to show. The database decides what to return:

SQL
-- In policies: only users with the pro entitlement read premium rows
create policy "pro users read premium" on public.lessons
  for select to authenticated
  using (not premium or purchases.has_entitlement(auth.uid(), 'pro'));

-- All of a user's active entitlements
select * from purchases.entitlements_of(auth.uid());

This is the step most homegrown setups skip. Without it, premium content is protected only by the app hiding a button, and anyone who reads the anon key out of the app can fetch it. With the policy, a free user gets no premium rows from the REST API, GraphQL or Realtime. Signed-in users can only ask about themselves; your server, with the service role, can ask about anyone. The Row Level Security docs cover policy basics.

In the app, stitchdb.purchases.me() returns the user's purchases and entitlements.

#Step 5: tell your backend

If other systems need to know, such as a CRM, an analytics pipeline or a "welcome to Pro" e-mail, add up to 5 webhooks under Webhooks to your backend. Each event in purchases.events (purchase, renewal, cancellation, refund, expiry, a purchase moved to another account, or a store notice) is sent as a JSON POST with the current purchase and the user's entitlements after the change.

Requests are signed: StitchDB-Signature: v1=<hex> is the HMAC-SHA256 of <timestamp>.<raw body> with the webhook's secret. Verify it before trusting the body. In an Edge Function:

TypeScript
const enc = new TextEncoder()

Deno.serve(async (req) => {
  const body = await req.text()
  const ts = req.headers.get('stitchdb-webhook-timestamp') ?? ''
  const key = await crypto.subtle.importKey('raw', enc.encode(Deno.env.get('PURCHASES_WEBHOOK_SECRET')!), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
  const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(ts + '.' + body)))
  const want = 'v1=' + [...mac].map((b) => b.toString(16).padStart(2, '0')).join('')
  if (req.headers.get('stitchdb-signature') !== want || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return new Response('Bad signature', { status: 401 })
  }
  const { event, purchase, entitlements } = JSON.parse(body)
  // … e.g. update your own records for event.user_id
  return new Response('ok')
})

Any 2xx within 15 seconds counts as delivered. Otherwise deliveries are retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. Events can arrive out of order and, rarely, more than once, so skip StitchDB-Webhook-Id values you already handled and treat purchase as the current state.

#Testing

Sandbox and TestFlight purchases on iOS, and license testers on Google Play, are stored with environment = 'sandbox' and grant access unless you turn that off in Studio. That lets QA test paid flows on production builds. Before launch, turn off sandbox access if you do not want testers' entitlements to work on production data. Send test in Studio sends a purchases.test webhook to check your endpoint.

#What you can see

Studio shows active purchases per product, active users per entitlement, App Store revenue over the last 30 days (approximate; Google Play does not report prices) and the latest events. The data is in ordinary tables:

TableHolds
purchases.transactionsOne row per purchase, kept across renewals; users can read their own rows
purchases.eventsEvery purchase, renewal, status change and store notification, kept 400 days; server side only
purchases.entitlementsThe product to entitlement map
purchases.webhook_deliveriesDeliveries to your backend, kept 30 days

#Plans

In-app purchases are included from the Pro plan at no extra charge; Pro is $15.99 a month as of November 2026 (pricing). The feature runs on your project's own server. Full setup is in the in-app purchases docs. If you also sell on the web, Stripe subscriptions with Postgres uses the same "check it in a policy" approach, and a policy can allow access when either is true.

#FAQ

#Do I still need RevenueCat or a similar service?

Not for validation, status tracking and entitlements; that is what this feature does. Paywall builders, experiments and cross-platform dashboards are outside its scope.

#Can users fake a purchase by calling the sync endpoint?

No. The sync checks the signed transaction or purchase token with Apple or Google. A made-up token is rejected.

#What happens when a user gets a refund?

The store sends a notification, the purchase becomes refunded, the entitlement turns off, and your policies stop returning premium rows.

#Does it work with Flutter and React Native?

Yes. Each SDK has purchases.sync, and the HTTP endpoint works from any client. See the Flutter guide.

Ship paid features with server-side checks: start a project on Pro.