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 only means auto-renew was turned off; the entitlement stays valid until expiry. Downgrade on the event and you confiscate time the user already paid for |
| Reordering and redelivery | Events are not ordered and may repeat. 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 ten times 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 turned off auto-renew 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 infinite redelivery, but this event will never map to an account no matter how many times it returns. 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.
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.
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