I paid, and the app still calls me a free user: designing two-path subscription sync
A subscription product has one moment it absolutely cannot fumble: the user taps buy, Face ID passes, money moves — and they return to your app.
If the screen still says "Free plan", every funnel optimization upstream was wasted. They will not think "it is probably syncing". They will think "I got scammed".
It looks like a purchase-flow problem. It is not. The purchase already completed on the App Store side. The real question is: when, and based on what, does your server mark this user as paid?
The naive architecture, and its three failure modes
The standard RevenueCat setup is a webhook: it pushes events on purchase, renewal, cancellation, refund, and you update your database. One path looks sufficient.
Write the event payload straight into the database, though, and there are three ways to die:
| Failure | What happens |
|---|---|
| Semantic misread | CANCELLATION also covers a voluntary cancel (cancel_reason UNSUBSCRIBE). The paid period has not ended yet. Downgrade on the event and you confiscate time the user already paid for |
| Reordering and redelivery | Events may repeat; the docs do not mention order, so this article assumes none. An early RENEWAL overwrites a later EXPIRATION, or the reverse |
| The path itself fails | An anonymous purchase maps to no account, your server happens to 5xx, the event dies in transit — the user paid and nothing happened on your side |
The first two write wrong data. The third writes nothing. What they share: with only a push path, state eventually drifts.
The core decision: the event is a doorbell, not a package
My webhook handler uses the event payload for exactly one thing: figuring out which user to sync. What tier that user gets is always recomputed by fetching RevenueCat's current entitlements:
// Derive tier from *current* entitlements; expires_date null = lifetime
export function deriveTier(subscriber, now = Date.now()) {
const entitlements = subscriber?.entitlements ?? {};
const active = (name) => {
const ent = entitlements[name];
if (!ent) return false;
if (ent.expires_date == null) return true;
return Date.parse(ent.expires_date) > now;
};
if (active('premium')) return 'premium';
if (active('pro')) return 'pro';
return 'free';
}This one decision kills the first two failure modes:
Ordering stops mattering — whatever order events arrive in, every sync recomputes from the truth as of now, and the same event delivered more than once writes the same result. The whole update is idempotent, which also simplifies error handling: any failed step returns 5xx and lets RevenueCat retry with backoff, and retries cannot double-grant or double-revoke anything.
CANCELLATION semantics also stop being my problem. A user who canceled voluntarily (UNSUBSCRIBE) still has an unexpired entitlement, so deriveTier keeps returning the paid tier; on expiry day the EXPIRATION event rings the doorbell and the recomputation naturally lands on free. There is not a single line of "if event is cancellation then downgrade" anywhere.
The second path: reconcile, the webhook's compensation
The third failure mode — the doorbell itself breaking — remains. The classic case is the anonymous purchase: the user buys before logging in, the event carries RevenueCat's anonymous id, and it maps to no account in my database. The webhook can only give up.
So there is a second path running in the opposite direction — the client pulls:
// POST /api/subscription/reconcile (with a Supabase JWT)
// Authoritative for the JWT's uuid: fetch current entitlements, write tier back
export async function handleSubscriptionReconcile(request, user, env) {
const tier = await syncTierForUser(user.id, env);
return { data: { tier }, status: 200 };
}The app calls it whenever it detects inconsistency — the local SDK says an entitlement exists but the JWT says free — then calls refreshSession() for a fresh token. The screen corrects immediately.
Note that this path shares syncTierForUser with the webhook: the same "RevenueCat's current entitlements are the truth" logic, different trigger. One door is rung by RevenueCat, the other by the user; what happens after the door opens is identical. That matters — give the two paths separate logic and they will eventually compute different answers for the same user.
The compensation is bidirectional: entitlements that should exist get granted, expired ones still showing locally get downgraded. Self-healing has no preferred direction.
Why the server reads tier from the JWT, never from client flags
One more spot that is easy to get wrong: what do server-side feature gates (who gets advanced AI, who gets quota) trust as the tier?
Not a client-supplied flag. A field that says "I am premium" is one edited request away from being forged. The tier must live inside the server-signed JWT — the client can present the token but cannot alter its contents.
Which surfaces a non-obvious detail: the tier is written to two places.
| Where | Who reads it |
|---|---|
| profiles.subscription_tier (table) | The JWT-issuing hook reads this when the hook is enabled |
| auth metadata tier | With the hook disabled the JWT carries this directly; the app's settings screen also reads it |
Why both? Because the JWT issuing chain has two configuration states, and each reads a different source. Write only one and one configuration is silently wrong — the "correct for some users, wrong for others" kind that is hardest to debug.
Five small decisions you only learn by stepping on them
Looking back, five details the documentation will not hand you:
One — return 200, not 5xx, for anonymous purchases that map to no account. A 5xx triggers redelivery, up to 5 retries over about 80 minutes (see the official docs in the next section). But this event will never map to an account, so retries cannot help. Acknowledge it and let reconcile pick the purchase up after the user logs in and the SDK aliases the anonymous id.
Two — TRANSFER events sync two people. When entitlements move from account A to B, A goes down and B goes up. Missing either side is data corruption. RevenueCat says this webhook goes only to the destination user (B). Find the source user (A) in the event payload.
Three — RevenueCat's lookup API is get-or-create. Query an id it has never seen and it creates an empty subscriber and returns empty entitlements. Harmless for real users (an aliased uuid returns true entitlements), but do not probe it with arbitrary strings — you will litter RevenueCat with ghost accounts.
Four — verify the webhook with a constant-time comparison. Matching the Authorization header against the secret with === leaks prefix information through timing. Also tolerate the common "Bearer <secret>" dashboard configuration and save yourself a debugging round-trip.
Five — the row might not exist. In theory signup created the profiles row; "in theory" is not a guarantee. Insert it during sync if missing, or some later COALESCE(NULL, 'free') will silently downgrade a paying user.
Example: what the official docs and this site's privacy policy say
The backend code is not in the website repo, so this section has no line numbers. It cites the RevenueCat docs (read 2026-11-05; the pages show no date) and files in the website repo.
The Webhooks page recommends calling GET /subscribers after receiving any webhook. That is the doorbell pattern.
The same page caps redelivery: a non-200 response is a failure, retried up to 5 times over about 80 minutes. Once retries stop, only reconcile can repair the state.
The same page says an event may arrive more than once and recommends idempotent processing. It does not mention ordering; "not ordered" is this article's design assumption.
The Event Types and Fields page defines CANCELLATION as canceled or refunded. After a refund, trust the re-fetched entitlements; this article did not test refunds.
In REST API v1, this endpoint is titled "Get or Create Customer". It creates a missing customer and returns 201, which matches point three.
The site's privacy policy (src/data/legalContent.ts, line 197) describes the same flow under the app's current name, KofAI. Without sign-in it uses an anonymous ID. The server asks RevenueCat for the current plan and saves it to the account.
If you are building this
The whole design compresses to four principles:
One — events are triggers; state is always re-fetched from the authority. This buys idempotency, reorder safety, and redelivery safety in one move.
Two — every push path (webhook) gets a pull path (reconcile), and both share one sync function.
Three — server-side tier checks trust the JWT only, never client flags.
Four — decide per failure what status code to return: recoverable by redelivery gets 5xx; unrecoverable gets 200 and is handed to the other path.
Between the buy button and the paid-plan screen sits an entire distributed system. Using RevenueCat does not make it disappear — it makes it manageable.
The product this machinery serves: KOFNote