Developers

Connect API

Send enquiries from your website or partner systems straight into the HolidayOS inbox, keep the clients, enquiries and trips behind them up to date, and receive customer-safe lifecycle events back over signed webhooks.

Spec 1.0 · Tenant your-tenant-slug

Quick start

One endpoint takes every inbound event. Authenticate with an API key prefix and an HMAC signature over the body, then POST a batch of up to 100 envelopes.

POSThttps://api.new.holidayos.ai/api/v1/crm/connect/events

Create a key in HolidayOS under Settings → Connect → API keys. The secret is shown once, at creation. Keep it server-side: anyone holding it can submit enquiries as your agency.

Sign and sendnode
import crypto from "node:crypto";

const tenantSlug = "your-tenant-slug";
const keyPrefix = process.env.HOLIDAYOS_CONNECT_KEY;      // hc_live_…
const connectSecret = process.env.HOLIDAYOS_CONNECT_SECRET; // sk_…

// Sign the EXACT bytes you transmit. Serialize once, reuse the string —
// re-serializing for the request can reorder keys and break the signature.
const rawBody = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000);
const digest = crypto
  .createHmac("sha256", connectSecret)
  .update(`${timestamp}.${rawBody}`)
  .digest("hex");

await fetch("https://api.new.holidayos.ai/api/v1/crm/connect/events", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Connect-Key": keyPrefix,
    "X-Connect-Tenant": tenantSlug,
    "X-Connect-Signature": `t=${timestamp},v1=${digest}`
  },
  body: rawBody
});
Smoke test from a shellbash
BODY='{"events":[{"specVersion":"1.0","eventId":"evt_smoke_1","eventType":"enquiry.submitted","occurredAt":"2026-08-23T09:15:00Z","tenant":"your-tenant-slug","origin":"source-system","actor":{"type":"contact","email":"traveler@example.com","name":"Ana Silva"},"payload":{"destination":"Bali"}}]}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$HOLIDAYOS_CONNECT_SECRET" -hex | sed 's/^.* //')

curl -X POST "https://api.new.holidayos.ai/api/v1/crm/connect/events" \
  -H "Content-Type: application/json" \
  -H "X-Connect-Key: $HOLIDAYOS_CONNECT_KEY" \
  -H "X-Connect-Tenant: your-tenant-slug" \
  -H "X-Connect-Signature: t=$TS,v1=$SIG" \
  -d "$BODY"

Authentication

Three headers on every request. All failures return the same generic message, so the error table is how you narrow down a 401.

HeaderValueNotes
X-Connect-Keykey prefixThe visible prefix from an active API key (looks like hc_live_…).
X-Connect-Tenantyour-tenant-slugMust match the tenant bound to the key. Compared case-insensitively and trimmed.
X-Connect-Signaturet=<unix>,v1=<hmac>HMAC-SHA256 of <t>.<raw body> keyed by the API key secret, hex encoded. The records routes (/clients, /leads) use v2 instead, which also signs the method and path — see Records API.

The most common integration bug. Sign the exact bytes you transmit. Serializing the body a second time for the request can reorder keys, and the signature will no longer verify — which surfaces as a 401 that looks like a bad key.

Inbound events

Most integrations only ever send enquiry.submitted. The rest let you mirror a traveller’s whole journey, and each one has a defined effect on the enquiry.

