Webhooks
An order changes state outside your own calls: a hold is ticketed after a delayed payment, a carrier cancels a flight, Norba support services a booking at your request, a payment deadline draws near. Rather than polling GET /v1/orders/{orderID}?refresh=true, register an HTTPS endpoint and Norba posts each lifecycle moment to it, signed, and retries until you acknowledge it.
Registering an endpoint
Endpoints are managed from Dashboard → Webhooks by an agency owner or admin, on a session opened with a second factor. Each one takes a URL, an optional description and the list of events it wants — leave the list empty to receive everything. The rules on the URL:
- https only. Plain http is refused at registration.
- A public host. localhost, *.local, *.internal, private, loopback and link-local addresses are refused, and the host is resolved again at every delivery — a name that starts resolving somewhere private stops being delivered to.
- Up to 200 characters of description; any subset of the six events below, or none for all of them.
Creating an endpoint returns its signing secret once — a whsec_-prefixed string shown in a dialog and never retrievable again, exactly like an API key. Store it where your receiver can read it; every delivery to that endpoint is signed with it. Deleting the endpoint and creating it again is the way to rotate it.
Events
Six events, all about orders. A servicing operation made from the dashboard, or by Norba support on your behalf, fires the same event as one made from your code — the order’s history names the actor.
| Event | When it fires |
|---|---|
| order.created | POST /v1/orders confirmed a reservation — a hold, or a booking ticketed in the same call. One per order. |
| order.ticketed | A hold became a ticket: a commit, a change/confirm or a refresh brought the order to ticketed. |
| order.updated | The order changed and is still open: a reissue, seats assigned, extras added, a partial cancellation, a payment settled without a document yet. |
| order.cancelled | The booking was closed — by you (cancel/confirm, DELETE), by Norba support on your behalf, or reported by the airline on a refresh. |
| order.refunded | POST /v1/orders/{id}/refund recorded the refund, or a refresh found the carrier had. |
| order.ticketing_deadline_approaching | An unpaid hold's payment time limit is inside the next 24 hours. Sent once per order, by the hourly sweep; the booking contact gets an e-mail at the same time. |
What arrives
One HTTP POST per event per endpoint, Content-Type: application/json, user agent norba-webhooks/1.0, with three headers of its own:
| Header | Value |
|---|---|
| X-Norba-Event | The event name, e.g. order.ticketed — the same value as event in the body. |
| X-Norba-Delivery | The delivery id. Stable across retries of the same delivery: use it to ignore a duplicate you already processed. |
| X-Norba-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. The MAC is computed with your endpoint's secret over the string "<t>.<raw body>" — the timestamp, a dot, then the request body byte for byte. |
The body is a flat JSON object. The order events all carry the same fields; the deadline reminder carries the deadline instead of the amounts.
// order.created · order.updated · order.ticketed · order.cancelled · order.refunded
{
"event": "order.ticketed",
"order_id": "AA001Y1XD7ZA6",
"pnr": "JBQGEY",
"airline": "AA",
"status": "ticketed",
"origin": "BCN",
"destination": "JFK",
"currency": "EUR",
"total": 380.62,
"occurred_at": "2026-09-27T10:02:11Z"
}
// order.ticketing_deadline_approaching
{
"order_id": "AA001Y1XD7ZA6",
"pnr": "JBQGEY",
"airline": "AA",
"ticketing_deadline": "2026-09-28T21:59:00Z"
}The payload is a notification, not the order: it tells you which order moved and to what status. Read GET /v1/orders/{orderID} (or …/history) for the passengers, tickets and amounts as they now stand.
Verifying the signature
Recompute the MAC over the raw body — before any JSON parsing or re-serialisation — with the secret the dashboard showed you, compare it to v1 in constant time, and reject a timestamp older than a few minutes so a captured delivery cannot be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyNorbaSignature(header, rawBody, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
const given = String(parts.v1 || "");
return expected.length === given.length &&
timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(given, "hex"));
}
// Express: keep the raw body — express.json() would re-serialise it.
app.post("/norba", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyNorbaSignature(req.get("X-Norba-Signature"), req.body, process.env.NORBA_WEBHOOK_SECRET)) {
return res.status(400).end();
}
const delivery = req.get("X-Norba-Delivery"); // dedupe on this
const event = JSON.parse(req.body);
// ... enqueue event, then:
res.status(204).end(); // any 2xx acknowledges
});Acknowledging, retries and dead endpoints
- Answer any 2xx, quickly. Do the work afterwards: a receiver that takes long to answer risks the delivery timing out and being retried, and the retry will look like a duplicate. Anything that is not a 2xx — or no answer at all — is a failed attempt.
- A failed delivery is retried with exponential backoff: 30 seconds after the first failure, doubling each time, never more than 6 hours apart, for up to 10 attempts. After the tenth it is marked dead and not sent again.
- Deliveries are at-least-once and unordered across events. Dedupe on X-Norba-Delivery, and use the status in the body (or re-read the order) rather than assuming events arrive in the sequence they happened.
- An endpoint that fails 50 deliveries in a row is switched off automatically and the agency's owner is e-mailed. Fix the receiver, then re-arm it from the dashboard; deliveries that were already dead are not resent. A successful delivery resets the streak.
- Every attempt — status code, error, timestamp — is listed per endpoint in Dashboard → Webhooks, so a broken integration is a lookup, not an investigation.
Webhooks announce; they do not replace reading the order. The lifecycle, the statuses and the document states are on Orders Lifecycle, and every operation that fires one is on Servicing.