POST
/
api
/
v1
/
billing
/
webhooks
/
revenuecat
RevenueCat webhook
curl --request POST \
  --url https://api.example.com/api/v1/billing/webhooks/revenuecat
import requests

url = "https://api.example.com/api/v1/billing/webhooks/revenuecat"

response = requests.post(url)

print(response.text)
const options = {method: 'POST'};

fetch('https://api.example.com/api/v1/billing/webhooks/revenuecat', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));
<?php

$curl = curl_init();

curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/api/v1/billing/webhooks/revenuecat",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
package main

import (
"fmt"
"net/http"
"io"
)

func main() {

url := "https://api.example.com/api/v1/billing/webhooks/revenuecat"

req, _ := http.NewRequest("POST", url, nil)

res, _ := http.DefaultClient.Do(req)

defer res.Body.Close()
body, _ := io.ReadAll(res.Body)

fmt.Println(string(body))

}
HttpResponse<String> response = Unirest.post("https://api.example.com/api/v1/billing/webhooks/revenuecat")
.asString();
require 'uri'
require 'net/http'

url = URI("https://api.example.com/api/v1/billing/webhooks/revenuecat")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)

response = http.request(request)
puts response.read_body

Overview

The single ingestion point for RevenueCat subscription lifecycle events. Each inbound event is persisted to the webhook_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 subscriptions collection 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 returns 200 { ok: true, deduped: true } without re-processing.
This endpoint has NO Bearer-token user auth. It authenticates the caller (RevenueCat itself) via a shared secret in the Authorization: Bearer ... header, compared in constant time against the REVENUECAT_WEBHOOK_AUTH environment variable.

Authentication

Webhook-level, NOT user-level. Two distinct failure modes:
ConditionResponse
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 var401 WEBHOOK_VERIFICATION_FAILED
The compare is done via 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 under event. Unknown fields are preserved on the audit row but are not validated — the schema uses z.object(...).loose() at both levels.
FieldTypeRequiredNotes
event.idstringyesRC notification id. Used as the idempotency key (paired with provider: 'revenuecat').
event.typeenumyesOne 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_msintegeryesms-since-epoch when RC fired the event. Persisted as occurredAt.
event.app_user_idstringyesMust 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_idenumyesOne of REVENUECAT_PRODUCT_IDS (the monthly + annual SKUs declared in packages/db).
event.storeenumoptionalAPP_STORE, PLAY_STORE, STRIPE, or PROMOTIONAL. Only APP_STORE (→ APPLE) and PLAY_STORE (→ GOOGLE) are processed; others return 400 VALIDATION_FAILED.
event.original_transaction_idstringyesStore-issued original transaction id — the key under which the local subscriptions row is upserted.
event.latest_receiptstring | nulloptionalOpaque receipt token. Stored verbatim.
event.expiration_at_msinteger | nulloptionalms-since-epoch for current period end (or trial end when period_type === 'TRIAL').
event.cancellation_at_msinteger | nulloptionalms-since-epoch when the user cancelled. The subscription remains usable until expiration_at_ms.
event.period_typeenumoptionalNORMAL, TRIAL, or INTRO. TRIAL is what flips INITIAL_PURCHASE into TRIALING instead of ACTIVE.

Example payload — INITIAL_PURCHASE with trial

{
  "event": {
    "id": "rc_evt_01HZQ7K3M4N5P6Q7R8S9T0V1W2",
    "type": "INITIAL_PURCHASE",
    "event_timestamp_ms": 1747915200000,
    "app_user_id": "66400a8f1c2b4d5e6f7a8b01",
    "product_id": "swappr.sub.monthly.v1",
    "store": "APP_STORE",
    "original_transaction_id": "1000000123456789",
    "latest_receipt": "MIIB...truncated...",
    "expiration_at_ms": 1748520000000,
    "period_type": "TRIAL"
  }
}

Example payload — RENEWAL

{
  "event": {
    "id": "rc_evt_01HZQ7XYZ...",
    "type": "RENEWAL",
    "event_timestamp_ms": 1748521000000,
    "app_user_id": "66400a8f1c2b4d5e6f7a8b01",
    "product_id": "swappr.sub.monthly.v1",
    "store": "APP_STORE",
    "original_transaction_id": "1000000123456789",
    "expiration_at_ms": 1751113000000,
    "period_type": "NORMAL"
  }
}

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.
{
  "event": {
    "id": "rc_evt_01J0A...",
    "type": "CANCELLATION",
    "event_timestamp_ms": 1748600000000,
    "app_user_id": "66400a8f1c2b4d5e6f7a8b01",
    "product_id": "swappr.sub.monthly.v1",
    "store": "APP_STORE",
    "original_transaction_id": "1000000123456789",
    "expiration_at_ms": 1751113000000,
    "cancellation_at_ms": 1748600000000,
    "period_type": "NORMAL"
  }
}