EventWhat it meansWhat HolidayOS does
contact.identifiedA traveller identified themselves — signed in, or filled in a form.Creates the client, or updates the one it matches, and appends a timeline entry. No enquiry is opened. Matching is by email when you send one, otherwise externalId, otherwise phone; an address on a company domain (not a free mailbox) joins that company's account as one of its contacts. A new client without a name is named after its email.
contact.updatedA known traveller's details changed on your system.Patches the matched contact's name and phone with the values sent and appends a timeline entry; a field you omit is left as it is. It cannot change an email: the event is matched by the email it carries. Matching works as for contact.identified; on a company account the change lands on that colleague's contact entry, and an unknown address on the company's domain is added to it. Otherwise it never creates a client: when none matches, the event comes back failed with error: "contact_not_found" and nothing is written — send contact.identified first, then retry the same eventId.
enquiry.submittedA traveller asked for a quote. This is the event most integrations send.Upserts the contact, appends a timeline entry, and opens an enquiry at stage inquiry with a trip workspace in the inbox. paxCount (or a party object with adults/children/rooms), travelDates (start/end) and budget become the enquiry's brief and seat the trip workspace; omit any of them and it stays visibly unstated rather than being assumed. Send budget as free text (around $3k pp) and it is filed verbatim as budget notes — send { amount, currency } only when you actually know the denomination. An optional attribution object (source, medium, campaign, term, content, landingPath, referrer) is recorded on the enquiry's marketing block and drives the pipeline campaign filter — send the campaign that earned the visit, not the last URL before submit. Send your own ID for the enquiry as externalLeadId to find it later with GET /leads?externalId=.
traveller.discovery_upsertedA traveller answered discovery questions — where, when, who with, what kind of trip — on your site.Upserts the contact and attaches to their open enquiry, or opens one at stage inquiry with a trip workspace. Then records payload.discovery (for example destinationInterest, dateRange, pax, tripNotes), payload.profilePatch (constraints, styleDefaults) and an optional payload.essence (text) on the traveller's profile; a value that fails validation is dropped and named in warnings. Requires Traveller Discovery on your agency — without it the enquiry still opens and the result carries the warning traveller_discovery_not_enabled. The result returns travellerDiscovery.discoveryId: send it back as payload.hosTripDiscoveryId so later events update the same record.
trip.planning_startedThe traveller began building a trip on your site.Timeline only — a planning signal carries no enquiry obligation.
trip.draft_updatedThe traveller changed their in-progress trip draft.Timeline only.
quote.requestedA price was fetched — often automatically, while the visitor browses.Timeline only. Deliberately does not open an enquiry: only an explicit enquiry.submitted may create or advance one.
booking.startedThe traveller entered checkout.Advances the open enquiry to proposal_approved if that is further along than its current stage. Never regresses a stage.
booking.abandonedThe traveller left checkout without completing.Flags the open enquiry for follow-up. Leaves its stage untouched.
booking.completedThe traveller paid and the booking is confirmed.Forces the enquiry to stage trip_booked.
Example requestjson
{
  "events": [
    {
      "specVersion": "1.0",
      "eventId": "source_event_id",
      "eventType": "enquiry.submitted",
      "occurredAt": "2026-08-23T09:15:00Z",
      "tenant": "your-tenant-slug",
      "origin": "source-system",
      "actor": {
        "type": "contact",
        "email": "traveler@example.com",
        "name": "Ana Silva",
        "phone": "+60123456789"
      },
      "payload": {
        "destination": "Bali",
        "travelDates": {
          "startDate": "2026-11-04",
          "endDate": "2026-11-10"
        },
        "party": { "adults": 3, "children": 0, "rooms": 1 },
        "message": "Customer requested advisor pricing before checkout.",
        "quote": { "status": "pending", "currency": "USD" },
        "attribution": {
          "source": "google",
          "medium": "cpc",
          "campaign": "bali-nov",
          "term": "bali holiday packages",
          "landingPath": "/campaign"
        }
      }
    }
  ]
}
Responsejson
{
  "accepted": 1,
  "duplicate": 0,
  "failed": 0,
  "results": [
    {
      "eventId": "source_event_id",
      "status": "accepted",
      "clientId": "6ab3d852869e40b4db978f8e",
      "leadId": "6ab8e3597a5f4efc2b5258cc",
      "tripId": "6ab8e3597a5f4efc2b5258ef"
    }
  ]
}

Rules

Scope
API keys need events:ingest to submit events.
Batching
Up to 100 events per request. A rejected envelope fails the whole batch — nothing is written.
Idempotency
Reuse the same eventId when retrying. A repeat is reported as duplicate and has no second effect.
Record IDs
Each accepted or duplicate result carries the clientId, leadId and tripId the event produced — keep them to update those records through the Records API. A replayed event returns the same IDs, so a lost response is recovered by retrying.
Freshness
Signatures expire after 5 minutes and the same signature cannot be replayed inside that window. Keep your server clock in sync.
Contact identity
Every actor needs at least one of email, phone, or externalId. Without one there is no stable key and every event would fork a phantom contact. Omit name when you do not know it: the client keeps the name it already has.
Tenant isolation
The envelope tenant must match the authenticated key's tenant. HolidayOS always stores the key's canonical tenant, never the header.

