Webhooks
We send an event to your endpoint whenever a lookup's status changes or a monitored booking changes. Every event is signed, and retried until your endpoint takes it.
Register an endpoint
POST/v1/webhooks
Give your HTTPS address, and the events you want. Leave events out to get all of them.
curl https://api.pnrstack.com/v1/webhooks \
-H "Authorization: Bearer pk_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pnrstack/events",
"events": ["lookup.status_changed", "reservation.changed"]
}'The answer includes the endpoint's signing secret, starting whsec_. It's shown once, so keep it with your API key.
GET /v1/webhooks/{id} reads an endpoint and DELETE /v1/webhooks/{id} removes it.
Events
| Event | Sent when |
|---|---|
lookup.status_changed | A lookup moves to a new status. It carries the whole lookup |
reservation.changed | A monitored booking differs from the last read. It carries what changed and the new reservation |
monitor.paused | A monitored booking's airline is paused. Checks start again when it's back |
Every event has the same envelope:
{
"id": "evt_01JC9W7K2P",
"type": "lookup.status_changed",
"created_at": "2026-12-04T09:12:47Z",
"data": {
"lookup": {
"id": "lkp_01JC9V2M8R",
"status": "completed",
"reservation": { "booking_status": "confirmed", "segments": [] }
}
}
}The airline's own text (raw) is left out of events to keep them small. Read the lookup when you need it.
Check the signature
Every event comes with a PNRStack-Signature header: a timestamp, and an HMAC-SHA256 of the timestamp and the body,
signed with your endpoint's secret. Check it before you trust the event, and refuse anything more than five minutes
old.
import { createHmac, timingSafeEqual } from "node:crypto";
/** Whether an event came from PNRStack: `rawBody` exactly as it arrived, `header` the PNRStack-Signature header. */
export function isFromPNRStack(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}Use the body exactly as it arrived: a body that was parsed and turned back into JSON won't match.
Retries
Answer with any 2xx status within ten seconds and the event is delivered. Anything else, or no answer, and we try
again after 1 minute, 5 minutes and 30 minutes, then 2, 6, 12 and 24 hours. The same event can arrive more than once,
so use its id to skip the ones you've handled.
Delivery log
GET/v1/webhooks/{id}/deliveries
Recent attempts, newest first, each with the event, the status your endpoint answered, how long it took, and when we'll try next. You can find out why an event didn't arrive without asking us.