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. 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. |
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" }
]
}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