# HolidayOS Connect API

Spec version `1.0`. Always-current version: https://holidayos.ai/developers/connect

Send enquiries from your website or partner systems into HolidayOS, and receive
customer-safe lifecycle events back over signed webhooks.

## Endpoint

```
POST https://api.new.holidayos.ai/api/v1/crm/connect/events
```

## Authentication

| 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. |

Sign the **raw body bytes you transmit**: `v1 = hmac-sha256(secret, "<t>." + rawBody)`, hex encoded.
Re-serializing the body for the request after signing is the single most common
cause of a 401 — key order can change, and the signature no longer matches.

## Inbound events

| 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's discovery answers (destination, dates, party, pace, interests, constraints) were captured or changed on your system. | Upserts the contact, appends a timeline entry and, like `enquiry.submitted`, reuses the open enquiry or opens one at stage `inquiry`. It then upserts the traveller's profile and trip discovery from the payload's discovery fields; unknown keys are ignored. Available only to tenants enabled for Traveller Discovery — for any other tenant the event is still accepted, with the warning `traveller_discovery_not_enabled`. The accepted result carries `travellerDiscovery.discoveryId`: send it back as `payload.hosTripDiscoveryId` so later events update the same record. Parts that did not apply are listed in `warnings` (`traveller_discovery_field_rejected:<field>`, `traveller_discovery_upsert_failed`) while the event itself is recorded, so re-send your current state as a new event rather than retrying this one. |
| `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`. |
| `booking.credit_applied` | Your reply to `booking.confirmed`: how much of the traveller's corporate credit you actually took (`tripId`, `bookingRef`, `requestedRupees`, `appliedRupees`, `reason`). Needs the `credit:write` scope. | Marks the invoice's credit confirmed. When less was taken than the invoice counted as paid, reopens the difference as a balance due and notifies the trip owner to collect it. |
| `employee.credit_updated` | The traveller's corporate credit balance changed on your side (`membershipId`, `corporateName`, `availablePaise`, `grantedPaise`, `redeemablePercent`, `asOf`). Send it on every change. Needs the `credit:write` scope. | Refreshes the credit shown on the client's open enquiries and their trips (newest `asOf` wins), so advisors see the current balance. Never opens an enquiry. |

### Example request

```json
{
  "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"
        }
      }
    }
  ]
}
```

### Signing and sending (Node)

