Webhooks — avisos en tiempo real
En vez de consultar la API cada rato, deja que R2 le avise a tu sistema en el momento en que ocurre un evento: una reserva creada, modificada o cancelada, un pago cobrado.
Un webhook es una URL de tu sistema que R2 llama con un POST cada vez que ocurre algo que te interesa. El dueño del negocio registra el endpoint desde su panel, elige qué eventos escuchar, y R2 empieza a avisar. Es la forma eficiente de mantenerte sincronizado: sin consultar en ciclo, sin retraso.
Eventos disponibles#
| Evento | Cuándo se dispara | Permiso para pedir el detalle |
|---|---|---|
reservation.created | Se creó una reserva de hospedaje (motor, recepción o canal). | read:reservations |
reservation.updated | Cambió una reserva ya existente: fechas, huésped, ocupación o importe. El aviso trae los valores nuevos. | read:reservations |
reservation.cancelled | Se canceló una reserva, sin importar quién la canceló (hotel, huésped, canal o proceso automático). | read:reservations |
order.created | Se creó cualquier orden: restaurante, tienda, experiencias, hospedaje. | read:orders |
payment.succeeded | Un pago se confirmó como cobrado. | read:payments |
Los webhooks y las API keys son independientes
La columna de arriba indica qué permiso necesita tu API key para pedir el detalle del recurso por la API de lectura — no es un requisito para recibir el aviso. La suscripción la autoriza el dueño del negocio al registrar el endpoint, y vive aparte de las llaves: revocar una API key no apaga los webhooks, ni crear una llave nueva cambia a qué eventos está suscrito el endpoint. Son dos cosas que se administran por separado en el panel. Si necesitas dejar de recibir avisos, hay que desactivar o borrar el webhook.
Sincronización sin consultar en ciclo
Con reservation.created, reservation.updated y reservation.cancelled cubres el ciclo de vida completo de una reserva por push. Solo necesitas la API de lectura para la carga inicial: un webhook nuevo empieza a avisar desde el momento en que se registra, nunca te manda el histórico previo.
Cuándo se dispara reservation.updated
Solo cuando cambia algo que te importa: fechas de estancia, estado, importe total, nombre del huésped u ocupación. Los movimientos internos del hotel —una nota, un recordatorio enviado, un ajuste de garantía— no generan aviso. Si el mismo dato vuelve a su valor anterior, sí recibes un aviso nuevo: trata cada evento como “esta reserva quedó así”, no como un diff.
El orden de llegada no está garantizado
Si una entrega falla, se reintenta con espera creciente — así que un aviso viejo puede llegarte después de uno nuevo de la misma reserva. Aplicar el viejo encima del nuevo dejaría tu sistema con datos que ya no son ciertos. Guarda el updated_at de cada reserva y descarta cualquier evento cuyo updated_at sea menor o igual al que ya tienes. Es una comparación y te ahorra el problema completo.
Latencia
R2 detecta y despacha los eventos en cuestión de un minuto. Los webhooks son para reaccionar rápido, no como reloj de precisión: para totales contables exactos, usa la API de lectura como fuente de verdad.
Cómo integrar, paso a paso#
Esta es la secuencia completa para mantener tu sistema al día con las reservas de un negocio, sin consultar la API en ciclo:
- El dueño genera la API key desde su panel (Configuración → R2 Connect API) con el permiso
read:reservations, y te la comparte. Se muestra una sola vez. - Carga inicial por la API de lectura: recorre
GET /reservationspaginando hasta quehas_moreseafalse. Este paso se hace UNA vez; el webhook no reproduce el histórico anterior a su registro. - Expón un endpoint HTTPS que acepte
POSTcon cuerpo JSON. Debe contestar2xxen menos de 5 segundos: confirma primero, procesa después en segundo plano. - El dueño registra el endpoint y elige los eventos. Para hospedaje:
reservation.created,reservation.updatedyreservation.cancelled. Guarda el secreto de firma que aparece al registrarlo — solo se muestra esa vez. - Verifica la firma de cada POST antes de hacerle caso (procedimiento abajo). Un aviso sin firma válida se descarta: no vino de R2.
- Deduplica por
R2-Delivery-Idy descarta los eventos cuyoupdated_atsea anterior al que ya guardaste para esa reserva. - Aplica el evento como estado, no como diferencia: cada aviso describe cómo quedó la reserva. Si necesitas el desglose completo (líneas, impuestos, pagos), pídelo con
GET /reservations/{order_number}.
No te suscribas a order.created para hospedaje
Una reserva de hospedaje dispara reservation.created y también order.created, porque toda reserva es una orden. Si escuchas los dos, vas a recibir dos avisos de la misma reserva. Para un sistema de hotelería, escucha solo los reservation.*; order.created es para cuando además te interesan restaurante, tienda o experiencias.
Los avisos son de solo lectura
Los webhooks van en una sola dirección: R2 te avisa a ti. De tu respuesta solo leemos el código de estado para saber si reintentar — el cuerpo se descarta. La API pública es de solo lectura (GET), así que desde tu sistema no es posible crear, modificar ni cancelar una reserva en R2. La escritura llegará en una versión futura.
Forma del payload#
Cada aviso llega como JSON. Trae el tipo de evento, cuándo ocurrió, y un objeto data con los campos clave. Para el detalle completo, pide el recurso por la API con el order_number o el 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"
}
}Los eventos de reserva traen la estancia completa. En una cancelación se agregan cancelled_at y 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"
}
}Campos del payload de reserva#
Esto es todo lo que viaja en un reservation.created, reservation.updated o reservation.cancelled. Los importes van en la moneda del negocio, con decimales; las fechas de estancia en AAAA-MM-DD y las marcas de tiempo en ISO 8601 (UTC).
| Campo | Tipo | Qué contiene |
|---|---|---|
event | string | El evento: reservation.created, reservation.updated o reservation.cancelled. |
occurred_at | ISO 8601 | Cuándo ocurrió el hecho: el alta, el cambio o la cancelación. |
data.object | string | Siempre "reservation" en estos tres eventos. |
data.id | string | Identificador interno de la reserva. Estable, nunca cambia. |
data.order_number | string | El número de reserva visible (ej. R2-QXRH6W). Es el que se usa en GET /reservations/{order_number}. |
data.type | string | booking (hospedaje) o space_booking (renta de espacio). |
data.status | string | Uno de: pending, placed, confirmed, in-progress, completed, cancelled, noshow, refunded. |
data.channel | string o null | Por dónde entró: motor web, recepción, teléfono, WhatsApp o el canal/OTA de origen. |
data.guest_name | string o null | Nombre del huésped de esta reserva. |
data.check_in | AAAA-MM-DD o null | Primera noche de la estancia (la más temprana si la reserva tiene varias habitaciones). |
data.check_out | AAAA-MM-DD o null | Día de salida (el más tardío si hay varias habitaciones). |
data.guests | objeto o null | { adults, children }, sumando todas las habitaciones de la reserva. |
data.currency | string | Moneda del negocio, ej. MXN. |
data.subtotal | número | Importe antes de impuestos. |
data.tax_total | número | Suma de impuestos. El desglose por impuesto se pide por la API. |
data.total | número | Total de la reserva, impuestos incluidos. |
data.created_at | ISO 8601 | Cuándo se creó la reserva. |
data.updated_at | ISO 8601 | Última modificación. Úsalo para descartar avisos que lleguen fuera de orden. |
data.cancelled_at | ISO 8601 | Solo en reservation.cancelled. Momento de la cancelación. |
data.cancellation_reason | string o null | Solo en reservation.cancelled. Motivo, ej. "Hold sin pagar vencido (liberado automáticamente)". |
Qué NO trae el aviso (y cuándo llamar a la API)
El aviso trae el encabezado de la reserva, suficiente para crearla o actualizarla en tu sistema con fechas, ocupación e importes. Si necesitas el detalle, pídelo con GET /reservations/{order_number}: ahí vienen las habitaciones asignadas (lines[].item_name, con sus fechas propias y sus horas de entrada y salida reales), el desglose de impuestos, el descuento, lo pagado y el saldo, y los pagos con su método. Regla práctica: una llamada por reserva, cuando la des de alta o cuando el aviso te diga que cambió.
Dos detalles de las líneas de una reserva
(1) lines[] no son solo habitaciones: también pueden venir consumos y extras (desayunos, actividades) y, en algunos negocios, impuestos como renglón. La regla para quedarte solo con las habitaciones es que la línea traiga check_in; las demás vienen con check_in: null. (2) Cuando una reserva se cancela, su habitación se libera pero check_in y check_out siguen indicando la estancia que se canceló — que es justo lo que necesitas para liberarla en tu sistema.
El correo y el teléfono del huésped no vienen en la reserva
Ni el aviso ni GET /reservations/{order_number} incluyen datos de contacto del huésped, y hoy no hay forma de enlazar una reserva con su ficha de /guests por identificador. Si tu integración necesita el correo o el teléfono, escríbenos: es una limitación conocida y queremos priorizarla con casos reales.
Encabezados de cada POST#
| Encabezado | Contenido |
|---|---|
R2-Signature | Firma HMAC + timestamp: t=<unix>,v1=<hex>. |
R2-Event | El tipo de evento, ej. payment.succeeded. |
R2-Delivery-Id | Identificador único de esta entrega (para deduplicar). |
R2-Webhook-Id | Identificador del webhook que recibió el aviso. |
Verificar la firma (importante)#
Al registrar el webhook, el dueño recibe un secreto de firma (empieza con whsec_) que solo se muestra una vez. Con él verificas que cada aviso viene de R2 y no de un impostor. El procedimiento es idéntico al de Stripe:
- Toma el encabezado
R2-Signaturey separat(timestamp) yv1(firma). - Construye la cadena firmada:
{t}.{cuerpo_crudo}— el cuerpo EXACTO que recibiste, sin re-serializar. - Calcula
HMAC-SHA256(secreto, cadena_firmada)en hexadecimal. - Compáralo con
v1usando comparación de tiempo constante. - Rechaza si no coincide, o si
ttiene más de 5 minutos (protección anti-repetición).
// Node.js / Express — verificación del webhook
import crypto from "node:crypto";
const SECRET = process.env.R2_WEBHOOK_SECRET; // whsec_...
// OJO: necesitas el cuerpo CRUDO (raw), no el ya parseado.
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) Anti-repetición: rechaza timestamps viejos
if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).end();
// 2) Recalcula la firma
const expected = crypto.createHmac("sha256", SECRET)
.update(`${t}.${body}`).digest("hex");
// 3) Comparación de tiempo constante
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!ok) return res.status(400).end();
// 4) Responde 2xx RÁPIDO y procesa después
const evento = JSON.parse(body);
encolarParaProcesar(evento); // no bloquees la respuesta
res.status(200).end();
});# Python / Flask — verificación del webhook
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() # crudo, en bytes
if abs(time.time() - t) > 300:
abort(400) # anti-repetición
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header.get("v1", "")):
abort(400)
evento = request.get_json()
encolar_para_procesar(evento)
return "", 200Reintentos y confiabilidad#
- Responde con cualquier código
2xxpara confirmar la recepción. Cualquier otra cosa cuenta como fallo. - Si tu endpoint falla o no responde, R2 reintenta con espera creciente: hasta 6 intentos a lo largo de varias horas.
- Un endpoint que falla muchas veces seguidas se desactiva solo; el dueño lo reactiva desde su panel cuando lo arregla.
- Responde rápido (menos de 5 segundos): confirma la recepción y procesa en segundo plano. Si tardas, R2 lo toma como timeout y reintenta.
- Puede haber entregas duplicadas en casos límite: usa
R2-Delivery-Idpara deduplicar y haz tu procesamiento idempotente.
Probar sin esperar un evento real#
Desde el panel del negocio (Configuración → R2 Connect API → Webhooks) hay un botón “Enviar evento de prueba” que dispara un aviso firmado con el mismo esquema que uno real — todos los campos de la tabla de arriba, con una reserva ficticia a 7 días de hoy. Si el endpoint está suscrito a varios eventos, se puede elegir cuál disparar, así pruebas la cancelación sin cancelar nada de verdad. El aviso trae "test": true, el order_number R2-TEST-0000 y una nota aclaratoria, para que tu sistema lo descarte sin tocar tus datos.
No hay un ambiente separado de pruebas
R2 no tiene sandbox: la API y los webhooks corren contra la cuenta real del negocio. Aun así, casi toda la integración se puede validar sin tocar operación: el evento de prueba dispara cualquiera de los tres eventos de reserva con el esquema completo, así que puedes desarrollar y probar tu parser, tu verificación de firma, tu deduplicado y tu manejo de errores de punta a punta. Lo único que conviene cerrar con datos verdaderos es el ciclo completo, y para eso basta con una reserva de prueba en la cuenta del hotel que se cancela al terminar.
Registro de entregas#
Cada intento de envío queda registrado y el dueño del negocio lo consulta en su panel, en la misma sección de Webhooks: qué evento fue, si se entregó, cuántos intentos llevó, con qué código HTTP respondió tu servidor y cuándo. Es la forma de resolver un “no me llegó”: ahí se ve si R2 lo intentó y qué contestó tu endpoint.
- El registro conserva los últimos 30 días; las entregas ya terminadas se purgan después.
- No guarda el cuerpo del aviso, solo el resultado del intento. Si necesitas auditar el contenido, registra el payload de tu lado cuando lo recibas.
- La consulta vive en el panel del negocio: no hay un endpoint de la API para leer el registro. Si eres el integrador, pídele al hotel que te lo comparta cuando haga falta diagnosticar.