> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.kitionz.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.kitionz.com/_mcp/server.

# 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

```http
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.

```js
// 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.

```js
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:

```http
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:

```http
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:

```http
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, and
its undelivered events end `DEAD`. Its id and secret stay the same. To bring it back:

1. Fix your receiver.
2. Verify it again: `POST /v1/webhooks/endpoints/<id>/verify` (Step 2). Once your server echoes
   the token, the endpoint is **`ENABLED`** again, with its failure count back at 0.
3. Recover what it missed:

```http
POST /v1/webhooks/endpoints/<id>/recover
Authorization: Bearer <keyId>:<secret>
Idempotency-Key: <a new UUID>
Content-Type: application/json

{}
```

Recovery sends the endpoint every event it did not receive since its last successful delivery:
its `DEAD` deliveries, and the events created while it was disabled or awaiting verification. To
start from another time, send `{ "since": "2026-10-27T08:00:00Z" }` (events are kept for 30 days,
7 in Test). The answer says how many deliveries were queued (`requeuedDeliveries`,
`newDeliveries`); they go out over the next few minutes, in no guaranteed order — your
de-duplication (Step 5) handles any you already had. At most 500 per call: when `more` is `true`,
call it again. The **Webhooks** page of your console has the same actions: **Verify again** and
**Recover missed events**.

## Events

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

```json
{
  "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.

| Event                              | When Kitionz sends it                                                     | Fields in `data`                                                                                                                                                              |
| ---------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webhook.test`                     | You called **Send a test event** (Step 4).                                | `endpointId`, `message`, `triggeredAt`                                                                                                                                        |
| `flight_booking.confirmed`         | A 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.rejected`          | A 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.received`           | Flight Status: Kitionz accepted a flight to watch.                        | `correlation` (`requestId`, `externalReference`), the flight, `status`, `monitoringStartsAt`                                                                                  |
| `flight_status.monitoring_started` | Flight Status: watching started (two days before departure).              | `correlation`, the flight, `startedAt`, `baselineSource`                                                                                                                      |
| `flight_status.changed`            | Flight 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.untrackable`        | Flight 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.