RC event type → local status transition

RC eventLocal statusNotes
INITIAL_PURCHASE (period_type=TRIAL)TRIALINGTrial window active until expiration_at_ms.
INITIAL_PURCHASE (period_type≠TRIAL)ACTIVEPaid period started immediately.
RENEWALACTIVENew period began.
NON_RENEWING_PURCHASEACTIVEOne-shot purchase.
PRODUCT_CHANGEACTIVEPlan switch (e.g. monthly → annual).
UNCANCELLATIONACTIVEUser reversed a prior cancellation while still in-period.
CANCELLATIONCANCELLEDAccess continues until expiration_at_ms.
EXPIRATIONEXPIREDAccess revoked.
BILLING_ISSUEPAST_DUECard declined, dunning in progress on RC side.
SUBSCRIPTION_PAUSEDPAST_DUERC-paused subscription.
TRANSFER / SUBSCRIBER_ALIAS / TEST(ignored)Event persisted to audit log; no state mutation.
See the Paywall and launch phase concept page for the full state diagram.

Response — 200 OK

FieldTypeNotesExample
okbooleanAlways true on a successful 200.true
dedupedbooleantrue iff the (revenuecat, event.id) pair was already in webhook_events — no state mutation occurred.false
{ "ok": true, "deduped": false }
On replay:
{ "ok": true, "deduped": true }

Side effects

On a fresh accept (deduped: false):
  1. One row inserted into webhook_events with processed: false, payload: <full body>.
  2. The user’s subscriptions row is upserted on (originalTransactionId) with the new status + dates.
  3. The user’s subscriptionStatus mirror field is set (denormalised fast-path for the requireActiveSubscription middleware that gates Phase 7 endpoints).
  4. The webhook_events row is flipped to processed: true. If the projection threw, the row is flipped to processed: true with the error message attached and the request fails with 500 INTERNAL_ERROR (RC will retry — the dedup index then short-circuits the next attempt once the bug is fixed).
On replay (deduped: true): no mutations. RC may safely retry the same event indefinitely.

Error responses

StatusCodeMeaning
400VALIDATION_FAILEDBody 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.
401WEBHOOK_VERIFICATION_FAILEDAuthorization header missing, malformed, or token mismatch.
503BILLING_NOT_CONFIGUREDREVENUECAT_WEBHOOK_AUTH env is unset on the server.

Example error — 401 WEBHOOK_VERIFICATION_FAILED

{
  "type": "https://api.swappr.co.uk/errors/webhook-verification-failed",
  "title": "Webhook verification failed",
  "status": 401,
  "code": "WEBHOOK_VERIFICATION_FAILED",
  "detail": "Missing or invalid Authorization Bearer header",
  "instance": "/api/v1/billing/webhooks/revenuecat",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2"
}

Example error — 503 BILLING_NOT_CONFIGURED

{
  "type": "https://api.swappr.co.uk/errors/billing-not-configured",
  "title": "Billing provider not configured",
  "status": 503,
  "code": "BILLING_NOT_CONFIGURED",
  "detail": "REVENUECAT_WEBHOOK_AUTH is not set; cannot accept inbound RevenueCat events",
  "instance": "/api/v1/billing/webhooks/revenuecat",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2"
}

See also

  • Paywall and launch phase — local subscription state machine + how subscriptionStatus interacts 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.

curl

curl -X POST https://api.swappr.co.uk/api/v1/billing/webhooks/revenuecat \
  -H "Authorization: Bearer $REVENUECAT_WEBHOOK_AUTH" \
  -H "Content-Type: application/json" \
  -d '{
    "event": {
      "id": "rc_evt_01HZQ7K3M4N5P6Q7R8S9T0V1W2",
      "type": "INITIAL_PURCHASE",
      "event_timestamp_ms": 1747915200000,
      "app_user_id": "66400a8f1c2b4d5e6f7a8b01",
      "product_id": "swappr.sub.monthly.v1",
      "store": "APP_STORE",
      "original_transaction_id": "1000000123456789",
      "expiration_at_ms": 1748520000000,
      "period_type": "TRIAL"
    }
  }'