Gareth Wilson Gareth Wilson

Guide to RevenueCat Webhooks: Features and Best Practices

Published


RevenueCat webhooks tell your backend what is happening to your app's subscriptions and purchases: a user starts a subscription, renews, cancels, hits a billing problem, changes product, gets a refund, or lets a subscription expire. If you keep subscription state in your own database, run lifecycle emails, or grant access in other systems, webhooks are how RevenueCat tells you about each change across the App Store, Google Play, Stripe and its other stores.

This guide covers how RevenueCat webhook integrations are set up, the two ways to authenticate them, the event types and their payloads, RevenueCat's retry and timing rules, and the best practices for running a RevenueCat receiver in production.

What are RevenueCat webhooks?

A RevenueCat webhook integration sends a JSON POST to your HTTPS URL for each event in a project. Webhooks are available on the Pro plan. A project can have several integrations, each filtered to production purchases, sandbox purchases or both, to one app or all apps, and to the event types you choose.

Every body has an api_version (currently 1.0) and an event object. The event's type names what happened, and its id identifies the event across retries.

RevenueCat offers two ways to authenticate deliveries. You can set an authorization header value that RevenueCat sends on every request. And you can turn on HMAC signing, which adds an X-RevenueCat-Webhook-Signature header with a timestamp and a signature over the raw body. Both are optional.

RevenueCat webhook features

FeatureDetails
PlanPro
ConfigurationDashboard: Project > Integrations > Webhooks > Add new configuration
IntegrationsSeveral per project, each with its own URL and filters
FiltersProduction, sandbox or both. One app or all apps. Optional event type filter
Authorization headerOptional. Sent verbatim on every request
Signature headerX-RevenueCat-Webhook-Signature: t=<unix>,v1=<hex>, when HMAC signing is on
SchemeHMAC-SHA256 over {t}.{raw body}, hex encoded
SecretPer integration, shown once. Rotate secret invalidates the old one immediately
Event nameevent.type
Deduplication keyevent.id, reused on retries
SuccessA 200 only. Any other status is a failure
RetriesUp to 5, at about 2.5, 5, 10, 20 and 40 minutes (about 80 minutes in total)
Manual retryRetry on the integration page or the event details page
Response limitRevenueCat disconnects after 60 seconds
Delivery timeUsually 5 to 60 seconds. Cancellations usually within 2 hours
TestingTest events from the dashboard, or sandbox purchases

Common events

RevenueCat documents 21 event types:

GroupEvents
Subscription lifecycleINITIAL_PURCHASE, RENEWAL, CANCELLATION, UNCANCELLATION, NON_RENEWING_PURCHASE, SUBSCRIPTION_PAUSED, EXPIRATION, BILLING_ISSUE, PRODUCT_CHANGE, SUBSCRIPTION_EXTENDED, REFUND_REVERSED, INVOICE_ISSUANCE
CustomersTRANSFER, TEMPORARY_ENTITLEMENT_GRANT, SUBSCRIBER_ALIAS (deprecated)
OtherVIRTUAL_CURRENCY_TRANSACTION, EXPERIMENT_ENROLLMENT, PURCHASE_REDEEMED, PRICE_INCREASE_CONSENT_REQUIRED, PRICE_INCREASE_CONSENT_APPROVED
TestingTEST

Not every event is sent for every store. For example, SUBSCRIPTION_PAUSED is Google Play only, and INVOICE_ISSUANCE is RevenueCat Billing only.

There is no separate refund event. A refund arrives as a CANCELLATION with a cancellation reason such as CUSTOMER_SUPPORT, and the subscription's auto-renewal setting may still be on. A CANCELLATION with reason UNSUBSCRIBE means the user turned off renewal, not that access has ended. Access ends with EXPIRATION.

Here is RevenueCat's example INITIAL_PURCHASE, shortened:

{
  "api_version": "1.0",
  "event": {
    "id": "UniqueIdentifierOfEvent",
    "type": "INITIAL_PURCHASE",
    "event_timestamp_ms": 1591121855319,
    "app_id": "yourAppID",
    "app_user_id": "yourCustomerAppUserID",
    "original_app_user_id": "OriginalAppUserID",
    "product_id": "onemonth_no_trial",
    "entitlement_ids": ["pro_cat"],
    "period_type": "NORMAL",
    "purchased_at_ms": 1591121853000,
    "expiration_at_ms": 1591726653000,
    "environment": "PRODUCTION",
    "store": "APP_STORE",
    "price": 2.49,
    "currency": "USD",
    "transaction_id": "170000869511114",
    "original_transaction_id": "1530648507000"
  }
}

environment is SANDBOX or PRODUCTION, set by the type of store transaction. The same customer can have both.

Setting up RevenueCat webhooks

  1. In the RevenueCat dashboard, open your project and choose Integrations > Webhooks.
  2. Click Add new configuration and give the integration a name.
  3. Enter your HTTPS URL.
  4. Optionally set an authorization header value. RevenueCat recommends it.
  5. Choose production, sandbox or both, one app or all apps, and optionally filter event types.
  6. To sign deliveries, toggle HMAC webhook signing on the integration and copy the signing secret straight away. It is shown only once. Store it server-side, for example as REVENUECAT_WEBHOOK_SECRET.
  7. Send a test event from the dashboard, or make a sandbox purchase.

If production and sandbox go to different backends, create two integrations, each filtered to one environment.

Securing RevenueCat webhooks

With HMAC signing on, parse t and v1 from X-RevenueCat-Webhook-Signature, compute an HMAC-SHA256 of {t}.{raw body} with your signing secret, hex-encode it, and compare it in constant time with v1.

