Webhooks
Kinapi avisa a tu sistema cuando pasa algo — sin polling. La asincronía del Ministerio la absorbemos nosotros.
Cómo funcionan
SES Hospedajes procesa los partes de forma asíncrona: el envío nocturno recibe un lote y la confirmación (o el rechazo) llega en una consulta posterior. En vez de obligarte a hacer polling de GET /checkins/{reservationId}/ses, configura un webhook y Kinapi hará POST a tu URL con cada evento. Se configura en el panel: Configuración → Webhooks (URL, secreto de firma y a qué eventos te suscribes).
Eventos
| Campo | Tipo | Descripción |
|---|---|---|
guest.registered | evento | Un huésped concreto ha terminado su registro. Lleva el contador dentro (registered/expected), para mantener un «3 de 4» sin consultar nada. |
checkin.completed | evento | Todos los huéspedes han terminado el registro y la firma del contrato. |
ses.submitted | evento | Parte transmitido al Ministerio: lote asignado, pendiente de proceso. Lleva expected y partial — el parte sale la noche de llegada con quien haya, y partial=true significa que faltan viajeros por comunicar: saldrán en el envío de la hora siguiente a que se registren. |
ses.confirmed | evento | Todos los huéspedes tienen código de comunicación del Ministerio. |
ses.error | evento | El Ministerio rechazó alguna comunicación o el envío falló. Incluye el motivo. |
ses.blocked | evento | El parte NO se ha comunicado porque la cuenta está suspendida por impago. Tu obligación con el Ministerio sigue vigente y su plazo corre igual: hay que regularizar el pago o comunicar esas estancias por otra vía. Al reactivar la cuenta, los partes retenidos salen solos. |
ses.cancelled | evento | Se han anulado ante el Ministerio partes ya comunicados de una reserva. reason dice por qué: cancelled, la reserva se canceló y no va a ocurrir; dates_changed, cambiaron sus fechas y se vuelve a comunicar con las nuevas, así que la reserva SIGUE EN PIE. No lo trates como una cancelación sin mirar reason. Lleva también codigosAnulados y lote. Sale cuando el Ministerio acepta la anulación; si al procesarla la rechaza, llega después ses.cancel_failed. |
ses.cancel_failed | evento | El Ministerio no anuló un parte que había que anular. phase dice dónde: request, no aceptó la petición en 72 intentos, uno por hora (tres días); processing, la aceptó pero la rechazó al procesarla (lleva también lote). reason trae el motivo. La comunicación SIGUE constando: revísala en SES.HOSPEDAJES. |
export.ready | evento | El ZIP de una exportación está listo. Lleva exportId, description, bookings, bytes y expiresAt: se puede descargar durante siete días. |
export.failed | evento | No se pudo generar una exportación. Lleva exportId, description y error. |
test | evento | Entrega de prueba lanzada desde el panel («Enviar prueba»). |
Formato de la entrega
Cuerpo JSON con estructura fija: { event, timestamp, livemode, data }. livemode es false en los eventos del entorno de pruebas, donde nada llega al Ministerio: si un mismo receptor atiende los dos entornos, descarta o separa por ese campo. El contenido de data depende del evento; los de reservas y partes incluyen reservationId y listingId para que enlaces con tu sistema, y los de exportación, exportId (ver Exportación y fin del servicio).
{
"event": "guest.registered",
"timestamp": "2026-08-14T18:03:11.000Z",
"livemode": true,
"data": {
"reservationId": "R-2026-0412",
"listingId": "38291",
"guestId": "5f0c33f1-…",
"name": "María García López",
"isLead": true,
"registered": 3,
"expected": 4,
"status": "in_progress",
"source": "guest"
}
}registered y expected se recuentan en el momento del envío, no se calculan sobre lo que hubiera antes: dos huéspedes del mismo grupo pueden estar enviando el formulario a la vez desde sus móviles. source distingue si el registro vino del formulario del huésped o de tu propia llamada a la API.
{
"event": "ses.confirmed",
"timestamp": "2026-08-15T07:30:12.000Z",
"livemode": true,
"data": {
"reservationId": "R-2026-0412",
"listingId": "38291",
"lote": "20260815-000123",
"comunicaciones": [
{
"guestName": "María García López",
"codigoComunicacion": "SES-2026-…",
"errores": []
}
]
}
}{
"event": "ses.error",
"timestamp": "2026-08-15T07:30:12.000Z",
"livemode": true,
"data": {
"reservationId": "R-2026-0412",
"listingId": "38291",
"phase": "poll",
"reason": "María García López: documento caducado",
"lote": "20260815-000123",
"comunicaciones": [ … ]
}
}{
"event": "ses.cancel_failed",
"timestamp": "2026-08-16T11:30:08.000Z",
"livemode": true,
"data": {
"reservationId": "R-2026-0412",
"listingId": "38291",
"phase": "processing",
"lote": "20260816-000045",
"reason": "…motivo literal del Ministerio…"
}
}{
"event": "export.ready",
"timestamp": "2026-10-14T09:03:12.000Z",
"livemode": true,
"data": {
"exportId": "3f6c1e2a-…",
"description": "Propietario Gestora Costa SL",
"bookings": 412,
"bytes": 184503112,
"expiresAt": "2026-10-21T09:03:12.000Z"
}
}Verificar la firma
Cada entrega incluye la cabecera X-Kinapi-Signature-SHA256: el HMAC-SHA256 (hex) del cuerpo crudo, calculado con el secreto de firma que ves en el panel. Recalcúlala y descarta cualquier petición que no coincida — así nadie puede inyectar eventos falsos en tu endpoint. La cabecera X-Kinapi-Event lleva el nombre del evento, y X-Kinapi-Livemode repite livemode (true o false) para poder decidir sin leer el cuerpo.
import { createHmac, timingSafeEqual } from 'node:crypto';
app.post('/webhooks/kinapi', express.raw({ type: 'application/json' }), (req, res) => {
const expected = createHmac('sha256', process.env.KINAPI_WEBHOOK_SECRET)
.update(req.body) // cuerpo CRUDO, sin parsear
.digest('hex');
const received = req.get('X-Kinapi-Signature-SHA256') ?? '';
const valid =
received.length === expected.length &&
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.status(401).end();
const { event, data } = JSON.parse(req.body);
// procesa y responde rápido — cualquier 2xx cuenta como entregado
res.status(200).end();
});Calcula el HMAC sobre el cuerpo crudo de la petición, no sobre el JSON re-serializado: cualquier diferencia de espacios u orden de claves cambia la firma.
Reintentos e idempotencia
Si tu endpoint no responde 2xx, Kinapi reintenta hasta 3 veces (inmediato, ~2 s y ~10 s después) con timeout de 10 s por intento. La entrega es al menos una vez: ante un timeout justo después de procesar, podrías recibir el mismo evento dos veces — haz tu procesado idempotente usando event + data.reservationId (o data.exportId) + timestamp como clave de deduplicación.
Cada intento queda registrado en el panel (Configuración → Webhooks) con su resultado, la duración y el cuerpo exacto enviado — el registro se conserva 30 días.
Reenviar una entrega fallida
Si una entrega agota sus tres intentos, en el registro del panel aparece Reenviar junto a ella. Se manda el mismo cuerpo, con su timestamp original intacto para que tu deduplicación siga funcionando, pero a la URL y con el secreto actuales: si el fallo vino de una URL mal puesta o de un secreto a medio rotar, reenviar al destino viejo volvería a fallar igual. La petición añade la cabecera X-Kinapi-Redelivery con el id de la entrega original, por si quieres distinguirla en tus logs.
Es un solo intento, sin la escalera de reintentos: lo dispara una persona mirando el registro, y no tiene sentido dejarla esperando.