Errors

Authentication failures deliberately return one generic message for several distinct causes — the service will not tell you which check failed, so this table is the way to debug one.

400The batch was rejected before anything was written.

  • An envelope failed validation (missing field, unknown eventType, malformed occurredAt).
  • actor carries none of email, phone, or externalId — there is no key to dedupe on.
  • The envelope tenant does not match the authenticated key's tenant.
  • An eventId starts with connect-api:, which the records API reserves for its own writes.
  • More than 100 events, or an empty events array.

Retry: Fix the payload. Retrying the same body will fail identically.

401Invalid Connect credentials — one generic message for every auth failure.

  • X-Connect-Key, X-Connect-Signature, or X-Connect-Tenant missing or malformed.
  • The key prefix is unknown, revoked, or expired.
  • The signature does not verify — usually because the signed bytes are not the bytes sent.
  • The timestamp is outside the ±5 minute window (check server clock drift).
  • The exact same signature was already used inside the freshness window (replay).

Retry: Re-sign with a fresh timestamp. If it still fails, verify you sign the raw body bytes you actually transmit.

403Authenticated, but not authorised.

  • The key does not hold the route's scope — events:ingest for /events, or the one the Records API lists.
  • X-Connect-Tenant does not match the tenant bound to the key.

Retry: Fix the key's scopes or the tenant header. Retrying unchanged will fail.

503Replay protection is temporarily unavailable.

  • The replay guard store could not be reached.

Retry: Safe to retry shortly with the same eventId values — nothing was ingested.

Records API

Events tell HolidayOS what happened on your site. The records API lets your own system create, read and update the clients, enquiries and trips HolidayOS keeps for you — register a customer as a client the moment they sign up, open an enquiry and get its IDs back, and post the customer’s changes to their trip so the advisor sees them in the inbox.

BASEhttps://api.new.holidayos.ai/api/v1/crm/connect

Scopes

ScopeAllows
events:ingestSend events to POST /events.
clients:readRead clients and look them up by email, phone or externalId.
clients:writeCreate and update clients.
leads:readRead enquiries and their trips; look enquiries up by your own ID.
leads:writeCreate enquiries, change their stage, owner, follow-up flag or title, and post a customer's changes to their trip.

Rules

Scopes
Each route needs one scope on the key (see the table). A key created before the records API holds only events:ingest; an admin adds scopes under **Settings → Connect → API keys**. A missing scope is a 403.
Signature v2
Records routes sign the method and path as well as the body: v2 = hmac-sha256(secret, t + "." + METHOD + "." + pathWithQuery + "." + rawBody), sent as X-Connect-Signature: t=<t>,v2=<hex>. pathWithQuery is the request target exactly as sent (/api/v1/crm/connect/leads/abc/trip, including any ?query); rawBody is the empty string for a GET. A v1 signature is refused here, and v2 is refused on /events.
Idempotency
Every POST needs an Idempotency-Key header (1–200 printable characters). Retrying with the same key and body returns the original record with Idempotent-Replayed: true, writes nothing and is not metered; the same key with a different body is 422 idempotency_key_reused. A signed request seen once cannot be re-sent under a different key, and a PATCH cannot be re-sent at all inside the freshness window — re-sign each new attempt.
Concurrency
Single-record responses carry ETag: "<updatedAt>". Send it back as If-Match on a PATCH and a change an advisor made in between answers 412 stale_write instead of being overwritten. Without If-Match your PATCH wins.
Envelope
Success is { "data": … }. Errors are { "statusCode", "code", "message" } plus record IDs where noted — branch on code. 401 and 403 keep the generic message the auth table explains. Bodies are checked strictly: an unknown field, a null, a string where a boolean or number belongs, or an impossible date is a 400 rather than a guess.
What you can read
Responses are customer-safe projections: no margins, costs, internal notes, assistant transcripts or supplier details. Records of another agency are always a 404.
Echo
Changes your key makes are not sent back to your own webhook subscription.
Metering
Every successful POST or PATCH counts as one event against the monthly quota; reads are rate-limited but not counted.
Sign and send (v2)node
import crypto from "node:crypto";

