
# Public API

The help desk API (v1) is what the [chat widget](https://blhp.app/docs/widget) is built on, so anything the widget does, your own code can do too. Your server signs in one of your users with a secret key, and their browser or app uses the session token it gets back:

```sh
curl -X POST https://api.blhp.app/v1/sessions \
  -H "Authorization: Bearer $BELLHOP_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"user": {"id": "user_8412", "email": "dana@example.com", "email_verified": true, "name": "Dana Lee"}}'
```

```json
{
  "token": "bh_us_EXAMPLE_SESSION_TOKEN",
  "expires_at": "2026-11-05T21:01:50.000Z",
  "user": {
    "id": "ct_18ddac26-429d-4400-80e0-72c003ff0217",
    "name": "Dana Lee",
    "email": "dana@example.com",
    "external_id": "user_8412",
    "verified_via": "host",
    "locale": "en",
    "anonymous": false
  }
}
```

Then, with that token, Dana opens a conversation:

```sh
curl -X POST https://api.blhp.app/v1/conversations \
  -H "Authorization: Bearer $SESSION" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821-message-1" \
  -d '{"message": {"body": "My invoice for October is wrong: it says $120, it should be $100."}}'
```

```json
{
  "id": "67e9902b-2fe4-4e60-bd94-17745a010358",
  "number": 70,
  "subject": "My invoice for October is wrong: it says $120, it should be $100.",
  "status": "open",
  "close_reason": null,
  "created_at": "2026-11-04T21:01:51.012Z",
  "updated_at": "2026-11-04T21:01:51.012Z",
  "assignee": null,
  "with_person": false,
  "last_item": {
    "id": "67e9902b-2fe4-4e60-bd94-17745a010358",
    "created_at": "2026-11-04T21:01:51.012Z",
    "kind": "message",
    "message": {
      "id": "67e9902b-2fe4-4e60-bd94-17745a010358",
      "created_at": "2026-11-04T21:01:51.012Z",
      "conversation_id": "67e9902b-2fe4-4e60-bd94-17745a010358",
      "author": { "type": "user", "id": "ct_18ddac26-429d-4400-80e0-72c003ff0217", "name": "Dana Lee" },
      "body": "My invoice for October is wrong: it says $120, it should be $100.",
      "edited_at": null,
      "attachments": []
    }
  },
  "unread": 0,
  "request_type": null,
  "fields": {},
  "verifications": []
}
```

The API is for workspaces in help desk mode. A conversation is a help desk request: the team sees it as request #70 and answers it like any other.

## Basics

- **Base URL:** `https://api.blhp.app/v1`. Every workspace host answers on `/v1` too, for example `https://acme.blhp.app/v1`.
- **Format:** JSON in and out, with times in ISO 8601. Lists are pages: `{ "data": [...], "next_cursor": "…" }`. Pass `next_cursor` back as `cursor` for the next page; it's `null` on the last one.
- **Stability:** within v1, fields and routes are only ever added, never renamed or removed. Ignore fields you don't know.
- **White label:** end users never see "Bellhop". Team members and agents appear as the workspace presents them.
- **OpenAPI:** [`GET /v1/openapi.json`](https://api.blhp.app/v1/openapi.json) describes every route. The [reference](#reference) below is generated from it.

## Keys

Workspace admins create keys in **Settings → API keys**. There are two kinds:

| Key | Looks like | Where it goes | What it can do |
| --- | --- | --- | --- |
| Publishable | `bh_pk_…` | Browsers and apps | Read the workspace's name and sign-in options, and start end-user sessions. Only from its allowed origins. |
| Secret | `bh_sk_…` | Your server only | Start sessions for your own users, and use the [server API](#server-api). Shown once. |

A publishable key works only from the exact origins you allow, such as `https://app.example.com`: no paths, no wildcards, and `http` only for `localhost`. A request whose `Origin` doesn't match is refused, so a copied key is no use on another site. The workspace's own site, `https://<slug>.blhp.app`, is always allowed.

A secret key is refused whenever the request has an `Origin` header. Browsers always send one on cross-origin calls, so a secret key that leaks into a page doesn't work there. Keep it on your server, and revoke it in Settings if it leaks: whatever uses it stops at once, and sessions it started end.

## End-user sessions

Everything a person does goes through a session: `Authorization: Bearer bh_us_…`. A session is one contact in one workspace. It uses no cookies, so it works in iframes, third-party embeds and native apps. There are four ways to start one:

| How | Credential | Body | When |
| --- | --- | --- | --- |
| Your server vouches for the user | Secret key | `{ "user": { "id", "email"?, "email_verified"?, "name"?, "locale"? } }` | Your app has signed-in users. The best option. |
| An identity token from your server | Publishable key | `{ "identity_token": "…" }` | Your server can sign a short-lived token but shouldn't call Bellhop itself. See [Identity](https://blhp.app/docs/identity). |
| An email code | Publishable key | `POST /v1/sessions/email`, then `POST /v1/sessions/email/verify` | Visitors without an account, on the workspace's own site. |
| Anonymously | Publishable key | `{ "anonymous": true, "turnstile_token": "…" }` | The workspace allows anonymous chats, on its own site. |

`user.id` is your user id. It becomes the contact's `external_id`, so the same person finds the same conversations on every device. Send `email_verified: true` only for an address your app has verified; an unverified email is kept for display and never used to match or reach anyone.

Email codes and anonymous sessions need a [Turnstile](https://developers.cloudflare.com/turnstile/) token, and the workspace's human check only runs on its own origin. On any other origin, `GET /v1/workspace` returns `turnstile_site_key: null`, so a page on your site can't offer those sign-ins. Sign people in through your server there.

### Lifetime and refresh

A session lasts 24 hours. `POST /v1/session/refresh` swaps it for a new token valid for up to 24 hours more, and the old token and its realtime sockets stop working at once. Sessions can be refreshed until 30 days after the first sign-in; after that, start a new one. Refresh before `expires_at`: an expired token can't be refreshed.

```sh
curl -X POST https://api.blhp.app/v1/session/refresh \
  -H "Authorization: Bearer $SESSION"
```

```json
{
  "token": "bh_us_EXAMPLE_SESSION_TOKEN",
  "expires_at": "2026-11-05T21:02:00.000Z",
  "user": {
    "id": "ct_18ddac26-429d-4400-80e0-72c003ff0217",
    "name": "Dana Lee",
    "email": "dana@example.com",
    "external_id": "user_8412",
    "verified_via": "host",
    "anonymous": false,
    "locale": "en"
  }
}
```

Only one refresh can win. If two tabs share a token, let one refresh it and hand the new token to the others (for example through `localStorage`), and never retry with the old token. Anonymous sessions can't be refreshed; they get `403 verification_required`.

### Linking existing contacts

Someone may have written in by email before your app vouched for them. By default, a verified email from your server does **not** connect them to that earlier email-only contact: your system saying someone owns an address isn't enough to hand them its private history. The new user gets their own contact, and the address is kept as display-only `unverified_email` until the conflict is resolved.

Add `"link_existing": true` (next to `email_verified: true`) when you have verified the address yourself and want this user to see the requests they made earlier by email. It links only to a contact that has no `external_id` yet, and a user you've already linked always keeps their own contact. Identity tokens take the same claim, and `PUT /v1/contacts/by-external-id/:id` follows the same rules.

### Email sign-in

`POST /v1/sessions/email` with `{ email, turnstile_token }` sends a 6-digit code, and its response has a `Challenge-Token` header. Keep that value in the same browser and send it back, with the code, to `POST /v1/sessions/email/verify`. A code entered anywhere else fails. Codes last 10 minutes and allow 5 tries.

If the visitor has an anonymous session, send both calls with its bearer as well. Its conversations then move to the verified contact, and the anonymous session ends.

## Conversations and messages

A session sees only its own conversations, with public messages and events. `GET /v1/conversations/:id/items` returns the timeline: `kind: "message"` items, and `kind: "event"` items for status changes, assignments and verification requests.

To open a thread at the newest messages, ask for the last few with `last`:

```sh
curl "https://api.blhp.app/v1/conversations/$CONVERSATION/items?last=2" \
  -H "Authorization: Bearer $SESSION"
```

```json
{
  "data": [
    {
      "id": "67e9902b-2fe4-4e60-bd94-17745a010358",
      "created_at": "2026-11-04T21:01:51.012Z",
      "kind": "message",
      "message": {
        "id": "67e9902b-2fe4-4e60-bd94-17745a010358",
        "created_at": "2026-11-04T21:01:51.012Z",
        "conversation_id": "67e9902b-2fe4-4e60-bd94-17745a010358",
        "author": { "type": "user", "id": "ct_18ddac26-429d-4400-80e0-72c003ff0217", "name": "Dana Lee" },
        "body": "My invoice for October is wrong: it says $120, it should be $100.",
        "edited_at": null,
        "attachments": []
      }
    }
  ],
  "next_cursor": null
}
```

| To | Ask for | Order |
| --- | --- | --- |
| Open at the newest | `?last=30` (1–100) | Display order, oldest of the page first |
| Load older ones | `?cursor=<next_cursor>`, or `?before=<item id>&last=30` | Display order; `next_cursor` keeps going back |
| Read everything | no parameters, then `?cursor=<next_cursor>` | Oldest first |
| Catch up after a gap | `?after=<last item id you have>` | Oldest first |

Don't combine `after` with `last` or `before`.

Send messages with `POST /v1/conversations/:id/messages` and `{ "body": "…", "attachments": ["<upload id>"] }`. Bodies are Markdown. `POST …/read` with `{ item_id }` marks everything up to that item as read; `unread` on a conversation counts messages from others and verification requests after that mark. `POST …/typing` shows the team that the person is typing for a few seconds.

### Talking to a person

Help desks can have an AI agent answer first. When someone would rather talk to a person, `POST /v1/conversations/:id/escalate` hands the conversation to the team, with an optional `reason`:

```sh
curl -X POST https://api.blhp.app/v1/conversations/$CONVERSATION/escalate \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "I would like to talk to a person."}'
```

It returns the conversation with `with_person: true`, and an `escalated` event appears in its items. For an end-user session, `assignee` is always `null`; assignment events do not identify the member either. The workspace view still includes the assignee. Abridged:

```json
{
  "id": "4b7e2c90-1d3f-4a68-9e05-7c2d8f1a6b34",
  "number": 31,
  "status": "open",
  "with_person": true,
  "last_item": {
    "id": "7e3f5705-0c70-40b3-814a-37735644b189",
    "created_at": "2026-11-04T23:19:01.300Z",
    "kind": "event",
    "actor": null,
    "event": { "type": "escalated" }
  }
}
```

A person from the right group is assigned and emailed (see [Handing off to a person](https://blhp.app/docs/agents#handing-off-to-a-person)). The requester never sees the team's reasons or groups, only that a person has it. A session can ask once per conversation; asking again, or while it's already with a person, is `409 handoff_unavailable`. With a secret key, your server escalates as the workspace. The [chat widget](https://blhp.app/docs/widget#behavior) has this as **Talk to a person**, and the SDK as `session.escalate(id, { reason })`.

### Request types

A workspace can sort new requests by type, such as "Billing" or "Bug" (**Settings → Request types**). `GET /v1/workspace` lists the active ones in `request_types`, in order, each with an `id`, `name`, `description`, `require_verification` and its `fields`. A field has a `key`, a `label`, a `kind` (`text`, `textarea`, `email`, `url`, `number`, or `select` with `options`), `required`, and optionally `help`.

Start the conversation with the type's `id` as `request_type`, and the answers by key, as strings, in `fields`. Leave out empty answers:

```json
{
  "message": { "body": "I was charged twice for October." },
  "request_type": "<a type id from GET /v1/workspace>",
  "fields": { "billing_email": "dana@example.com", "invoice": "4821" }
}
```

- A 400 whose message starts with a field key, such as `billing_email: invalid email` or `area: required`, names the answer to fix.
- Types with `require_verification`, and answers to email fields with `verify`, need a verified person, or the request fails with `401 contact_required`. A `verify` email field also sends that address a code, which shows up in the conversation's `verifications`.
- Internal fields (an account id, say) aren't listed for end users and a session can't answer them. Your server can fill them with a secret key, and only the workspace view returns them.
- Requests without a type are fine too; the help desk's own form offers "Something else".

When the team asks someone to prove an address ("the email on your billing account"), the conversation's `verifications` lists it and a `verification_requested` event appears. The person enters the code with `POST …/verifications/:vid/confirm`.

## Uploads

Upload a file with `POST /v1/uploads`, as `multipart/form-data` with one part named `file`, and put the returned `id` in a message's `attachments` within 24 hours.

| Limit | Value |
| --- | --- |
| Images | 10 MiB each |
| Videos | 100 MiB each; the content must be a known video container |
| Other files | 25 MiB each |
| Uploads per person | 10 per minute, and 40 per hour |
| Bytes per day | 256 MiB per person, 2 GiB per workspace (UTC day) |

Errors you should show kindly: `413 too_large` (one file is over its limit), `413 upload_byte_quota` (today's budget is used up), `415 unsupported_video` (a "video" that isn't one) and `429 rate_limited`.

### Signed media URLs

An attachment's `url` is signed for that person and file, and works for 1 hour without a token, so it can go straight into an `<img>` or a download link. That convenience is a tradeoff: anyone the URL is copied to can open it until it expires. Each request still checks that the person exists and the message is visible, so erasing the contact or hiding the message stops the URL at once. Fetch the conversation again for fresh URLs after they expire.

## Realtime

`POST /v1/realtime` returns a single-use `wss://` URL, valid for 1 minute. Open it unchanged. The socket sends one JSON frame per event, for all of the person's conversations:

| `type` | When |
| --- | --- |
| `item.created` | A message or event was added. `item` is the full item. |
| `message.updated` | A message was edited. |
| `conversation.updated` | The conversation changed, for example its status. `conversation` is the whole conversation. |
| `typing` | A team member is typing a public reply. |
| `activity` | An agent is working on it: `text` is a short status such as "Looking up your invoice…", and `null` when it's done. |

Send the text `ping` every 25 seconds; the server answers `pong`, which is a heartbeat, not a JSON event. When the socket closes with code `4401`, the session is no longer valid: sign in again rather than reconnecting.

Events aren't replayed. To reconnect without missing anything:

1. Get a fresh URL and open the socket, buffering its frames.
2. For each conversation you show, page `items?after=<last item id you have>` and merge by item id.
3. Refresh the conversation list for new requests and changed states, then apply the buffered frames.

If the item you're catching up from was deleted or hidden, reload that conversation from the start.

## Idempotency

Send an `Idempotency-Key` header (any unique string up to 200 characters, such as a UUID) on `POST` requests and server writes, and a retry won't do it twice. A repeat with the same key gets the same response: successes are kept for 24 hours and client errors for 5 minutes. Reusing a key with a different body is `422 idempotency_mismatch`; a repeat while the first is still running is `409 request_pending`. Refresh and realtime URLs aren't replayed.

A request refused with a client error never ran. If you fix the cause and retry, for example after the person verifies their email following `401 contact_required`, use a new key.

## Rate limits

When you go over a limit you get `429` and a `Retry-After` header with the seconds to wait.

| What | Limit |
| --- | --- |
| A session reading | 300 per minute, shared by every token after refreshes |
| A session writing | 60 per minute |
| Typing signals | 300 per minute |
| Starting sessions and entering codes | 60 per minute per key and IP |
| Email codes sent | 30 per minute per IP, and per address and workspace |
| A secret key, in total | 6000 per minute |
| Any request | 600 per minute per IP |
| Sign-in routes, and requests without a valid-looking key | 120 per minute per IP |

`OPTIONS` preflights are free, and browsers may cache them for a day.

## Errors

Errors have a stable `code` and a readable `message`. Here, a session that has ended:

```sh
curl https://api.blhp.app/v1/me \
  -H "Authorization: Bearer $ENDED_SESSION_TOKEN"
```

```json
{ "error": { "code": "invalid_credential", "message": "Invalid or expired credential" } }
```

| Status | Codes | Meaning |
| --- | --- | --- |
| 400 | `invalid_input`, `invalid_code`, `invalid_attachment` | A field is wrong, or the code didn't match. |
| 401 | `invalid_credential` | The key or session is missing, wrong, revoked or expired. |
| 401 | `contact_required` | This needs a verified person: sign in by email first. |
| 403 | `forbidden`, `anonymous_disabled`, `verification_required`, `blocked`, `locked` | Not allowed: the wrong origin, a disabled sign-in, or the person or conversation is blocked. |
| 404 | `not_found` | It doesn't exist, or this credential can't see it. |
| 409 | `request_pending`, `conflict` | The same idempotent request is still running, or something changed meanwhile. |
| 410 | `expired`, `erased` | The report metadata or saved response is gone. |
| 413 | `too_large`, `upload_byte_quota` | Over a size or daily limit. |
| 415 | `expected_json`, `expected_multipart`, `unsupported_video` | The wrong content type. |
| 422 | `idempotency_mismatch` | The key was used for a different request. |
| 428 | `human_required` | A Turnstile token is required. |
| 429 | `rate_limited`, `idempotency_limit`, `code_limit` | Too many. Wait `Retry-After` seconds. |

## Server API

With a secret key and no `Origin`, the same conversation routes give the workspace's view, and a few more manage contacts. Use it from your backend to sync your users, open requests for them, reply from your own tools and handle deletions. AI agents should use [MCP](https://blhp.app/docs/mcp) instead. Every server write takes an `Idempotency-Key` and is recorded in the workspace's audit log with the key's name.

### Contacts

`PUT /v1/contacts/by-external-id/:id` creates or updates the contact for one of your users, with the same [linking rules](#linking-existing-contacts) as starting a session:

```sh
curl -X PUT https://api.blhp.app/v1/contacts/by-external-id/user_9001 \
  -H "Authorization: Bearer $BELLHOP_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "sam@example.com", "email_verified": true, "name": "Sam Ortiz"}'
```

```json
{
  "id": "ct_770b8a27-f21c-4c9e-a6ef-f0b30159671c",
  "name": "Sam Ortiz",
  "email": "sam@example.com",
  "external_id": "user_9001",
  "unverified_email": null,
  "verified_via": "host",
  "locale": "en",
  "created_at": "2026-11-04T21:02:00.940Z",
  "last_seen_at": "2026-11-04T21:02:00.940Z",
  "anonymous": false
}
```

`GET /v1/contacts?q=` searches contacts, and `GET /v1/contacts/:id` or `/v1/contacts/by-external-id/:id` reads one. Anonymous visitors aren't contacts until they verify.

### The workspace view

`GET /v1/conversations` lists the workspace's requests, filtered with `status`, `contact_id` or `q` (the same search as the team's list). Conversations and items in this view also carry `contact_id`, `labels`, `hidden`, `internal` notes and staff-only events, so keep them away from end users. Attachment URLs in it need the secret key to fetch; they aren't shareable.

- **Open a request for someone:** `POST /v1/conversations` with `contact: { "external_id": "user_9001" }` (or `{ "id": "ct_…" }`). It's marked `created_via: "api"`.
- **Reply as a team member:** `POST /v1/conversations/:id/messages` with `as: { "member": "agent@yourcompany.com" }`, the email of someone who has joined the workspace. Add `"internal": true` for a team-only note.
- **Triage:** `PATCH /v1/conversations/:id` with any of `status`, `close_reason`, `assignee` (`{ "member": "<email>" }`, `{ "agent": "<agent id>" }` or `null`) and `labels` (names).
- **Ask to verify an email:** `POST /v1/conversations/:id/verifications` with `{ email }` sends that address a code only the requester can enter.

A reply from your own tools. Dana sees it from Alex, on your team:

```sh
curl -X POST https://api.blhp.app/v1/conversations/$CONVERSATION/messages \
  -H "Authorization: Bearer $BELLHOP_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821-reply-1" \
  -d '{"as": {"member": "alex@acme.example"}, "body": "Thanks Dana, I have refunded the $20."}'
```

```json
{
  "id": "1f2d3c4b-5a69-4788-9a0b-c1d2e3f4a5b6",
  "conversation_id": "67e9902b-2fe4-4e60-bd94-17745a010358",
  "author": { "type": "team", "id": "b46c1221-6f61-435f-890c-f46a7243cda7", "name": "Alex Kim", "avatar_url": null },
  "body": "Thanks Dana, I have refunded the $20.",
  "attachments": [],
  "created_at": "2026-11-04T21:02:00.512Z",
  "edited_at": null,
  "internal": false
}
```

Then close it:

```sh
curl -X PATCH https://api.blhp.app/v1/conversations/$CONVERSATION \
  -H "Authorization: Bearer $BELLHOP_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "closed", "close_reason": "completed"}'
```

```json
{
  "id": "67e9902b-2fe4-4e60-bd94-17745a010358",
  "number": 70,
  "subject": "My invoice for October is wrong: it says $120, it should be $100.",
  "status": "closed",
  "close_reason": "completed",
  "created_at": "2026-11-04T21:01:51.012Z",
  "updated_at": "2026-11-04T21:02:00.994Z",
  "contact_id": "ct_18ddac26-429d-4400-80e0-72c003ff0217",
  "with_person": false,
  "unread": 0,
  "request_type": null,
  "fields": {},
  "effective_autonomy": "suggest",
  "labels": [],
  "assignee": null,
  "hidden": false,
  "last_item": {
    "id": "62ad41b5-4031-4977-ae59-4bff711d6463",
    "created_at": "2026-11-04T21:02:00.994Z",
    "internal": false,
    "hidden": false,
    "kind": "event",
    "actor": { "type": "team", "id": "workspace-team", "name": "Acme Support", "avatar_url": null },
    "event": { "type": "status_changed", "status": "closed", "close_reason": "completed" }
  },
  "verifications": []
}
```

### Approvals

When an agent asks the team before an action with consequences, your action service checks the signed token it gets: verify it against your workspace's keys at `/v1/approvals/jwks.json?workspace=<slug>`, then `POST /v1/approvals/:id/consume` with your secret key before acting. `GET /v1/conversations/:id/approvals` lists a request's approvals, never with tokens. [Running support agents](https://blhp.app/docs/agents#approvals) has the verifier and the rules.

### Erasure

`DELETE /v1/contacts/:id` erases a person: their name, emails and external id, their sessions (which stop at once), their notification subscriptions and the verification records on their requests. Their requests and files stay so the team's history makes sense, written by "Deleted user". Your user id is free again afterwards; vouching for it later creates a new, empty contact.

## Reference

Generated from [`/v1/openapi.json`](https://api.blhp.app/v1/openapi.json). Paths are relative to `https://api.blhp.app`, and "or" between credentials means either works.

### Sessions

#### Start a session

`POST /v1/sessions` · publishable key or secret key

With a secret key, for a user your server vouches for. With a publishable key, from an identity token or anonymously (where the workspace allows it).

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `Idempotency-Key` | header | string |  |

Body: [`SessionInput`](#sessioninput-object)

Returns `201` with [`Session`](#session-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `428`, `429`, `500`, `502` and `503`.

#### Send an email sign-in code

`POST /v1/sessions/email` · publishable key or session

Needs a Turnstile token, so it works only where the workspace's human check runs. Returns `Challenge-Token`; keep it in this browser.

Publishable key, or an anonymous session on its allowed origin. Verifying with the anonymous bearer claims only that session’s history and revokes it.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `Idempotency-Key` | header | string |  |
| `Challenge-Token` | header | string |  |

Body: [`EmailStart`](#emailstart-object)

Returns `200` with [`OK`](#ok-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `428`, `429`, `500`, `502` and `503`.

#### Sign in with the email code

`POST /v1/sessions/email/verify` · publishable key or session

Send the code with the same `Challenge-Token`. With an anonymous session's bearer, its conversations move to the verified contact and that session ends.

Publishable key, or an anonymous session on its allowed origin. Verifying with the anonymous bearer claims only that session’s history and revokes it.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `Idempotency-Key` | header | string |  |
| `Challenge-Token` | header | string | yes |

Body: [`EmailVerify`](#emailverify-object)

Returns `201` with [`Session`](#session-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Refresh a session

`POST /v1/session/refresh` · session

A new token for up to 24 hours more, at most 30 days after the first sign-in. The old token and its sockets stop working.

Verified sessions only. Issues a new bearer, invalidates the old bearer, and preserves the absolute 30-day lifetime. Not replayable.

Returns `200` with [`Session`](#session-object). [Errors](#errors): `400`, `401`, `403`, `404`, `413`, `415`, `429`, `500` and `503`.

#### Sign out

`DELETE /v1/session` · session

Ends the session and closes its sockets.

Returns `200` with [`OK`](#ok-object). [Errors](#errors): `401`, `403`, `404`, `429` and `500`.

#### Get the workspace

`GET /v1/workspace` · publishable key or session

The name and icon to show, and which sign-ins this origin offers.

Returns `200` with [`Workspace`](#workspace-object). [Errors](#errors): `401`, `403`, `404`, `429` and `500`.

#### Get the user

`GET /v1/me` · session

The person this session belongs to.

Returns `200` with [`EndUser`](#enduser-object). [Errors](#errors): `401`, `403`, `404`, `429` and `500`.

#### Update the user

`PATCH /v1/me` · session

Their display name and language.

Body: [`Profile`](#profile-object)

Returns `200` with [`EndUser`](#enduser-object). [Errors](#errors): `400`, `401`, `403`, `404`, `413`, `415`, `429`, `500` and `503`.

### Conversations

#### List conversations

`GET /v1/conversations` · secret key or session

A session gets the user's own, newest activity first. A secret key gets the workspace's, filtered by `status`, `contact_id` or `q`.

A session receives only its own public conversation view. A secret key without Origin receives the workspace view, including marked staff-only items. For server creation supply contact; for replies supply as.member.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `cursor` | query | string |  |
| `status` | query | `"open"` or `"closed"` or `"in_progress"` or `"waiting"` |  |
| `q` | query | string |  |
| `contact_id` | query | string |  |

Returns `200` with [`Conversations`](#conversations-object). [Errors](#errors): `400`, `401`, `403`, `404`, `429` and `500`.

#### Start a conversation

`POST /v1/conversations` · secret key or session

With its first message. A secret key opens it for a `contact`.

A session receives only its own public conversation view. A secret key without Origin receives the workspace view, including marked staff-only items. For server creation supply contact; for replies supply as.member.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `Idempotency-Key` | header | string |  |

Body: [`ConversationInput`](#conversationinput-object)

Returns `201` with [`Conversation`](#conversation-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `410`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Get a conversation

`GET /v1/conversations/{id}` · secret key or session

A session receives only its own public conversation view. A secret key without Origin receives the workspace view, including marked staff-only items. For server creation supply contact; for replies supply as.member.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |

Returns `200` with [`Conversation`](#conversation-object). [Errors](#errors): `401`, `403`, `404`, `429` and `500`.

#### List messages and events

`GET /v1/conversations/{id}/items` · secret key or session

Oldest first by default; `after` catches up. `last` opens at the newest items and `before` pages back from an item.

A session receives only its own public conversation view. A secret key without Origin receives the workspace view, including marked staff-only items. For server creation supply contact; for replies supply as.member.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `cursor` | query | string |  |
| `after` | query | string |  |
| `before` | query | string |  |
| `last` | query | integer (1–100) |  |

Returns `200` with [`Items`](#items-object). [Errors](#errors): `400`, `401`, `403`, `404`, `429` and `500`.

#### Send a message

`POST /v1/conversations/{id}/messages` · secret key or session

Markdown, with upload ids in `attachments`. A secret key replies `as` a team member.

A session receives only its own public conversation view. A secret key without Origin receives the workspace view, including marked staff-only items. For server creation supply contact; for replies supply as.member.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Body: [`MessageInput`](#messageinput-object)

Returns `201` with [`Message`](#message-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Mark as read

`POST /v1/conversations/{id}/read` · session

Everything up to `item_id`.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Body: [`Read`](#read-object)

Returns `200` with [`OK`](#ok-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Show typing

`POST /v1/conversations/{id}/typing` · session

The team sees the user typing for a few seconds.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Returns `200` with [`OK`](#ok-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Confirm an email verification

`POST /v1/conversations/{id}/verifications/{vid}/confirm` · session

The code the team's verification request sent to an address.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `vid` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Body: [`Confirm`](#confirm-object)

Returns `200` with [`Verification`](#verification-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Talk to a person

`POST /v1/conversations/{id}/escalate` · secret key or session

Hands the conversation to the team, with an optional `reason`. With a session, once per conversation; with a secret key, as the workspace.

A session receives only its own public conversation view. A secret key without Origin receives the workspace view, including marked staff-only items. For server creation supply contact; for replies supply as.member.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Body: [`EscalateInput`](#escalateinput-object)

Returns `200` with [`Conversation`](#conversation-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

### Uploads and realtime

#### Upload a file

`POST /v1/uploads` · session

`multipart/form-data` with one part named `file`. Attach the returned id to a message within 24 hours.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `Idempotency-Key` | header | string |  |

Body: `multipart/form-data`, [`MultipartFile`](#multipartfile-object)

Returns `201` with [`Upload`](#upload-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Get a realtime URL

`POST /v1/realtime` · session

A single-use WebSocket URL, valid for 1 minute.

Returns `200` with [`RealtimeTicket`](#realtimeticket-object). [Errors](#errors): `400`, `401`, `403`, `404`, `413`, `415`, `429`, `500` and `503`.

#### Connect to realtime

`GET /v1/realtime` · realtime ticket

Open the URL from the call above unchanged. Frames are `RealtimeEvent` JSON.

Returns `101` with [`RealtimeEvent`](#realtimeevent-object). [Errors](#errors): `401`, `403`, `404`, `426`, `429` and `500`.

### Server API

#### List contacts

`GET /v1/contacts` · secret key

Persistent contacts, not anonymous visitors. Search them with `q`.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `q` | query | string |  |
| `cursor` | query | string |  |

Returns `200` with [`Contacts`](#contacts-object). [Errors](#errors): `400`, `401`, `403`, `404`, `429` and `500`.

#### Get a contact

`GET /v1/contacts/{id}` · secret key

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |

Returns `200` with [`Contact`](#contact-object). [Errors](#errors): `401`, `403`, `404`, `429` and `500`.

#### Get a contact by your user id

`GET /v1/contacts/by-external-id/{external_id}` · secret key

The contact whose `external_id` is your user id.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `external_id` | path | string | yes |

Returns `200` with [`Contact`](#contact-object). [Errors](#errors): `401`, `403`, `404`, `429` and `500`.

#### Create or update a contact

`PUT /v1/contacts/by-external-id/{external_id}` · secret key

Keyed by your user id. The same email-linking rules as starting a session.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `external_id` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Body: [`ContactInput`](#contactinput-object)

Returns `200` with [`Contact`](#contact-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Erase a contact

`DELETE /v1/contacts/{id}` · secret key

Removes their identity, sessions and verification records. Their requests stay, written by a deleted user.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Returns `200` with [`OK`](#ok-object). [Errors](#errors): `401`, `403`, `404`, `409`, `422`, `429` and `500`.

#### Update a conversation

`PATCH /v1/conversations/{id}` · secret key

Status and close reason, assignee and labels.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Body: [`ConversationPatch`](#conversationpatch-object)

Returns `200` with [`Conversation`](#conversation-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

#### Ask to verify an email

`POST /v1/conversations/{id}/verifications` · secret key

Emails a code to the address; the requester enters it in the conversation.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |
| `Idempotency-Key` | header | string |  |

Body: [`VerificationInput`](#verificationinput-object)

Returns `201` with [`Verification`](#verification-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`, `429`, `500` and `503`.

### Approvals

#### Get the approval signing keys

`GET /v1/approvals/jwks.json` · none

The keys action services verify one workspace's approval tokens with (Ed25519). Name the workspace with `workspace`.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `workspace` | query | string | yes |

Returns `200` with [`JWKS`](#jwks-object). [Errors](#errors): `400`, `404`, `429` and `500`.

#### Consume an approval

`POST /v1/approvals/{id}/consume` · secret key

Marks an approved, unexpired approval used, once, before your service acts. See [Running support agents](https://blhp.app/docs/agents#6-verify-consume-act).

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |

Returns `200` with [`Approval`](#approval-object). [Errors](#errors): `400`, `401`, `403`, `404`, `409`, `410`, `413`, `415`, `429`, `500` and `503`.

#### List a conversation's approvals

`GET /v1/conversations/{id}/approvals` · secret key

Every approval agents asked for on it, with its state. Never tokens.

| Parameter | In | Type | Required |
| --- | --- | --- | --- |
| `id` | path | string | yes |

Returns `200` with [`Approvals`](#approvals-object). [Errors](#errors): `401`, `403`, `404`, `429` and `500`.

### Discovery

#### Get the OpenAPI document

`GET /v1/openapi.json` · none

This reference as OpenAPI 3.1.

Returns `200` with [`OpenAPI`](#openapi-object). [Errors](#errors): `404`, `429` and `500`.


### Objects

#### `Actor` object

One of:

- `type`: `"guest"`, `id`: string, `handle`: string, `name`: string or null
- `type`: `"member"`, `id`: string, `name`: string, `avatar_url`: string or null
- `type`: `"agent"`, `id`: string, `name`: string

#### `Approval` object

| Field | Type |
| --- | --- |
| `details_hash` | string |
| `id` | string |
| `issue_number` | integer |
| `action` | string |
| `summary` | string |
| `details` | object of number,boolean,string (up to 256 characters). At most 4096 UTF-8 bytes as JSON. |
| `state` | `"pending"` or `"approved"` or `"rejected"` or `"expired"` or `"used"` |
| `requested_by` | [`Actor`](#actor-object) |
| `decided_by` | [`Actor`](#actor-object) or null |
| `decided_at` | string (date-time) or null |
| `note` | string or null |
| `expires_at` | string (date-time) |
| `created_at` | string (date-time) |

#### `Approvals` object

array of [`Approval`](#approval-object)

#### `Confirm` object

| Field | Type |
| --- | --- |
| `code` | string |

#### `Contact` object

| Field | Type |
| --- | --- |
| `id` | string |
| `name` | string or null |
| `email` | string (email) or null |
| `external_id` | string or null |
| `verified_via` | `"host"` or `"email"` or `null` |
| `anonymous` | boolean |
| `locale` | `"en"` or `"es"` or `"pt-BR"` or `"fr"` or `"de"` or `"ja"` or `"zh-CN"` or `"ko"` |
| `unverified_email` | string or null |
| `created_at` | string (date-time) |
| `last_seen_at` | string (date-time) or null |

#### `ContactInput` object

| Field | Type | Required |
| --- | --- | --- |
| `email` | string |  |
| `email_verified` | boolean |  |
| `link_existing` | boolean |  |
| `name` | string |  |
| `locale` | `"en"` or `"es"` or `"pt-BR"` or `"fr"` or `"de"` or `"ja"` or `"zh-CN"` or `"ko"` |  |

#### `Contacts` object

| Field | Type |
| --- | --- |
| `data` | array of [`Contact`](#contact-object) |
| `next_cursor` | string or null |

#### `Conversation` object

| Field | Type | Required |
| --- | --- | --- |
| `with_person` | boolean | yes |
| `request_type` | { `id`, `name` } or null | yes |
| `fields` | object of string | yes |
| `effective_autonomy` | `"suggest"` or `"approve_actions"` or `"autonomous"` |  |
| `labels` | array of string |  |
| `contact_id` | string or null |  |
| `hidden` | boolean |  |
| `created_via` | `"api"` |  |
| `id` | string | yes |
| `number` | integer | yes |
| `subject` | string | yes |
| `status` | `"open"` or `"in_progress"` or `"waiting"` or `"closed"` | yes |
| `close_reason` | `"completed"` or `"not_planned"` or `"duplicate"` or null | yes |
| `created_at` | string (date-time) | yes |
| `updated_at` | string (date-time) | yes |
| `last_item` | [`Item`](#item-object) or null | yes |
| `unread` | integer | yes |
| `assignee` | [`Participant`](#participant-object) or null. Workspace view only; always null for end users. | yes |
| `verifications` | array of [`Verification`](#verification-object) | yes |

#### `ConversationInput` object

| Field | Type | Required |
| --- | --- | --- |
| `request_type` | string |  |
| `fields` | object of string |  |
| `subject` | string (up to 256 characters) |  |
| `message` | [`MessageInput`](#messageinput-object) | yes |
| `metadata_id` | string |  |
| `contact` | { `id` } or { `external_id` }. Required with a server key; not available to an end user. |  |

#### `ConversationPatch` object

| Field | Type | Required |
| --- | --- | --- |
| `status` | `"open"` or `"in_progress"` or `"waiting"` or `"closed"` |  |
| `close_reason` | `"completed"` or `"not_planned"` or `"duplicate"` or null |  |
| `assignee` | { `member` } or { `agent` } or null |  |
| `labels` | array of string |  |

#### `Conversations` object

| Field | Type |
| --- | --- |
| `data` | array of [`Conversation`](#conversation-object) |
| `next_cursor` | string or null |

#### `EmailStart` object

| Field | Type |
| --- | --- |
| `email` | string |
| `turnstile_token` | string |

#### `EmailVerify` object

| Field | Type |
| --- | --- |
| `email` | string |
| `code` | string |

#### `EndUser` object

| Field | Type |
| --- | --- |
| `id` | string |
| `name` | string or null |
| `email` | string (email) or null |
| `external_id` | string or null |
| `verified_via` | `"host"` or `"email"` or `null` |
| `anonymous` | boolean |
| `locale` | `"en"` or `"es"` or `"pt-BR"` or `"fr"` or `"de"` or `"ja"` or `"zh-CN"` or `"ko"` |

#### `Error` object

| Field | Type |
| --- | --- |
| `error` | { `code`, `message` } |

#### `EscalateInput` object

| Field | Type | Required |
| --- | --- | --- |
| `reason` | string (up to 1000 characters) |  |

#### `Event` object

One of:

- `type`: `"escalated"`
- `type`: `"workspace_event"`, `action`: string, `data`: object
- `type`: `"status_changed"`, `status`: `"open"` or `"in_progress"` or `"waiting"` or `"closed"`, `close_reason`: `"completed"` or `"not_planned"` or `"duplicate"` or null
- `type`: `"assigned"`, `assignee`: [`Participant`](#participant-object) or null
- `type`: `"verification_requested"`, `verification_id`: string
- `type`: `"verified"`, `verification_id`: string

#### `Item` object

| Field | Type | Required |
| --- | --- | --- |
| `internal` | boolean |  |
| `hidden` | boolean |  |
| `held` | boolean |  |

#### `Items` object

| Field | Type |
| --- | --- |
| `data` | array of [`Item`](#item-object) |
| `next_cursor` | string or null |

#### `JWKS` object

| Field | Type |
| --- | --- |
| `keys` | array of { `kid`, `kty`, `crv`, `alg`, `use`, `x` } |

#### `Message` object

| Field | Type | Required |
| --- | --- | --- |
| `held` | boolean |  |
| `internal` | boolean |  |
| `id` | string | yes |
| `conversation_id` | string | yes |
| `author` | [`Participant`](#participant-object) | yes |
| `body` | string | yes |
| `attachments` | array of [`Upload`](#upload-object) | yes |
| `created_at` | string (date-time) | yes |
| `edited_at` | string (date-time) or null | yes |

#### `MessageInput` object

| Field | Type | Required |
| --- | --- | --- |
| `body` | string (up to 65536 characters) | yes |
| `attachments` | array of string |  |
| `internal` | boolean |  |
| `as` | { `member` }. Required with a server key; not available to an end user. |  |

#### `MultipartFile` object

| Field | Type |
| --- | --- |
| `file` | string (binary) |

#### `OK` object

| Field | Type |
| --- | --- |
| `ok` | `true` |

#### `OpenAPI` object

| Field | Type |
| --- | --- |
| `openapi` | `"3.1.0"` |
| `info` | object |
| `paths` | object |

#### `Participant` object

One of:

- `type`: `"user"`, `id`: string, `name`: string or null
- `type`: `"team"`, `id`: string, `name`: string, `avatar_url`: string or null
- `type`: `"agent"`, `id`: string, `name`: string, `avatar_url`: string or null

#### `Profile` object

| Field | Type | Required |
| --- | --- | --- |
| `name` | string |  |
| `locale` | `"en"` or `"es"` or `"pt-BR"` or `"fr"` or `"de"` or `"ja"` or `"zh-CN"` or `"ko"` |  |

#### `Read` object

| Field | Type |
| --- | --- |
| `item_id` | string |

#### `RealtimeEvent` object

One of:

- `type`: `"item.created"`, `conversation_id`: string, `item`: [`Item`](#item-object)
- `type`: `"message.updated"`, `conversation_id`: string, `message`: [`Message`](#message-object)
- `type`: `"conversation.updated"`, `conversation`: [`Conversation`](#conversation-object)
- `type`: `"typing"`, `conversation_id`: string, `participant`: [`Participant`](#participant-object)
- `type`: `"activity"`, `conversation_id`: string, `participant`: [`Participant`](#participant-object), `text`: string or null

#### `RealtimeTicket` object

| Field | Type |
| --- | --- |
| `url` | string (uri) |
| `expires_at` | string (date-time) |

#### `RequestField` object

| Field | Type | Required |
| --- | --- | --- |
| `key` | string | yes |
| `label` | string | yes |
| `kind` | `"text"` or `"textarea"` or `"email"` or `"url"` or `"number"` or `"select"` | yes |
| `required` | boolean | yes |
| `options` | array of string |  |
| `help` | string |  |
| `verify` | boolean |  |

#### `RequestForm` object

| Field | Type |
| --- | --- |
| `id` | string |
| `name` | string |
| `description` | string |
| `fields` | array of [`RequestField`](#requestfield-object) |
| `require_verification` | boolean |

#### `Session` object

| Field | Type |
| --- | --- |
| `token` | string |
| `expires_at` | string (date-time) |
| `user` | [`EndUser`](#enduser-object) |

#### `SessionInput` object

One of:

- `user`: { `id`, `email`, `email_verified`, `link_existing`, `name`, `locale` }
- `identity_token`: string
- `anonymous`: `true`, `turnstile_token`: string

#### `Upload` object

| Field | Type |
| --- | --- |
| `id` | string |
| `name` | string |
| `type` | string |
| `size` | integer |
| `url` | string |

#### `Verification` object

| Field | Type |
| --- | --- |
| `id` | string |
| `email` | string |
| `state` | `"pending"` or `"verified"` or `"expired"` or `"failed"` |
| `requested_by` | [`Actor`](#actor-object) or { `type`, `id`, `name`, `avatar_url` } |
| `created_at` | string (date-time) |
| `verified_at` | string (date-time) or null |

#### `VerificationInput` object

| Field | Type |
| --- | --- |
| `email` | string |

#### `Workspace` object

| Field | Type |
| --- | --- |
| `request_types` | array of [`RequestForm`](#requestform-object) |
| `name` | string |
| `icon_url` | string or null |
| `allow_anonymous` | boolean |
| `require_verified_email` | boolean |
| `turnstile_site_key` | string or null |
| `brand` | { `accent` } |

