is_paying flag by folding webhook
events into a boolean starts correct and decays: missed deliveries,
refunds hiding inside CANCELLATION, transfers, out-of-order arrival. The
durable design is the one RevenueCat itself recommends — store each event
idempotently, fetch that customer's current state from the API, persist
it, and run a scheduled reconciliation as the repair layer. And keep
"entitled" and "paying" as separate ideas; they are not the same fact.
Wiring RevenueCat webhooks into your own database is the single best upgrade an indie dashboard can get: trials, renewals, and most refunds arrive as a POST you control, per user — typically within about a minute of the store transaction, though cancellation events can lag by up to two hours and a refund of an earlier period may produce no webhook at all. It's also where a very specific class of bug lives. This is the guide I wish I'd had before building it: the event names that don't mean what they sound like, the delivery limits RevenueCat documents but few people read, and the sync pattern that keeps the numbers honest.
Why bother with webhooks at all
RevenueCat's dashboard already shows charts. What it can't show is your
revenue joined to your behavioral data — which onboarding path produced
this subscriber, what the user did before refunding. For that, purchase
events have to land in the same database as your analytics events, keyed
by the same user ID. The webhook's app_user_id is that key,
which is why the
one-ID pattern
has to be in place first: if RevenueCat only knows its anonymous IDs,
every event arrives attached to a user your database has never met.
Setup, the short version
- Add a webhook endpoint per project in RevenueCat's dashboard and set an Authorization header value. Verify it on every request — the endpoint is a public URL, and unauthenticated writes into your revenue table are exactly as bad as they sound. RevenueCat also supports HMAC signature verification with timestamp validation; prefer it over a static Authorization header where available.
- Use the TEST event button to prove the path before trusting it.
- Branch on
environmentfrom day one. Sandbox and TestFlight purchases arrive asSANDBOX, and one afternoon of testing can outnumber a small app's real transactions. - Return 2xx fast and process async if you do anything slow. A response RevenueCat counts as failed triggers a retry, which is a feature until it duplicates your processing — and the retry budget is small, as the next section covers.
The vocabulary trap: CANCELLATION isn't what it sounds like
The event names read like plain English and two of them aren't. The one that bites everyone:
CANCELLATIONdoes not end access. It usually means the subscriber switched auto-renew off — they keep full access until the period ends, and many switch it back on (UNCANCELLATION) before it does. Access ends atEXPIRATION. Count CANCELLATION as churn and you'll double-book it (once when they opt out, again when it expires), and you'll book people who uncancel as churned when they never left.- Refunds hide inside CANCELLATION too. There's no
REFUND event type; a refund arrives as CANCELLATION with
cancel_reason: CUSTOMER_SUPPORT, and it covers the latest period only. Auto-renew can stay on after a refund (the subscriber may still renew next month), and a refund of an earlier period may fire no webhook at all — treat refund events as a signal, not a complete refund ledger. The reason field is the real signal:UNSUBSCRIBEis a normal opt-out,BILLING_ERRORis a failed payment,CUSTOMER_SUPPORTis money going back. - Trials look like purchases. A trial start arrives as
INITIAL_PURCHASEwithperiod_type: TRIAL— an event that pays you nothing. The actual money lands later, at the firstRENEWAL, when the trial converts. Sum "purchases" without checkingperiod_typeand your revenue chart counts free trials as income; your trial-to-paid funnel needs the distinction anyway, since trial starts and conversions are its two ends. BILLING_ISSUEstarts a story, not an ending. The store retries the payment, often for weeks, and a grace period may keep entitlement alive meanwhile. Some of these recover; the ones that don't eventually produce EXPIRATION.TRANSFERdepends on your project's restore settings. When a purchase is restored under a differentapp_user_id, what happens is configurable in RevenueCat: the default behavior transfers the purchase to the restoring user (firing TRANSFER), but a project can be set to keep purchases with the original user or to share across aliased IDs instead. Whichever you chose, model it — if a subscription moves and your flag stays on the old user, you have one phantom payer and one invisible one.
A worked default: treat CANCELLATION with UNSUBSCRIBE as an intent signal (worth watching, maybe worth a win-back), CUSTOMER_SUPPORT as revenue reversal for that period, and only EXPIRATION as the end of a paying relationship.
Delivery reality: five retries, then silence
The guarantee is best-effort at-least-once, and the retry budget is finite: when your endpoint fails to return a 2xx, RevenueCat retries the delivery five times, at roughly 5, 10, 20, 40, and 80-minute intervals. Add that up and it lands somewhere uncomfortable — an outage on your side longer than about two and a half hours means dropped events, and they stay dropped until you notice and resend them from the RevenueCat dashboard. Duplicates are still normal (a slow response counts as a failure), ordering isn't guaranteed, and after downtime a burst of stale events arrives at once. Three habits make all of this boring:
- Dedupe on the event
id. Every event carries one; an insert keyed on it makes replays free. - Store raw events append-only. The event log is your history and your debugging tool. When a number looks wrong six weeks later, the log answers; a mutated flag can't.
- Treat the event as a change notification, not the change itself. This is the pattern RevenueCat recommends: after storing the event, fetch that customer's current subscription state from the RevenueCat API and persist what it returns. The event says something changed; the API response says what's true now — a duplicate or reordered event then costs one redundant fetch instead of a corrupted flag.
Events vs entitlement truth
The failure that motivates the architecture looks like this. Say you
maintain is_paying per user: set it on INITIAL_PURCHASE and
RENEWAL, clear it on EXPIRATION. Correct on day one. Then the errors
start: your endpoint was down for three hours and the retries ran out; a
TRANSFER moved a subscription to a user you didn't update; a refund's
cancel_reason wasn't handled; an EXPIRATION processed before the RENEWAL
that preceded it. Each miss is rare, but the flag has no correction
mechanism — every error is permanent, and they accumulate. Months later
your dashboard says 212 paying users, RevenueCat says 189, and you no
longer trust either.
The fix is to stop treating events as the source of truth for
state. On every webhook, after the idempotent insert, call the
RevenueCat API for that app_user_id and persist the
customer's current subscriptions and entitlements — that's the primary
mechanism. On top of it, run a scheduled reconciliation as the repair
layer:
- Daily, sweep your user table against RevenueCat. Be honest about what this costs: there is no single endpoint that returns every currently entitled user in a project, so the sweep means paging through your customer list and fetching each customer's entitlements, inside the API rate limits. At indie volume that's a short nightly job; at larger volume, scope it to users your event log touched recently plus a rolling slice of everyone else.
- Set the entitlement fields from what the API returns, in both directions — grant and revoke. A repair layer that only flags and never unflags isn't one.
- Log the diff. Expect occasional corrections; a growing diff means a bug in your event handling worth finding.
One definition matters more than any mechanism: "entitled" is not
"paying now." An active entitlement can be a monthly subscription
mid-period, but it can also be a lifetime purchase from two years ago, a
promotional entitlement you granted a reviewer, a billing grace period
after a failed payment, or the temporary grace RevenueCat applies during a
store outage. Fold all of those into one boolean and the number stops
meaning anything you can act on. Store separate fields
(has_active_entitlement, is_trialing,
is_in_grace_period, owns_lifetime) and let each
dashboard number choose the definition it means.
With the fetch-on-event pattern plus reconciliation in place, your dashboard's count of users with at least one active entitlement, computed from RevenueCat API responses, matches RevenueCat's own count under that same predicate, and any missed webhook heals at that customer's next event or the next sweep. Events keep their real jobs: per-user revenue history, timing, and the join to behavior. And keep the money hierarchy straight — webhook prices are live estimates, while what Apple pays you is a different, smaller number on a different calendar; we broke that down in revenue vs proceeds vs your Apple payout.
FAQ
Does CANCELLATION mean the user lost access?
No — auto-renew off, access continues until period end, and EXPIRATION is
what ends it. Check cancel_reason: CUSTOMER_SUPPORT means the
latest period was refunded, auto-renew may still be on afterwards, and a
refund of an earlier period may fire no webhook at all.
Why does my paying count drift from RevenueCat's?
Event-folded flags accumulate permanent errors from missed, duplicated, reordered, or transferred events. Fetch the customer's current state from the API on every event and reconcile on a schedule; without those, the drift only grows.
Should I compute revenue totals from webhook events?
For live estimates and per-user analysis, yes. For money truth, RevenueCat owns purchase state and Apple's financial reports own what you're paid. Refunds and currency conversion revise history after events fire.
How do I handle duplicates, ordering, and missed events?
Append-only storage keyed on the event id, idempotent processing, and state fetched from the API rather than folded from events. And respect the retry budget: five retries over roughly two and a half hours, then manual resend from the dashboard — monitor your endpoint's uptime like the revenue infrastructure it is.