Webhooks
The API calls your endpoint when a booking is created, modified, cancelled or refunded, or gets a client request. The call is only a signal, so fetch the booking to see what changed.
When something happens to a booking on one of your microsites, the API sends a small JSON body to your URL. The body names the booking and nothing else. Treat it as a signal to fetch the booking, never as the booking itself.
Set up an endpoint
In the back office, under Data > Webhooks, sign in as a user with the micrositeSettings permission and add your URL.
Test the URL with the Try Notification button first. After that, the endpoint is created and enabled. The Options menu can test or delete it later.
Repeat for every microsite you sell on. Each microsite needs its own endpoint, and a microsite without one never notifies you.
- You can add at most 3 endpoints per microsite. The list can be viewed per microsite or for all microsites of the operator.
- Only the platform operations team can enable or disable an endpoint. Ask your Nava account manager.
The webhook body
That is the whole body: no statuses, prices, dates or guests. Always fetch the booking.
{
"timestamp": "2026-10-01T09:15:42",
"type": "MODIFIED",
"micrositeId": "<your microsite id>",
"bookingReference": "TST-1464"
}Event types
Five types are documented. CANCELED is the API's spelling (sic).
type | Fires when | What to do |
|---|---|---|
CREATED | A booking was created. | Fetch the booking and create your record. |
MODIFIED | A booking changed, for example an agent edited it in the back office or an RQ booking was confirmed. | Re-fetch, compare it with your last stored hash and record what changed (dates, room, price, passengers). historical[] explains the change in plain words. |
CANCELED | A booking or one of its services was cancelled. | Re-fetch, check each service's status, notify the customer and reverse the accounting. |
CLIENT_REQUEST | A conversation on the booking was created or replied to. Booking data did not change. | Store it. Read clientrequest[] or the client requests endpoints if you show conversations. |
REFUND | A refund was processed, for example after POST /booking/refund. | Record the refund against the order. |
A cancellation made on the platform side, by the supplier or by an agent, can arrive as CANCELED or as MODIFIED. Decide from the service status in the fetched booking, not from the webhook type.
Secure the endpoint
The platform sends no custom headers: no signature and no authorisation header. The only secret you can check is the one in the URL.
- Put a long random secret in the URL path, for example
https://hooks.example.com/webhooks/bookings/<secret>. - Compare it in constant time and answer 403 on a mismatch.
- Reverse proxies and APM tools log full URLs, so the secret ends up in logs. Mask it in your logging.
- If the secret is exposed, rotate it. Ask your Nava account manager to update the endpoint.
Delivery and retries
If a delivery fails, the platform makes up to 4 more attempts, after 1, 5, 15 and 30 minutes. After that it emails the microsite's booking mail addresses (the "Booking Mails" setting).
- Answer fast. Store the event, queue the work and return 2xx. Call
getBookingsfrom a worker, not from the request handler. - There is no documented replay API. A delivery that ran out of retries is gone, and the sweep is your only way back to it.
- If you received neither a webhook nor the fallback email, the platform never tried to notify you.
No ordering, no exactly-once
Webhooks arrive in no guaranteed order and can arrive more than once. Expect bursts, such as a CREATED followed by a MODIFIED within seconds, and duplicates.
- Dedupe on a hash of the body. Store each event under that hash with a unique index, so an exact re-delivery becomes a no-op.
- Coalesce per booking. Keep at most one waiting sync job per microsite and
bookingReference. The job fetches the current state, so the order in which events arrived doesn't matter.
The webhook is only a signal
Fetch the booking with GET /booking/getBookings/{micrositeId}/{bookingReference}?lang=EN and read hotelservice[]. Reading bookings explains every field.
- Use a cached token of your own for this. It doesn't need to be a booking-flow token. On a 401 or 403, re-authenticate once, then alert.
- A 404 means the reference doesn't exist in that microsite. It is not an outage. 5xx, 429 and timeouts are retryable.
One booking, several microsite ids
If one endpoint serves several microsites, and a booking can be reached from more than one of them, map every micrositeId to one canonical value before you key anything. Otherwise the same booking gets two identities and can turn into two orders in your system. We have seen this risk in production.
When a getBookings lookup returns 404 for the microsite in the webhook, try your other microsites before you give up.
The whole flow
Platform ── POST {timestamp, type, micrositeId, bookingReference} ──► https://hooks.example.com/webhooks/bookings/<secret>
receiver
1. compare the path secret in constant time wrong secret: 403
2. map micrositeId to one canonical microsite id
3. store the event under a hash of the body exact re-delivery: no-op
4. CREATED, MODIFIED, CANCELED: queue one sync job per (canonical microsite, bookingReference)
any other type: store only
5. answer 2xx fast, for every type
│
▼
worker
GET /booking/getBookings/{micrositeId}/{bookingReference}?lang=EN
404: try your other microsites 401 or 403: re-authenticate once, then alert
5xx, 429, timeout: retry later
normalise hotelservice[], distribution[] and manualServices
idempotent upsert keyed by booking, service and passenger ids
sweep (hourly)
walk the reference sequence past your high-water mark and probe getBookings,
to catch lost deliveries and microsites without an endpointA minimal receiver
Framework-agnostic TypeScript for Node. Call handleBookingWebhook from any HTTP server with the secret from the URL path and the raw request body. events, queue, getBackOfficeToken and upsertTrip stand for your own storage, job queue, token cache and sync code.
import { createHash, timingSafeEqual } from 'node:crypto';
type BookingWebhook = {
timestamp: string;
type: string; // CREATED, MODIFIED, CANCELED, CLIENT_REQUEST, REFUND, or a future type
micrositeId: string;
bookingReference: string;
};
// Every microsite id that can see the same bookings maps to one canonical id.
const CANONICAL_MICROSITE: Record<string, string> = {
'second-microsite-id': 'main-microsite-id',
};
export const canonical = (micrositeId: string) => CANONICAL_MICROSITE[micrositeId] ?? micrositeId;
const SYNC_TYPES = new Set(['CREATED', 'MODIFIED', 'CANCELED']);
const sha256 = (s: string) => createHash('sha256').update(s).digest();
// Constant-time compare. Hashing first gives both sides the same length.
function secretMatches(given: string): boolean {
return timingSafeEqual(sha256(given), sha256(process.env.NAVA_WEBHOOK_SECRET!));
}
export async function handleBookingWebhook(pathSecret: string, rawBody: string) {
if (!secretMatches(pathSecret)) return { status: 403 };
let body: BookingWebhook;
try {
body = JSON.parse(rawBody);
} catch {
return { status: 400 };
}
if (!body?.bookingReference || !body?.micrositeId) return { status: 400 };
// Unique index on dedupKey: an exact re-delivery inserts nothing.
const dedupKey = sha256(rawBody).toString('hex');
const isNew = await events.insertIfAbsent({ dedupKey, ...body, receivedAt: new Date() });
if (isNew && SYNC_TYPES.has(body.type)) {
// One waiting job per booking: a burst of events becomes one fetch.
await queue.addUnique(`sync:${canonical(body.micrositeId)}:${body.bookingReference}`, {
micrositeId: body.micrositeId,
bookingReference: body.bookingReference,
});
}
return { status: 200 }; // every type gets a 2xx, including CLIENT_REQUEST, REFUND and unknown ones
}const BASE = process.env.NAVA_BASE_URL!;
const MY_MICROSITES = ['main-microsite-id', 'second-microsite-id'];
// The trip and the microsite that resolved it, or null when every microsite answers 404.
export async function findTrip(token: string, ref: string, preferred?: string) {
const order = preferred ? [preferred, ...MY_MICROSITES.filter((m) => m !== preferred)] : MY_MICROSITES;
for (const micrositeId of order) {
const res = await fetch(
`${BASE}/booking/getBookings/${encodeURIComponent(micrositeId)}/${encodeURIComponent(ref)}?lang=EN`,
{ headers: { 'auth-token': token, 'Accept-Encoding': 'gzip' }, signal: AbortSignal.timeout(60_000) },
);
if (res.status === 404) continue; // not in this microsite
if (!res.ok) throw new Error(`getBookings ${ref} failed with HTTP ${res.status}`); // retry the job later
const trip = await res.json();
if (trip?.auditData?.authToken) trip.auditData.authToken = '[REDACTED]';
return { micrositeId, trip };
}
return null;
}
export async function syncJob(job: { micrositeId: string; bookingReference: string }) {
const token = await getBackOfficeToken(); // cached; one forced re-auth on 401 or 403
const found = await findTrip(token, job.bookingReference, job.micrositeId);
if (!found) return; // unknown reference: log it for review
await upsertTrip(canonical(job.micrositeId), found.trip); // idempotent, see Reading bookings
}Catch lost webhooks with a sweep
Webhooks go missing in practice. We have seen bookings that reached the downstream system only through a sweep, from a microsite that had no endpoint configured.
Don't build this on the listing endpoint. GET /booking/bookings is scoped to one microsite and filters by creation date, so bookings from sibling microsites that share the reference sequence are missing from it. Walk the reference sequence instead.
Keep a high-water mark: the highest reference number you have stored, for example <PREFIX>-1043.
Probe getBookings for the next numbers in turn: <PREFIX>-1044, <PREFIX>-1045 and so on.
HTTP 200 means the booking is real: queue a normal sync job. HTTP 404 means the number was never issued in the microsites you asked.
Stop after K consecutive 404s, and move the high-water mark to the last number that answered 200.
- Run it hourly, next to the webhook receiver. Because your upserts are idempotent, finding a booking a webhook already delivered is harmless.
- Count only real 404s as misses. A 5xx, 429 or timeout is not a miss: stop the run and try again later, or you will step over bookings.
- A 404 means "not in the microsites you asked". If your reference sequence is shared with microsites you can't read, their numbers look like gaps to you, so choose K large enough to step over them.
- The sweep only finds references you have never seen. It won't replay a lost
MODIFIEDfor a booking you already hold, so keep polling open bookings such asRQ(see Booking statuses).
// Hourly. Finds bookings past the high-water mark that no webhook announced.
export async function sweep(token: string, prefix: string, highWaterMark: number, k: number) {
let last = highWaterMark;
let misses = 0;
for (let n = highWaterMark + 1; misses < k; n++) {
const ref = `${prefix}-${n}`;
const found = await findTrip(token, ref); // throws on 5xx, 429 and timeouts: the run stops there
if (found) {
await queue.addUnique(`sync:${canonical(found.micrositeId)}:${ref}`, { micrositeId: found.micrositeId, bookingReference: ref });
last = n;
misses = 0;
} else {
misses++; // a real 404 in every microsite
}
}
return last; // store it as the new high-water mark
}Related
- Reading bookings: what to do with the booking once you have fetched it.
- Get booking: the
getBookingsreference. - List bookings: why the listing is not enough for reconciliation.
- Booking statuses: what each service status means.
- Playbooks: back-office sync, cancellations on the platform side and back-office edits.
Cancellations and refunds
How to read cancellation policies, quote the fee before you cancel, cancel without double calls, and return money to the guest as a separate step.
Reading bookings
Fetch a whole trip with getBookings, find its hotel services, passengers and money, and design a downstream sync that survives cancellations, rebookings and repricing.