const tenantSlug = "your-tenant-slug";
const keyPrefix = process.env.HOLIDAYOS_CONNECT_KEY;      // hc_live_…
const connectSecret = process.env.HOLIDAYOS_CONNECT_SECRET; // sk_…

async function connect(method, path, body, headers = {}) {
  const url = new URL(path, "https://api.new.holidayos.ai");
  const target = url.pathname + url.search; // sign exactly what you request
  const rawBody = body === undefined ? "" : JSON.stringify(body);
  const timestamp = Math.floor(Date.now() / 1000);
  const v2 = crypto
    .createHmac("sha256", connectSecret)
    .update(`${timestamp}.${method}.${target}.${rawBody}`)
    .digest("hex");
  const res = await fetch(url, {
    method,
    headers: {
      ...(rawBody ? { "Content-Type": "application/json" } : {}),
      "X-Connect-Key": keyPrefix,
      "X-Connect-Tenant": tenantSlug,
      "X-Connect-Signature": `t=${timestamp},v2=${v2}`,
      ...headers,
    },
    body: rawBody || undefined,
  });
  return { status: res.status, etag: res.headers.get("etag"), body: await res.json() };
}

// The customer changed their dates on your site:
const { data: trip } = (await connect("GET", "/api/v1/crm/connect/leads/LEAD_ID/trip")).body;
await connect(
  "PATCH",
  "/api/v1/crm/connect/leads/LEAD_ID/trip",
  { travelDates: { startDate: "2026-12-01", endDate: "2026-12-07" }, changeSummary: "Customer moved the trip" },
  { "If-Match": `"${trip.updatedAt}"` },
);
Create a client from a shellbash
BODY='{"name":"Ana Silva","email":"ana@example.com","externalId":"user_8841"}'
TS=$(date +%s)
SIG=$(printf '%s.%s.%s.%s' "$TS" "POST" "/api/v1/crm/connect/clients" "$BODY" | openssl dgst -sha256 -hmac "$HOLIDAYOS_CONNECT_SECRET" -hex | sed 's/^.* //')

curl -X POST "https://api.new.holidayos.ai/api/v1/crm/connect/clients" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-user_8841" \
  -H "X-Connect-Key: $HOLIDAYOS_CONNECT_KEY" \
  -H "X-Connect-Tenant: your-tenant-slug" \
  -H "X-Connect-Signature: t=$TS,v2=$SIG" \
  -d "$BODY"

Create a client

POST/clients

Scope clients:write · returns 201 with { "data": Client }

Creates a client owned by your Connect identity. Needs at least one of email, phone or externalId. When that identity already exists the answer is 409 client_exists with its clientId — read or patch that one instead. An address on a company domain your agency already has as a company account joins that account.

FieldNotes
nameFull name. A new client without one is named after its email.
emailThe strongest identity: matched case-insensitively.
phoneUsed to match only when no email is sent.
companyThe person's company, as a label.
externalIdYour own ID for this person. Matched when no email is sent.
marketingOptInWhether they agreed to marketing.
travelersArray of { name, type?: adult|child|infant, age?, email?, phone? }, at most 50. On a PATCH it replaces the client's traveller roster.
Example bodyjson
{
  "name": "Ana Silva",
  "email": "ana@example.com",
  "phone": "+60123456789",
  "externalId": "user_8841",
  "marketingOptIn": true,
  "travelers": [
    {
      "name": "Leo Silva",
      "type": "child",
      "age": 9
    }
  ]
}

Look a client up

GET/clients

Scope clients:read · returns 200 with { "data": Client[] }

Exact identity lookup, with the same precedence as matching: email, else externalId, else phone. Returns an empty list or a list of one.

QueryNotes
emailCase-insensitive.
phoneCompared by digits.
externalIdYour own ID for the person.

Read a client

GET/clients/{clientId}

Scope clients:read · returns 200 with { "data": Client }

A client of another agency is a 404, never a 403.

Update a client

PATCH/clients/{clientId}

Scope clients:write · returns 200 with { "data": Client }

