Developers
R2 Connect API

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#

EventWhen it firesScope to fetch the detail
reservation.createdA lodging reservation was created (engine, front desk or channel).read:reservations
reservation.updatedAn existing reservation changed: dates, guest, occupancy or amount. The notification carries the new values.read:reservations
reservation.cancelledA reservation was cancelled, no matter who cancelled it (hotel, guest, channel or automated process).read:reservations
order.createdAny order was created: restaurant, store, experiences, lodging.read:orders
payment.succeededA 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:

  1. The owner generates the API key from their dashboard (Settings → R2 Connect API) with the read:reservations scope, and shares it with you. It is shown only once.
  2. Initial backfill via the read API: walk GET /reservations, paginating until has_more is false. This runs ONCE; the webhook does not replay history from before it was registered.
  3. Expose an HTTPS endpoint accepting POST with a JSON body. It must answer 2xx in under 5 seconds: acknowledge first, process in the background afterwards.
  4. The owner registers the endpoint and picks the events. For lodging: reservation.created, reservation.updated and reservation.cancelled. Save the signing secret shown at registration — it appears only that once.
  5. 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.
  6. Deduplicate by R2-Delivery-Id, and discard events whose updated_at is older than the one you already stored for that reservation.
  7. 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.

json
{
  "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:

json
{
  "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).

FieldTypeWhat it holds
eventstringThe event: reservation.created, reservation.updated or reservation.cancelled.
occurred_atISO 8601When it happened: the creation, the change or the cancellation.
data.objectstringAlways "reservation" for these three events.
data.idstringInternal reservation identifier. Stable, never changes.
data.order_numberstringThe visible reservation number (e.g. R2-QXRH6W). This is what you pass to GET /reservations/{order_number}.
data.typestringbooking (lodging) or space_booking (venue rental).
data.statusstringOne of: pending, placed, confirmed, in-progress, completed, cancelled, noshow, refunded.
data.channelstring or nullWhere it came from: web engine, front desk, phone, WhatsApp, or the originating channel/OTA.
data.guest_namestring or nullGuest name for this reservation.
data.check_inYYYY-MM-DD or nullFirst night of the stay (the earliest one if the reservation has several rooms).
data.check_outYYYY-MM-DD or nullDeparture day (the latest one if there are several rooms).
data.guestsobject or null{ adults, children }, summed across all rooms in the reservation.
data.currencystringBusiness currency, e.g. MXN.
data.subtotalnumberAmount before taxes.
data.tax_totalnumberSum of taxes. The per-tax breakdown is available through the API.
data.totalnumberReservation total, taxes included.
data.created_atISO 8601When the reservation was created.
data.updated_atISO 8601Last modification. Use it to discard notifications that arrive out of order.
data.cancelled_atISO 8601Only in reservation.cancelled. Moment of cancellation.
data.cancellation_reasonstring or nullOnly 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#

HeaderContent
R2-SignatureHMAC signature + timestamp: t=<unix>,v1=<hex>.
R2-EventThe event type, e.g. payment.succeeded.
R2-Delivery-IdUnique identifier of this delivery (for deduplication).
R2-Webhook-IdIdentifier 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:

  1. Take the R2-Signature header and split t (timestamp) and v1 (signature).
  2. Build the signed string: {t}.{raw_body} — the EXACT body you received, without re-serializing.
  3. Compute HMAC-SHA256(secret, signed_string) in hexadecimal.
  4. Compare it with v1 using constant-time comparison.
  5. Reject if it does not match, or if t is older than 5 minutes (replay protection).
javascript
// 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
# 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 "", 200

Retries and reliability#

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.