Guide to RevenueCat Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Plan | Pro |
| Configuration | Dashboard: Project > Integrations > Webhooks > Add new configuration |
| Integrations | Several per project, each with its own URL and filters |
| Filters | Production, sandbox or both. One app or all apps. Optional event type filter |
| Authorization header | Optional. Sent verbatim on every request |
| Signature header | X-RevenueCat-Webhook-Signature: t=<unix>,v1=<hex>, when HMAC signing is on |
| Scheme | HMAC-SHA256 over {t}.{raw body}, hex encoded |
| Secret | Per integration, shown once. Rotate secret invalidates the old one immediately |
| Event name | event.type |
| Deduplication key | event.id, reused on retries |
| Success | A 200 only. Any other status is a failure |
| Retries | Up to 5, at about 2.5, 5, 10, 20 and 40 minutes (about 80 minutes in total) |
| Manual retry | Retry on the integration page or the event details page |
| Response limit | RevenueCat disconnects after 60 seconds |
| Delivery time | Usually 5 to 60 seconds. Cancellations usually within 2 hours |
| Testing | Test events from the dashboard, or sandbox purchases |
Common events
RevenueCat documents 21 event types:
| Group | Events |
|---|---|
| Subscription lifecycle | INITIAL_PURCHASE, RENEWAL, CANCELLATION, UNCANCELLATION, NON_RENEWING_PURCHASE, SUBSCRIPTION_PAUSED, EXPIRATION, BILLING_ISSUE, PRODUCT_CHANGE, SUBSCRIPTION_EXTENDED, REFUND_REVERSED, INVOICE_ISSUANCE |
| Customers | TRANSFER, TEMPORARY_, SUBSCRIBER_ALIAS (deprecated) |
| Other | VIRTUAL_, EXPERIMENT_ENROLLMENT, PURCHASE_REDEEMED, PRICE_, PRICE_ |
| Testing | TEST |
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
- In the RevenueCat dashboard, open your project and choose Integrations > Webhooks.
- Click Add new configuration and give the integration a name.
- Enter your HTTPS URL.
- Optionally set an authorization header value. RevenueCat recommends it.
- Choose production, sandbox or both, one app or all apps, and optionally filter event types.
- 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 - 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_ 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-Signatureon every delivery, can deduplicate onevent.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 /subscribersfor 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
200once 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 /subscribersand 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.