Hotel webhooks
You receive signed booking events at your HTTPS endpoint.
You receive a signed POST at your HTTPS endpoint when a hotel booking of your organization reaches an outcome. Every credential of your organization manages the same subscriptions, and each subscription receives events for every booking your organization makes.
Subscribe
You create a subscription with your endpoint URL and the events you want. 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 |
|---|---|
| hotel.booking.confirmed | Your booking is confirmed. |
| hotel.booking.failed | Your prebook or booking fails. |
| hotel.booking.canceled | A cancellation leaves every room canceled. |
| hotel.booking.partially_canceled | A cancellation releases rooms and others remain. |
| hotel.booking.cancel_failed | Your cancellation is refused, by the supplier or because its cancellation terms restrict it, and releases no room. |
Every hotel event type starts with hotel., and flight events start with flight., so one endpoint can serve both.
The create response contains signing_secret. You receive it only once, so store it on your server.
Read the event
Your request body is JSON. This complete example uses synthetic identifiers and dates:
{
"id": "evt_example",
"type": "hotel.booking.confirmed",
"created_at": "2027-10-01T10:03:00Z",
"data": {
"booking": {
"id": "bkg_example",
"reference_id": "order-example",
"status": "CONFIRMED",
"hotel": {
"id": "htl_example",
"name": "Example Marina Hotel",
"address": "Example Avenue, Dubai, AE"
},
"check_in": "2027-11-22",
"check_out": "2027-11-24",
"total": {
"amount": 600,
"currency": "USD"
},
"created_at": "2027-10-01T10:01:00Z",
"updated_at": "2027-10-01T10:03:00Z",
"confirmed_at": "2027-10-01T10:03:00Z",
"canceled_at": null,
"version": 7,
"user_id": "apikey_example"
}
}
}Each request also carries x-oex-event-id, x-oex-event-type and x-oex-delivery-id headers and the user agent oex-hotel-webhooks/1.0.
Through the API gateway, user_id is the API-key ID that owns the booking; a replacement key does not automatically inherit that owner. data.booking has the fields of a booking list item except payments, plus version and user_id. It carries no guest or payment details; read the booking for rooms, fees, payments and confirmation codes using a credential for the organization and user that created it. Managing an organization's subscriptions does not grant access to another user's booking. If your receiver has no credential for that owner, contact your account administrator; changing identity headers does not grant access through the gateway.
Verify the signature
Each request carries x-oex-signature: t=,v1=. 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}.`)
.update(rawBody)
.digest();
return parts
.filter((part) => part.startsWith("v1="))
.some((part) => {
const signature = part.slice(3);
if (!/^[a-fA-F0-9]{64}$/.test(signature)) return false;
const actual = Buffer.from(signature, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
});
}A retry repeats the original request with the same signature, so the 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.
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 verify the signature before parsing the body. Use the verified body's id to ignore repeats and its type to select your handler, because the signature covers the body and timestamp, not the x-oex-event-id, x-oex-event-type or x-oex-delivery-id headers. You can check those event headers against the body, but you do not use them independently to identify or route an event.
You record the event ID atomically with your durable event or queue entry, so simultaneous deliveries cannot both start processing. A duplicate does not enqueue another copy. 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. You replay a delivered or failed delivery, a delivery pending for more than one 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 one 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. 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.