Skip to navigation

Receive events with webhooks

What you’ll do: register an HTTPS endpoint, prove you own it (echo a challenge token), then verify the signature on every event we deliver. Webhooks let your app react to changes — a request booking getting its PNR, a watched flight running late — without polling.

Console shortcut: you can also add and verify endpoints on the Webhooks page of your console — sandbox.kitionz.com for Test endpoints, kitionz.com for Live ones. Each console registers endpoints for its own world. This guide shows the same thing from your own server, with your API key.

Step 1 — Register an endpoint

POST /v1/webhooks/endpoints
Authorization: Bearer <keyId>:<secret>
Idempotency-Key: <a new UUID>
Content-Type: application/json
{
"url": "https://api.your-app.com/webhooks/kitionz",
"description": "Booking events receiver",
"enabledEvents": ["flight_booking.*"]
}

Every call that creates or changes something here — registering, verifying, testing, replaying — needs a new Idempotency-Key (a UUID), like every other write.

The response includes a signing secret (whsec_…) shown once — store it securely. The endpoint starts in status PENDING (Awaiting verification). Deploy your handler (Step 2) before you register: if the challenge finds nobody, the endpoint stays PENDING until you call POST /v1/webhooks/endpoints/:id/verify.

Subscribe to flight_booking.* to receive every booking event (current and future), or * for everything Kitionz emits and filter on the type field in your handler. The events are listed under Events below.

Step 2 — Verify you own the URL (echo the challenge)

When you create an endpoint (or call POST /v1/webhooks/endpoints/:id/verify), Kitionz sends a verification request to your URL. Its body is { "type": "webhook.endpoint.verify", "timestamp": "…", "data": { "token": "…", "endpointId": "whk_…" } }. Respond 200 with the token in the body — then the endpoint flips to ENABLED and events start flowing. Handle this request before your de-duplication (Step 5): it is signed like an event, but its webhook-id is the endpoint’s id (whk_…), the same on every re-verification.

// Express — verification + delivery on the same route.
app.post("/webhooks/kitionz", express.raw({ type: "*/*" }), (req, res) => {
const body = JSON.parse(req.body.toString("utf8"));
// Verification handshake: echo the challenge token back with a 200.
if (body.type === "webhook.endpoint.verify") {
return res.status(200).json({ token: body.data.token });
}
// …normal event handling below (verify the signature first — Step 3).
res.status(200).end();
});

Step 3 — Verify the signature on every delivery

Every delivery carries Standard Webhooks headers: webhook-id, webhook-timestamp, and webhook-signature. Recompute the HMAC over ${webhook-id}.${webhook-timestamp}.${rawBody} with your whsec_ secret and compare — reject anything that doesn’t match, or whose timestamp is more than 5 minutes old.

import crypto from "node:crypto";
// secret is the whsec_… value from Step 1.
function verify(rawBody, headers, secret) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
// Refuse old deliveries, so a captured request cannot be sent to you again later.
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (!Number.isFinite(age) || age > 5 * 60) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
crypto
.createHmac("sha256", key)
.update(`${id}.${ts}.${rawBody}`)
.digest("base64"),
);
// webhook-signature can carry several space-separated "v1,<signature>" values.
return String(headers["webhook-signature"] || "")
.split(" ")
.some((value) => {
const [version, signature] = value.split(",");
if (version !== "v1" || !signature) return false;
const sent = Buffer.from(signature);
// timingSafeEqual needs two values of the same length.
return (
sent.length === expected.length &&
crypto.timingSafeEqual(sent, expected)
);
});
}

Always read the raw request body for both the echo (Step 2) and the signature check — JSON re-serialization changes bytes and breaks the HMAC. Reply 2xx quickly; we retry failed deliveries with backoff and you can inspect attempts in the delivery log. Any Standard Webhooks library (for example standardwebhooks) verifies these headers too.

Step 4 — Send yourself a test event

Prove the whole receiver (raw body, signature, dedupe) before any real event exists:

POST /v1/webhooks/endpoints/<id>/test
Authorization: Bearer <keyId>:<secret>
Idempotency-Key: <a new UUID>

A signed webhook.test delivery travels the real pipeline to your endpoint (it must be verified first, Step 2). If your handler logs webhook.test … verified, your integration is correct — no need to wait for a real event.

Step 5 — Deduplicate, watch deliveries, replay

Deliveries are at-least-once: store each webhook-id you’ve processed and skip repeats (a tiny processed_webhooks table keyed on that id is enough). A retry of the same event carries the same webhook-id.

Check delivery attempts (status, attempt count, last response) per endpoint:

GET /v1/webhooks/deliveries?endpointId=<id>&limit=50
Authorization: Bearer <keyId>:<secret>

Failed deliveries retry automatically for ~27 hours (backoff: 5s → 5m → 30m → 2h → 5h → 10h → 10h). If your server was down past that window the delivery ends DEAD — recover it with a fresh retry chain:

POST /v1/webhooks/deliveries/<deliveryId>/replay
Authorization: Bearer <keyId>:<secret>
Idempotency-Key: <a new UUID>

The Webhooks page of your console shows the same log under each endpoint’s Deliveries.

After every 5 failed deliveries in a row, Kitionz waits 5 minutes before trying that endpoint again. After 40 in a row it disables the endpoint: a disabled endpoint receives nothing, cannot be verified again, and its undelivered events end DEAD and cannot be replayed. Fix your receiver, delete the endpoint and register it again (a new id and a new secret), then read what you missed from the API — for example GET /v1/flights/bookings.

Events

Every delivery has the same body. type says which event it is, and data carries the event’s own fields:

{
"id": "evt_…",
"type": "flight_booking.confirmed",
"version": 1,
"apiVersion": "…",
"timestamp": "2026-10-29T10:14:00.000Z",
"data": { … }
}

id is the event’s id — the same value as the webhook-id header. timestamp is when this delivery attempt was sent (a retry has a later one). version is the version of data’s shape: a new field never changes it, a breaking change does.

EventWhen Kitionz sends itFields in data
webhook.testYou called Send a test event (Step 4).endpointId, message, triggeredAt
flight_booking.confirmedA request booking got its PNR (you submitted it with Submit PNR).bookingId, status (CONFIRMED), pnr, supplierReference, externalReference, confirmedAt, supplier and returnSupplier (name, website, support phone and email)
flight_booking.rejectedA request booking closed without a PNR: you withdrew it, or it expired.bookingId, status (CANCELLED), reason (WITHDRAWN or EXPIRED), externalReference, rejectedAt, supplier, returnSupplier
flight_status.receivedFlight Status: Kitionz accepted a flight to watch.correlation (requestId, externalReference), the flight, status, monitoringStartsAt
flight_status.monitoring_startedFlight Status: watching started (two days before departure).correlation, the flight, startedAt, baselineSource
flight_status.changedFlight Status: the flight now differs from the time your customer booked.correlation, the flight’s new state, and a ready-to-send customer message
flight_status.untrackableFlight Status: the flight cannot be tracked, so watching stopped.correlation, the flight, endedAt, reason (NOT_FOUND)

Every Flight Status event can also carry notification, a ready-to-send customer message. Booking events come to endpoints in both worlds, Test and Live. Flight Status is a Live-only module: its events come only to Live endpoints. Booking events still name the seller supplier and returnSupplier; a later version adds seller beside them, as bookings already have.