Cross-Platform Subscriptions

One authoritative subscription row shared by web checkout and App Store purchases.

Subscriptions are unified across channels: a purchase through web checkout (Creem or Stripe) and an in-app purchase through the App Store (via RevenueCat) both land in the same one-row-per-user subscription snapshot, and both web pages and native clients compute entitlement from it with the same server-side rule. Buy on the web, unlock in the app — and vice versa.

The model: one row per user

The existing subscription table is the authoritative snapshot — subscription.userId is unique, so a user has at most one current subscription. There are no extra provider_subscriptions or user_entitlements tables; entitlement is always computed from the current row. A (provider, subscriptionId) unique constraint additionally prevents one external subscription (e.g. one Apple subscription) from attaching to multiple accounts.

Per-provider field mapping:

FieldWeb (Creem / Stripe)App Store (via RevenueCat)
providercreem / stripeapp_store (play_store reserved)
customerIdPayment-platform customer IDRevenueCat original App User ID
subscriptionIdPayment-platform subscription IDApple original transaction ID
lastOrderNoOrder numberRevenueCat transaction ID
productIdProvider product/price IDApp Store product ID
productTypeone_time | month | yearsame
statustrialing | active | unpaid | canceled | expiredsame
providerEventId / providerEventAtWebhook event ID / timestamp — idempotency and orderingsame

Entitlement: one pure function

src/server/app/subscriptions/entitlement.ts is the single rule every surface uses — web pages, the App API, and (via shared test vectors) the Swift client:

  • active / trialing — entitled; one_time never expires, recurring plans require periodEnd > now.
  • canceled — still entitled until periodEnd.
  • unpaid / expired / unknown — not entitled.
  • No row — not entitled.

Native clients read it from GET /api/app/v1/subscription, which returns the snapshot (or null) with a server-computed isEntitled — clients must not re-derive entitlement themselves.

Blocking duplicate purchases

One subscription at a time also means one purchase channel at a time. Every purchase entry point checks the current row server-side:

  • Web checkout (/api/payment/create) returns 409 when the user is already entitled — hiding the buy button is not the protection; the server check is.
  • Native paywall: the app refreshes /api/app/v1/subscription before showing the paywall. If a valid web subscription exists it must not offer an App Store purchase; if the subscription is from the App Store it shows Apple's subscription management instead.
  • Switching channels is allowed only after the current subscription expires.

RevenueCat webhook

App Store purchases sync through RevenueCat's webhook:

POST /api/payment/notify/revenuecat

Set the Authorization header

In the RevenueCat dashboard, configure the webhook's Authorization header to exactly match REVENUECAT_WEBHOOK_AUTHORIZATION (including any Bearer prefix). Requests without a matching header are rejected before parsing.

Map the products explicitly

Set all three mappings — product names are never used to infer a plan interval:

REVENUECAT_PRODUCT_PRO_MONTHLY=com.example.app.pro.monthly
REVENUECAT_PRODUCT_PRO_YEARLY=com.example.app.pro.yearly
REVENUECAT_PRODUCT_LIFETIME=com.example.app.lifetime

Enable lifecycle events

Initial purchases, renewals, cancellations, expirations, billing issues, refunds/revocations, and non-renewing purchases.

Disable Transfer

Keep RevenueCat's Restore Behavior set to Transfer disabled. TRANSFER events are acknowledged but ignored and logged as configuration drift; receipt-account conflicts go to support instead of silently moving an entitlement between users.

The webhook resolves app_user_id to the Better Auth user (the native app sets the RevenueCat appUserID to the Better Auth user ID) and rejects anonymous IDs. Also note the mirror-image requirement on the client: purchases must only be possible after the RevenueCat identity is synced to the signed-in user.

Webhook safety: replay, ordering, conflicts

All three webhooks (Creem, Stripe, RevenueCat) write through one policy, decideSubscriptionWrite in src/server/app/subscriptions/subscriptionWritePolicy.ts:

DecisionWhen
duplicateSame providerEventId already applied — replays converge to the same state
staleSame provider but an older providerEventAt — out-of-order events never roll the row back
preserve-longer-entitlementA different channel tries to shorten an existing paid entitlement — the longer row wins and the conflict is logged for manual support
applyEverything else

RevenueCat retries non-2xx deliveries, so replays are expected and safe. The rare cross-channel concurrent purchase is deliberately not auto-reconciled: the paid user keeps the longer entitlement, and a structured log surfaces the conflict for a human.

Managing a subscription from the app

POST /api/app/v1/subscription/portal returns a short-lived provider-hosted management URL for creem/stripe subscriptions, or null when there is no hosted portal — App Store subscriptions are managed through Apple's own subscription settings, which the client opens based on provider.

On this page