Changes only the fields you send; an omitted field is left as it is. Changing email or externalId to one another client holds is 409 identity_taken with that client's ID.

FieldNotes
nameFull name. A new client without one is named after its email.
emailThe strongest identity: matched case-insensitively.
phoneUsed to match only when no email is sent.
companyThe person's company, as a label.
externalIdYour own ID for this person. Matched when no email is sent.
marketingOptInWhether they agreed to marketing.
travelersArray of { name, type?: adult|child|infant, age?, email?, phone? }, at most 50. On a PATCH it replaces the client's traveller roster.
Example bodyjson
{
  "phone": "+447700900123",
  "marketingOptIn": false
}

Create an enquiry

POST/leads

Scope leads:write · returns 201 with { "data": Lead }

Runs exactly the path an enquiry.submitted event takes: the client is matched or created, the enquiry opens at inquiry with an enquiry number, is routed to an advisor, gets a trip workspace, and message opens its inbox thread. Send clientId to attach it to a client you already have, or contact to match one. Returns the enquiry with clientId and tripId.

FieldNotes
contact{ name, email, phone, externalId }. Needed unless you send clientId.
clientIdAttach to this client; it is never duplicated.
externalIdYour own ID for this enquiry, for GET /leads?externalId=.
titleThe enquiry or trip title advisors see.
destinationFree text, e.g. Bali.
travelDates{ startDate, endDate }, each YYYY-MM-DD.
party{ adults, children, childAges, rooms }. Stored as the party count, never inferred from a traveller list.
budget{ amount, currency } when you know the figure and currency, otherwise { notes } with the customer's words. Replaces the stated budget as a whole.
guestNationalityISO 3166-1 alpha-2 passport country of the party; "" clears it.
itineraryA customized itinerary snapshot — the same object enquiry.submitted takes as itinerarySnapshot: { title, days: [{ title, items: [{ title, … }] }] }.
messageThe customer's own words. Opens the enquiry's inbox thread.
attribution{ source, medium, campaign, term, content, landingPath, referrer } — the campaign that earned the enquiry.
Example bodyjson
{
  "clientId": "6ab3d852869e40b4db978f8e",
  "externalId": "enquiry_2207",
  "destination": "Bali",
  "travelDates": {
    "startDate": "2026-11-04",
    "endDate": "2026-11-10"
  },
  "party": {
    "adults": 2,
    "children": 1,
    "childAges": [
      7
    ],
    "rooms": 1
  },
  "budget": {
    "notes": "around $3k per person"
  },
  "message": "Anniversary trip; we'd like quiet villas."
}

Look an enquiry up by your ID

GET/leads

Scope leads:read · returns 200 with { "data": Lead[] }

Finds the enquiry you created with that externalId (or sent as payload.externalLeadId), in your agency only.

QueryNotes
externalIdYour own ID for the enquiry.

Read an enquiry

GET/leads/{leadId}

Scope leads:read · returns 200 with { "data": Lead }

The enquiry with its trip summary. Once a trip exists it owns the pipeline stage, so stage is the trip's — what the advisor's board shows.

Update an enquiry

PATCH/leads/{leadId}

Scope leads:write · returns 200 with { "data": Lead }

Enquiry-level changes. stage goes through the same rules an advisor meets: proposal, booking and travel stages are set by their own workflows (422 stage_not_allowed), and lost needs a reason of 5+ characters (422 reason_required). owner must be an active member of your agency (422 owner_not_member); the new owner is notified.

FieldNotes
stageinquiry, follow_up, building_proposal, negotiation or lost.
reasonWhy the stage changed. Required for lost.
ownerAn active member's email.
followUpFlag the enquiry for follow-up.
titleThe enquiry title.
Example bodyjson
{
  "stage": "lost",
  "reason": "Booked with another agency"
}

Read an enquiry's trip

GET/leads/{leadId}/trip

Scope leads:read · returns 200 with { "data": Trip }

The trip workspace: brief, stage, owner and whether a proposal has been sent. 409 trip_not_open when the enquiry has no trip yet.

Post a customer's changes to their trip

PATCH/leads/{leadId}/trip

Scope leads:write · returns 200 with { "data": Trip }

