Webhooks

Kinapi tells your system when something happens — no polling. We absorb the Ministry’s asynchrony for you.

How they work

SES Hospedajes processes reports asynchronously: the nightly submission gets a batch number and the confirmation (or the rejection) arrives on a later query. Instead of making you poll GET /checkins/{reservationId}/ses, configure a webhook and Kinapi will POST to your URL on every event. You set it up in the panel: Settings → Webhooks (URL, signing secret and which events you subscribe to).

Events

Available events
FieldTypeDescription
guest.registeredeventOne individual guest has finished registering. It carries the running count (registered/expected), so you can hold a “3 of 4” without polling.
checkin.completedeventEvery guest has finished registering and signing the contract.
ses.submittedeventReport transmitted to the Ministry: batch assigned, awaiting processing. Carries expected and partial — the report goes out on the night of arrival with whoever has registered, and partial=true means travellers are still to be filed: they go out in the hourly run after they register.
ses.confirmedeventEvery guest has a communication code from the Ministry.
ses.erroreventThe Ministry rejected one of the communications, or the submission failed. Includes the reason.
ses.blockedeventThe report has NOT been filed because the account is suspended for non-payment. Your obligation to the Ministry still stands and its deadline runs regardless: settle the payment or file those stays another way. When the account is reactivated, the held reports go out on their own.
ses.cancelledeventAlready-filed reports for a booking were annulled with the Ministry. reason says why: cancelled, the booking was cancelled and will not happen; dates_changed, its dates changed and it is being filed again with the new ones, so the booking IS STILL ON. Do not treat it as a cancellation without checking reason. It also carries codigosAnulados and lote. It is sent when the Ministry accepts the annulment; if it rejects it when processing it, ses.cancel_failed follows.
ses.cancel_failedeventThe Ministry did not annul a report that had to be annulled. phase says where: request, it did not accept the request in 72 attempts, one per hour (three days); processing, it accepted it but rejected it when processing it (it also carries lote). reason carries the cause. The communication STILL stands: check it in SES.HOSPEDAJES.
export.readyeventThe ZIP of an export is ready. It carries exportId, description, bookings, bytes and expiresAt: it can be downloaded for seven days.
export.failedeventAn export could not be generated. It carries exportId, description and error.
testeventA test delivery fired from the panel (“Send test”).

Delivery format

A JSON body with a fixed shape: { event, timestamp, livemode, data }. livemode is false for events from test mode, where nothing reaches the Ministry: if one receiver serves both environments, drop or route by that field. What is inside data depends on the event; booking and report events include reservationId and listingId so you can tie them back to your system, and export events carry exportId (see Exports and offboarding).

POST to your 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 and expected are recounted at send time rather than derived from what was there before: two guests in the same party can be submitting the form from their phones at the same moment. source tells you whether the registration came from the guest form or from your own API call.

POST to your 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 to your 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 to your 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": "…the Ministry’s reason, verbatim…"
  }
}
POST to your 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"
  }
}

Verifying the signature

Every delivery carries the X-Kinapi-Signature-SHA256 header: the HMAC-SHA256 (hex) of the raw body, computed with the signing secret you can see in the panel. Recompute it and discard any request that does not match — that way nobody can inject fake events into your endpoint. The X-Kinapi-Event header carries the event name, and X-Kinapi-Livemode repeats livemode (true or false) so you can decide without reading the body.

Node.js — verification
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) // the RAW body, unparsed
    .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);
  // process and answer quickly — any 2xx counts as delivered
  res.status(200).end();
});

Compute the HMAC over the raw request body, not over re-serialised JSON: any difference in whitespace or key order changes the signature.

Retries and idempotency

If your endpoint does not answer 2xx, Kinapi retries up to 3 times (immediately, ~2 s and ~10 s later) with a 10 s timeout per attempt. Delivery is at least once: on a timeout just after you processed it, you could receive the same event twice — make your processing idempotent using event + data.reservationId (or data.exportId) + timestamp as the deduplication key.

Every attempt is logged in the panel (Settings → Webhooks) with its outcome, its duration and the exact body sent — the log is kept for 30 days.

Resending a failed delivery

If a delivery exhausts its three attempts, a Resend button appears next to it in the panel’s log. It sends the same body, with its original timestamp untouched so your deduplication keeps working, but to the current URL and signed with the current secret: if the failure came from a wrong URL or a half-rotated secret, resending to the old destination would fail the same way. The request carries an X-Kinapi-Redelivery header with the original delivery’s id, in case you want to tell it apart in your logs.

It is a single attempt, without the retry ladder: a person triggers it while looking at the log, and there is no point making them wait.