RevenueCat Webhooks: HMAC Signature Verification and Entitlement Sync
Verify X-RevenueCat-Webhook-Signature over the raw body, stay idempotent across retries, and map event types to entitlement grants and revocations on your backend.
Published on • November 13, 2026
AI Assistant

in-app-purchases-revenuecat-storekit2 covered the app side: offerings, paywalls, purchases, and entitlement checks. But the app’s entitlement view is a cached opinion — the source of truth for your backend is RevenueCat’s webhook stream. This post implements the receiving end: signature verification, retry-safe processing, and the event-to-entitlement mapping rules that decide when a user loses access.
Register the endpoint, then authenticate it two ways
In the RevenueCat dashboard → Integrations → Webhooks you register an HTTPS URL and, optionally, an authorization header value that RevenueCat sends with every POST. The docs recommend setting it and verifying it on every request — that’s your baseline.
For real verification there’s HMAC signing, which puts this in a header:
X-RevenueCat-Webhook-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>
The HMAC-SHA256 is computed over "<timestamp>.<raw_json_body>" using your integration’s signing secret. Four rules from the docs that decide whether verification works at all:
- Hash the raw body bytes exactly as received — before any JSON parsing. Re-serializing a parsed object changes the bytes and fails every valid request. Most frameworks need explicit opt-in to expose the raw body (
express.raw(),request.get_data(),request.raw_post). - Compare with a constant-time comparison (
crypto.timingSafeEqual,hmac.compare_digest). - Reject requests where
abs(now - t)exceeds your tolerance (5 minutes) to block replays. Thetis the time RevenueCat signed this delivery attempt — retries recomputetandv1, so a 5-minute window only needs to cover clock skew, not the whole retry schedule. - The signing secret is shown once at creation or rotation; store it in your secret manager and rotate rather than hunt for it.
function verifyRevenueCat(req, secret, tolerance = 300): boolean {
const header = req.headers['x-revenuecat-webhook-signature'] as string;
const parts = Object.fromEntries(header.split(',').map(p => p.split('=') as [string, string]));
const signed = `${parts.t}.${req.rawBody.toString('utf8')}`; // raw bytes!
const digest = createHmac('sha256', secret).update(signed).digest('hex');
if (!timingSafeEqual(Buffer.from(digest), Buffer.from(parts.v1))) return false;
return Math.abs(Date.now() / 1000 - Number(parts.t)) <= tolerance;
}
Respond fast, process later, stay idempotent
The delivery contract:
- Return 200 quickly; RevenueCat disconnects if you don’t respond within 60s.
- Non-200 triggers retries — up to 5 times at roughly 2.5, 5, 10, 20, and 40 minutes (with jitter), a cumulative window of about 80 minutes. After that, the event is gone unless you re-fire it from the dashboard.
- Delivery is “at least once”: duplicates happen. Guard with the event’s
id, which retries reuse along withevent_timestamp_ms.
The practical shape: verify → enqueue {event.id, type, payload} in a durable queue → return 200 → process asynchronously with INSERT ... ON CONFLICT (event_id) DO NOTHING as the idempotency fence.
Read the payload before you trust it
A lifecycle webhook body looks like this (abridged from the sample events):
{
"api_version": "1.0",
"event": {
"id": "9a1f2c3d-unique-event-id",
"type": "INITIAL_PURCHASE",
"app_user_id": "user_123",
"original_app_user_id": "user_123",
"aliases": ["user_123"],
"product_id": "premium_sub:p1m",
"entitlement_ids": ["pro"],
"environment": "SANDBOX",
"period_type": "TRIAL",
"purchased_at_ms": 1794500000000,
"expiration_at_ms": 1797180000000,
"store": "PLAY_STORE",
"event_timestamp_ms": 1794500001000
}
}
Parsing rules the docs insist on: “Always” fields can still be null, “Sometimes” keys may be absent entirely, and unknown fields will appear without warning — so treat every key as optional and never throw on an unrecognized event type. Look users up by both app_user_id and the aliases array (the same human can appear under multiple IDs after a login change), and derive the period length from expiration_at_ms - purchased_at_ms. Note environment: "SANDBOX" — RevenueCat has no sandbox of its own; the value just mirrors which store environment produced the transaction.
The processing pipeline
Putting the contract together, the handler that survives production looks like this:
app.post('/webhooks/revenuecat', async (req, res) => {
if (!verifyAuthorizationHeader(req)) return res.status(401).end();
if (!verifyRevenueCat(req, SIGNING_SECRET)) return res.status(401).end();
const event = req.body.event;
await db.queue.insert({ // idempotency fence
eventId: event.id, type: event.type, payload: req.body
}).onConflictDoNothing(); // retries reuse the id
res.status(200).end(); // respond before processing
await processQueue(); // async, at-least-once safe
});
processQueue fetches ground truth via GET /subscribers/{app_user_id} (the docs’ recommended normalization), upserts the subscriber row, and maps entitlements to grants/revocations using the event table below. Because retries arrive on a ~80-minute tail, make every step — queue insert, subscriber upsert, entitlement write — idempotent on event.id or original_transaction_id.
The event-to-access mapping
From the event types reference, the lifecycle events that matter for entitlement sync:
| Event | What happened | Your backend |
|---|---|---|
INITIAL_PURCHASE | New subscription | Grant access |
RENEWAL | Renewed (or lapsed user resubscribed) | Extend access |
CANCELLATION | Auto-renew off, or refunded | Keep access until EXPIRATION — cancel ≠ expire |
UNCANCELLATION | Re-enabled before expiry | Keep/restore access |
EXPIRATION | Period ended for good | Revoke access |
BILLING_ISSUE | Charge failed | Don’t revoke yet; grace period may still run (grace_period_expiration_at_ms) |
PRODUCT_CHANGE | Plan switch (takes effect later) | Re-evaluate on next purchase event |
SUBSCRIPTION_PAUSED | Google Play pause scheduled | Do not revoke — only revoke on EXPIRATION with reason SUBSCRIPTION_PAUSED |
The docs’ own recommendation for sync: rather than hand-rolling logic per event type, call GET /subscribers (the REST API) after receiving any webhook and normalize from that single response shape. Your handler becomes “something changed for this app_user_id — fetch ground truth and upsert,” which is simpler and survives new event types. Parse defensively either way: unknown fields and new event types will appear without an API version bump.
Test without spending money
RevenueCat issues a TEST event from the dashboard that uses a purchase-like payload (not persisted in production), and sandbox purchases deliver with environment: "SANDBOX" — filter or label those so sandbox data never grants production access. The signature check uses the same secret in both cases, so one verification path covers both.
Takeaway: Verify HMAC over the raw body with constant-time compare and a 5-minute tolerance, return 200 and process asynchronously behind an event-id idempotency fence, revoke only on EXPIRATION (never on CANCELLATION or SUBSCRIPTION_PAUSED), and normalize by calling GET /subscribers instead of hand-mapping every event type.