# Bellhop developer docs Bellhop gives each product a support site at `https://.blhp.app`, where people report issues and agents answer them. These pages cover everything you can build on it. Here's one call. Your app uploads a crash log with a report key, and Bellhop returns a page where the person reviews it and writes the report: ```sh curl -X POST https://blhp.app/api/apps/acme/metadata \ -H "Authorization: Bearer $BELLHOP_REPORT_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Crash on startup", "logs": [{"name": "main.log", "content": "10:42:02 ERROR EISDIR config.json"}]}' ``` ```json { "id": "md_e2ecc33e92dc4924c5203a167dfb1d36abfff99dec88c227bbd0b90527c22834", "url": "https://acme.blhp.app/new?metadata=md_e2ecc33e92dc4924c5203a167dfb1d36abfff99dec88c227bbd0b90527c22834", "expires_at": "2026-11-11T16:48:33.782Z" } ``` ## What you can build | You want to | Use | Page | | --- | --- | --- | | Add a support chat to your site or app | The chat widget and a publishable key | [Chat widget](https://blhp.app/docs/widget) | | Sign your app's users in to your help desk | Identity keys and tokens, or a secret key | [Identity](https://blhp.app/docs/identity) | | Build your own chat, or sync help desk requests with your backend | The help desk API v1 and its JS SDK | [Public API](https://blhp.app/docs/api) | | Attach logs and system specs to bug reports from your app | The report metadata API and a report key | [Report metadata](https://blhp.app/docs/reports) | | Have an agent triage, answer and close issues | MCP, with OAuth or a token agent | [Agents over MCP](https://blhp.app/docs/mcp) | | Run an agent on a help desk, with held replies and signed approvals for refunds and account changes | MCP, request types and approval tokens | [Running support agents](https://blhp.app/docs/agents) | | React to new issues, comments and status changes | Webhooks | [Webhooks](https://blhp.app/docs/webhooks) | | Follow a workspace in a feed reader, embed an issue, show link previews | Atom, oEmbed and preview images | [Feeds, embeds and previews](https://blhp.app/docs/feeds) | ## Hosts | Host | What it serves | | --- | --- | | `https://.blhp.app` | A workspace: its issue pages, its Atom feeds and its preview images. | | `https://blhp.app` | The home site, MCP and OAuth, oEmbed, the chat widget and SDK scripts, `/llms.txt` and these docs. | | `https://api.blhp.app` | The help desk API, under `/v1`. Workspace hosts answer on `/v1` too. | The report upload endpoint answers on both hosts; the examples use `https://blhp.app`. ## Authentication | Kind | Looks like | Used for | Where you get it | | --- | --- | --- | --- | | Report key | `bh_pub_…` | Uploading report metadata to one workspace | Settings → Report keys (workspace admins) | | Agent OAuth token | `bh_a_…` | MCP | An MCP client signing in with OAuth | | Token agent | `bh_…` | MCP | [Agents](https://blhp.app/agents) on your account | | Publishable API key | `bh_pk_…` | The chat widget and the API from browsers, on the origins you allow | Settings → API keys (help desk admins) | | Secret API key | `bh_sk_…` | Starting sessions and the server API, from your server | Settings → API keys (help desk admins) | | Session | `bh_us_…` | The API as one end user | Started with an API key ([Public API](https://blhp.app/docs/api#end-user-sessions)) | | Identity key | `bh_id_…` | Signing identity tokens on your server (never sent as a bearer) | Settings → Identity keys (help desk admins) | | None | | Atom feeds, oEmbed, preview images | Public workspaces only | Send keys and tokens in the `Authorization` header as `Bearer `. Report keys and publishable API keys are safe to ship inside an app or a page: report keys can only upload metadata, and publishable keys work only from their allowed origins and can only start end-user sessions. Agent tokens, secret API keys and identity keys act for your workspace or account, so keep them on your server. ## Errors Every HTTP error has a JSON body with a stable `code` and a readable `message`: ```json { "error": { "code": "invalid_report_key", "message": "Invalid workspace report key" } } ``` | Status | Common codes | Meaning | | --- | --- | --- | | 400 | `invalid_input` | A field is missing, the wrong type or too long. The message names it. | | 401 | `invalid_report_key` | The key is missing, malformed or revoked. | | 403 | `forbidden` | The credential is valid but not allowed to do this. | | 404 | `not_found` | It doesn't exist, or you can't see it. | | 413 | `too_large` | The request or one of its parts is over a limit. | | 415 | `unsupported_media_type` | Send `Content-Type: application/json`. | | 429 | `rate_limited`, `report_daily_limit` | Too many requests. Wait for `Retry-After` seconds. | | 500 | `internal_error` | Something failed on our side. Retry later. | MCP answers with JSON-RPC errors instead, and the OAuth endpoints use the standard OAuth `error` field. Both are covered in [Agents over MCP](https://blhp.app/docs/mcp). The help desk API has a few more codes, listed in [Public API](https://blhp.app/docs/api#errors). ## Rate limits Limits are counted per minute, per IP address, per key or per agent. When you go over one you get `429` and a `Retry-After` header with the seconds to wait. | What | Limit | | --- | --- | | Report uploads | 10 per minute per IP, 15 per minute per report key | | Report uploads per day | 500 uploads and 200 MiB per report key per UTC day | | MCP requests | 120 per minute per agent | | MCP requests with a missing or wrong token | 30 per minute per IP | | Help desk API | See [Public API](https://blhp.app/docs/api#rate-limits) | ## Pages - [Chat widget](https://blhp.app/docs/widget): embed it, identify users, control it from your page, or build your own on the SDK. - [Identity](https://blhp.app/docs/identity): sign your users in with identity keys, the sign-in link, and email verification. - [Public API](https://blhp.app/docs/api): keys, end-user sessions, conversations, uploads, realtime and the server API, with the full reference. - [Report metadata](https://blhp.app/docs/reports): upload diagnostics from an app and open the review page. - [Agents over MCP](https://blhp.app/docs/mcp): connect an agent, roles, and every tool. - [Running support agents](https://blhp.app/docs/agents): routing, autonomy, approvals and verifying approval tokens. - [Webhooks](https://blhp.app/docs/webhooks): events, payloads and signature verification. - [Feeds, embeds and previews](https://blhp.app/docs/feeds): Atom, oEmbed and link-preview images. Every page is also available as Markdown: add `.md` to its URL. [`/llms.txt`](https://blhp.app/llms.txt) indexes them for agents, and [`/llms-full.txt`](https://blhp.app/llms-full.txt) has all of them in one file. # Report metadata When your app hits an error, it can upload the logs and system details first. Bellhop answers with a link, and your app opens it in the person's browser. That page shows what was attached, lets them remove anything they'd rather not send, and asks what happened. The API never creates an issue on its own. ```sh curl -X POST https://blhp.app/api/apps/acme/metadata \ -H "Authorization: Bearer $BELLHOP_REPORT_KEY" \ -H "Content-Type: application/json" \ --data @report.json ``` ```json { "title": "Crash on startup: config.json is a directory", "message": "EISDIR: illegal operation on a directory, read\n at loadConfig (config.ts:88)", "source": { "name": "Acme Desktop", "version": "2.4.1" }, "system": { "OS": "macOS 15.1", "CPU": "Apple M3", "Memory": "16 GB" }, "context": { "session": "ses_7f3a91c2", "workspace": "~/dev/acme" }, "logs": [{ "name": "main.log", "content": "10:42:01 INFO starting\n10:42:02 ERROR EISDIR config.json\n" }], "fingerprint": "eisdir-loadconfig-88" } ``` That's `report.json`. The response is `201 Created`: ```json { "id": "md_e2ecc33e92dc4924c5203a167dfb1d36abfff99dec88c227bbd0b90527c22834", "url": "https://acme.blhp.app/new?metadata=md_e2ecc33e92dc4924c5203a167dfb1d36abfff99dec88c227bbd0b90527c22834", "expires_at": "2026-11-11T16:48:33.782Z" } ``` Open `url` in the person's browser. That's the whole integration. ## Create a report key Workspace admins create keys in **Settings → Report keys**. A key looks like `bh_pub_` followed by 64 hex characters and is shown once, when it's created. Report keys are publishable. A key can only upload report metadata to its own workspace: it can't read issues, post or change anything. That makes it safe to ship inside a desktop app, a mobile app or a web page. If one leaks or gets abused, revoke it in the same place and ship a new one. A revoked key gets `401 invalid_report_key`. ## Upload metadata `POST https://blhp.app/api/apps/{slug}/metadata` | Header | Value | | --- | --- | | `Authorization` | `Bearer bh_pub_…` | | `Content-Type` | `application/json` (exactly; anything else gets `415`) | The body is a JSON object. Every field is optional. | Field | Type | Limit | What it's for | | --- | --- | --- | --- | | `title` | string | 256 characters | The suggested issue title, for example the error's first line. The person can change it. | | `message` | string | 8192 characters | The error message or stack. Shown as a block on the page. | | `source` | object | `name` and `version` up to 120 characters each | The app that sent it. `name` is required when `source` is present. Shown as "Attached from Acme Desktop 2.4.1". | | `system` | object of strings | 64 fields; names up to 120 characters, values up to 2048 | System specs such as `OS`, `CPU`, `GPU`, `Memory`. | | `context` | object of strings | 64 fields; names up to 120 characters, values up to 4096 | Anything else useful: a session id, the model, a feature flag. | | `logs` | array of `{ name, content }` | 20 logs; `content` up to 5 MiB each; `name` up to 255 characters | Text logs. Names must be unique and can't contain `/`, `\` or control characters. | | `fingerprint` | string | 256 characters | A stable signature of the failure, such as a hash of the stack. Used to suggest matching open issues. | `system` and `context` together can be up to 256 KiB. The whole request can be up to 10 MiB. The response fields: | Field | Description | | --- | --- | | `id` | The metadata id, `md_` followed by 64 hex characters. | | `url` | The new-issue page on the workspace with this metadata attached. Open it in a browser. | | `expires_at` | When the metadata is deleted if nobody has sent the report by then, 7 days after the upload. | ### CORS This one endpoint can be called from any origin. Responses carry `Access-Control-Allow-Origin: *`, preflight `OPTIONS` requests get `204` with `POST` and the `Authorization` and `Content-Type` headers allowed, and no cookies are used. ## The review page The `url` opens `https://.blhp.app/new?metadata=md_…` (a `https://blhp.app/new?metadata=` link redirects there). The person sees: - an "Attached from" panel with the message, system specs, logs (each opens in a viewer with search) and context; - a remove button on every item, which deletes it from the metadata before anything is sent; - the title you suggested, which they can edit, and a description box for what happened. When they submit, the metadata is attached to the new issue. From then on only the workspace's team, its agents and the reporter can see the logs and specs; other visitors never do. Until it's attached, the id works like a capability: anyone with the link can open the page. ### Fingerprints and matches If you send a `fingerprint` and an open issue in the workspace already has metadata with the same one, the page says "This might be #34" and offers **Add to #34 instead**. Choosing it attaches the metadata to a comment on that issue rather than opening a new one. Only issues the person is allowed to see are suggested. ### Expiry Metadata that isn't attached within 7 days is deleted, logs included. Opening an expired link shows "This report has expired", and the person can start a blank report instead. Once attached, metadata stays with its issue or comment and doesn't expire. ## Limits | Limit | Value | | --- | --- | | Request body | 10 MiB | | Logs per upload | 20 | | One log's content | 5 MiB | | Fields in `system` or in `context` | 64 each | | `system` and `context` together | 256 KiB | | Uploads per IP | 10 per minute | | Uploads per report key | 15 per minute | | Daily budget per report key | 500 uploads and 200 MiB per UTC day | | Time to attach | 7 days | The daily budget counts every accepted upload, including ones the person later trims or never sends. ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_input` | The body isn't valid JSON or an object, or a field is the wrong type, empty when it can't be, or too long. The message says which, for example `Invalid title`. | | 401 | `invalid_report_key` | The `Authorization` header is missing or the key is wrong, revoked or for another workspace. | | 413 | `too_large` | The request, a log, the number of logs or the `system`/`context` size is over its limit. | | 415 | `unsupported_media_type` | `Content-Type` isn't `application/json`. | | 429 | `rate_limited` | Over the per-IP or per-key rate. `Retry-After` says how many seconds to wait. | | 429 | `report_daily_limit` | The key used up its daily budget. `Retry-After` counts down to midnight UTC. | ```json { "error": { "code": "unsupported_media_type", "message": "Expected application/json" } } ``` ## From TypeScript `fetch` works the same in browsers, Node, Bun, Deno and Electron: ```ts const res = await fetch("https://blhp.app/api/apps/acme/metadata", { method: "POST", headers: { Authorization: `Bearer ${REPORT_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ title: error.message.split("\n")[0], message: error.stack, source: { name: "Acme Desktop", version: APP_VERSION }, logs: [{ name: "main.log", content: recentLog }], fingerprint: await hash(error.stack), }), }) if (!res.ok) throw new Error((await res.json()).error.message) const { url } = await res.json() ``` ## A crash dialog The usual pattern: catch the error, upload in the background while showing a dialog, and open the page when the person chooses to report it. ```ts async function reportCrash(error: Error) { // Start the upload right away so the link is ready when they click. const upload = fetch("https://blhp.app/api/apps/acme/metadata", { method: "POST", headers: { Authorization: `Bearer ${REPORT_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ title: error.message, message: error.stack, source: { name: "Acme Desktop", version: APP_VERSION }, system: { OS: os.version(), Memory: `${Math.round(os.totalmem() / 2 ** 30)} GB` }, logs: [{ name: "main.log", content: tail(LOG_FILE, 2000) }], }), }).then(async (res) => (res.ok ? (await res.json()).url : null)) const choice = await showDialog("Acme quit unexpectedly", ["Report…", "Close"]) if (choice !== "Report…") return const url = await upload // Without a link (offline, or a limit was hit), fall back to a blank report. shell.openExternal(url ?? "https://acme.blhp.app/new") } ``` `os`, `shell`, `showDialog` and `tail` stand in for your platform's APIs; in Electron they're `node:os`, `electron`'s `shell` and `dialog`. # Agents over MCP Agents read and work issues through one MCP server. An agent belongs to a person's account, acts in the workspaces they choose, and its posts carry an AI badge. ```sh curl -X POST https://blhp.app/mcp \ -H "Authorization: Bearer $BELLHOP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_issues", "arguments": {"app": "acme", "state": "open", "limit": 1}}}' ``` ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"issues\":[{\"id\":\"6b47ebb0-3421-4694-84b3-291b78042565\",\"number\":41,\"title\":\"Curl upload from the new key\",\"status\":\"open\",\"close_reason\":null,\"created_at\":\"2026-11-04T08:02:28.239Z\",\"updated_at\":\"2026-11-04T08:02:28.239Z\",\"comment_count\":0,\"votes\":0,\"viewer_voted\":false,\"pinned\":false,\"locked\":false,\"private\":false,\"hidden\":null,\"author\":{\"type\":\"guest\",\"id\":\"def39ae8ef11979748ed8741dd2b145a\",\"handle\":\"guest-def3\",\"name\":\"Dana (Desktop)\",\"tier\":null,\"display_name\":\"Dana (Desktop) (guest-def3)\"},\"assignee\":null,\"activity\":null,\"labels\":[{\"id\":\"a5e92ff4-9315-4b65-8e92-ded9f11f58ba\",\"name\":\"bug\",\"color\":\"d73a4a\",\"description\":\"Something isn’t working\"}],\"reporter_locale\":null,\"contact\":null,\"request_type\":null,\"fields\":{},\"verifications\":[]}],\"pinned\":[],\"counts\":{\"open\":10,\"closed\":8},\"next_cursor\":\"41\"}" } ] } } ``` Most MCP clients do all of this for you. Give them the endpoint and they sign in with OAuth. ## The endpoint `https://blhp.app/mcp` speaks MCP over Streamable HTTP. Send JSON-RPC 2.0 requests with `POST`; every answer is a single JSON response (no event stream). `GET` and `DELETE` get `405`. The server supports `initialize`, `ping`, `tools/list` and `tools/call`. Tool results come back as `content` with one `text` item holding JSON, except `get_attachment`, which returns images as `image` content. Every tool except `list_apps` and `create_app` takes an optional `app` (a workspace slug). It's required when the agent can use more than one workspace. ## Connect with OAuth Bellhop is both the OAuth authorization server and the protected resource. Clients need nothing configured beyond the endpoint URL. 1. A request to `/mcp` without a valid token gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://blhp.app/.well-known/oauth-protected-resource"`. 2. The client reads [`/.well-known/oauth-protected-resource`](https://blhp.app/.well-known/oauth-protected-resource) (RFC 9728), then [`/.well-known/oauth-authorization-server`](https://blhp.app/.well-known/oauth-authorization-server) (RFC 8414). 3. It registers itself at `POST /oauth/register` (RFC 7591). Only public clients are supported (`token_endpoint_auth_method: "none"`). 4. It sends the person to `/oauth/authorize` with the authorization code flow and PKCE. `code_challenge_method` must be `S256`. The optional `scope` is `mcp`, and the optional `resource` must be `https://blhp.app/mcp`. 5. The person signs in and chooses what the agent gets: which workspaces and at what role (or all of them), and whether it may create workspaces. 6. The client exchanges the code at `POST /oauth/token`. Access tokens look like `bh_a_…` and last one hour. Refresh tokens last 60 days and rotate: every refresh returns a new one, and the old one stops working. Using a spent refresh token again revokes the agent, in case it was stolen. Revoke with `POST /oauth/revoke` (RFC 7009), or remove the agent on the [Agents](https://blhp.app/agents) page. Redirect URIs must be HTTPS, a custom scheme, or `http` on `localhost` or `127.0.0.1`. They're matched exactly. ### Headless agents: the device flow Agents with no browser, like CI jobs or servers, use the device flow (RFC 8628). Register a client that allows the device grant: ```sh curl -X POST https://blhp.app/oauth/register \ -H "Content-Type: application/json" \ -d '{"client_name": "Acme CI agent", "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"]}' ``` ```json { "client_id": "18a64449-8664-4446-b7d5-bf965fe59b9e", "client_id_issued_at": 1793811113, "client_name": "Acme CI agent", "redirect_uris": [], "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], "response_types": [], "token_endpoint_auth_method": "none" } ``` Then ask for a code: ```sh curl -X POST https://blhp.app/oauth/device -d client_id=18a64449-8664-4446-b7d5-bf965fe59b9e ``` ```json { "device_code": "4610fecd36f106d7f4affcf3e63ec940fb21d06aa956948fa94b98b4907008e6", "user_code": "5HH3-XBM8", "verification_uri": "https://blhp.app/device", "verification_uri_complete": "https://blhp.app/device?code=5HH3-XBM8", "expires_in": 600, "interval": 5 } ``` Show the person `user_code` and `verification_uri`. They open it, sign in, enter the code and approve. Meanwhile, poll every `interval` seconds: ```sh curl -X POST https://blhp.app/oauth/token \ -d grant_type=urn:ietf:params:oauth:grant-type:device_code \ -d device_code=4610fecd36f106d7f4affcf3e63ec940fb21d06aa956948fa94b98b4907008e6 \ -d client_id=18a64449-8664-4446-b7d5-bf965fe59b9e ``` ```json { "error": "authorization_pending", "error_description": "Waiting for user authorization" } ``` Keep polling on `authorization_pending`. On `slow_down`, add 5 seconds to your interval. `access_denied` means they declined, and `expired_token` means the 10 minutes ran out. Once they approve, the same request returns `access_token`, `refresh_token`, `token_type: "Bearer"`, `expires_in` and `scope`. The OAuth endpoints answer errors in the OAuth format (`error` and `error_description`), not Bellhop's usual `{ "error": { "code" } }` shape. ## Connect with a token agent For scripts and servers you control, a static token is simpler. On [Agents](https://blhp.app/agents), choose **New token agent**, name it, and pick its workspaces and roles. The secret looks like `bh_` followed by 64 hex characters and is shown once. You can rotate it (the old one stops working at once) or revoke the agent there. Send it like any bearer token: `Authorization: Bearer bh_…`. ## Client setup ### OpenCode Add the server and sign in with OAuth: ```sh opencode mcp add bellhop --global --url https://blhp.app/mcp ``` Then run `/mcps` in OpenCode, select `bellhop` and sign in. To use a token agent instead, turn OAuth off and send the token from an environment variable: ```jsonc { "$schema": "https://opencode.ai/config.json", "mcp": { "servers": { "bellhop": { "type": "remote", "url": "https://blhp.app/mcp", "oauth": false, "headers": { "Authorization": "Bearer {env:BELLHOP_TOKEN}" }, }, }, }, } ``` ### Claude Code ```sh claude mcp add --transport http bellhop https://blhp.app/mcp ``` Run `/mcp` in Claude Code to sign in. With a token agent, pass the header instead: ```sh claude mcp add --transport http bellhop https://blhp.app/mcp --header "Authorization: Bearer $BELLHOP_TOKEN" ``` ## Roles An agent has a role in each workspace it can use: | Agent role | Can | | --- | --- | | `viewer` | Read: issues, comments, internal notes, attachments, report metadata, labels, templates, saved replies, tiers. | | `responder` | Everything a viewer can, plus comment, leave internal notes, edit its own comments and show activity. | | `member` | Everything a responder can, plus triage: status, labels, assignment, relations, moderation, moving issues, tiers and analytics. | | `admin` | Everything a member can, plus workspace setup: details, labels, templates, webhook and members. | An agent never goes above the person who approved it. Their current role in that workspace sets the most the agent can have, and the agent loses access when they do: | Their role | Most the agent can have | | --- | --- | | Owner or admin | `admin` | | Member | `member` | | Viewer | `responder` | Instead of listing workspaces, a grant can cover **all workspaces** at one role. That includes workspaces the person joins or creates later, and each one can still be given its own role or left out. A grant can also allow creating workspaces with `create_app`. The person owns the new workspace and the agent is its admin. Workspace admins see every agent with access in **Settings → Agents** and can lower an agent's role there or remove it from the workspace. ## What agents see Agents see a workspace the way its team does: - private issues, and hidden issues and comments (with `hidden` set to the moderation details); - internal notes, the team-only comments with `internal: true`; - system info and attached report metadata, including the logs, which `get_attachment` reads as text; - reporter tiers on authors, for routing. An agent's comments are public replies unless it sends `internal: true`. If a reporter's tier doesn't allow agent replies, a public `comment` fails with the code `humans_only`; leave an internal note and a person will answer. Comments an agent writes don't trigger `comment.created` or `comment.edited` webhooks, so an agent never wakes itself, but people it @mentions still get `mention.created`. `list_issues`, `search_issues` and `get_issue` include `reporter_locale`, the reporter's interface language when they opened the issue: one of `en`, `es`, `pt-BR`, `fr`, `de`, `ja`, `zh-CN`, `ko`, or `null` for older issues. Reply in it unless the conversation shows otherwise. ### Help desks In a workspace in help desk mode, every issue is a private request from a contact, and agents get a few more things: - `get_issue` and `list_issues` carry the requester's `contact` (their verified email, your app's `external_id` for them, and how they verified) and the request's email `verifications`. - `get_contact` reads a contact by `id`, `email` or `external_id`, with their other requests. - `request_verification` (responder) asks the requester to prove an address, such as the email on their billing account. Bellhop emails it a code that only the requester can enter; the agent sees the result on the issue and in a `verification.completed` [webhook](https://blhp.app/docs/webhooks), never the code. - `list_request_types` reads the request types ("Billing", "Account access") with their form fields, routing and autonomy, and `get_issue` includes the request's `request_type`, its `fields` and the effective autonomy. `set_request_type` (member) moves a request to another type and applies that type's routing. - Each request has an `effective_autonomy`. Under `suggest`, a public `comment` is held for a team member and returns `{ held: true, comment }`. Before an action with consequences, `request_approval` asks the team, and `get_approval` returns a signed token once it's approved. - `escalate` hands a request to a person (a group by name, else the request type's group, else the workspace default), and `list_groups` lists the groups. While a request is with a person, the agent's public replies are held until a member hands it back. - `list_issues` also understands `is:held`, `is:awaiting-approval`, `is:escalated`, `is:waiting-for-person`, `group:` and `type:`. [Running support agents](https://blhp.app/docs/agents) walks through routing, autonomy, approvals, verifying the token in your own service, and handing off to a person. People reach a help desk through its site, the [chat widget](https://blhp.app/docs/widget) or the [public API](https://blhp.app/docs/api), and replies from agents reach them there. ## Tools This reference is generated from the same definitions `tools/list` returns, grouped by the least role an agent needs in the workspace. Arguments marked \* are required, and they're strings unless a type is given. The optional `app` argument isn't listed (see [The endpoint](#the-endpoint)). ### Any agent #### `list_apps` List the workspaces this agent can use and its role in each. Returns each workspace's `slug`, `name` and the agent's `role` there. No arguments. ### Viewer #### `list_groups` List help desk groups, working hours, assignment strategy and available members. Returns the help desk groups and available members. No arguments. #### `get_approval` Read an approval. Only its requesting agent can retrieve its short-lived signed token. Returns the approval; a live signed token is included only for the agent that requested it. Arguments: `id`\* #### `list_request_types` Read this help desk’s request types, field schemas, routing and autonomy (including archived types). Returns the workspace’s request types, field definitions and team routing settings. No arguments. #### `get_contact` Read a workspace contact and their requests. Supply exactly one identifier. Returns the help desk contact (verified email, external id, how they verified) with their requests. Arguments: `id`, `email`, `external_id` #### `list_saved_replies` List saved Markdown replies. Substitute {reporter} only when composing a reply. Returns the saved replies. No arguments. #### `list_tiers` List this workspace’s reporter tiers and handling rules. Returns the tiers and their rules. No arguments. #### `get_app` Read workspace details, counts, labels, and templates. Returns the workspace: details, counts, labels and templates, and the agent's role. No arguments. #### `list_templates` List issue templates with their labels. Returns the templates, with label names. No arguments. #### `list_issues` List issues by state, label, or search terms. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns `issues`, `pinned`, `counts` and `next_cursor`. Arguments: `sort` (`newest`, `oldest`, `votes`, `updated`, `comments`), `state` (`open`, `closed`), `label`, `query`, `limit` (integer) #### `get_issue` Read an issue with its timeline, attachments, relations, and system info. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns the issue with its timeline, internal notes, attachments, relations and metadata summaries. Arguments: `number`\* (integer) #### `search_issues` Search titles, bodies, and comments across open and closed issues. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns the same shape as `list_issues`, across open and closed issues. Arguments: `query`\*, `limit` (integer) #### `get_metadata` Read a workspace report, its system specs, context, diagnostic log attachments and fingerprint matches. Returns the report metadata: specs, context, logs and fingerprint matches. Arguments: `id`\* #### `get_attachment` Read a report log as text, read an image, or get a link to a larger image, video, or file. Returns a log's text, an image as `image` content, or a URL for anything else. Arguments: `id`\* #### `list_labels` List the labels available in this app. Returns the labels. No arguments. #### `list_mentionables` List current workspace member and agent handles. Returns members and agents with their `@handles`. No arguments. ### Responder #### `escalate` Hand a help desk request to a person. Optionally name a group; otherwise use routing defaults. Returns the updated request with its human handoff. Arguments: `number`\* (integer), `reason`\*, `group` #### `request_approval` Ask a member to approve an action on this help desk request. Returns a pending, team-only action approval. Arguments: `number`\* (integer), `action`\*, `summary`\*, `details` (object) #### `request_verification` Email a verification code. Only the requester can confirm it; codes are never returned. Returns the new verification, `pending` until the requester enters the code. Arguments: `number`\* (integer), `email`\* #### `comment` Post a reply or team-only note. In suggest mode, public replies return { held: true, comment } for member review. Returns the new comment. Arguments: `number`\* (integer), `body`\*, `internal` (boolean), `metadata_id` #### `edit_comment` Edit this agent's own comment. Returns the updated comment. Arguments: `comment_id`\*, `body`\* #### `set_activity` Show typing or a short status for 60 seconds, or clear it. Returns `{ ok: true }`. Arguments: `number`\* (integer), `text` (string or null), `clear` (boolean) ### Member #### `set_request_type` Change a request type by ID or unique name, or null to clear. Keep matching answers, validate replacements and re-apply routing. Returns the updated request with its validated answers and effective autonomy. Arguments: `number`\* (integer), `request_type`\* (string or null), `fields` (object) #### `get_analytics` Read workspace response, resolution and agent-resolved metrics (member; defaults to 30 days). Returns response, resolution and agent metrics for the period. Arguments: `days` (`7`, `30`, `90`) #### `set_tier` Set a non-team reporter’s tier by email or actor id; null clears it (member). Returns the reporter's new tier. Arguments: `email`, `actor_id`, `tier`\* (string or null) #### `transfer_issue` Move to another workspace in which this agent may triage. Returns the issue in its new workspace. Arguments: `number`\* (integer), `to_app`\* #### `moderate` Hide or unhide an issue or comment (member). Returns the updated issue or comment. Arguments: `number`\* (integer), `comment_id`, `action`\* (`hide`, `unhide`), `reason` (`spam`, `abuse`, `off_topic`) #### `update_issue` Change the title, status, close reason, or complete list of label names. Returns the updated issue. Arguments: `number`\* (integer), `status` (`open`, `in_progress`, `waiting`, `closed`), `close_reason` (`completed`, `not_planned`, `duplicate`), `labels` (array), `private` (boolean), `locked` (boolean), `pinned` (boolean), `title` #### `relate_issues` Add a directional relation from one issue to another. Returns the updated issue. Arguments: `number`\* (integer), `other_number`\* (integer), `type`\* (`duplicate_of`, `related`, `blocks`) #### `claim_issue` Assign the issue to this agent. Returns the updated issue. Arguments: `number`\* (integer) ### Admin #### `update_app` Update workspace name, description, or public listing (admin). Returns the updated workspace. Arguments: `name`, `description`, `public` (boolean) #### `get_webhook` Read webhook configuration and recent deliveries (admin). Returns `url`, `has_secret`, `events` and recent `deliveries`. No arguments. #### `redeliver_webhook` Replay a stored webhook delivery (admin). Returns `{ delivery_id }` of the new delivery. Arguments: `delivery_id`\* #### `set_webhook` Set an HTTPS webhook URL and event subscriptions, or remove it (admin). Returns `url` and `events`. Arguments: `url`\* (string or null), `events` (array of `issue.escalated`, `issue.handed_back`, `ping`, `issue.opened`, `issue.updated`, `comment.created`, `mention.created`, `issue.closed`, `issue.reopened`, `issue.labeled`, `issue.unlabeled`, `issue.assigned`, `issue.deleted`, `issue.moved`, `comment.edited`, `comment.deleted`, `verification.completed`, `reply.held`, `approval.requested`, `approval.decided`) #### `rotate_webhook_secret` Rotate the signing secret and return the new secret once (admin). Returns `{ secret }`, shown once. No arguments. #### `test_webhook` Queue a ping to the configured webhook (admin). Returns `{ delivery_id }` of the ping. No arguments. #### `create_label` Create a label (admin). Returns the new label. Arguments: `name`\*, `color`, `description` #### `update_label` Update a label by its current name (admin). Returns the updated label. Arguments: `name`\*, `new_name`, `color`, `description` #### `delete_label` Delete a named label and remove it from issues (admin). Returns `{ ok: true }`. Arguments: `name`\* #### `create_template` Create an issue template using label names (admin). Returns the new template. Arguments: `name`\*, `description`, `title`, `body`, `labels` (array), `ask_system_info` (boolean), `position` (integer) #### `update_template` Update a template by its current name (admin). Returns the updated template. Arguments: `name`\*, `description`, `title`, `body`, `labels` (array), `ask_system_info` (boolean), `position` (integer), `new_name` #### `delete_template` Delete a template by name (admin). Returns `{ ok: true }`. Arguments: `name`\* #### `list_members` List the team and invitations (admin). Returns members and pending invitations. No arguments. #### `invite_member` Invite an account; defaults to member (admin). Returns `{ ok: true }`. Arguments: `email`\*, `role` (`viewer`, `member`, `admin`, `owner`) #### `set_member_role` Change a team role (admin; managing admins/owners needs owner delegation). Returns `{ ok: true }`. Arguments: `email`\*, `role`\* (`viewer`, `member`, `admin`, `owner`) #### `remove_member` Remove a team member or invitation (admin). Returns `{ ok: true }`. Arguments: `email`\* ### Allowed to create workspaces #### `create_app` Create a workspace owned by your account. Requires separate permission to create workspaces. Returns the new workspace. Arguments: `name`\*, `slug`\*, `description`, `public` (boolean) ## Errors and limits Problems with the request itself come back as JSON-RPC errors: | HTTP | JSON-RPC code | Meaning | | --- | --- | --- | | 401 | `-32001` | The token is missing, malformed, expired or revoked. | | 403 | `-32003` | The agent's role is too low, the workspace isn't in its grant, or `data.code` is `humans_only`. | | 400 | `-32600` | Not a valid JSON-RPC request. | | 200 | `-32601` | Unknown method. | | 200 | `-32602` | Invalid params, such as a missing tool name. | | 400 | `-32700` | The body isn't JSON. | | 429 | `-32029` | Over the rate limit. Wait `Retry-After` seconds. A `tools/call` over the limit gets a tool result instead, with `isError: true` and `structuredContent.error.code` set to `rate_limited`. | When a tool runs but fails, for example on a bad argument or a missing issue, the result has `isError: true` and the message in its text. | Limit | Value | | --- | --- | | Requests per agent | 120 per minute | | Requests with a missing or wrong token | 30 per minute per IP | | Comments per author | 60 per hour; over it, the tool answers `429` with `Retry-After: 3600` | # Running support agents An agent on a help desk works its own requests. Request types route new requests to it. It answers in the requester's language, within the autonomy the team gave it, and asks a team member for a signed approval before it does anything with consequences, such as a refund. The agent connects over [MCP](https://blhp.app/docs/mcp); everything below uses its tools. Here it looks for open requests assigned to it: ```sh curl -X POST https://blhp.app/mcp \ -H "Authorization: Bearer $BELLHOP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_issues", "arguments": {"app": "acme", "query": "assignee:@me is:open"}}}' ``` Tool results are JSON in the text content. Decoded, and abridged to the fields this page uses: ```json { "issues": [ { "number": 14, "title": "Ich wurde im Oktober doppelt belastet.", "status": "open", "assignee": { "type": "agent", "id": "0f3e2d1c-6b5a-4c39-8e27-1d0c9b8a7f65", "name": "Billing agent" }, "request_type": { "id": "rt_3b9d2f61-0c4e-4a7b-9d15-6e2f8a1c0b47", "name": "Billing" }, "fields": { "billing_email": "dana@example.com", "invoice": "4821" }, "effective_autonomy": "approve_actions", "reporter_locale": "de", "verifications": [] } ], "next_cursor": null } ``` ## Finding work Request types decide who gets a new request (**Settings → Request types**). Each type can route to an agent or a team member and sets the agent's autonomy; requests without a type use the workspace's default mode. The team can still reassign a request, and `set_request_type` (member) moves it to another type and applies that type's routing. An agent learns about its work in one of two ways: - **Webhooks.** `issue.opened` arrives with the new request: `issue.request_type`, `issue.fields` (every answer, including internal fields), `issue.effective_autonomy`, `issue.assignee`, `issue.reporter_locale` and the requester's `contact`. `issue.assigned` fires when routing or a person assigns it. See [Webhooks](https://blhp.app/docs/webhooks). - **Polling.** `list_issues` with a search query, like the example above. `get_issue` returns the same fields with the whole timeline, and `list_request_types` describes each type's fields, routing and autonomy. ### Search qualifiers `list_issues` takes `query`, which combines free text with these qualifiers: | Qualifier | Finds | | --- | --- | | `assignee:@me`, `assignee:` | Requests assigned to this agent, or to someone else. | | `no:assignee`, `no:label` | Unassigned or unlabeled requests. | | `is:open`, `is:closed`, `status:in_progress`, `status:waiting` | Requests by state. | | `type:` | Requests of one type, such as `type:Billing`. | | `is:held` | Requests with a held agent reply waiting for review. | | `is:awaiting-approval` | Requests with a pending approval. | | `is:escalated` (or `is:with-person`) | Requests handed to a person. | | `is:waiting-for-person` | Escalated requests nobody has picked up yet: outside working hours, or nobody available. | | `group:` | Requests escalated to one group, such as `group:"Billing team"`. | | `label:`, `author:`, `has:metadata`, `reason:` | The usual filters. | | `sort:newest`, `sort:updated`, … | The order. | `is:held`, `is:awaiting-approval`, `is:escalated`, `is:waiting-for-person`, `group:` and `type:` work for the team and agents only. ## Autonomy Each request has an effective autonomy: its type's, or else the workspace default (**Settings → Help desk → Default agent mode**). `get_issue`, `list_issues` and webhooks include it as `effective_autonomy`. | Mode | Public replies | Actions with consequences | | --- | --- | --- | | `suggest` | Held for a team member to send, edit or discard. | Ask for an approval first. | | `approve_actions` | Sent right away. | Ask for an approval first. | | `autonomous` | Sent right away. | May act alone; approvals are there when it wants a second opinion. | Internal notes, triage, verifications and replies from people are never held. While a request is [with a person](#handing-off-to-a-person), the agent's public replies are held whatever the mode. ### Held replies Under `suggest`, a public `comment` doesn't reach the requester. It returns `held: true` with the comment: ```sh curl -X POST https://blhp.app/mcp \ -H "Authorization: Bearer $BELLHOP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "comment", "arguments": {"app": "acme", "number": 15, "body": "Thanks! Could you try reloading the page?"}}}' ``` ```json { "held": true, "comment": { "id": "5c1e9a7d-3f2b-4d80-b6a4-9e0d7c2b1a38", "body": "Thanks! Could you try reloading the page?", "created_at": "2026-11-04T21:37:10.600Z", "updated_at": "2026-11-04T21:37:10.600Z", "author": { "type": "agent", "id": "0f3e2d1c-6b5a-4c39-8e27-1d0c9b8a7f65", "name": "Billing agent" }, "hidden": null, "edited_at": null, "internal": false, "mentions": [], "via": "mcp", "helpful": 0, "viewer_helpful": false, "held": { "at": "2026-11-04T21:37:10.600Z" }, "sent_by": null, "by_issue_author": false, "attachments": [], "viewer_is_author": true } } ``` The team sees the draft in the request, finds such requests with `is:held`, and gets a `reply.held` webhook. A member sends it as written or edited, and it stays the agent's reply, with `sent_by` naming who sent it; or they discard it. Only one of them wins: if two people send or discard the same reply at once, the second gets `409 reply_unavailable`. The requester never sees a held reply, and isn't emailed about it, until it's sent. ## Approvals An approval is a team member's yes to one specific action, which your own service checks before it acts. It works the same in every mode. Under `suggest` and `approve_actions`, your agent shouldn't act without one, and your action tools enforce that by requiring the token. Approvals, like held replies, exist only in help desk workspaces; elsewhere the tools answer `403 helpdesk_only`. > **The `summary` is only a description. The `details` are what's signed.** The team approves the details exactly as the card shows them, the token carries them as `det`, and your action service must compare `det` with the parameters it's really about to use. Never act on anything the summary says that the details don't. 1. The agent asks with `request_approval`. 2. A team member approves or rejects it in the request. 3. The agent polls `get_approval`, or reacts to the `approval.decided` webhook. 4. Once approved, `get_approval` gives the agent a signed token. 5. The agent calls its own action tool with the token. 6. The action service verifies the token against the public keys, consumes it, and acts. ### 1. Ask `action` is a machine name you choose. `details` are the exact parameters your service will check: strings, numbers or booleans, at most 20 keys, each string at most 256 characters, 4 KiB in all. `summary` is a sentence for the team; it isn't signed. ```sh curl -X POST https://blhp.app/mcp \ -H "Authorization: Bearer $BELLHOP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "request_approval", "arguments": {"app": "acme", "number": 14, "action": "refund", "summary": "Refund €20.00 for invoice 4821 (duplicate charge)", "details": {"invoice": "4821", "amount": 2000, "currency": "eur"}}}}' ``` ```json { "id": "7d3c1a52-4e0b-4f6d-9a21-5b8c0e9f3a14", "issue_number": 14, "action": "refund", "summary": "Refund €20.00 for invoice 4821 (duplicate charge)", "details": { "amount": 2000, "currency": "eur", "invoice": "4821" }, "details_hash": "FF1GfpynlYnUPgPb51-ZQ3pngAS_i9OPC0YPy_X9K7g", "state": "pending", "requested_by": { "type": "agent", "id": "0f3e2d1c-6b5a-4c39-8e27-1d0c9b8a7f65", "name": "Billing agent" }, "decided_by": null, "decided_at": null, "note": null, "expires_at": "2026-11-11T21:37:05.000Z", "created_at": "2026-11-04T21:37:05.939Z" } ``` A pending approval expires after 7 days. The agent needs at least the responder role. ### 2. The team decides The request shows the approval as a card to the team only: the summary, and the details verbatim, with **Approve**, **Reject** and an optional note. Members (not viewers) can decide, once; the decision is recorded in the audit log. A decision covers exactly the details on the card: it's sent with their hash (the approval's `details_hash`: base64url SHA-256 of the details as JSON with sorted keys and no whitespace), and if they don't match, it's refused with `409 details_changed`. Reviewers get an email when an agent asks for an approval or holds a reply; it's on by default for admins and the request's assignee. A webhook gets `approval.requested`. ### 3. Wait for the decision Poll `get_approval` with the id, or wait for the `approval.decided` webhook, whose `approval` says `approved` or `rejected`. Webhooks never contain the token. ### 4. Get the token Once it's approved, `get_approval` adds `token`, for the agent that asked and nobody else: ```sh curl -X POST https://blhp.app/mcp \ -H "Authorization: Bearer $BELLHOP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "get_approval", "arguments": {"app": "acme", "id": "7d3c1a52-4e0b-4f6d-9a21-5b8c0e9f3a14"}}}' ``` ```json { "id": "7d3c1a52-4e0b-4f6d-9a21-5b8c0e9f3a14", "issue_number": 14, "action": "refund", "summary": "Refund €20.00 for invoice 4821 (duplicate charge)", "details": { "amount": 2000, "currency": "eur", "invoice": "4821" }, "details_hash": "FF1GfpynlYnUPgPb51-ZQ3pngAS_i9OPC0YPy_X9K7g", "state": "approved", "requested_by": { "type": "agent", "id": "0f3e2d1c-6b5a-4c39-8e27-1d0c9b8a7f65", "name": "Billing agent" }, "decided_by": { "type": "member", "id": "a6b2c9d4-1e7f-4a35-8c60-2d9e4f1b7a03", "name": "Alex Kim", "avatar_url": null, "role": "member" }, "decided_at": "2026-11-04T21:37:05.953Z", "note": "Duplicate confirmed in Stripe.", "expires_at": "2026-11-04T22:07:05.000Z", "created_at": "2026-11-04T21:37:05.939Z", "token": "APPROVAL_TOKEN_EXAMPLE" } ``` The token is valid for 30 minutes after the decision; `expires_at` says when. Once it's used or expired, `get_approval` returns the approval without a token. ### 5. Call your action Pass the token to the tool that does the work, next to its real parameters, for example `refund({ invoice: "4821", amount: 2000, currency: "eur", request: 14, approval_token })`. The tool is yours: an MCP tool on your own server, or an endpoint the agent calls. ### 6. Verify, consume, act The token is a JWT signed with Ed25519 (`alg: "EdDSA"`), one key per workspace. Each workspace's public keys are at `https://api.blhp.app/v1/approvals/jwks.json?workspace=`. This Node function checks the token against the action you're about to take. It uses only `node:crypto`, caches the keys in memory and fetches them again once if it meets an unknown key id: ```js import { createPublicKey, verify } from 'node:crypto'; const fail = (why) => { throw new Error(`Approval token rejected: ${why}`); }; const canonical = (o) => JSON.stringify(Object.entries(o ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); const jwksCache = new Map(); async function keys(url, refresh = false) { if (refresh || !jwksCache.has(url)) { const response = await fetch(url, { redirect: 'error' }); if (!response.ok) fail(`couldn't load the signing keys (${response.status})`); jwksCache.set(url, (await response.json()).keys ?? []); } return jwksCache.get(url); } // Every expected value comes from your service (the action it's about to take), never from the token. export async function verifyApproval(jwt, { aud, act, det, req }, jwksURL = `https://api.blhp.app/v1/approvals/jwks.json?workspace=${encodeURIComponent(aud)}`) { const parts = String(jwt).split('.'); if (parts.length !== 3) fail('not a JWT'); const [h, p, s] = parts; const json = (part) => { try { return JSON.parse(Buffer.from(part, 'base64url')); } catch { fail('malformed'); } }; const header = json(h); if (header.alg !== 'EdDSA' || header.typ !== 'JWT') fail('unexpected header'); if (typeof header.kid !== 'string') fail('no key id'); const find = (list) => list.find((k) => k.kid === header.kid && k.kty === 'OKP' && k.crv === 'Ed25519'); const key = find(await keys(jwksURL)) ?? find(await keys(jwksURL, true)) ?? fail('unknown signing key'); const signed = verify(null, Buffer.from(`${h}.${p}`), createPublicKey({ key, format: 'jwk' }), Buffer.from(s, 'base64url')); if (!signed) fail('bad signature'); const c = json(p), now = Math.floor(Date.now() / 1000); if (c.iss !== 'https://blhp.app') fail('wrong issuer'); if (c.aud !== aud) fail('wrong workspace'); if (c.act !== act) fail('wrong action'); if (canonical(c.det) !== canonical(det)) fail('details differ from this action'); if (c.req !== req) fail('wrong request'); if (typeof c.sub !== 'string' || !c.sub || c.sub !== c.jti) fail('bad id'); if (!Number.isSafeInteger(c.iat) || !Number.isSafeInteger(c.exp)) fail('bad times'); if (c.iat > now + 60 || now >= c.exp || c.exp - c.iat > 1800) fail('expired or not yet valid'); return c; // Valid, but not yet single use: consume c.jti before acting. } ``` | Claim | Meaning | | --- | --- | | `iss` | Always `https://blhp.app`. | | `aud` | The workspace slug. | | `act` | The approved action, such as `refund`. | | `det` | The approved details, exactly as requested. | | `req` | The request number. | | `sub`, `jti` | The approval id. | | `by` | Who approved it: `{ id, name }`. | | `iat`, `exp` | The decision time, and 30 minutes later. | **A valid signature doesn't make a token single use.** For the 30 minutes it's valid, the same token verifies every time. So before you act, consume it with your workspace's secret key, from your server (or record its `jti` somewhere that refuses duplicates), and use the `jti` as the action's idempotency key: ```sh curl -X POST https://api.blhp.app/v1/approvals/$APPROVAL_ID/consume \ -H "Authorization: Bearer $BELLHOP_SECRET_KEY" ``` ```json { "id": "7d3c1a52-4e0b-4f6d-9a21-5b8c0e9f3a14", "issue_number": 14, "action": "refund", "summary": "Refund €20.00 for invoice 4821 (duplicate charge)", "details": { "amount": 2000, "currency": "eur", "invoice": "4821" }, "details_hash": "FF1GfpynlYnUPgPb51-ZQ3pngAS_i9OPC0YPy_X9K7g", "state": "used", "requested_by": { "type": "agent", "id": "0f3e2d1c-6b5a-4c39-8e27-1d0c9b8a7f65", "name": "Billing agent" }, "decided_by": { "type": "member", "id": "a6b2c9d4-1e7f-4a35-8c60-2d9e4f1b7a03", "name": "Alex Kim", "avatar_url": null, "role": "member" }, "decided_at": "2026-11-04T21:37:05.953Z", "note": "Duplicate confirmed in Stripe.", "expires_at": "2026-11-04T22:07:05.000Z", "created_at": "2026-11-04T21:37:05.939Z" } ``` Consuming works once. A second try gets `409`: ```sh curl -X POST https://api.blhp.app/v1/approvals/$APPROVAL_ID/consume \ -H "Authorization: Bearer $BELLHOP_SECRET_KEY" ``` ```json { "error": { "code": "approval_unavailable", "message": "Approval is not available for use" } } ``` | Status | Means | | --- | --- | | `409 approval_unavailable` | It was already used. Don't act. | | `410 approval_unavailable` | It's pending, rejected or expired. Don't act. | | `404 not_found` | It belongs to another workspace, or doesn't exist. | Put together, the rule for every action service: 1. **Verify against the real action.** Pass `verifyApproval` the parameters you're about to use, not the ones in the token: the action, the details, the request number, your workspace slug. A token approved for €20 must not refund €200, whatever the summary says. 2. **Consume the `jti` before acting.** Only a successful consume means this token hasn't been used. If it fails, stop. 3. **Use the `jti` as the action's idempotency key.** If the action is retried after a crash, your payment provider or database sees the same key and doesn't do it twice. ```js import { verifyApproval } from './verify-approval.mjs'; export async function refund({ invoice, amount, currency, request, approval_token }) { const claims = await verifyApproval(approval_token, { aud: 'acme', act: 'refund', det: { invoice, amount, currency }, req: request, }); const used = await fetch(`https://api.blhp.app/v1/approvals/${claims.jti}/consume`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.BELLHOP_SECRET_KEY}` }, }); if (!used.ok) throw new Error(`Approval not usable (${used.status})`); return payments.refund({ invoice, amount, currency }, { idempotencyKey: claims.jti }); } ``` ## Handing off to a person When an agent can't finish a request, or shouldn't (an angry customer, a legal question, an account it can't verify), it hands the request to a person with `escalate` and a `reason` for the team: ```sh curl -X POST https://blhp.app/mcp \ -H "Authorization: Bearer $BELLHOP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "escalate", "arguments": {"app": "acme", "number": 16, "reason": "Wants their account deleted; needs a person to confirm ownership.", "group": "Billing team"}}}' ``` It returns the request, as `get_issue` does. Abridged: ```json { "number": 16, "status": "open", "assignee": { "type": "member", "id": "a6b2c9d4-1e7f-4a35-8c60-2d9e4f1b7a03", "name": "Alex Kim", "avatar_url": null, "role": "member", "tier": null }, "effective_autonomy": "approve_actions", "escalated_to_person": true, "escalation": { "reason": "Wants their account deleted; needs a person to confirm ownership.", "source": "agent", "by": { "type": "agent", "id": "0f3e2d1c-6b5a-4c39-8e27-1d0c9b8a7f65", "name": "Billing agent" }, "at": "2026-11-04T23:19:01.205Z", "group": { "id": "grp_5d0e7a3c-2b18-4f96-8c4d-1a9e6b3f0c72", "name": "Billing team" }, "waiting": null, "respond_by": null } } ``` - **Which group.** The `group` named in the call (the agent can read them with `list_groups`), else the group the request type routes to, else the workspace's default group. With none of these, the request waits unassigned and the workspace admins are told. - **Who.** The group picks an available member, in turn or whoever is least busy. Outside working hours the request waits quietly until the next opening. If nobody is available during working hours, the admins are told once. When an assigned member misses the response window, the request passes once if there is an alternative. If the next window is missed, or there is no alternative, the assignee gets one reminder and the admins are told once. The timer then stops. Handoff email is limited to 20 messages per member per UTC day. - **The requester** sees that a person will take it from here, not the assignee, reason or group. They see who actually replies on each reply. - **The agent stays on.** It can still read the request, write internal notes and triage, but its public replies are held for a person to send, whatever the autonomy. It can't escalate the same request again while it's with a person (`409 handoff_unavailable`). - **Handing back.** When the person is done, a team member hands the request back to agents (in the request, or `DELETE /api/apps//issues//escalation`). `escalation` becomes `null` and the agent's replies go out under its normal autonomy again. Webhooks get `issue.escalated`, with the same `escalation`, when a request is handed to a person and again if it passes to the next member, and `issue.handed_back` when it comes back. Requesters can ask for a person themselves, with **Talk to a person** in the [chat widget](https://blhp.app/docs/widget#behavior) or through the [public API](https://blhp.app/docs/api#talking-to-a-person). ## Verifying an email Before changing an account, an agent can ask the requester to prove an address, such as "the email on your billing account". `request_verification` (responder) emails that address a code that only the requester can enter, on the request page or in the chat widget: ```sh curl -X POST https://blhp.app/mcp \ -H "Authorization: Bearer $BELLHOP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "request_verification", "arguments": {"app": "acme", "number": 14, "email": "billing@example.com"}}}' ``` ```json { "id": "2e8f4b61-9c3d-4a07-b5e1-7f0a6d2c9b84", "email": "billing@example.com", "state": "pending", "created_at": "2026-11-04T21:37:09.929Z", "verified_at": null, "requested_by": { "type": "agent", "id": "0f3e2d1c-6b5a-4c39-8e27-1d0c9b8a7f65", "name": "Billing agent" } } ``` The agent never sees the code. The result shows on the request's `verifications` as `verified`, `failed` or `expired`, and arrives as a `verification.completed` webhook. `get_contact` reads the requester's contact, with how they were verified and their other requests. ## Replying in their language `list_issues`, `search_issues`, `get_issue` and webhooks include `reporter_locale`: the language the requester used when they wrote in, one of `en`, `es`, `pt-BR`, `fr`, `de`, `ja`, `zh-CN`, `ko`, or `null` for older requests. Write replies in it, held ones included, unless the conversation shows otherwise. Internal notes and approval summaries are for the team, so write those in the team's language. # Webhooks A workspace can send a signed JSON `POST` to your server whenever something happens to its issues. Check the signature, skip deliveries you've already handled, and answer `2xx` quickly: ```ts export default { async fetch(req: Request, env: { BELLHOP_WEBHOOK_SECRET: string }) { const body = await req.text() // the raw body: the signature covers these exact bytes const ok = await verifyBellhop(env.BELLHOP_WEBHOOK_SECRET, req.headers.get("Bellhop-Signature"), body) if (!ok) return new Response("Bad signature", { status: 401 }) const event = JSON.parse(body) if (await seen(event.id)) return new Response(null, { status: 204 }) if (event.type === "issue.opened") await triage(event.issue, event.metadata ?? []) return new Response(null, { status: 204 }) }, } ``` `verifyBellhop` is under [Verify the signature](#verify-the-signature); `seen` and `triage` are yours. ## Set up Workspace admins set the webhook in **Settings → Webhook**: - **Payload URL**: an `https` URL on a public domain name. IP addresses, `localhost`, `.local`, `.internal`, `.localdomain`, `.home.arpa` and Bellhop's own hosts aren't allowed, and neither is `user:password@`. - **Events**: which events to send. A new webhook gets the defaults marked below. - **Signing secret**: reveal, copy or rotate it. Rotating takes effect at once, so update your server first or accept both for a moment. - **Send test** sends a `ping` event, which is always delivered whatever you've selected. - **Recent deliveries** lists the last 50 with their status, response code and attempts, and can **Redeliver** any of them. Agents with the admin role can do the same with the `get_webhook`, `set_webhook`, `test_webhook`, `redeliver_webhook` and `rotate_webhook_secret` tools. ## Events | Event | Default | When | | --- | --- | --- | | `issue.escalated` | No | A request was handed to a person: by an agent, the requester, the team or routing. `escalation` says why, who and which group; it fires again if it passes to the next member. | | `issue.handed_back` | No | A team member handed the request back to agents. | | `ping` | Always | **Send test** was pressed. Always delivered, whatever is selected. | | `issue.opened` | Yes | An issue was opened. | | `issue.updated` | Yes | Anything about an issue changed. `changes` lists what. | | `comment.created` | Yes | A comment or internal note was posted, not by an agent. | | `mention.created` | Yes | Someone @mentioned a team member or an agent. | | `issue.closed` | No | An issue was closed. `issue.close_reason` says why. | | `issue.reopened` | No | A closed issue was reopened. | | `issue.labeled` | No | A label was added. The label is in `label`. | | `issue.unlabeled` | No | A label was removed. The label is in `label`. | | `issue.assigned` | No | The assignee changed. `issue.assignee` is `null` when it was cleared. | | `issue.deleted` | No | An issue was deleted. | | `issue.moved` | No | An issue was moved to another workspace. `moved_to` says where. | | `comment.edited` | No | A comment was edited, not by an agent. | | `comment.deleted` | No | A comment was deleted. | | `verification.completed` | No | An email verification on a request finished. `verification` says whether it was verified, failed or expired. | | `reply.held` | No | An agent's public reply was held for review (autonomy `suggest`). The reply is in `comment`. | | `approval.requested` | No | An agent asked for an approval. It's in `approval`. | | `approval.decided` | No | A team member approved or rejected an approval. It's in `approval`, never with its token. | `issue.updated` fires for every change and says which fields changed in `changes`: for example `["status", "close_reason"]`, `["labels"]`, `["assignee"]` or `["comment.edited"]`. The specific events fire alongside it if you've selected them. Comments written by agents don't send `comment.created` or `comment.edited`, so an agent that listens to webhooks never wakes itself. ## Payload Every delivery is one JSON object: | Field | Present | Description | | --- | --- | --- | | `id` | always | The delivery id, the same as the `Bellhop-Delivery` header. A redelivery has a new one. | | `type` | always | The event. | | `created_at` | always | When the event happened. A redelivery keeps the original time. | | `app` | always | The workspace: `id`, `slug`, `name`. | | `actor` | always | Who did it: a member, a guest or an agent, with their tier if they have one. | | `mcp_url` | always | The MCP endpoint, for agents that act on the event. | | `issue` | all but `ping` | The issue as it is after the change, with its `url` and the reporter's language in `reporter_locale`. | | `comment` | comment events, `mention.created`, `reply.held` | The comment, including internal notes (`internal: true`). For `reply.held`, the held agent reply, with `held.at`. | | `changes` | `issue.updated` | Which fields changed. | | `label` | `issue.labeled`, `issue.unlabeled` | The label. | | `mentioned` | `mention.created` | Who was mentioned: a team member or an agent. | | `moved_to` | `issue.moved` | Where it went: `slug`, `number`, `url`. | | `metadata` | `issue.opened`, `comment.created` | Report metadata attached to that post: specs, context, logs and fingerprint matches. | | `contact` | help desk requests | The requester's contact: `id`, `name`, verified `email`, display-only `unverified_email`, your app's `external_id`, `verified_via` (`host` or `email`), `created_at` and `last_seen_at`. | | `verification` | `verification.completed` | The email verification: `id`, `email`, `state` (`verified`, `failed` or `expired`), `requested_by`, `created_at` and `verified_at`. The code is never included. | | `approval` | `approval.requested`, `approval.decided` | The approval: `action`, `summary`, `details`, `state` (`pending`, then `approved` or `rejected`), `requested_by`, `decided_by` and `note`. Never its token; see [Running support agents](https://blhp.app/docs/agents#approvals). | | `escalation` | `issue.escalated` | Why and how the request went to a person: `reason`, `source` (`agent`, `requester`, `team` or `routing`), `by`, `at`, `group`, `waiting` (`hours`, `nobody_available` or `null`) and `respond_by`. The new assignee is in `issue.assignee`. | | `redelivery_of` | redeliveries | The id of the delivery this repeats. | Webhooks are for the team, so payloads include private issues, internal notes and reporter tiers. Keep them out of anything public. An `issue.opened` delivery for a report with metadata: ```json { "actor": { "type": "member", "id": "085329df-a2d5-42bf-a4ad-a1b15c742067", "name": "ryan", "avatar_url": null, "role": "owner", "tier": null }, "issue": { "id": "d667e97e-8996-4602-b6b2-993e4dd2b4b0", "number": 80, "title": "Crash on startup: config.json is a directory", "status": "open", "close_reason": null, "created_at": "2026-11-04T16:49:54.075Z", "updated_at": "2026-11-04T16:49:54.075Z", "comment_count": 0, "votes": 0, "viewer_voted": false, "pinned": false, "locked": false, "private": false, "hidden": null, "author": { "type": "member", "id": "085329df-a2d5-42bf-a4ad-a1b15c742067", "name": "ryan", "avatar_url": null, "role": "owner", "tier": null }, "assignee": null, "activity": null, "labels": [], "reporter_locale": "en", "url": "https://acme.blhp.app/issues/80" }, "app": { "id": "2377e83c-1cb3-4bde-b1de-f10ee8912acb", "slug": "acme", "name": "Acme" }, "mcp_url": "https://blhp.app/mcp", "type": "issue.opened", "metadata": [ { "id": "md_947e87d776e850f971022a808ecc8bc9a4934f2e5141ced515fd8249c5a67dba", "title": "Crash on startup: config.json is a directory", "message": "EISDIR: illegal operation on a directory, read\n at loadConfig (config.ts:88)", "source": { "name": "Acme Desktop", "version": "2.4.1" }, "system": { "OS": "macOS 15.1", "CPU": "Apple M3", "Memory": "16 GB" }, "context": { "session": "ses_7f3a91c2", "workspace": "~/dev/acme" }, "fingerprint": "eisdir-loadconfig-88", "created_at": "2026-11-04T16:49:54.048Z", "expires_at": null, "logs": [ { "id": "ml_77408702-3cbe-4aa7-b1c3-f2002b3ccdbb", "name": "main.log", "size": 57, "kind": "file", "mime": "text/plain", "url": "/media/ml_77408702-3cbe-4aa7-b1c3-f2002b3ccdbb/main.log?metadata=md_947e87d776e850f971022a808ecc8bc9a4934f2e5141ced515fd8249c5a67dba" } ], "matches": [], "attached_to": { "number": 80, "comment_id": null } } ], "id": "2cf308bf-5527-49b2-a935-587dfcaf11cc", "created_at": "2026-11-04T16:49:54.083Z" } ``` Log `url`s are paths on the workspace host and need a team member's session. An agent reads logs with the `get_attachment` tool instead. ## Headers | Header | Value | | --- | --- | | `Content-Type` | `application/json` | | `Bellhop-Event` | The event type, such as `issue.opened`. | | `Bellhop-Delivery` | The delivery id, the same as the payload's `id`. | | `Bellhop-Signature` | `t=,v1=`, where the signature is the lowercase hex HMAC-SHA256 of `.` keyed with the signing secret. | ## Verify the signature Compute the HMAC over the timestamp, a dot and the raw body exactly as received, compare it in constant time, and reject old timestamps so a captured request can't be replayed later. TypeScript, with the Web Crypto API (Workers, Node 18+, Bun, Deno): ```ts export async function verifyBellhop(secret: string, header: string | null, body: string, toleranceSeconds = 300) { const parts = new Map((header ?? "").split(",").map((p) => p.split("=", 2) as [string, string])) const t = Number(parts.get("t")), v1 = parts.get("v1") ?? "" if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !/^[0-9a-f]{64}$/.test(v1)) return false const enc = new TextEncoder() const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]) const signature = Uint8Array.from(v1.match(/../g)!, (h) => parseInt(h, 16)) return crypto.subtle.verify("HMAC", key, signature, enc.encode(`${t}.${body}`)) } ``` Python 3, with the standard library: ```python import hashlib import hmac import time def verify_bellhop(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), str(t).encode() + b"." + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` Use the raw body, not JSON you've parsed and serialized again: any change in spacing or key order breaks the signature. ## Delivery, retries and redelivery - Bellhop waits up to 10 seconds for an answer. A `2xx` marks the delivery as delivered. Do slow work after you've answered. - A non-`2xx` answer, a timeout or a network error is retried up to 6 more times, after 1, 2, 4, 8, 16 and 32 seconds. After that the delivery is marked failed. - Redirects aren't followed. A `3xx` fails the delivery at once. - **Redeliver** in Settings sends the same payload again with a new `id` and `redelivery_of` set to the original. It goes to the URL configured now, and only if the webhook still subscribes to that event. - Deliveries can arrive more than once and out of order. Use `id` to skip ones you've handled, and `created_at` or the issue's `updated_at` to ignore stale ones. - Deleting an issue or a comment also deletes stored deliveries that contain it, so they can't be redelivered. # Feeds, embeds and previews Public workspaces publish their issues as Atom feeds, answer oEmbed requests and have a preview image for every page. None of it needs a key, and none of it ever includes private, hidden or internal content. ```sh curl https://acme.blhp.app/feed.atom ``` ```xml https://acme.blhp.app/feed.atomAcme issues2026-11-04T03:16:19.489ZBellhoptag:blhp.app,2026:issue/ffac947e-37c2-4952-9934-1c668496ba37Guest author feed check2026-11-04T03:16:19.489Z… ``` ## Atom feeds | Feed | URL | Entries | | --- | --- | --- | | A workspace | `https://.blhp.app/feed.atom` | Its 50 most recently updated issues, with title, author and the start of the description. | | One issue | `https://.blhp.app/issues//feed.atom` | Its 50 latest public replies and status changes. | Workspace and issue pages link their feed with ``, so feed readers find it from the page URL. Public workspaces' feeds can be cached for a minute (`s-maxage=60`). Entries are plain text: titles, author names and summaries of up to 320 characters, never the full description. Email addresses are never shown as author names. ## oEmbed `GET https://blhp.app/oembed?url=&format=json` `url` is an issue (`https://.blhp.app/issues/`) or a workspace (`https://.blhp.app/`). The answer is an oEmbed `link` with the preview image as its thumbnail: ```sh curl "https://blhp.app/oembed?url=https%3A%2F%2Facme.blhp.app%2Fissues%2F2&format=json" ``` ```json { "version": "1.0", "type": "link", "title": "Guest author feed check", "author_name": "Renamed reporter", "provider_name": "Bellhop", "provider_url": "https://blhp.app", "thumbnail_url": "https://acme.blhp.app/og/issues/2.png", "thumbnail_width": 1200, "thumbnail_height": 630 } ``` Only `format=json` is supported. Bellhop never fetches the URL you pass; it reads it and looks up the issue itself. Anything that isn't a public issue in a listed workspace gets `404`. Listed pages advertise the endpoint with ``. ## Preview images Every workspace and issue page has a 1200×630 PNG card in its `og:image` and `twitter:image` tags: | Card | URL | | --- | --- | | Bellhop | `https://blhp.app/og/home.png` | | A workspace | `https://.blhp.app/og/app.png?v=` | | An issue | `https://.blhp.app/og/issues/.png?v=` | | A docs page | `https://blhp.app/og/docs/.png?v=` | An issue card shows its title, number, status and labels, and who answered if an agent did. `v` changes whenever anything on the card changes, so each version can be cached forever. Use the URLs from the page's tags rather than building them: the version is part of the URL. ## What's included Feeds, oEmbed and cards only ever show what anyone could see on the public page: - **Private issues**, **hidden issues and comments** and **internal notes** never appear. A private, hidden or deleted issue's card is the workspace card. - **Unlisted workspaces** (not public) don't answer oEmbed and aren't in sitemaps. Their feeds and cards still work for anyone who has the workspace's link, like the workspace itself. Their feeds are marked `noindex` and aren't cached publicly. - Nothing depends on cookies or who's asking: a team member and a stranger get the same feed. # 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://.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=`, or `?before=&last=30` | Display order; `next_cursor` keeps going back | | Read everything | no parameters, then `?cursor=` | Oldest first | | Catch up after a gap | `?after=` | Oldest first | Don't combine `after` with `last` or `before`. Send messages with `POST /v1/conversations/:id/messages` and `{ "body": "…", "attachments": [""] }`. 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": "", "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 `` 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=` 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": "" }`, `{ "agent": "" }` 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=`, 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` } | # Chat widget The chat widget puts a support button in the corner of your site. People start a conversation, see replies arrive live, attach files and come back to earlier conversations, without leaving the page. Your team answers in the help desk like any other request. The widget never mentions Bellhop: it shows your workspace's name and icon. Paste this before ``, with a publishable key from **Settings → API keys**: ```html ``` **Settings → Chat widget** has the same snippet with your key filled in. The key must allow your site's origin, such as `https://app.example.com`. ## Identifying users The widget works best when your page tells it who is signed in. They arrive verified, see their own conversations on every device, and nobody has to type a code. There are two ways. ### A session from your server Your server starts a session with a secret key (see [end-user sessions](https://blhp.app/docs/api#end-user-sessions)) and hands the token to the page: ```ts // On your server, for the signed-in user (the secret key never reaches a browser) const res = await fetch("https://api.blhp.app/v1/sessions", { method: "POST", headers: { Authorization: `Bearer ${process.env.BELLHOP_SECRET_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ user: { id: user.id, email: user.email, email_verified: true, name: user.name }, }), }) const { token } = await res.json() ``` ```html ``` Start a session for each page load, or for each browser, rather than caching one per user: the widget refreshes the token to keep long-lived pages signed in, and a refreshed token replaces the old one. On the next page load with the same token, it picks up where the refreshed one left off. ### An identity token Or sign a short-lived token on your server with an identity key, without calling Bellhop, and pass it as `identityToken`. [Identity](https://blhp.app/docs/identity) has the signing code for Node and Python. ```html ``` If someone signs in or out while the page is open, call `Bellhop.identify(token)` with a new session or identity token, or `Bellhop.logout()`. ### Visitors who aren't signed in On your site, the widget can't offer email codes or anonymous chats. Both need a human check (Turnstile) that runs only on the workspace's own site, `https://.blhp.app`. So when nobody is identified, the widget says "Sign in to app.example.com to contact support", and logs a hint in the browser console for you. Identify your signed-in users, or link visitors to your help desk site, where they can sign in by email. ## Options `Bellhop.init(options)` takes: | Option | Values | Default | | --- | --- | --- | | `key` | Your publishable key (`bh_pk_…`). Required. | | | `sessionToken` | A session token your server started (`bh_us_…`). | | | `identityToken` | An identity token your server signed. | | | `locale` | One of `en`, `es`, `pt-BR`, `fr`, `de`, `ja`, `zh-CN`, `ko`. | The browser's language, or the user's saved one | | `theme` | `"light"`, `"dark"` or `"auto"` (follows the visitor's setting) | `"light"` | | `position` | `"right"` or `"left"` | `"right"` | The script tag takes `data-key`, `data-locale`, `data-theme` and `data-position` too. With `data-key` and no `init` call, the widget starts on its own for visitors. ## Methods and events The snippet queues calls until the widget loads, so you can call these straight away: | Call | Does | | --- | --- | | `Bellhop.open()`, `close()`, `toggle()` | Show or hide the chat. | | `Bellhop.newConversation({ subject?, message?, metadataId? })` | Open a new conversation, optionally with a subject and a draft message. `metadataId` attaches [report metadata](https://blhp.app/docs/reports) such as logs. | | `Bellhop.identify(token)` | Switch to another signed-in user (a session or identity token). | | `Bellhop.logout()` | Sign out of the chat and forget the stored session. | | `Bellhop.on(event, fn)` | Listen for `"unread"` (the number of unread replies), `"open"` or `"close"`. | For example, a "Contact support" button and an unread count in your own menu: ```html ``` If the script loads before your own code, add the queue yourself so early calls aren't lost: ```html ``` ## Request types If your workspace sorts requests by type (**Settings → Request types**), a new conversation starts with "What do you need help with?" and the types to choose from, plus "Something else". The chosen type's fields then appear above the message box: - text, long text, email, link, number and choice fields, with required ones marked and their help text below; - answers are checked before sending, and a problem the server finds lands on its field, with the message kept; - an email field set to verify says "We'll send a code to confirm it", and the code card appears in the conversation after sending; - for a type that needs a verified email, a visitor who isn't signed in confirms their email first, then fills in the form; - internal fields, which your team or server fills in, never show. Arrow keys move through the types, and Enter picks one. Without request types, the chat starts with the message box as before. ## Theming and languages The widget lives in its own shadow root, so your page's CSS doesn't change it and its CSS doesn't leak into your page. It takes the workspace's name and icon from **Settings**, `theme` sets light, dark or automatic, and `position` picks the corner. On phones it opens full screen. It's translated into `en`, `es`, `pt-BR`, `fr`, `de`, `ja`, `zh-CN`, `ko`. Without `locale`, it uses the signed-in user's saved language, or else the browser's. ## Behavior - Conversations open at the newest messages; older ones load as you scroll up. - Replies, typing and an agent's activity ("Looking up your invoice…") arrive live. Without a socket, it polls. - A message that fails to send stays in the thread with a Retry button, and is never sent twice. - In an open conversation, **Talk to a person** hands it to your team, with an optional line about what they need. The thread then says "A person will take it from here" and the button becomes "With the team". People can ask once per conversation; your team, an agent or another tab asking flips it too, live. See [Handing off to a person](https://blhp.app/docs/agents#handing-off-to-a-person) for where it goes. - Files: images up to 10 MiB, videos up to 100 MiB, other files up to 25 MiB. - Long-lived pages stay signed in: the session is refreshed before it expires, for up to 30 days. - The loader at `/widget/v1.js` is cached only briefly, and recovers by itself if a deploy replaced the version it points to. ## Build your own on the SDK The widget is built only on the JS SDK, so you can build your own chat UI, or use the API from Node, with the same calls. Import it as an ES module: ```ts import { createClient, BellhopError, escalateErrorKind, uploadErrorKind } from "https://blhp.app/sdk/v1.js" const client = createClient({ key: "bh_pk_…" }) const workspace = await client.workspace() // name, icon_url, which sign-ins this origin offers // A token your server started; save rotated tokens so a reload picks up the newest one const session = client.fromSessionToken(tokenFromYourServer, undefined, { onRefresh: (s) => localStorage.setItem("support-session", s.token), }) const billing = workspace.request_types.find((t) => t.name === "Billing") // or let the person pick const conversation = await session.createConversation({ message: { body: "Hi! My invoice is wrong." }, ...(billing && { request_type: billing.id, fields: { invoice: "4821" } }), }) const latest = await session.latest(conversation.id, 30) // the newest 30, oldest first const earlier = latest.next_cursor ? await session.older(conversation.id, { cursor: latest.next_cursor }) : null // Live updates (a socket, or polling where sockets aren't available) const live = session.subscribe((event) => { if (event.type === "item.created") render(event.item) }) // Files try { const file = await session.upload(input.files[0]) await session.sendMessage(conversation.id, { body: "Here's the receipt.", attachments: [file.id] }) } catch (e) { if (uploadErrorKind(e) === "too_large") alert("That file is too big.") else throw e } // "Talk to a person": once per conversation; afterwards conversation.with_person is true try { const handed = await session.escalate(conversation.id, { reason: "My account is locked" }) showStatus(handed.with_person ? "With the team" : "") } catch (e) { if (escalateErrorKind(e) === "already") showStatus("You've already asked for a person here.") else throw e } ``` From your server, the same SDK starts sessions with a secret key: ```ts import { createClient } from "https://blhp.app/sdk/v1.js" const server = createClient({ key: process.env.BELLHOP_SECRET_KEY }) const { token } = await server.createSession({ id: user.id, email: user.email, email_verified: true, name: user.name }) ``` `session.escalate(id, { reason })` is the widget's "Talk to a person": it returns the conversation with `with_person: true`, and live updates bring an `escalated` item to every open tab. `escalateErrorKind(e)` says `"already"` (asked before, or a person already has it) or `"rate_limited"`. See [Talking to a person](https://blhp.app/docs/api#talking-to-a-person). Errors are `BellhopError`s with the API's `status` and `code`. Every call is described in the [API reference](https://blhp.app/docs/api#reference). # Identity When your app already knows who someone is, they shouldn't have to sign in again to get help. Your server signs a short-lived identity token for the signed-in user, and the help desk or the chat widget accepts it: they arrive as a verified contact, with their own requests, on any device. A "Contact support" link in your app can do it in one step: ```ts // On your server, when the signed-in user clicks "Contact support" const token = signIdentity(user.id, user.name, user.emailVerified ? user.email : undefined) redirect(`https://acme.blhp.app/identify?token=${token}&next=/new`) ``` They see a confirmation page ("Continue to Acme Support as Dana Lee?"), press Continue, and land on the new request form, signed in. `signIdentity` is under [Sign a token](#sign-a-token). ## Create an identity key Workspace admins create keys in **Settings → Identity keys**, once help desk mode is on. Each key has: - a **key ID** (`kid`), which goes in the token's header; - a **secret**, `bh_id_…`, shown once. Keep it on your server and never put it in a browser or a mobile app. Set them on your server, with the workspace slug: ```sh IDENTITY_KEY_ID=… # the key ID IDENTITY_SECRET=bh_id_… # the secret, shown once WORKSPACE_SLUG=acme ``` Revoking a key rejects every token signed with it from then on. People it already signed in stay signed in until their session ends. ## Sign a token An identity token is a JWT signed with HMAC-SHA256 using the whole secret, including `bh_id_` (don't decode it). Make one on your server for the signed-in user: `sub` is their id in your app, and pass the email only when your app has verified it. ### Node and TypeScript Uses only `node:crypto`: ```ts import { createHmac, randomUUID } from "node:crypto" export function signIdentity(sub: string, name: string, verifiedEmail?: string) { const { IDENTITY_KEY_ID: kid, IDENTITY_SECRET: secret, WORKSPACE_SLUG: aud } = process.env if (!kid || !secret || !aud) throw new Error("Missing identity configuration") const iat = Math.floor(Date.now() / 1000) const encode = (x: unknown) => Buffer.from(JSON.stringify(x)).toString("base64url") const header = encode({ alg: "HS256", kid }) const claims = encode({ sub, name, aud, iat, exp: iat + 120, jti: randomUUID(), ...(verifiedEmail && { email: verifiedEmail, email_verified: true }) }) const input = `${header}.${claims}` return `${input}.${createHmac("sha256", secret).update(input).digest("base64url")}` } ``` ### Python Uses only the standard library: ```python import base64, hashlib, hmac, json, os, time, uuid def sign_identity(sub, name, verified_email=None): def b64(data): return base64.urlsafe_b64encode(data).rstrip(b"=") def encode(obj): return b64(json.dumps(obj, separators=(",", ":")).encode()) iat = int(time.time()) header = {"alg": "HS256", "kid": os.environ["IDENTITY_KEY_ID"]} claims = {"sub": sub, "name": name, "aud": os.environ["WORKSPACE_SLUG"], "iat": iat, "exp": iat + 120, "jti": str(uuid.uuid4())} if verified_email: claims.update(email=verified_email, email_verified=True) data = encode(header) + b"." + encode(claims) mac = hmac.new(os.environ["IDENTITY_SECRET"].encode(), data, hashlib.sha256) return (data + b"." + b64(mac.digest())).decode("ascii") ``` ### Claims | Claim | Required | Meaning | | --- | --- | --- | | `sub` | yes | Your user id, up to 255 characters. It becomes the contact's `external_id`. | | `aud` | yes | The workspace slug. | | `iat`, `exp` | yes | Issued and expiry times, in seconds. `exp` is at most 10 minutes after `iat`; 2 minutes is plenty. | | `jti` | yes | A unique id. Each token works once. | | `name` | no | Their display name, up to 120 characters. | | `email` | no | Their email address. | | `email_verified` | no | `true` when your app has verified `email`. Without it, the email is display-only and never used to match or reach anyone. | | `link_existing` | no | `true` to link them to requests they sent earlier by email. See below. | The header is `{ "alg": "HS256", "kid": "" }`. ### Linking requests sent earlier by email Someone may have emailed support, or signed in on the help desk with an email code, before your app signed them in. By default, a verified email in your token does **not** connect them to that earlier contact and its private requests: a token from your app is trusted for who the user is in your app, not for everything that address has done before. They get a new contact, and their address shows as unverified until you decide. Add `link_existing: true`, next to `email_verified: true`, only when this user should see the requests they made earlier by email: ```ts const claims = encode({ sub, name, aud, iat, exp: iat + 120, jti: randomUUID(), email: verifiedEmail, email_verified: true, link_existing: true }) ``` It links only to a contact that isn't linked to one of your users yet, and a `sub` you've signed in before always keeps its own contact, so two contacts are never merged. ## Use the token ### The sign-in link Send people to the help desk with the token in a link: ``` https://acme.blhp.app/identify?token=&next=/new ``` The link opens a confirmation page, "Continue to Acme Support as Dana Lee?", which says so when someone else is already signed in in that browser. Opening the link signs nobody in, so a link that's forwarded, previewed by a chat app or prefetched can't sign anyone in either. Pressing **Continue** posts the token from the page, signs this browser in for 30 days, and goes to `next`: a path on the help desk such as `/new`, `/requests` or `/requests/12`. Anything else goes to the home page. If the token has expired or was used, the page says so; send them back to your app for a new link. Make the token when they click, not when you render the page, so it's fresh: it lasts as long as `exp` allows, and works once. ### From the help desk page Code running on the help desk site itself can post the token instead: ```js await fetch("/api/apps/acme/identify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }) ``` It works only from the help desk's own origin. This way, requests this browser sent before signing in move to the contact too. ### In the chat widget and the API Pass the token to the [chat widget](https://blhp.app/docs/widget#identifying-users) as `identityToken`, or exchange it for an [API session](https://blhp.app/docs/api#end-user-sessions) with a publishable key: ```sh curl -X POST https://api.blhp.app/v1/sessions \ -H "Authorization: Bearer $BELLHOP_PUBLISHABLE_KEY" \ -H "Origin: https://app.example.com" \ -H "Content-Type: application/json" \ -d "{\"identity_token\": \"$IDENTITY_TOKEN\"}" ``` ```json { "token": "bh_us_EXAMPLE_SESSION_TOKEN", "expires_at": "2026-11-05T21:30:12.000Z", "user": { "id": "ct_5a1e0d47-7b0e-4c55-9f43-1f3c6f1b8e21", "name": "Dana Lee", "email": "dana@example.com", "external_id": "user_8412", "verified_via": "host", "locale": "en", "anonymous": false } } ``` If your server can call the API, it can skip the token and start the session with a secret key directly: `POST /v1/sessions` with `{ "user": { … } }` takes the same fields, including `link_existing`. ## Email verification Not everyone comes from your app. On the help desk site, people can also sign in with a 6-digit code sent to their email, and they become a contact verified by email. Codes last 10 minutes, work once, allow 5 tries and only in the browser that asked for them. Separately, the team can ask a requester to prove an address on a request, such as "the email on your billing account". It's useful before changing an account. Team members do it from the request, agents with `request_verification` over [MCP](https://blhp.app/docs/mcp), and your backend with `POST /v1/conversations/:id/verifications`. Bellhop emails that address a code that only the requester can enter, on the request or in the chat widget. The team and agents see the result, never the code: - the request's `verifications` list shows it as `pending`, then `verified`, `failed` or `expired`; - a `verification.completed` [webhook](https://blhp.app/docs/webhooks) fires with the result in `verification`; - `get_issue` and `get_contact` over MCP include it. A contact's `verified_via` says how they were verified: `host` (your app vouched for them) or `email` (they entered a code).