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

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

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

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

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

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

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

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

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