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 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. | Upserts the contact and appends a timeline entry. No enquiry is opened. |
contact.updated | A known traveller's details changed on your system. | Patches the existing contact's fields and appends a timeline entry. |
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. |
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" }
}
}
]
}{
"accepted": 1,
"duplicate": 0,
"failed": 0,
"results": [
{ "eventId": "source_event_id", "status": "accepted" }
]
}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. - 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. - 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. - 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
events:ingestscope. 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.
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