Revenue data · how-to

RevenueCat webhooks vs entitlements: keeping status in sync

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

Short answer: webhooks tell you what happened; a customer's current entitlement state tells you where things stand now. Deriving an 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

  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. RevenueCat also supports HMAC signature verification with timestamp validation; prefer it over a static Authorization header where available.
  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. 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:

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:

  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. 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:

  1. 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.
  2. 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.
  3. 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.

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, current-state fetches on every event, and a daily two-way reconciliation against RevenueCat entitlements, so the entitled-user count matches RevenueCat's under the same definition. Setup is part of the standard integration.

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.

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