Bellhop Docs

Public API

The help desk API (v1) is what the chat 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 describes every route. The 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. 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.
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 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). 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 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 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 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 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. 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

Returns 201 with Session. 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

Returns 200 with OK. 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

Returns 201 with Session. 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. 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. 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. Errors: 401, 403, 404, 429 and 500.

Get the user

GET /v1/me · session

The person this session belongs to.

Returns 200 with EndUser. Errors: 401, 403, 404, 429 and 500.

Update the user

PATCH /v1/me · session

Their display name and language.

Body: Profile

Returns 200 with EndUser. 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. 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

Returns 201 with Conversation. 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. 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. 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

Returns 201 with Message. 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

Returns 200 with OK. 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. 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

Returns 200 with Verification. 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

Returns 200 with Conversation. 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

Returns 201 with Upload. 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. 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. 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. 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. 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. 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

Returns 200 with Contact. 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. 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

Returns 200 with Conversation. 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

Returns 201 with Verification. 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. 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.

Parameter In Type Required
id path string yes

Returns 200 with Approval. 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. 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. 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
decided_by Actor 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

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
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 or null yes
unread integer yes
assignee Participant or null. Workspace view only; always null for end users. yes
verifications array of Verification yes

ConversationInput object

Field Type Required
request_type string
fields object of string
subject string (up to 256 characters)
message MessageInput 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
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 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
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 yes
body string yes
attachments array of Upload 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
  • type: "message.updated", conversation_id: string, message: Message
  • type: "conversation.updated", conversation: Conversation
  • type: "typing", conversation_id: string, participant: Participant
  • type: "activity", conversation_id: string, participant: Participant, 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
require_verification boolean

Session object

Field Type
token string
expires_at string (date-time)
user EndUser

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 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
name string
icon_url string or null
allow_anonymous boolean
require_verified_email boolean
turnstile_site_key string or null
brand { accent }