Revenue data · how-to

RevenueCat webhook events vs entitlements: keeping status in sync

Building on RevenueCat webhooks for your own dashboard: what each event means for access, the CANCELLATION and refund traps, signature checks, the five-retry delivery limit, why entitled is not the same as paying, and the store-event-then-fetch-state pattern that keeps subscription status in sync.

Razvan Popa, runs four live iOS apps ·Updated ·First published · 

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'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

  1. 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.
  2. Better, turn on HMAC signing. Each delivery then carries an X-RevenueCat-Webhook-Signature header shaped t=<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.
  3. Use the TEST event button to prove the path before trusting it.
  4. 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.
  5. 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:

  • CANCELLATION does 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 at EXPIRATION. 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, a REFUND_REVERSED event arrives. The reason field carries the meaning: UNSUBSCRIBE is a normal opt-out, BILLING_ERROR is a failed payment, CUSTOMER_SUPPORT is money going back.
  • Trials look like purchases. A trial start arrives as INITIAL_PURCHASE with period_type: TRIAL, an event that pays you nothing. The money lands later, at the first RENEWAL, when the trial converts. Sum "purchases" without checking period_type and 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_ISSUE starts 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.
  • TRANSFER depends on your project's restore settings. When a purchase is restored under a different app_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):

EventWhat happenedAccessWhat to do
INITIAL_PURCHASENew subscription, or a trial when period_type is TRIALStartsCount money only when it isn't a trial
RENEWALRenewed, including a trial convertingContinuesBook revenue
CANCELLATIONAuto-renew off, or a refund of the latest periodContinues to period end (unless refunded)Read cancel_reason
UNCANCELLATIONAuto-renew back onContinuesClear the churn-risk flag
BILLING_ISSUEA charge failedMay continue in a grace periodWatch; don't count as churn yet
EXPIRATIONSubscription endedEndsRemove access; this is churn
PRODUCT_CHANGEUpgrade, downgrade or crossgradeContinues; a downgrade may only apply at the next renewalUpdate plan fields
TRANSFERPurchases moved between app user IDsMoves to the destination userRe-fetch both users
NON_RENEWING_PURCHASEOne-time purchaseDepends on the productBook revenue
SUBSCRIPTION_EXTENDEDExpiration pushed laterExtendedUpdate the end date
TEMPORARY_ENTITLEMENT_GRANTRevenueCat couldn't validate with the store and granted access for at most 24 hoursTemporaryDon't count as paying
REFUND_REVERSEDA refund was reversedAs before the refundRe-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:

  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. RevenueCat's docs say it directly: "we recommend calling the GET /subscribers REST 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:

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

Where FolioKit fits: FolioKit takes in RevenueCat webhooks per app, joins each event to the user's behavior on one user ID, and reconciles the paying flag against RevenueCat entitlements every day, so you don't build this pipeline yourself. Sandbox purchases and Family Sharing stay out of revenue metrics. Setup is part of the standard integration.

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.

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.

Start free