Webhooks — real-time notifications
Instead of polling the API, let R2 notify your system the moment an event happens: a reservation created, modified or cancelled, 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 to fetch the detail |
|---|---|---|
reservation.created | A lodging reservation was created (engine, front desk or channel). | read:reservations |
reservation.updated | An existing reservation changed: dates, guest, occupancy or amount. The notification carries the new values. | read:reservations |
reservation.cancelled | A reservation was cancelled, no matter who cancelled it (hotel, guest, channel or automated process). | read:reservations |
order.created | Any order was created: restaurant, store, experiences, lodging. | read:orders |
payment.succeeded | A payment was confirmed as collected. | read:payments |
Webhooks and API keys are independent
The column above tells you which scope your API key needs to fetch the resource detail through the read API — it is not a requirement for receiving the notification. The subscription is authorized by the business owner when registering the endpoint, and lives separately from keys: revoking an API key does not turn webhooks off, nor does creating a new key change which events the endpoint is subscribed to. They are managed separately in the dashboard. To stop receiving notifications, the webhook must be disabled or deleted.
Staying in sync without polling
With reservation.created, reservation.updated and reservation.cancelled you cover a reservation’s full life cycle by push. You only need the read API for the initial backfill: a new webhook starts notifying from the moment it is registered, and never replays prior history.
When reservation.updated fires
Only when something you care about changes: stay dates, status, total amount, guest name or occupancy. Internal hotel activity — a note, a reminder sent, a guarantee adjustment — does not trigger a notification. If a value returns to its previous state you do get a fresh notification: treat each event as “this reservation now looks like this”, not as a diff.
Arrival order is not guaranteed
If a delivery fails it is retried with growing backoff, so an older notification can reach you after a newer one for the same reservation. Applying the old one on top of the new one would leave your system with data that is no longer true. Store each reservation’s updated_at and discard any event whose updated_at is older than or equal to the one you already have. It is one comparison and it removes the whole problem.
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.
How to integrate, step by step#
This is the full sequence to keep your system in sync with a business’s reservations, without polling the API:
- The owner generates the API key from their dashboard (Settings → R2 Connect API) with the
read:reservationsscope, and shares it with you. It is shown only once. - Initial backfill via the read API: walk
GET /reservations, paginating untilhas_moreisfalse. This runs ONCE; the webhook does not replay history from before it was registered. - Expose an HTTPS endpoint accepting
POSTwith a JSON body. It must answer2xxin under 5 seconds: acknowledge first, process in the background afterwards. - The owner registers the endpoint and picks the events. For lodging:
reservation.created,reservation.updatedandreservation.cancelled. Save the signing secret shown at registration — it appears only that once. - Verify the signature of every POST before acting on it (procedure below). A notification without a valid signature is discarded: it did not come from R2.
- Deduplicate by
R2-Delivery-Id, and discard events whoseupdated_atis older than the one you already stored for that reservation. - Apply the event as state, not as a diff: each notification describes how the reservation now looks. If you need the full breakdown (lines, taxes, payments), request it with
GET /reservations/{order_number}.
Do not subscribe to order.created for lodging
A lodging reservation fires reservation.created and also order.created, because every reservation is an order. If you listen to both you will receive two notifications for the same reservation. For a hospitality system, listen only to the reservation.* events; order.created is for when you also care about restaurant, store or experiences.
Notifications are read-only
Webhooks travel in one direction: R2 notifies you. From your response we only read the status code to decide whether to retry — the body is discarded. The public API is read-only (GET), so your system cannot create, modify or cancel a reservation in R2. Writes are coming in a future release.
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"
}
}Reservation events carry the full stay. A cancellation adds cancelled_at and cancellation_reason:
{
"event": "reservation.updated",
"occurred_at": "2026-08-14T18:03:22.114Z",
"data": {
"object": "reservation",
"id": "p97cbaneya88az5ra",
"order_number": "ORD-MRNCRMX2-3BAW",
"type": "booking",
"status": "confirmed",
"channel": "booking_com",
"guest_name": "María Fernández",
"check_in": "2026-09-05",
"check_out": "2026-09-08",
"guests": { "adults": 2, "children": 0 },
"currency": "MXN",
"subtotal": 8275.86,
"tax_total": 1324.14,
"total": 9600,
"created_at": "2026-08-01T15:41:09.522Z",
"updated_at": "2026-08-14T18:03:22.114Z"
}
}Reservation payload fields#
This is everything that travels in a reservation.created, reservation.updated or reservation.cancelled. Amounts are in the business’s currency, with decimals; stay dates use YYYY-MM-DD and timestamps use ISO 8601 (UTC).
| Field | Type | What it holds |
|---|---|---|
event | string | The event: reservation.created, reservation.updated or reservation.cancelled. |
occurred_at | ISO 8601 | When it happened: the creation, the change or the cancellation. |
data.object | string | Always "reservation" for these three events. |
data.id | string | Internal reservation identifier. Stable, never changes. |
data.order_number | string | The visible reservation number (e.g. R2-QXRH6W). This is what you pass to GET /reservations/{order_number}. |
data.type | string | booking (lodging) or space_booking (venue rental). |
data.status | string | One of: pending, placed, confirmed, in-progress, completed, cancelled, noshow, refunded. |
data.channel | string or null | Where it came from: web engine, front desk, phone, WhatsApp, or the originating channel/OTA. |
data.guest_name | string or null | Guest name for this reservation. |
data.check_in | YYYY-MM-DD or null | First night of the stay (the earliest one if the reservation has several rooms). |
data.check_out | YYYY-MM-DD or null | Departure day (the latest one if there are several rooms). |
data.guests | object or null | { adults, children }, summed across all rooms in the reservation. |
data.currency | string | Business currency, e.g. MXN. |
data.subtotal | number | Amount before taxes. |
data.tax_total | number | Sum of taxes. The per-tax breakdown is available through the API. |
data.total | number | Reservation total, taxes included. |
data.created_at | ISO 8601 | When the reservation was created. |
data.updated_at | ISO 8601 | Last modification. Use it to discard notifications that arrive out of order. |
data.cancelled_at | ISO 8601 | Only in reservation.cancelled. Moment of cancellation. |
data.cancellation_reason | string or null | Only in reservation.cancelled. Reason, e.g. "Unpaid hold expired (released automatically)". |
What the notification does NOT carry (and when to call the API)
The notification carries the reservation header, enough to create or update it in your system with dates, occupancy and amounts. If you need the detail, request it with GET /reservations/{order_number}: that returns the assigned rooms (lines[].item_name, each with its own dates and its real check-in and check-out times), the tax breakdown, the discount, the paid amount and balance, and the payments with their method. Rule of thumb: one call per reservation, when you first create it or when a notification tells you it changed.
Two details about a reservation’s lines
(1) lines[] are not only rooms: they may also include consumption and extras (breakfasts, activities) and, in some businesses, taxes as a line item. The rule for keeping only rooms is that the line carries check_in; the others come with check_in: null. (2) When a reservation is cancelled its room is released, but check_in and check_out still describe the stay that was cancelled — exactly what you need to free it in your system.
Guest email and phone are not part of the reservation
Neither the notification nor GET /reservations/{order_number} includes guest contact details, and today there is no way to link a reservation to its /guests record by identifier. If your integration needs the email or phone, write to us: it is a known limitation and we want to prioritize it with real cases.
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 with the same schema as a real one — every field in the table above, with a dummy reservation 7 days out. If the endpoint is subscribed to several events you can choose which one to fire, so you can test cancellation without actually cancelling anything. The notification carries "test": true, the order_number R2-TEST-0000 and an explanatory note, so your system can discard it without touching your data.
There is no separate test environment
R2 has no sandbox: the API and webhooks run against the business’s real account. Even so, almost the whole integration can be validated without touching operations: the test event fires any of the three reservation events with the complete schema, so you can develop and test your parser, signature verification, deduplication and error handling end to end. The only thing worth closing with real data is the full cycle, and a test reservation in the hotel’s account, cancelled when you are done, is enough for that.
Delivery log#
Every delivery attempt is logged and the business owner can review it in their dashboard, in the same Webhooks section: which event it was, whether it was delivered, how many attempts it took, which HTTP code your server returned, and when. That is how a “we never got it” gets settled: you can see whether R2 tried and what your endpoint answered.
- The log keeps the last 30 days; finished deliveries are purged afterwards.
- It does not store the notification body, only the attempt outcome. If you need to audit content, log the payload on your side when you receive it.
- It lives in the business dashboard: there is no API endpoint to read the log. If you are the integrator, ask the hotel to share it when something needs diagnosing.