RevenueCat webhook
Billing
RevenueCat webhook
Inbound machine-to-machine endpoint for RevenueCat subscription events. Bearer-token authenticated, idempotent on (provider, notificationId).
POST
RevenueCat webhook
Overview
The single ingestion point for RevenueCat subscription lifecycle events. Each inbound event is persisted to thewebhook_events audit log (keyed by (provider, notificationId)), then projected into the user’s subscriptions row and the denormalised subscriptionStatus mirror on the user document.
Per QUESTIONS.md §7.2 the locked decisions are:
- RevenueCat is the source of truth for subscription state. The local
subscriptionscollection is a projection — it gets fully rewritten by each accepted event, never patched ad-hoc. - Pricing (£5.99/mo, £59.99/yr) is configured in the RevenueCat dashboard, NOT hardcoded server-side.
- Idempotency is via the unique index on
(provider, notificationId)— RevenueCat may redeliver the same event multiple times. On replay the server returns200 { ok: true, deduped: true }without re-processing.
Authentication
Webhook-level, NOT user-level. Two distinct failure modes:| Condition | Response |
|---|---|
REVENUECAT_WEBHOOK_AUTH is unset / blank on the server (dev default) | 503 BILLING_NOT_CONFIGURED |
Authorization header missing, malformed, or its Bearer token does not constant-time match the env var | 401 WEBHOOK_VERIFICATION_FAILED |
node:crypto.timingSafeEqual on equal-length buffers, so it leaks no timing information about the expected secret. The 503 path exists so that pointing RC at a dev environment with no secret configured returns a clear “not wired” signal rather than a misleading 401.
Set the
REVENUECAT_WEBHOOK_AUTH env var to a high-entropy random string (≥32 bytes base64). Use the same string in the RevenueCat dashboard’s webhook configuration. Rotating it means updating both sides simultaneously.Path parameters
None.Query parameters
None.Request body
RevenueCat posts a single JSON object with the inbound event nested underevent. Unknown fields are preserved on the audit row but are not validated — the schema uses z.object(...).loose() at both levels.
| Field | Type | Required | Notes |
|---|---|---|---|
event.id | string | yes | RC notification id. Used as the idempotency key (paired with provider: 'revenuecat'). |
event.type | enum | yes | One of: INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, PRODUCT_CHANGE, CANCELLATION, UNCANCELLATION, EXPIRATION, BILLING_ISSUE, SUBSCRIPTION_PAUSED, TRANSFER, SUBSCRIBER_ALIAS, TEST. The first nine drive a status transition; the last three are accepted but ignored. |
event.event_timestamp_ms | integer | yes | ms-since-epoch when RC fired the event. Persisted as occurredAt. |
event.app_user_id | string | yes | Must equal the local userId (24-char ObjectId). RC’s “app user id” is configured to be the local userId at sign-up time. |
event.product_id | enum | yes | One of REVENUECAT_PRODUCT_IDS (the monthly + annual SKUs declared in packages/db). |
event.store | enum | optional | APP_STORE, PLAY_STORE, STRIPE, or PROMOTIONAL. Only APP_STORE (→ APPLE) and PLAY_STORE (→ GOOGLE) are processed; others return 400 VALIDATION_FAILED. |
event.original_transaction_id | string | yes | Store-issued original transaction id — the key under which the local subscriptions row is upserted. |
event.latest_receipt | string | null | optional | Opaque receipt token. Stored verbatim. |
event.expiration_at_ms | integer | null | optional | ms-since-epoch for current period end (or trial end when period_type === 'TRIAL'). |
event.cancellation_at_ms | integer | null | optional | ms-since-epoch when the user cancelled. The subscription remains usable until expiration_at_ms. |
event.period_type | enum | optional | NORMAL, TRIAL, or INTRO. TRIAL is what flips INITIAL_PURCHASE into TRIALING instead of ACTIVE. |
Example payload — INITIAL_PURCHASE with trial
Example payload — RENEWAL
Example payload — CANCELLATION
CANCELLATION does NOT revoke access immediately — the local status flips to CANCELLED and access continues until expiration_at_ms, at which point RC will deliver a separate EXPIRATION event that flips the status to EXPIRED.
RC event type → local status transition
| RC event | Local status | Notes |
|---|---|---|
INITIAL_PURCHASE (period_type=TRIAL) | TRIALING | Trial window active until expiration_at_ms. |
INITIAL_PURCHASE (period_type≠TRIAL) | ACTIVE | Paid period started immediately. |
RENEWAL | ACTIVE | New period began. |
NON_RENEWING_PURCHASE | ACTIVE | One-shot purchase. |
PRODUCT_CHANGE | ACTIVE | Plan switch (e.g. monthly → annual). |
UNCANCELLATION | ACTIVE | User reversed a prior cancellation while still in-period. |
CANCELLATION | CANCELLED | Access continues until expiration_at_ms. |
EXPIRATION | EXPIRED | Access revoked. |
BILLING_ISSUE | PAST_DUE | Card declined, dunning in progress on RC side. |
SUBSCRIPTION_PAUSED | PAST_DUE | RC-paused subscription. |
TRANSFER / SUBSCRIBER_ALIAS / TEST | (ignored) | Event persisted to audit log; no state mutation. |
Response — 200 OK
| Field | Type | Notes | Example |
|---|---|---|---|
ok | boolean | Always true on a successful 200. | true |
deduped | boolean | true iff the (revenuecat, event.id) pair was already in webhook_events — no state mutation occurred. | false |
Side effects
On a fresh accept (deduped: false):
- One row inserted into
webhook_eventswithprocessed: false,payload: <full body>. - The user’s
subscriptionsrow is upserted on(originalTransactionId)with the new status + dates. - The user’s
subscriptionStatusmirror field is set (denormalised fast-path for therequireActiveSubscriptionmiddleware that gates Phase 7 endpoints). - The
webhook_eventsrow is flipped toprocessed: true. If the projection threw, the row is flipped toprocessed: truewith the error message attached and the request fails with500 INTERNAL_ERROR(RC will retry — the dedup index then short-circuits the next attempt once the bug is fixed).
deduped: true): no mutations. RC may safely retry the same event indefinitely.
Error responses
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_FAILED | Body failed zod validation, OR event.store was something other than APP_STORE / PLAY_STORE, OR the event type is not in the handled set AND no matching subscription row exists yet. |
| 401 | WEBHOOK_VERIFICATION_FAILED | Authorization header missing, malformed, or token mismatch. |
| 503 | BILLING_NOT_CONFIGURED | REVENUECAT_WEBHOOK_AUTH env is unset on the server. |
Example error — 401 WEBHOOK_VERIFICATION_FAILED
Example error — 503 BILLING_NOT_CONFIGURED
See also
- Paywall and launch phase — local subscription state machine + how
subscriptionStatusinteracts with the paywall flag. - Get billing state — what the projected status looks like to the client.
- Idempotency — how the
(provider, notificationId)unique index works in general.