How do I receive and verify RevenueDot webhooks?
Add a webhook in the dashboard or with the API and keep the whsec_... signing secret it returns. RevenueDot then POSTs each event to your URL as JSON in RevenueCat's webhook format. Verify the X-RevenueCat-Webhook-Signature header against the raw request body, answer 200 quickly, and ignore events whose event.id you have already handled. Anything but 200 is retried after 5, 10, 20, 40 and 80 minutes.
1. Add a webhook#
In the dashboard: Integrations → Webhooks → Add webhook. Or with the API:
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks" \
-H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \
-d '{"name":"Backend","url":"https://api.example.com/webhooks/revenuedot","authorization_header":"Bearer my-shared-token","environment":"production"}'{"object":"webhook_integration","id":"wh_ceps8nr7mczvhaqw","project_id":"proj18pzzkao","name":"Backend","url":"https://api.example.com/webhooks/revenuedot","environment":"production","event_types":[],"app_id":null,"created_at":1790801342625,"signing_secret":"whsec_3f5b7fb5a591c17aaa9108376df0bddbe1555f0a908bfdc0"}signing_secretis shown once, in this answer. Store it as a secret in your backend.authorization_header(optional) is sent verbatim as theAuthorizationheader, the same role as the authorization header setting in RevenueCat's webhooks.environment:production,sandbox, ornullfor both.event_types: lower-case types such as["initial_purchase","renewal"]; empty means all.app_id: only one app's events;nullfor all apps.- Pause without deleting: send
{"enabled":false}toPOST /v2/projects/{project_id}/integrations/webhooks/{id}, or use the Deliveries switch on the webhook's dashboard page. Events recorded while it is paused are not sent; queued retries resume when you turn it back on.enabledis a RevenueDot addition; read it withGET /v2/projects/{project_id}/webhooks.
2. What a delivery looks like#
POST /webhooks/revenuedot HTTP/1.1
Content-Type: application/json
User-Agent: RevenueDot-Webhooks/1.0
Authorization: Bearer my-shared-token
X-RevenueCat-Webhook-Signature: t=1790800914,v1=0a1552334e825926036f7efe21527800ea45caa63eca523c6120c6da9041ef99
{"api_version":"1.0","event":{"id":"66339910-3BFF-49F4-B873-D1283D673DE2","type":"INITIAL_PURCHASE","app_user_id":"user_1","original_app_user_id":"user_1","aliases":["user_1"],"product_id":"pro_monthly","entitlement_ids":["pro"],"period_type":"NORMAL","purchased_at_ms":1790800914000,"expiration_at_ms":1793392914000,"environment":"SANDBOX","store":"TEST_STORE","price":9.99,"currency":"USD","presented_offering_id":"default","transaction_id":"test_1790800914000_quickstart","...":"..."}}Every field of every event type, with full examples, is on Webhook events. Handlers written for RevenueCat's webhooks work unchanged: the field names and values are the same.
3. Verify the signature#
The header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of "<t>.<raw body>" keyed with the signing secret. RevenueDot signs again on every attempt, so t is the attempt's time.
- Read the raw body bytes. Parsing JSON and serializing it again changes the bytes and breaks the check.
- Parse
tandv1from the header. - Refuse the request if
tis more than 5 minutes from your clock. This stops replays. - Compute the HMAC and compare it to
v1in constant time.
// Node.js. Verifies the HMAC signature on a RevenueDot webhook delivery.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(rawBody, header, secret, { now = new Date(), toleranceSeconds = 300 } = {}) {
const match = /(?:^|,)\s*t=(\d+)\s*,\s*v1=([0-9a-f]{64})\s*(?:,|$)/.exec(header ?? "");
if (!match) return false;
const timestamp = Number(match[1]);
// Refuse old deliveries so a captured request cannot be replayed.
if (Math.abs(Math.floor(now.getTime() / 1000) - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from(match[2], "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}# Python. Verifies the HMAC signature on a RevenueDot webhook delivery.
import hashlib, hmac, re, time
_PATTERN = re.compile(r"(?:^|,)\s*t=(\d+)\s*,\s*v1=([0-9a-f]{64})\s*(?:,|$)")
def verify_signature(raw_body: bytes, header: str | None, secret: str, tolerance_seconds: int = 300) -> bool:
match = _PATTERN.search(header or "")
if not match:
return False
timestamp = int(match.group(1))
# Refuse old deliveries so a captured request cannot be replayed.
if abs(int(time.time()) - timestamp) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, match.group(2))// Go. Verifies the HMAC signature on a RevenueDot webhook delivery.
var signaturePattern = regexp.MustCompile(`(?:^|,)\s*t=(\d+)\s*,\s*v1=([0-9a-f]{64})\s*(?:,|$)`)
func VerifySignature(rawBody []byte, header, secret string, now time.Time, tolerance time.Duration) bool {
m := signaturePattern.FindStringSubmatch(header)
if m == nil {
return false
}
ts, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return false
}
// Refuse old deliveries so a captured request cannot be replayed.
if age := now.Sub(time.Unix(ts, 0)); age > tolerance || age < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1] + "."))
mac.Write(rawBody)
received, err := hex.DecodeString(m[2])
return err == nil && hmac.Equal(received, mac.Sum(nil))
}A complete Express handler that keeps the raw body:
// Node.js + Express: receive RevenueDot webhooks.
import express from "express";
import { verifySignature } from "./verify.js";
const app = express();
const seen = new Set(); // use your database in production
app.post("/webhooks/revenuedot", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(req.body, req.get("X-RevenueCat-Webhook-Signature"), process.env.REVENUEDOT_WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
const { event } = JSON.parse(req.body.toString("utf8"));
if (seen.has(event.id)) return res.sendStatus(200); // a retry of an event you already handled
seen.add(event.id);
// Grant or remove access in your own database here. Keep it fast: RevenueDot waits at most 60 seconds.
res.sendStatus(200);
});
app.listen(3000);Runnable, tested receivers: Node.js + Express, Next.js, Python + FastAPI and Go.
If you set an authorization_header, also compare the Authorization header with it. The HMAC check is the stronger of the two, because it also proves the body was not changed.
4. Retries and delivery guarantees#
- Only HTTP 200 counts as delivered. A 201, 204 or redirect is treated as a failure. RevenueCat documents the same rule for its webhooks.
- Timeout: 60 seconds per attempt.
- Schedule: after a failure, RevenueDot retries after 5, 10, 20, 40 and 80 minutes (6 attempts in all), then marks the delivery
failed. - At least once: a delivery can arrive more than once, for example when your 200 is lost. Deduplicate on
event.id. - Order is not guaranteed. Use the timestamps in the event (
event_timestamp_ms,purchased_at_ms,expiration_at_ms), or fetch the customer's current state withGET /v1/subscribers/{app_user_id}when order matters. - Every matching webhook gets its own delivery, so two URLs each receive every event.
5. Test and debug#
- Send a test event: dashboard Send test event, or
POST /v2/projects/{project_id}/integrations/webhooks/{id}/test. It sends a purchase-shapedTESTevent, signed like the others; filters do not apply. - Make real events with the Test Store:
POST /v2/projects/{project_id}/test_purchaseswith ascenariosuch asrenewal,cancelorrefundproduces the matching events. See Test Store. - Delivery log:
GET /v2/projects/{project_id}/webhooks/{id}/deliveries?status=failedshows each attempt's HTTP status, duration and error. Retry one now withPOST .../deliveries/{delivery_id}/retry. - Health:
GET /v2/projects/{project_id}/setup_healthcounts deliveries in the last 24 hours and lists failing webhooks. - Local backend: from a RevenueDot running in Docker, reach your laptop at
http://host.docker.internal:3000/....
See Why are my webhooks not arriving? for common causes.