Revenue data · how-to

RevenueCat webhooks: why your paying-user flag rots

FolioKit · July 15, 2026 · from building this exact pipeline for four live apps

Short answer: webhooks tell you what happened; entitlements tell you who's paying now. If you derive an 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

  1. 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.
  2. Use the TEST event button to prove the path before trusting it.
  3. Branch on environment from day one. Sandbox and TestFlight purchases arrive as SANDBOX, and one afternoon of testing can outnumber a small app's real transactions.
  4. 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:

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:

  1. Dedupe on the event id. Every event carries one; an insert keyed on it makes replays free.
  2. 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.
  3. 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:

  1. Daily, fetch the currently-entitled users per project.
  2. Set is_paying true for exactly that set, false for everyone else — flag and unflag, both directions.
  3. 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.

Where FolioKit fits: this is the pipeline FolioKit runs so you don't build it — webhook ingestion per app joined to behavior on one user ID, plus a daily two-way reconciliation against RevenueCat entitlements, so the paying-user count always equals RevenueCat's. Setup is part of the standard integration.

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.

FolioKit is analytics for indie iOS developers — RevenueCat revenue joined to user behavior on one record, reconciled daily against entitlements, across every app you run.

Get early access