Use this when the customer changes their trip on your side. Brief fields update the trip workspace; a date change is recorded as a correction with changeSummary as its reason. A new itinerary replaces the draft proposal only while the enquiry is at inquiry/follow_up and nothing has been sent — otherwise (or if the update itself fails) the response says itineraryApplied: false with itineraryNotAppliedReason (advisor_working, proposal_sent, no_proposal or update_failed) and the advisor decides. Either way, what changed (before → after), changeSummary and the customer's message are posted to the enquiry's inbox thread and its owner is notified. A closed enquiry (lost, archived, booked or travelled) answers 409 trip_closed: send the change as a new enquiry instead.

FieldNotes
titleThe enquiry or trip title advisors see.
destinationFree text, e.g. Bali.
travelDates{ startDate, endDate }, each YYYY-MM-DD.
party{ adults, children, childAges, rooms }. Stored as the party count, never inferred from a traveller list.
budget{ amount, currency } when you know the figure and currency, otherwise { notes } with the customer's words. Replaces the stated budget as a whole.
guestNationalityISO 3166-1 alpha-2 passport country of the party; "" clears it.
itineraryA customized itinerary snapshot — the same object enquiry.submitted takes as itinerarySnapshot: { title, days: [{ title, items: [{ title, … }] }] }.
messageThe customer's own words, posted to the inbox thread.
changeSummaryOne line on what changed; also the reason recorded on a date change.
Example bodyjson
{
  "travelDates": {
    "startDate": "2026-12-01",
    "endDate": "2026-12-07"
  },
  "party": {
    "adults": 3
  },
  "changeSummary": "Customer moved the trip to December and added a traveller",
  "message": "My sister is joining us!"
}

Records

Clientjson
{
  "id": "6ab3d852869e40b4db978f8e",
  "name": "Ana Silva",
  "email": "ana@example.com",
  "phone": "+60123456789",
  "company": null,
  "externalId": "user_8841",
  "marketingOptIn": true,
  "clientType": "individual",
  "travelers": [
    {
      "name": "Leo Silva",
      "type": "child",
      "age": 9,
      "email": null,
      "phone": null
    }
  ],
  "createdAt": "2026-10-07T09:04:37.000Z",
  "updatedAt": "2026-10-07T09:04:37.000Z"
}
Leadjson
{
  "id": "6ab8e3597a5f4efc2b5258cc",
  "enquiryNumber": "HOS-2026-000412",
  "title": "Ana Silva — Bali",
  "stage": "inquiry",
  "followUp": false,
  "clientId": "6ab3d852869e40b4db978f8e",
  "tripId": "6ab8e3597a5f4efc2b5258ef",
  "externalId": "enquiry_2207",
  "owner": {
    "email": "meera@youragency.com",
    "name": "Meera"
  },
  "destination": "Bali",
  "travelDates": {
    "startDate": "2026-11-04",
    "endDate": "2026-11-10"
  },
  "party": {
    "adults": 2,
    "children": 1,
    "childAges": [
      7
    ],
    "rooms": 1
  },
  "budget": {
    "amount": null,
    "currency": null,
    "notes": "around $3k per person"
  },
  "createdAt": "2026-10-07T09:05:12.000Z",
  "updatedAt": "2026-10-07T09:05:13.000Z"
}
Tripjson
{
  "id": "6ab8e3597a5f4efc2b5258ef",
  "leadId": "6ab8e3597a5f4efc2b5258cc",
  "enquiryNumber": "HOS-2026-000412",
  "title": "Ana Silva — Bali",
  "stage": "inquiry",
  "destination": "Bali",
  "travelDates": {
    "startDate": "2026-12-01",
    "endDate": "2026-12-07"
  },
  "party": {
    "adults": 3,
    "children": 1,
    "childAges": [
      7
    ],
    "rooms": 1
  },
  "budget": {
    "amount": null,
    "currency": null,
    "notes": "around $3k per person"
  },
  "guestNationality": null,
  "owner": {
    "email": "meera@youragency.com",
    "name": "Meera"
  },
  "proposal": {
    "status": "draft",
    "sentAt": null
  },
  "createdAt": "2026-10-07T09:05:13.000Z",
  "updatedAt": "2026-10-08T14:20:00.000Z"
}

