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 4xx is still treated as a delivery failure by RevenueCat and will trigger retries. Only a 200 confirms 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 id values 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

  1. BILLING_ISSUE — Payment failed.
  2. CANCELLATION — with cancel_reason: BILLING_ERROR — Subscription is now past-due.
  3. 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_ISSUE always arrives before CANCELLATION. 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_ISSUE is fired immediately.
  • CANCELLATION (cancel_reason: BILLING_ERROR) is fired immediately.
  • No EXPIRATION yet — the customer still has access.
  • If billing succeeds: a RENEWAL event is sent, and access continues normally.
  • If billing fails through the entire grace period: EXPIRATION is 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:

  1. Dashboard test events: On the webhook integration page, send a TEST event type to your endpoint to verify connectivity and authorization. Source
  2. Sandbox purchases: Make purchases using sandbox accounts. These produce real event flows with environment: SANDBOX in 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 200 for all successfully received events; never respond with 201, 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 id field to prevent double-processing.
  • [ ] Handle out-of-order events — especially BILLING_ISSUE, CANCELLATION, and EXPIRATION which can arrive in any order.
  • [ ] Check grace_period_expiration_at_ms on BILLING_ISSUE before revoking access.
  • [ ] Sync via GET /subscribers rather 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.

Sources