Skip to content
Blog

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:

  1. 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).
  2. Compare with a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest).
  3. Reject requests where abs(now - t) exceeds your tolerance (5 minutes) to block replays. The t is the time RevenueCat signed this delivery attempt — retries recompute t and v1, so a 5-minute window only needs to cover clock skew, not the whole retry schedule.
  4. 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 with event_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:

EventWhat happenedYour backend
INITIAL_PURCHASENew subscriptionGrant access
RENEWALRenewed (or lapsed user resubscribed)Extend access
CANCELLATIONAuto-renew off, or refundedKeep access until EXPIRATION — cancel ≠ expire
UNCANCELLATIONRe-enabled before expiryKeep/restore access
EXPIRATIONPeriod ended for goodRevoke access
BILLING_ISSUECharge failedDon’t revoke yet; grace period may still run (grace_period_expiration_at_ms)
PRODUCT_CHANGEPlan switch (takes effect later)Re-evaluate on next purchase event
SUBSCRIPTION_PAUSEDGoogle Play pause scheduledDo 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.

Sources