RevenueCatSubscriptionWebhookJWTBackendIndie Dev

I paid, and the app still calls me a free user: designing two-path subscription sync

·6 min read

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:

FailureWhat happens
Semantic misreadCANCELLATION 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 redeliveryEvents are not ordered and may repeat. An early RENEWAL overwrites a later EXPIRATION, or the reverse
The path itself failsAn 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
Three ways treating webhook events as facts goes wrong

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:

javascript
// 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:

javascript
// 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.

WhereWho reads it
profiles.subscription_tier (table)The JWT-issuing hook reads this when the hook is enabled
auth metadata tierWith the hook disabled the JWT carries this directly; the app's settings screen also reads it
The dual write — both required

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

Sources