Webhooks — real-time notifications
Instead of polling the API, let R2 notify your system the moment an event happens: a reservation created, a payment collected.
A webhook is a URL in your system that R2 calls with a POST whenever something you care about happens. The business owner registers the endpoint from their dashboard, picks which events to listen to, and R2 starts notifying. It is the efficient way to stay in sync: no polling, no lag.
Available events#
| Event | When it fires | Scope |
|---|---|---|
reservation.created | A lodging reservation was created (engine, front desk or channel). | read:reservations |
order.created | Any order was created: restaurant, store, experiences, lodging. | read:orders |
payment.succeeded | A payment was confirmed as collected. | read:payments |
Latency
R2 detects and dispatches events within about a minute. Webhooks are for reacting fast, not for precise timing: for exact accounting totals, use the read API as the source of truth.
Payload shape#
Each notification arrives as JSON. It carries the event type, when it occurred, and a data object with the key fields. For the full detail, request the resource via the API using the order_number or id.
{
"event": "payment.succeeded",
"occurred_at": "2026-07-16T10:20:14.907Z",
"data": {
"object": "payment",
"id": "n47dk3mzq81ba5tv",
"order_id": "p97cbaneya88az5ra",
"order_number": "ORD-MRNCRMX2-3BAW",
"amount": 2088,
"currency": "MXN",
"status": "succeeded",
"method": "card",
"succeeded_at": "2026-07-16T10:20:14.907Z"
}
}Headers on every POST#
| Header | Content |
|---|---|
R2-Signature | HMAC signature + timestamp: t=<unix>,v1=<hex>. |
R2-Event | The event type, e.g. payment.succeeded. |
R2-Delivery-Id | Unique identifier of this delivery (for deduplication). |
R2-Webhook-Id | Identifier of the webhook that received the notification. |
Verify the signature (important)#
When the webhook is registered, the owner receives a signing secret (starts with whsec_) shown only once. With it you verify that each notification comes from R2 and not from an impostor. The procedure is identical to Stripe’s:
- Take the
R2-Signatureheader and splitt(timestamp) andv1(signature). - Build the signed string:
{t}.{raw_body}— the EXACT body you received, without re-serializing. - Compute
HMAC-SHA256(secret, signed_string)in hexadecimal. - Compare it with
v1using constant-time comparison. - Reject if it does not match, or if
tis older than 5 minutes (replay protection).
// Node.js / Express — webhook verification
import crypto from "node:crypto";
const SECRET = process.env.R2_WEBHOOK_SECRET; // whsec_...
// NOTE: you need the RAW body, not the parsed one.
app.post("/webhooks/r2", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("R2-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
const body = req.body.toString("utf8");
// 1) Replay protection: reject old timestamps
if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).end();
// 2) Recompute the signature
const expected = crypto.createHmac("sha256", SECRET)
.update(`${t}.${body}`).digest("hex");
// 3) Constant-time comparison
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!ok) return res.status(400).end();
// 4) Respond 2xx FAST and process afterwards
const event = JSON.parse(body);
enqueueForProcessing(event); // do not block the response
res.status(200).end();
});# Python / Flask — webhook verification
import hmac, hashlib, time, os
from flask import request, abort
SECRET = os.environ["R2_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/r2")
def r2_webhook():
header = dict(p.split("=") for p in request.headers.get("R2-Signature", "").split(","))
t = int(header.get("t", "0"))
body = request.get_data() # raw bytes
if abs(time.time() - t) > 300:
abort(400) # replay protection
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header.get("v1", "")):
abort(400)
event = request.get_json()
enqueue_for_processing(event)
return "", 200Retries and reliability#
- Respond with any
2xxcode to acknowledge receipt. Anything else counts as a failure. - If your endpoint fails or does not respond, R2 retries with growing backoff: up to 6 attempts over several hours.
- An endpoint that fails many times in a row disables itself; the owner re-enables it from their dashboard once fixed.
- Respond fast (under 5 seconds): acknowledge receipt and process in the background. If you are slow, R2 treats it as a timeout and retries.
- Duplicate deliveries can happen in edge cases: use
R2-Delivery-Idto deduplicate and make your processing idempotent.
Test without waiting for a real event#
From the business dashboard (Settings → R2 Connect API → Webhooks) there is a “Send test event” button that fires a signed notification, identical to a real one but with dummy data, so you can verify your endpoint end to end. The delivery log shows the result of each attempt with its response code.