Flight webhooks
Receive signed booking events at your HTTPS endpoint, verify them and replay one your endpoint missed.
In short
2 calls, in the order this page makes themFor people and agentsThe same list heads this page's Markdown:/flight/flight-webhooks.md
You want to hear about a booking's outcome without polling it, including outcomes that finish after your request returns, such as a cancellation.
You receive a signed POST at your HTTPS endpoint when one of your organisation's flight bookings is confirmed, fails, is cancelled, has its cancellation refused or has its flights changed by the airline. Every credential of your organisation manages the same subscriptions, and each subscription receives events for every booking your organisation makes.
Subscribe
curl -X POST "$BASE_URL/v1/flight/webhook-subscriptions" \
-H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://partner.example/hooks/flight", "event_types": ["flight.booking.confirmed", "flight.booking.failed", "flight.booking.canceled", "flight.booking.cancel_failed", "flight.booking.airline_initiated_change_detected"] }'Your URL uses https and a public DNS name of your own, not an IP address, a local name or a host of this platform or its message queue, because requests there would carry this platform's own signature. It carries no user name, password or fragment. You can hold five subscriptions, one per URL.
| Event | You receive it when |
|---|---|
flight.booking.confirmed | Your booking is confirmed. |
flight.booking.failed | Your prebook or booking fails; failure says which step. |
flight.booking.canceled | Your cancellation succeeds; cancellation says what the airline kept and who refunds you. |
flight.booking.cancel_failed | Your cancellation is refused, by the airline or because its refund is not cash or cannot be priced; your ticket stays valid and failure says why. |
flight.booking.airline_initiated_change_detected | The airline changes a flight on your booking; airline_initiated_change shows what it removed and added. |
Every event type starts with flight., so you can receive flight and hotel events at one endpoint and tell them apart.
The 201 contains signing_secret. You receive it only once, so store it on your server.
Read the event
{
"id": "evt_0192f3a4b5c64d7e8f9a0b1c2d3e4f5b",
"type": "flight.booking.canceled",
"created_at": "2026-11-02T09:15:01.000Z",
"data": {
"booking": {
"id": "bkg_0192f3a4b5c64d7e8f9a0b1c2d3e4f5b",
"supplier": "DUFFEL",
"status": "CANCELED",
"total_amount": { "amount": 381.14, "currency": "USD" },
"fare_amount": { "amount": 381.14, "currency": "USD" },
"fare_tax_amount": { "amount": 62.1, "currency": "USD" },
"services_amount": { "amount": 0, "currency": "USD" },
"supplier_reference": "ord_0000B9M0HSU2oNVmKw0UKj",
"airline_pnr": "J6AK3S",
"confirmed_at": "2026-11-01T12:40:12.000Z",
"canceled_at": "2026-11-02T09:15:00.000Z",
"cancellation": { "refunded_by": "OEX", "fee": { "amount": 95, "currency": "USD" }, "refund_amount": { "amount": 286.14, "currency": "USD" }, "refund_to": [{ "payment_id": "pay_0192f3a4b5c64d7e8f9a0b1c2d3e4f60" }] },
"created_at": "2026-11-01T12:34:56.000Z",
"updated_at": "2026-11-02T09:15:00.000Z",
"version": 9,
"user_id": "user_01KZ0E6RAJTH7854KCFJVEEF9A"
}
}
}Each request also carries the headers x-oex-event-id, x-oex-event-type and x-oex-delivery-id and the user agent oex-flight-webhooks/1.0.
data.booking has the fields of a booking read in Flight Bookings, without passengers, journeys, extras, payments and airline changes, plus version and user_id. It carries no passenger names or cards; read the booking for its itinerary, passengers, extras, payments and airline changes.
Airline-initiated changes
A subscription you created earlier receives flight.booking.airline_initiated_change_detected only after you add it to its event_types. data.airline_initiated_change.removed lists the flights the airline took away and added the flights it put in their place. Your booking keeps the flights you booked in journeys, and GET /v1/flight/bookings/{id} lists every change in airline_initiated_changes, in the order they were recorded, so show both to the traveller. You cannot yet accept, refuse or change an airline-initiated change through this API.
Verify the signature
Each request carries x-oex-signature: t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 keyed with your whole signing secret, including the whsec_ prefix and without decoding it, over the timestamp, a dot and the raw body, and compare it in constant time with each v1. Accept the request when one matches and the timestamp is at most 8100 seconds old and no more than five minutes ahead of your clock. Use the raw body bytes, before any JSON parsing. This scheme is not Standard Webhooks, so a library for that scheme cannot verify these signatures.
import { createHmac, timingSafeEqual } from "node:crypto";
export function isValidSignature(header, rawBody, secret, nowSeconds = Date.now() / 1000) {
if (!header) return false;
const parts = header.split(",");
const timestamp = Number(parts.find((part) => part.startsWith("t="))?.slice(2));
const age = nowSeconds - timestamp;
if (!Number.isInteger(timestamp) || age > 8100 || age < -300) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return parts
.filter((part) => part.startsWith("v1="))
.some((part) => {
const actual = Buffer.from(part.slice(3), "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
});
}A retry repeats the original request with the same signature, so its timestamp can be up to 2 hours 15 minutes old. After you rotate your secret, new requests carry one v1 for each secret for 24 hours. Keep accepting your previous secret for at least 2 hours 15 minutes after you rotate, because requests queued before the rotation carry only its signature. Rotating again replaces your previous secret, so keep every secret you retired in the last 2 hours 15 minutes.
Respond
Verify the signature, store the event or put it on your own durable queue, then return a 2xx status within 30 seconds and process it afterwards, because a 2xx ends delivery and an event you acknowledged but did not store is lost. Keep the response body small, because a very large response cannot be recorded and leaves the delivery PUBLISHED until you replay it. Do not send Retry-After or rate-limit headers, because they can push a retry past the signature's age limit and you then reject it. Any other answer is retried; the waits between attempts are about 1, 6, 16, 32 and 56 minutes, so the last retry comes about 1 hour 50 minutes after the first request, and the delivery then fails.
Handle duplicates and order
You can receive an event more than once; use x-oex-event-id, the body's id, to ignore repeats. Events can arrive out of order; keep the highest data.booking.version you have seen for each booking.
Review and replay deliveries
You list a subscription's deliveries, newest first, with your endpoint's responses for the current replay, and you filter them by status; Flight Webhooks has the calls. You replay a delivered or failed delivery, a delivery pending for more than a minute, or a delivery published more than 2 hours 15 minutes ago without a result. A replay sends the same event with a fresh signature, so your endpoint can receive an event it already processed; ignore it by event ID. A replay that leaves the delivery PENDING was not confirmed as queued, though the request may already be on its way; replay it again after a minute if it stays PENDING. A delivery that stays PENDING or PUBLISHED may not be sent again automatically; find it by status and replay it.
Change a subscription
You change a subscription's URL, events or status. A paused subscription receives no new events, and events that occur while it is paused are not sent later. Events not yet queued when you pause fail with subscription_paused; resume the subscription, then replay them. Requests already queued, including their retries, still arrive after you pause or change the URL, at the URL they were queued with. Deleting a subscription removes its delivery history; requests already queued can still arrive.