Skip to content

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

EventSent when
lookup.status_changedA lookup moves to a new status. It carries the whole lookup
reservation.changedA monitored booking differs from the last read. It carries what changed and the new reservation
monitor.pausedA 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.