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.
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
});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.
| Header | Value | Notes |
|---|---|---|
X-Connect-Key | key prefix | The visible prefix from an active API key (looks like hc_live_…). |
X-Connect-Tenant | your-tenant-slug | Must match the tenant bound to the key. Compared case-insensitively and trimmed. |
X-Connect-Signature | t=<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.
| Event | What it means | What HolidayOS does |
|---|---|---|
contact.identified | A 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.updated | A 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.submitted | A 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_upserted | A 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_started | The traveller began building a trip on your site. | Timeline only — a planning signal carries no enquiry obligation. |
trip.draft_updated | The traveller changed their in-progress trip draft. | Timeline only. |
quote.requested | A 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.started | The traveller entered checkout. | Advances the open enquiry to proposal_approved if that is further along than its current stage. Never regresses a stage. |
booking.abandoned | The traveller left checkout without completing. | Flags the open enquiry for follow-up. Leaves its stage untouched. |
booking.completed | The traveller paid and the booking is confirmed. | Forces the enquiry to stage trip_booked. |
{
"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"
}
}
}
]
}{
"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:ingestto submit events. - Batching
- Up to 100 events per request. A rejected envelope fails the whole batch — nothing is written.
- Idempotency
- Reuse the same
eventIdwhen retrying. A repeat is reported asduplicateand has no second effect. - Record IDs
- Each
acceptedorduplicateresult carries theclientId,leadIdandtripIdthe 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
actorneeds at least one ofemail,phone, orexternalId. Without one there is no stable key and every event would fork a phantom contact. Omitnamewhen you do not know it: the client keeps the name it already has. - Tenant isolation
- The envelope
tenantmust 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, malformedoccurredAt). actorcarries none ofemail,phone, orexternalId— there is no key to dedupe on.- The envelope
tenantdoes not match the authenticated key's tenant. - An
eventIdstarts withconnect-api:, which the records API reserves for its own writes. - More than 100 events, or an empty
eventsarray.
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, orX-Connect-Tenantmissing 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:ingestfor/events, or the one the Records API lists. X-Connect-Tenantdoes 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
| Scope | Allows |
|---|---|
events:ingest | Send events to POST /events. |
clients:read | Read clients and look them up by email, phone or externalId. |
clients:write | Create and update clients. |
leads:read | Read enquiries and their trips; look enquiries up by your own ID. |
leads:write | Create 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 asX-Connect-Signature: t=<t>,v2=<hex>.pathWithQueryis the request target exactly as sent (/api/v1/crm/connect/leads/abc/trip, including any?query);rawBodyis the empty string for a GET. Av1signature is refused here, andv2is refused on/events. - Idempotency
- Every POST needs an
Idempotency-Keyheader (1–200 printable characters). Retrying with the same key and body returns the original record withIdempotent-Replayed: true, writes nothing and is not metered; the same key with a different body is422 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 asIf-Matchon a PATCH and a change an advisor made in between answers412 stale_writeinstead of being overwritten. WithoutIf-Matchyour PATCH wins. - Envelope
- Success is
{ "data": … }. Errors are{ "statusCode", "code", "message" }plus record IDs where noted — branch oncode. 401 and 403 keep the generic message the auth table explains. Bodies are checked strictly: an unknown field, anull, 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.
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}"` },
);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.
| Field | Notes |
|---|---|
name | Full name. A new client without one is named after its email. |
email | The strongest identity: matched case-insensitively. |
phone | Used to match only when no email is sent. |
company | The person's company, as a label. |
externalId | Your own ID for this person. Matched when no email is sent. |
marketingOptIn | Whether they agreed to marketing. |
travelers | Array of { name, type?: adult|child|infant, age?, email?, phone? }, at most 50. On a PATCH it replaces the client's traveller roster. |
{
"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.
| Query | Notes |
|---|---|
email | Case-insensitive. |
phone | Compared by digits. |
externalId | Your 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.
| Field | Notes |
|---|---|
name | Full name. A new client without one is named after its email. |
email | The strongest identity: matched case-insensitively. |
phone | Used to match only when no email is sent. |
company | The person's company, as a label. |
externalId | Your own ID for this person. Matched when no email is sent. |
marketingOptIn | Whether they agreed to marketing. |
travelers | Array of { name, type?: adult|child|infant, age?, email?, phone? }, at most 50. On a PATCH it replaces the client's traveller roster. |
{
"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.
| Field | Notes |
|---|---|
contact | { name, email, phone, externalId }. Needed unless you send clientId. |
clientId | Attach to this client; it is never duplicated. |
externalId | Your own ID for this enquiry, for GET /leads?externalId=. |
title | The enquiry or trip title advisors see. |
destination | Free 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. |
guestNationality | ISO 3166-1 alpha-2 passport country of the party; "" clears it. |
itinerary | A customized itinerary snapshot — the same object enquiry.submitted takes as itinerarySnapshot: { title, days: [{ title, items: [{ title, … }] }] }. |
message | The customer's own words. Opens the enquiry's inbox thread. |
attribution | { source, medium, campaign, term, content, landingPath, referrer } — the campaign that earned the enquiry. |
{
"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.
| Query | Notes |
|---|---|
externalId | Your 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.
| Field | Notes |
|---|---|
stage | inquiry, follow_up, building_proposal, negotiation or lost. |
reason | Why the stage changed. Required for lost. |
owner | An active member's email. |
followUp | Flag the enquiry for follow-up. |
title | The enquiry title. |
{
"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.
| Field | Notes |
|---|---|
title | The enquiry or trip title advisors see. |
destination | Free 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. |
guestNationality | ISO 3166-1 alpha-2 passport country of the party; "" clears it. |
itinerary | A customized itinerary snapshot — the same object enquiry.submitted takes as itinerarySnapshot: { title, days: [{ title, items: [{ title, … }] }] }. |
message | The customer's own words, posted to the inbox thread. |
changeSummary | One line on what changed; also the reason recorded on a date change. |
{
"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
{
"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"
}{
"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"
}{
"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
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | A field failed validation, or the body carries a field the route does not accept. message names it. |
| 400 | idempotency_key_required | A POST without an Idempotency-Key header. |
| 400 | identity_required | No email, phone or externalId (or clientId) to identify the person. |
| 400 | invalid_itinerary | The itinerary snapshot failed validation; errors lists why. |
| 400 | lookup_field_required | A lookup without email, phone or externalId (clients) or externalId (leads). |
| 404 | not_found | No such record in your agency. |
| 409 | client_exists | A client with that identity exists; clientId names it. |
| 409 | identity_taken | Another client already holds that email or externalId; clientId names it. |
| 409 | trip_not_open | The enquiry has no trip workspace yet. |
| 409 | trip_closed | The enquiry is lost, archived, booked or travelled; send the change as a new enquiry. stage says which. |
| 412 | stale_write | If-Match no longer matches: the record changed since you read it. updatedAt is the current version. |
| 422 | idempotency_key_reused | That Idempotency-Key was used for a different body. |
| 422 | reason_required | lost without a reason of 5+ characters. |
| 422 | stage_not_allowed | That stage is set by the proposal, booking or travel workflow, or needs a trip first. |
| 422 | owner_not_member | The owner is not an active member of your agency. |
| 500 | lead_create_failed | The 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.
| Event | What it means | Emitted |
|---|---|---|
lead.assigned | An advisor was assigned, or reassigned, to an enquiry. | Yes |
lead.stage_changed | An enquiry moved between pipeline stages. | Yes |
proposal.sent | A proposal was sent to the traveller. Carries the hosted proposal link. | Yes |
proposal.ready | Reserved in the allowlist. No code path emits it yet — do not wait on it. | Not yet |
proposal.viewed | The traveller opened the hosted proposal. | Yes |
message.posted | Reserved in the allowlist. No code path emits it yet — do not wait on it. | Not yet |
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"
}
}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.
- OpenAPI 3.1 specImport into Postman, Insomnia, or a client generator.
- Postman collectionRequest signing is already wired as a pre-request script — set
connectKeyandconnectSecret, then send. - This reference as MarkdownAttach it to an email or drop it into your repo.
Questions: hello@holidayos.ai