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

Eventos disponibles
CampoTipoDescripción
guest.registeredeventoUn huésped concreto ha terminado su registro. Lleva el contador dentro (registered/expected), para mantener un «3 de 4» sin consultar nada.
checkin.completedeventoTodos los huéspedes han terminado el registro y la firma del contrato.
ses.submittedeventoParte 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.confirmedeventoTodos los huéspedes tienen código de comunicación del Ministerio.
ses.erroreventoEl Ministerio rechazó alguna comunicación o el envío falló. Incluye el motivo.
ses.blockedeventoEl 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.cancelledeventoSe 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_failedeventoEl 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.readyeventoEl ZIP de una exportación está listo. Lleva exportId, description, bookings, bytes y expiresAt: se puede descargar durante siete días.
export.failedeventoNo se pudo generar una exportación. Lleva exportId, description y error.
testeventoEntrega 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).

POST a tu URL — guest.registered
{
  "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.

POST a tu URL — ses.confirmed
{
  "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": []
      }
    ]
  }
}
POST a tu URL — ses.error
{
  "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": [ … ]
  }
}
POST a tu URL — ses.cancel_failed
{
  "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…"
  }
}
POST a tu URL — export.ready
{
  "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.

Node.js — verificación
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.