const crypto = require('crypto');
const express = require('express');

function verifyRevenueCatSignature(rawBody, signatureHeader, secret) {
  if (typeof signatureHeader !== 'string' || !secret) return null;

  const parts = Object.fromEntries(
    signatureHeader.split(',').map((part) => {
      const index = part.indexOf('=');
      return [part.slice(0, index).trim(), part.slice(index + 1).trim()];
    })
  );
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return null;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  const received = Buffer.from(signature, 'hex');
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return null;
  }
  return Number(timestamp);
}

const app = express();

app.post('/webhooks/revenuecat', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const timestamp = verifyRevenueCatSignature(
    req.body,
    req.headers['x-revenuecat-webhook-signature'],
    secret
  );
  if (timestamp === null) return res.sendStatus(401);

  // Optional: if your own policy needs it, reject a t that is too far from now.
  const { event } = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.type, then process async.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac


def verify_revenuecat_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    parts = dict(part.strip().split("=", 1) for part in signature_header.split(",") if "=" in part)
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return False
    signed_payload = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), signature.encode())

RevenueCat recomputes t and v1 for every attempt, including retries and manual retries, so t is when that request was signed. Its examples apply an optional 5-minute tolerance. If you add one, size it for clock skew and the request's own latency, not the retry window.

To check your verifier, use the secret rc_guide_test_signing_secret and this exact body:

{"api_version":"1.0","event":{"id":"12345678-1234-1234-1234-123456789012","type":"INITIAL_PURCHASE"}}

With t=1761040800, the X-RevenueCat-Webhook-Signature header should be:

t=1761040800,v1=faba625545f664b699c29279a37fcc802ed572d69e1d33ffe01477334a550260

If you use only the authorization header, compare the full Authorization value with the one you set, in constant time. In Hookdeck, leave verification off on the RevenueCat source if you didn't turn on HMAC signing, or use Hookdeck's generic API key verification on the Authorization header instead.

Make RevenueCat webhooks production-ready. Hookdeck Event Gateway verifies X-RevenueCat-Webhook-Signature on every delivery, can deduplicate on event.id, and keeps retrying long after RevenueCat's 80-minute window has closed.

RevenueCat webhook limitations and pain points

Retries stop after about 80 minutes

The Problem: An outage of a couple of hours means lost purchases and renewals in your database.

Why It Happens: RevenueCat retries a failed delivery up to 5 times, at about 2.5, 5, 10, 20 and 40 minutes. After that it stops sending that event.

Workarounds:

  • Watch the integration page for failed events and click Retry once your service is back.
  • Call GET /subscribers for affected customers to rebuild their state.

How Hookdeck Can Help: Hookdeck accepts each event as soon as it arrives, so RevenueCat's first attempt succeeds even while your service is down. You then set your own retry policy and replay anything that failed, and issues alert you when delivery to your service breaks.

Only 200 counts as success

The Problem: Events you processed are retried, or show as failed in the dashboard.

Why It Happens: RevenueCat treats any status other than 200 as a failure, so 201, 202 or 204 all trigger retries.

Workarounds:

  • Return exactly 200 once you've accepted an event.

How Hookdeck Can Help: Hookdeck answers RevenueCat with a success response for every accepted event, whatever status your own service returns.

Event payloads differ by type and store

The Problem: Custom logic for each event type gets complicated, and a refund looks like a cancellation.

Why It Happens: Fields vary by event type and store, refunds arrive as CANCELLATION with a reason, and RevenueCat may add new fields and event types "without changing the API version".

Workarounds:

  • Follow RevenueCat's advice: after any webhook, call GET /subscribers and sync the customer from that single format.
  • Acknowledge unknown event types with a 200.

How Hookdeck Can Help: Hookdeck filters can route events by event.type or event.environment, so sandbox events never reach production systems.

Rotating the secret breaks verification at once

The Problem: After you rotate the signing secret, every delivery fails verification until your receiver has the new one.

Why It Happens: Rotate secret invalidates the old secret immediately. There is no overlap.

Workarounds:

  • Rotate during a quiet period and update your receiver straight away.
  • Use Retry on any events that failed in between.

How Hookdeck Can Help: Update the secret on your Hookdeck source right after rotating. Events that fail in between can be retried from RevenueCat's dashboard.

Best practices

Deduplicate on event.id

Delivery is at least once. Retries reuse event.id and event_timestamp_ms, so store each id and skip repeats.

Turn on HMAC signing

The authorization header is a static value. The signature covers the body and a fresh timestamp, so use it where you can.

Separate sandbox from production

Filter integrations by environment, or check event.environment before you change real customer data.

Plan for delivery delays

Most webhooks arrive within a minute, but cancellations can take up to about 2 hours. Don't rely on a webhook for anything that has to happen instantly.

Acknowledge fast and process asynchronously

Return 200 quickly and queue the work. See why to process webhooks asynchronously.

Make handlers idempotent

Processing the same event twice should have the same result as processing it once. See our guide to webhook idempotency.

Conclusion

RevenueCat sends subscription and purchase events as JSON POSTs, authenticated by an optional authorization header and an optional HMAC signature over the timestamp and raw body. Turn on signing, return exactly 200, deduplicate on event.id, and use GET /subscribers as the source of truth, because RevenueCat stops retrying after about 80 minutes.

Hookdeck Event Gateway verifies each signature, answers RevenueCat straight away, and holds every event until your service has processed it, so a long outage doesn't leave gaps in your subscription data.

Get started with Hookdeck for free and handle RevenueCat webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.