The Problem
You've wired up RevenueCat webhooks to sync subscription state into your backend. Then production happens: your server goes down for 15 minutes, a deployment causes a spike in 500 errors, or your endpoint takes too long to respond. Now you're not sure which events were processed, which were missed, and whether your customers' access is still accurate.
Webhook reliability is one of the most underestimated challenges in subscription infrastructure. This guide covers everything you need to handle RevenueCat webhook errors correctly — from response contracts and retry behavior, to idempotency, billing-failure flows, and manual recovery.
1. The Response Contract: What RevenueCat Expects
RevenueCat has a strict, simple contract with your server: return HTTP 200. Any other status code — 4xx, 5xx, even a 201 or 302 — is treated as a failure. Source
The 60-Second Rule
If your server doesn't finish its response within 60 seconds, RevenueCat disconnects and treats the attempt as failed. Source
The most important implication: never do heavy work synchronously inside the webhook handler. Respond with 200 immediately, then process the event asynchronously using a job queue, message bus, or background worker.
# ✅ Correct pattern: respond fast, process later
@app.route("/revenuecat/webhook", methods=["POST"])
def webhook():
payload = request.get_json()
# 1. Validate the authorization header (see Security section)
if not is_valid_auth(request.headers.get("Authorization")):
return "", 401 # This will NOT trigger RC retries — see note below
# 2. Enqueue for async processing — do NOT block here
job_queue.enqueue("process_revenuecat_event", payload)
# 3. Respond immediately with 200
return "", 200
Note on 4xx responses: Returning a
4xxis still treated as a delivery failure by RevenueCat and will trigger retries. Only a200confirms successful receipt. Source
2. Retry Behavior: What Happens When You Fail
RevenueCat implements an exponential backoff retry policy. If your server returns anything other than 200, or if the connection times out, RevenueCat retries the webhook up to 5 times with the following delay schedule: Source
| Attempt | Delay After Previous Failure |
|---|---|
| Retry 1 | 5 minutes |
| Retry 2 | 10 minutes |
| Retry 3 | 20 minutes |
| Retry 4 | 40 minutes |
| Retry 5 | 80 minutes |
After 5 retries with no success, RevenueCat stops sending the notification. The event is not permanently lost — you can manually retrigger it — but it will not be auto-delivered again.
Key insight for architecture: If your server has an extended outage (say, 3 hours), the total retry window above gives you ~155 minutes before events are abandoned. Plan your recovery procedures accordingly.
3. Handling Duplicate Events: Idempotency Is Non-Negotiable
RevenueCat guarantees at-least-once delivery, which means your server may receive the same event more than once. This is not a bug — it is an intentional design for reliability. Source
The rule: make every webhook handler idempotent — processing the same event twice must produce the same result as processing it once.
The tool RevenueCat gives you is the event id field. Critically, retried webhooks carry the same id and event_timestamp_ms as the original attempt. Source
def process_revenuecat_event(payload):
event_id = payload.get("event", {}).get("id")
event_type = payload.get("event", {}).get("type")
# Idempotency check: skip if already processed
if db.processed_events.exists(event_id):
logger.info(f"Skipping duplicate event {event_id}")
return
# Process the event
handle_event(event_type, payload)
# Mark as processed AFTER successful handling
db.processed_events.insert(event_id, processed_at=now())
Implementation tip: Store processed
idvalues in a fast, indexed table (Redis or a dedicated DB table with a unique index). Set a TTL of 7–30 days — long enough to cover any realistic retry window.
4. Security: Verify Every Request
Before worrying about processing errors, verify the request is actually from RevenueCat. The recommended approach is to set an Authorization header value in your RevenueCat dashboard. RevenueCat will include this header in every POST request. Source
Your server should reject any request missing or mismatching this header:
REVENUECAT_WEBHOOK_AUTH = os.environ.get("REVENUECAT_WEBHOOK_AUTH_TOKEN")
def is_valid_auth(auth_header: str) -> bool:
return auth_header == REVENUECAT_WEBHOOK_AUTH
Store the token as an environment variable or secrets manager value — never hardcode it. This single check prevents replay attacks and spoofed events from reaching your processing queue.
5. Billing Failure Events: The Trickiest Flow
Billing failures produce a cascade of related events. Mishandling these is the most common cause of incorrectly revoked or retained customer access.
The Event Sequence
When a customer's payment method fails, RevenueCat dispatches the following events in order, at the same time: Source
BILLING_ISSUE— Payment failed.CANCELLATION— withcancel_reason: BILLING_ERROR— Subscription is now past-due.EXPIRATION(only if no grace period is configured) — Entitlement revoked.
Important: Because these events are dispatched simultaneously, network irregularities can cause them to arrive out of order. Do not assume
BILLING_ISSUEalways arrives beforeCANCELLATION. Source
With Grace Periods Enabled
If you've enabled a grace period on the App Store, Google Play, Stripe, or Web Billing, the flow changes significantly. The customer retains entitlement access while the store retries billing: Source
BILLING_ISSUEis fired immediately.CANCELLATION(cancel_reason: BILLING_ERROR) is fired immediately.- No
EXPIRATIONyet — the customer still has access. - If billing succeeds: a
RENEWALevent is sent, and access continues normally. - If billing fails through the entire grace period:
EXPIRATIONis sent and entitlements are revoked.
To detect an active grace period in webhook events, inspect the grace_period_expiration_at_ms field on BILLING_ISSUE events. Source
def handle_billing_issue(event):
grace_period_ms = event.get("grace_period_expiration_at_ms")
if grace_period_ms:
# Grace period active — do NOT revoke access yet
user_id = event["app_user_id"]
notify_user_to_update_payment(user_id, grace_expires=grace_period_ms)
else:
# No grace period — treat like immediate expiration
revoke_user_entitlements(event["app_user_id"])
One billing issue event per failure cycle: RevenueCat sends only one BILLING_ISSUE event per billing failure cycle. Subsequent store retry attempts won't fire additional events. Source
6. The Safest Sync Pattern: Fetch, Don't Parse
Every webhook event contains a snapshot of data at a point in time, but different event types expose different fields. Writing custom parsing logic for all 15+ event types is complex and fragile.
RevenueCat's recommended approach is simpler: after receiving any webhook event, call the GET /subscribers/{app_user_id} REST API endpoint to fetch the full, current subscription state. Source
import requests
def sync_subscriber(app_user_id: str):
"""Fetch the latest subscriber state from RevenueCat and update your DB."""
response = requests.get(
f"https://api.revenuecat.com/v1/subscribers/{app_user_id}",
headers={
"Authorization": f"Bearer {REVENUECAT_API_KEY}",
"Content-Type": "application/json",
}
)
response.raise_for_status()
subscriber_data = response.json()["subscriber"]
entitlements = subscriber_data.get("entitlements", {})
# Update your DB with authoritative state
update_user_access(app_user_id, entitlements)
return subscriber_data
def process_revenuecat_event(payload):
event_id = payload["event"]["id"]
if db.processed_events.exists(event_id):
return # Idempotency guard
app_user_id = payload["event"]["app_user_id"]
# Always fetch fresh state rather than relying solely on webhook payload
sync_subscriber(app_user_id)
db.processed_events.insert(event_id)
This pattern has several advantages over parsing each event type individually: - Always authoritative — you're reading current state, not a snapshot. - Handles out-of-order delivery gracefully. - Automatically handles new event types RevenueCat might add in the future.
7. Future-Proofing Your Handler
RevenueCat may add new event types or new fields to existing events without a version bump. Fields will not be removed without proper versioning and deprecation, but your handler must not break on unexpected additions. Source
def handle_event(event_type: str, payload: dict):
handlers = {
"INITIAL_PURCHASE": handle_initial_purchase,
"RENEWAL": handle_renewal,
"CANCELLATION": handle_cancellation,
"EXPIRATION": handle_expiration,
"BILLING_ISSUE": handle_billing_issue,
"PRODUCT_CHANGE": handle_product_change,
"SUBSCRIPTION_PAUSED": handle_subscription_paused,
"UNCANCELLATION": handle_uncancellation,
}
handler = handlers.get(event_type)
if handler:
handler(payload)
else:
# Don't crash on unknown event types — log and move on
logger.info(f"Unrecognized event type '{event_type}' — skipping.")
8. Manual Retry and Recovery
When your server has an outage and events are abandoned after 5 retries, you have two manual recovery options:
Option A — Dashboard retry: On the webhook integration page, find the failed or retrying event in the event table and click Retry. The event is immediately re-dispatched to your endpoint. Source
Option B — Event details page: Navigate to a specific customer's profile, find the event in their history, open the event details page, and use the resend option from there. Source
Option C — Bulk sync via API: For a prolonged outage where many customers are affected, don't try to replay individual events. Instead, pull a list of affected users and call GET /subscribers/{app_user_id} for each one to restore authoritative state in your backend.
9. Delivery Timing Expectations
Not all events arrive instantly. Understanding normal delivery windows helps you distinguish a true error from expected latency: Source
| Event Category | Typical Delivery Time |
|---|---|
| Most events (purchases, renewals, etc.) | 5–60 seconds |
| Cancellation events | Up to 2 hours |
Design your monitoring to avoid false-positive alerts for cancellation events that are simply slow to arrive.
10. Testing Before You Ship
RevenueCat provides two ways to test your webhook endpoint:
- Dashboard test events: On the webhook integration page, send a
TESTevent type to your endpoint to verify connectivity and authorization. Source - Sandbox purchases: Make purchases using sandbox accounts. These produce real event flows with
environment: SANDBOXin the payload. Source
Use sandbox testing to exercise full event flows — especially the billing issue flow — before going to production.
Error Handling Checklist
Use this checklist when building or auditing your webhook endpoint:
- [ ] Respond with
200for all successfully received events; never respond with201,204, etc. - [ ] Respond within 60 seconds — defer all processing to an async queue.
- [ ] Validate the Authorization header on every request.
- [ ] Implement idempotency using the event
idfield to prevent double-processing. - [ ] Handle out-of-order events — especially
BILLING_ISSUE,CANCELLATION, andEXPIRATIONwhich can arrive in any order. - [ ] Check
grace_period_expiration_at_msonBILLING_ISSUEbefore revoking access. - [ ] Sync via
GET /subscribersrather than parsing every event type individually. - [ ] Handle unknown event types gracefully — log and skip, don't throw.
- [ ] Set up monitoring on your webhook endpoint with delay-aware alerting.
- [ ] Test with sandbox purchases and dashboard test events before launch.