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:
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"}}'
{
"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:
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."}}'
{
"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/v1too, for examplehttps://acme.blhp.app/v1. - Format: JSON in and out, with times in ISO 8601. Lists are pages:
{ "data": [...], "next_cursor": "…" }. Passnext_cursorback ascursorfor the next page; it'snullon 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.jsondescribes 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.
curl -X POST https://api.blhp.app/v1/session/refresh \
-H "Authorization: Bearer $SESSION"
{
"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:
curl "https://api.blhp.app/v1/conversations/$CONVERSATION/items?last=2" \
-H "Authorization: Bearer $SESSION"
{
"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:
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:
{
"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:
{
"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 emailorarea: required, names the answer to fix. - Types with
require_verification, and answers to email fields withverify, need a verified person, or the request fails with401 contact_required. Averifyemail field also sends that address a code, which shows up in the conversation'sverifications. - 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:
- Get a fresh URL and open the socket, buffering its frames.
- For each conversation you show, page
items?after=<last item id you have>and merge by item id. - 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:
curl https://api.blhp.app/v1/me \
-H "Authorization: Bearer $ENDED_SESSION_TOKEN"
{ "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:
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"}'
{
"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/conversationswithcontact: { "external_id": "user_9001" }(or{ "id": "ct_…" }). It's markedcreated_via: "api". - Reply as a team member:
POST /v1/conversations/:id/messageswithas: { "member": "agent@yourcompany.com" }, the email of someone who has joined the workspace. Add"internal": truefor a team-only note. - Triage:
PATCH /v1/conversations/:idwith any ofstatus,close_reason,assignee({ "member": "<email>" },{ "agent": "<agent id>" }ornull) andlabels(names). - Ask to verify an email:
POST /v1/conversations/:id/verificationswith{ 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:
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."}'
{
"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:
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"}'
{
"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 nulltype:"member",id: string,name: string,avatar_url: string or nulltype:"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: objecttype:"status_changed",status:"open"or"in_progress"or"waiting"or"closed",close_reason:"completed"or"not_planned"or"duplicate"or nulltype:"assigned",assignee:Participantor nulltype:"verification_requested",verification_id: stringtype:"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 nulltype:"team",id: string,name: string,avatar_url: string or nulltype:"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:Itemtype:"message.updated",conversation_id: string,message:Messagetype:"conversation.updated",conversation:Conversationtype:"typing",conversation_id: string,participant:Participanttype:"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: stringanonymous: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 } |