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, 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
reservation.createdA lodging reservation was created (engine, front desk or channel).read:reservations
order.createdAny order was created: restaurant, store, experiences, lodging.read:orders
payment.succeededA 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.

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"
  }
}

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, 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.