is_paying flag by folding webhook events into a boolean, it
starts correct and decays — missed events, refunds hiding inside
CANCELLATION, transfers, out-of-order delivery. The durable design: store
events append-only for history, and reconcile the paying flag on a schedule
against RevenueCat's entitlements, where "paying" means currently entitled.
Wiring RevenueCat webhooks into your own database is the single best upgrade an indie dashboard can get: every trial, renewal, and refund arrives as a POST you control, per user, seconds after it happens. 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 vocabulary that lies to you, the delivery realities, and the reconciliation loop 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.
- 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 — failed responses get retried, which is a feature until it duplicates your processing.
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 actually 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. 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.TRANSFERmoves purchases between users. A restore on a new device or account can reassign the subscription from oneapp_user_idto another. If your flag lives on the old user, you now 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, and only EXPIRATION as the end of a paying relationship.
Delivery reality: at-least-once, eventually, in some order
Webhooks are retried until acknowledged, which means the guarantee is at-least-once — duplicates are normal, and after an outage on your side, a burst of stale events arrives whenever you come back. Ordering isn't guaranteed either. 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.
- Never mutate state directly from an arriving event. Derive state by reading the log — or better, don't derive the paying flag from events at all, which brings us to the actual point.
Events vs entitlement truth
Here's the failure that motivates the whole post. Say you maintain
is_paying per user: set it on INITIAL_PURCHASE and RENEWAL,
clear it on EXPIRATION. Correct on day one. Then reality leaks in — your
endpoint was down for an hour and a retry got dropped by a deploy; 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 a boolean has no self-healing:
every error is permanent, and they only 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. RevenueCat's entitlements API knows exactly who is currently entitled — active subscription or lifetime purchase — so reconcile against it on a schedule:
- Daily, fetch the currently-entitled users per project.
- Set
is_payingtrue for exactly that set, false for everyone else — flag and unflag, both directions. - Log the diff. A handful of corrections a week is normal; a growing diff means a bug in your event handling worth finding.
With reconciliation in place, "paying users" on your dashboard means paying now, matches RevenueCat's count by construction, and any single missed webhook heals within a day. 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 actually 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. EXPIRATION ends
access. And check cancel_reason: CUSTOMER_SUPPORT means it was
a refund, which is a different fact entirely.
Why does my paying count drift from RevenueCat's?
Event-folded flags accumulate permanent errors from missed, duplicated, reordered, or transferred events. Scheduled reconciliation against entitlements is the fix; without it 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 actually paid. Refunds and currency conversion revise history after events fire.
How do I handle duplicates and ordering?
Append-only storage keyed on the event id, idempotent processing, state derived rather than mutated. Design for replays on day one and the retry behavior becomes a safety feature instead of a bug source.