Error codes

StatuscodeMeaning
400invalid_requestA field failed validation, or the body carries a field the route does not accept. message names it.
400idempotency_key_requiredA POST without an Idempotency-Key header.
400identity_requiredNo email, phone or externalId (or clientId) to identify the person.
400invalid_itineraryThe itinerary snapshot failed validation; errors lists why.
400lookup_field_requiredA lookup without email, phone or externalId (clients) or externalId (leads).
404not_foundNo such record in your agency.
409client_existsA client with that identity exists; clientId names it.
409identity_takenAnother client already holds that email or externalId; clientId names it.
409trip_not_openThe enquiry has no trip workspace yet.
409trip_closedThe enquiry is lost, archived, booked or travelled; send the change as a new enquiry. stage says which.
412stale_writeIf-Match no longer matches: the record changed since you read it. updatedAt is the current version.
422idempotency_key_reusedThat Idempotency-Key was used for a different body.
422reason_requiredlost without a reason of 5+ characters.
422stage_not_allowedThat stage is set by the proposal, booking or travel workflow, or needs a trip first.
422owner_not_memberThe owner is not an active member of your agency.
500lead_create_failedThe enquiry could not be opened. Retry with the same Idempotency-Key: a lead that was saved is returned, never duplicated.

Outbound webhooks

Subscribe a public HTTPS endpoint under Settings → Connect → Webhooks. Deliveries carry the same envelope and the same signing scheme as inbound requests, pointed the other way.

EventWhat it meansEmitted
lead.assignedAn advisor was assigned, or reassigned, to an enquiry.Yes
lead.stage_changedAn enquiry moved between pipeline stages.Yes
proposal.sentA proposal was sent to the traveller. Carries the hosted proposal link.Yes
proposal.readyReserved in the allowlist. No code path emits it yet — do not wait on it.Not yet
proposal.viewedThe traveller opened the hosted proposal.Yes
message.postedReserved in the allowlist. No code path emits it yet — do not wait on it.Not yet
Example deliveryhttp
POST https://your-system.example.com/hooks/holidayos
Content-Type: application/json
X-Connect-Signature: t=1787654321,v1=<hex>
X-Connect-Tenant: your-tenant-slug
X-Connect-Event: proposal.sent
X-Connect-Delivery: dlv_01H…

{
  "specVersion": "1.0",
  "eventId": "5f1c…-uuid",
  "eventType": "proposal.sent",
  "occurredAt": "2026-08-23T09:15:00Z",
  "tenant": "your-tenant-slug",
  "origin": "crm",
  "actor": {
    "type": "contact",
    "email": "traveler@example.com",
    "name": "Ana Silva"
  },
  "payload": {
    "proposalId": "prop_01H…",
    "tripId": "trip_01H…",
    "title": "Bali — 6 nights",
    "proposalUrl": "https://app.holidayos.ai/p/…",
    "channel": "email"
  }
}
Verifying a deliverynode
import crypto from "node:crypto";

// Read the RAW body — a JSON-parsing middleware that re-serializes will
// change the bytes and every signature will fail to verify.
export function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=").map((s) => s.trim())),
  );
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(parts.v1, "hex"),
  );
}

Delivery rules

Signing
Deliveries are signed with the same scheme as inbound: v1 = hmac-sha256(secret, t + "." + rawBody). Verify before trusting a delivery.
Acknowledging
Return any 2xx within 10 seconds. Anything else — including a timeout — counts as a failure.
Retries
Exponential backoff from 30s, doubling, capped at 60 minutes, for up to 6 attempts. After that the delivery is dead-lettered and never retried automatically.
Duplicates
A retry re-sends an identical eventId. Dedupe on it — at-least-once delivery is the guarantee, not exactly-once.
Loop prevention
Events your own system originated are not echoed back to you. Advisor actions carry origin: "crm".
Reachability
Subscription URLs must be public HTTPS endpoints. Private, loopback, and link-local addresses are refused by the SSRF guard.

Downloads

Generated from the same source as this page, so they cannot describe a different API. Import by URL and they stay current.

Questions: hello@holidayos.ai