Developers

Connect API

Send enquiries from your website or partner systems straight into the HolidayOS inbox, 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 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.Upserts the contact and appends a timeline entry. No enquiry is opened.
contact.updatedA known traveller's details changed on your system.Patches the existing contact's fields and appends a timeline entry.
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.
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" }
      }
    }
  ]
}
Responsejson
{
  "accepted": 1,
  "duplicate": 0,
  "failed": 0,
  "results": [
    { "eventId": "source_event_id", "status": "accepted" }
  ]
}

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.
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.
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.
  • 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 events:ingest scope.
  • 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.

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