On this page

In-app purchases

Check App Store and Google Play purchases and subscriptions with the stores, keep them per user and use entitlements in row level security.

In-app purchases (included from the Pro plan) checks every App Store and Google Play purchase with Apple or Google, never trusting the device, and saves it for the signed-in user in purchases.transactions. Store notifications keep subscriptions current on renewals, cancellations, refunds and billing problems. Products are grouped into entitlements (such as pro) that you check in SQL and row level security policies. It runs on your project's own server; there is no extra charge.

#Set it up

Open Studio › Integrations › In-app purchases.

  1. App Store: enter your app's bundle ID. Add an App Store Connect API key too (Users and Access › Integrations › In-App Purchase: the .p8 file, its Key ID and the Issuer ID); with it, the subscription status comes straight from Apple. Then in App Store Connect › your app › App Information › App Store Server Notifications, set both the Production and the Sandbox URL to the address shown in Studio (https://<project-ref>.p.stitchdb.com/purchases/v1/apple), Version 2.
  2. 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 in Play Console › Monetization setup › Real-time developer notifications, and add a push subscription to it with the endpoint shown in Studio (it contains a key; keep it private).
  3. Entitlements: list which product IDs give which entitlement, for example pro: com.example.pro.monthly, com.example.pro.yearly.

Keys are stored encrypted and used only on your project's server.

#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; the purchase is linked to them and the answer includes their entitlements.

// 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,
})
// Tip: pass appAccountToken = the user's id when purchasing, so store notifications find the user
// even if the app never sends the purchase.

Without an App Store API key, the signed transaction from the app is checked against Apple's certificates; with the key, its status is also read from Apple. Restoring a purchase on another account moves it to that account (you can turn this off in Studio; the request is then refused with 409).

#Check access

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());

An entitlement is active while one of its products has a purchase that is active or in its grace period. Signed-in users can only ask about themselves; your server (service role) can ask about anyone. In the app, stitchdb.purchases.me() returns the user's purchases and entitlements, and hasEntitlement('pro') a yes or no.

#Statuses

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

Test purchases (App Store sandbox and TestFlight, Google license testers) have environment = 'sandbox' and give access unless you turn that off in Studio. If a store notification is missed, subscriptions past their end date are checked with the store again automatically.

#Tables

TableWhat it holds
purchases.transactionsOne row per purchase (a subscription keeps its row across renewals): user, store, product, kind, status, expiry, auto-renew, last price (App Store), and the store's data in attrs. Users can read their own rows.
purchases.eventsEvery purchase, renewal, status change and store notification, kept 400 days. Server side only.
purchases.entitlementsThe product → entitlement map from Studio.
purchases.webhook_deliveriesWebhook deliveries to your backend with their result, kept 30 days. Server side only.

#Webhooks

To tell your own backend about purchases, add up to 5 webhooks in Studio › Integrations › In-app purchases › Webhooks to your backend. Every event that lands in purchases.events (a purchase, renewal, cancellation, refund, expiry, a purchase moved to another account, or a store notice such as a test notification) is sent to each webhook as a JSON POST, from your project's own server. For each webhook choose:

  • Events: all events, or purchases only (leaves out store notices that name no purchase).
  • Test purchases: sent or not sent (sandbox, TestFlight and license-tester purchases).

The URL must be a public https:// address; private and internal addresses are refused, and redirects are not followed.

JSON
{
  "id": "pe_1842",
  "type": "purchases.event",
  "created_at": "2026-10-29T10:00:00.000Z",
  "project_ref": "abcdefghijklmnop",
  "event": {
    "id": 1842, "at": "2026-10-29T10:00:00.000Z", "store": "apple", "type": "DID_RENEW",
    "original_id": "2000000123456789", "user_id": "6f1c…", "product_id": "com.example.pro.monthly",
    "status": "active", "previous_status": "billing_retry", "price": 4.99, "currency": "EUR", "attrs": {}
  },
  "purchase": {
    "id": 77, "store": "apple", "product_id": "com.example.pro.monthly", "kind": "subscription", "status": "active",
    "environment": "production", "auto_renew": true, "expires_at": "2026-11-29T10:00:00.000Z", "…": "…"
  },
  "entitlements": ["pro"]
}

event.type is what happened: purchase, renewal or the new status for purchases sent from the app, otherwise the store's notification type (DID_RENEW, REFUND, SUBSCRIPTION_CANCELED, EXPIRED when a subscription ran out, …). purchase is the purchase as it is now (null for store notices) and entitlements the user's entitlements after the change. price is set for new charges on the App Store. Send test in Studio sends "type": "purchases.test".

#Verify the signature

Every request has StitchDB-Webhook-Id (the payload id, the same on every retry: skip ids you already handled), StitchDB-Event, StitchDB-Webhook-Timestamp (Unix seconds) and StitchDB-Signature: v1=<hex>, the HMAC-SHA256 of <timestamp>.<raw body> with the webhook's signing secret (Studio › Secret). Compute it over the body exactly as received, before parsing the JSON.

import crypto from 'node:crypto'

// rawBody: the request body exactly as received (a string or Buffer), before JSON parsing.
export function verify(secret, headers, rawBody) {
  const ts = headers['stitchdb-webhook-timestamp']
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false // older than 5 minutes
  const want = 'v1=' + crypto.createHmac('sha256', secret).update(ts + '.' + rawBody).digest('hex')
  const got = String(headers['stitchdb-signature'] || '')
  return got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))
}

#Delivery and retries

  • Any 2xx answer within 15 seconds counts as delivered. Answer quickly and do slow work afterwards.
  • Otherwise the delivery is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, then marked failed. Send again in Studio queues a failed delivery once more.
  • Events can arrive out of order and, rarely, more than once: use StitchDB-Webhook-Id, and treat purchase as the current state.
  • Studio shows the latest deliveries with their result. The log (purchases.webhook_deliveries) is kept 30 days.

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. Turn off stops checking and refuses store notifications; the purchases schema stays until you drop it.