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:
| Field | Web (Creem / Stripe) | App Store (via RevenueCat) |
|---|---|---|
provider | creem / stripe | app_store (play_store reserved) |
customerId | Payment-platform customer ID | RevenueCat original App User ID |
subscriptionId | Payment-platform subscription ID | Apple original transaction ID |
lastOrderNo | Order number | RevenueCat transaction ID |
productId | Provider product/price ID | App Store product ID |
productType | one_time | month | year | same |
status | trialing | active | unpaid | canceled | expired | same |
providerEventId / providerEventAt | Webhook event ID / timestamp — idempotency and ordering | same |
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_timenever expires, recurring plans requireperiodEnd > now.canceled— still entitled untilperiodEnd.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) returns409when 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/subscriptionbefore 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/revenuecatSet 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.lifetimeEnable 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:
| Decision | When |
|---|---|
duplicate | Same providerEventId already applied — replays converge to the same state |
stale | Same provider but an older providerEventAt — out-of-order events never roll the row back |
preserve-longer-entitlement | A different channel tries to shorten an existing paid entitlement — the longer row wins and the conflict is logged for manual support |
apply | Everything 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.
