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's docs recommend: 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. RevenueCat's webhook docs say most are delivered within 5 to 60 seconds of the event and cancellations usually within two hours, and a refund of an earlier subscription period produces 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 right.
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. Check it on every request: the endpoint is a public URL, and unauthenticated writes into your revenue table are exactly as bad as they sound.
- Better, turn on HMAC signing. Each delivery then
carries an
X-RevenueCat-Webhook-Signatureheader shapedt=<unix timestamp>,v1=<hex HMAC-SHA256>, computed over<timestamp>.<raw body>with the integration's signing secret. Two gotchas: compute it over the raw request bytes before any JSON parsing (re-serialized JSON won't match), and save the secret when it's shown, because RevenueCat shows it only once. The docs suggest a 5-minute tolerance on the timestamp. If you find a late-2025 community answer saying RevenueCat sends no signature header, it predates the current docs. - 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. RevenueCat disconnects after 60 seconds, and a response it counts as failed triggers a retry. That's a feature until it duplicates your processing, and the retry budget is small, as a later 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. RevenueCat's event reference says it fires only when the latest subscription period is refunded; refunds of earlier periods don't trigger it. Auto-renew can stay on after a refund, so treat refund events as a signal, not a complete refund ledger. If Apple later reverses a refund, aREFUND_REVERSEDevent arrives. The reason field carries the meaning: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 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's restore behavior: the default, "Transfer to new App User ID", moves the purchase to the restoring user and fires TRANSFER, which is sent only for the destination user. Other options transfer only when there's no active subscription, keep purchases with the original user, or share them across IDs (a legacy mode). 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.
The events an iOS subscription app sees most, with what each means for access (descriptions paraphrased from RevenueCat's event reference):
| Event | What happened | Access | What to do |
|---|---|---|---|
INITIAL_PURCHASE | New subscription, or a trial when period_type is TRIAL | Starts | Count money only when it isn't a trial |
RENEWAL | Renewed, including a trial converting | Continues | Book revenue |
CANCELLATION | Auto-renew off, or a refund of the latest period | Continues to period end (unless refunded) | Read cancel_reason |
UNCANCELLATION | Auto-renew back on | Continues | Clear the churn-risk flag |
BILLING_ISSUE | A charge failed | May continue in a grace period | Watch; don't count as churn yet |
EXPIRATION | Subscription ended | Ends | Remove access; this is churn |
PRODUCT_CHANGE | Upgrade, downgrade or crossgrade | Continues; a downgrade may only apply at the next renewal | Update plan fields |
TRANSFER | Purchases moved between app user IDs | Moves to the destination user | Re-fetch both users |
NON_RENEWING_PURCHASE | One-time purchase | Depends on the product | Book revenue |
SUBSCRIPTION_EXTENDED | Expiration pushed later | Extended | Update the end date |
TEMPORARY_ENTITLEMENT_GRANT | RevenueCat couldn't validate with the store and granted access for at most 24 hours | Temporary | Don't count as paying |
REFUND_REVERSED | A refund was reversed | As before the refund | Re-fetch the customer |
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 up to five times with increasing delays of 5, 10, 20, 40 and 80 minutes, then stops. 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 press Retry on the failed event in the webhook integration page. 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. RevenueCat's docs say it directly: "we recommend
calling the
GET /subscribersREST API endpoint after receiving any webhook." Store the event, fetch that customer's current subscription state, and persist what the API returns. The event says something changed and the API response says what's true now, so a duplicate or reordered event 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, so every error is permanent and they accumulate. Months
later your dashboard says 212 paying users, RevenueCat says 189 (made-up
numbers, familiar shape), 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 entitlement of up to 24 hours
RevenueCat grants when it can't reach the store. 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, as broken down in Apple payout vs App Store proceeds vs RevenueCat revenue.
FAQ
Does CANCELLATION mean the user lost access?
No. Auto-renew is off, access continues until the period ends, 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 fires 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 a manual Retry from the dashboard. Monitor your endpoint's uptime like the revenue infrastructure it is.