Guides · Webhooks

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.

EventWhen it fires
order.createdPOST /v1/orders confirmed a reservation — a hold, or a booking ticketed in the same call. One per order.
order.ticketedA hold became a ticket: a commit, a change/confirm or a refresh brought the order to ticketed.
order.updatedThe order changed and is still open: a reissue, seats assigned, extras added, a partial cancellation, a payment settled without a document yet.
order.cancelledThe booking was closed — by you (cancel/confirm, DELETE), by Norba support on your behalf, or reported by the airline on a refresh.
order.refundedPOST /v1/orders/{id}/refund recorded the refund, or a refresh found the carrier had.
order.ticketing_deadline_approachingAn 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:

HeaderValue
X-Norba-EventThe event name, e.g. order.ticketed — the same value as event in the body.
X-Norba-DeliveryThe delivery id. Stable across retries of the same delivery: use it to ignore a duplicate you already processed.
X-Norba-Signaturet=<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.

Order Webhooks: Events, Signatures & Retries | Norba Docs