```js
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 (curl)

```bash
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"
```

### Response

```json
{
  "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

### 400 — The 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.

### 401 — `Invalid 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.

### 403 — Authenticated, 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.

### 503 — Replay 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

Create, read and update the clients, enquiries and trips HolidayOS keeps for you,
under `https://api.new.holidayos.ai/api/v1/crm/connect`. Same key and tenant headers as
`/events`; the signature is `v2` (below).

### 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. |
| `credit:write` | Send the corporate credit events (`booking.credit_applied`, `employee.credit_updated`) to `POST /events`, alongside `events:ingest`. Without it those events fail and are retried. |

### Routes

#### `POST /clients` — Create a client

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 | Type | Notes |
| --- | --- | --- |
| `name` | string | Full name. A new client without one is named after its email. |
| `email` | string | The strongest identity: matched case-insensitively. |
| `phone` | string | Used to match only when no email is sent. |
| `company` | string | The person's company, as a label. |
| `externalId` | string | Your own ID for this person. Matched when no email is sent. |
| `marketingOptIn` | boolean | Whether they agreed to marketing. |
| `travelers` | array | Array of `{ name, type?: adult|child|infant, age?, email?, phone? }`, at most 50. On a PATCH it replaces the client's traveller roster. |

```json
{
  "name": "Ana Silva",
  "email": "ana@example.com",
  "phone": "+60123456789",
  "externalId": "user_8841",
  "marketingOptIn": true,
  "travelers": [
    {
      "name": "Leo Silva",
      "type": "child",
      "age": 9
    }
  ]
}
```

#### `GET /clients` — Look a client up

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.

| Field | Type | Notes |
| --- | --- | --- |
| `email` | query | Case-insensitive. |
| `phone` | query | Compared by digits. |
| `externalId` | query | Your own ID for the person. |

#### `GET /clients/{clientId}` — Read a client

Scope `clients:read`. Returns `200` with `{ "data": Client }`.

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

#### `PATCH /clients/{clientId}` — Update a client

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 | Type | Notes |
| --- | --- | --- |
| `name` | string | Full name. A new client without one is named after its email. |
| `email` | string | The strongest identity: matched case-insensitively. |
| `phone` | string | Used to match only when no email is sent. |
| `company` | string | The person's company, as a label. |
| `externalId` | string | Your own ID for this person. Matched when no email is sent. |
| `marketingOptIn` | boolean | Whether they agreed to marketing. |
| `travelers` | array | Array of `{ name, type?: adult|child|infant, age?, email?, phone? }`, at most 50. On a PATCH it replaces the client's traveller roster. |

```json
{
  "phone": "+447700900123",
  "marketingOptIn": false
}
```

#### `POST /leads` — Create an enquiry

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 | Type | Notes |
| --- | --- | --- |
| `contact` | object | `{ name, email, phone, externalId }`. Needed unless you send `clientId`. |
| `clientId` | string | Attach to this client; it is never duplicated. |
| `externalId` | string | Your own ID for this enquiry, for `GET /leads?externalId=`. |
| `title` | string | The enquiry or trip title advisors see. |
| `destination` | string | Free text, e.g. `Bali`. |
| `travelDates` | object | `{ startDate, endDate }`, each `YYYY-MM-DD`. |
| `party` | object | `{ adults, children, childAges, rooms }`. Stored as the party count, never inferred from a traveller list. |
| `budget` | object | `{ amount, currency }` when you know the figure and currency, otherwise `{ notes }` with the customer's words. Replaces the stated budget as a whole. |
| `guestNationality` | string | ISO 3166-1 alpha-2 passport country of the party; `""` clears it. |
| `itinerary` | object | A customized itinerary snapshot — the same object `enquiry.submitted` takes as `itinerarySnapshot`: `{ title, days: [{ title, items: [{ title, … }] }] }`. |
| `message` | string | The customer's own words. Opens the enquiry's inbox thread. |
| `attribution` | object | `{ source, medium, campaign, term, content, landingPath, referrer }` — the campaign that earned the enquiry. |

```json
{
  "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."
}
```

#### `GET /leads` — Look an enquiry up by your ID

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.

| Field | Type | Notes |
| --- | --- | --- |
| `externalId` | query | Your own ID for the enquiry. |

#### `GET /leads/{leadId}` — Read an enquiry

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.

#### `PATCH /leads/{leadId}` — Update an enquiry

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 | Type | Notes |
| --- | --- | --- |
| `stage` | string | `inquiry`, `follow_up`, `building_proposal`, `negotiation` or `lost`. |
| `reason` | string | Why the stage changed. Required for `lost`. |
| `owner` | string | An active member's email. |
| `followUp` | boolean | Flag the enquiry for follow-up. |
| `title` | string | The enquiry title. |

```json
{
  "stage": "lost",
  "reason": "Booked with another agency"
}
```

#### `GET /leads/{leadId}/trip` — Read an enquiry's 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.

#### `PATCH /leads/{leadId}/trip` — Post a customer's changes to their 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 | Type | Notes |
| --- | --- | --- |
| `title` | string | The enquiry or trip title advisors see. |
| `destination` | string | Free text, e.g. `Bali`. |
| `travelDates` | object | `{ startDate, endDate }`, each `YYYY-MM-DD`. |
| `party` | object | `{ adults, children, childAges, rooms }`. Stored as the party count, never inferred from a traveller list. |
| `budget` | object | `{ amount, currency }` when you know the figure and currency, otherwise `{ notes }` with the customer's words. Replaces the stated budget as a whole. |
| `guestNationality` | string | ISO 3166-1 alpha-2 passport country of the party; `""` clears it. |
| `itinerary` | object | A customized itinerary snapshot — the same object `enquiry.submitted` takes as `itinerarySnapshot`: `{ title, days: [{ title, items: [{ title, … }] }] }`. |
| `message` | string | The customer's own words, posted to the inbox thread. |
| `changeSummary` | string | One line on what changed; also the reason recorded on a date change. |

```json
{
  "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

`Client`

```json
{
  "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"
}
```

`Lead`

```json
{
  "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"
}
```

`Trip`

```json
{
  "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"
}
```

### 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.

### Signing and sending (Node)

```js
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}"` },
);
```

### Smoke test (curl)

```bash
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"
```

### 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 in HolidayOS under **Settings → Connect → Webhooks**.
Deliveries are signed with the same scheme as inbound requests.

| 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 |
| `booking.confirmed` | The trip is booked. Carries the booking amount and, for a corporate employee, the travel credit the booking used. `bookingRef` identifies this booking; dedupe on it. | Yes |
| `booking.cancelled` | Sent by the Cancel booking action, with the same `bookingRef` as the booking.confirmed it cancels. Return any credit that booking used. | Yes |

### Example delivery

```http
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 delivery (Node)

```js
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.

## Machine-readable

- OpenAPI 3.1: https://holidayos.ai/developers/connect/openapi.json
- Postman collection: https://holidayos.ai/developers/connect/postman.json
- This document: https://holidayos.ai/developers/connect/connect-api.md
