# HTTP routes

URL: https://better-helpdesk.com/docs/reference/routes/

Every route the handler serves under basePath, with its method, who may call it, and what it does.



`helpdesk.handler(request: Request): Promise<Response>` serves every route below under `basePath` (`/api/helpdesk` by default). Paths match with or without a trailing slash; the widget and the agent UI request them with one. Ids are UUIDs. Request and response bodies are JSON unless a row says otherwise. Related guides: [Receive email](/docs/email/receive), [Run jobs and retention](/docs/operations/jobs).

<Callout type="warn">
  The `widget/*` and `agent/*` routes are the contract between the handler and the widget and agent UI that ship with it. They can change in any minor release and are not an API for other callers. Host code calls the helpdesk through the functions `buildHelpdesk` returns, as in [Call the helpdesk from your own code](/docs/app/own-code). `inbound`, `jobs` and `rate` are the routes meant for callers outside the package.
</Callout>

```ts title="app/api/helpdesk/[...slug]/route.ts"
import { helpdesk } from '@/lib/helpdesk';

const handle = (request: Request) => helpdesk.handler(request);
export { handle as GET, handle as POST, handle as PATCH, handle as PUT, handle as DELETE, handle as OPTIONS };
```

## Callers [#callers]

| Caller     | How the handler knows them                                                                                                                      | Routes     |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| Customer   | `identify(request)`; else, when `identityTokenSecret` is set, an `x-helpdesk-identity` token signed with it; else an `x-helpdesk-visitor` token | `widget/*` |
| Agent      | `identify(request)` returning `isAgent: true`. 401 without an identity, 403 when it is not an agent, 403 on a change from a `viewer`            | `agent/*`  |
| Anyone     | Nothing                                                                                                                                         | `health`   |
| Email link | The signature in the query string                                                                                                               | `rate`     |
| Mail relay | `Authorization: Bearer {inboundWebhookSecret}`                                                                                                  | `inbound`  |
| Scheduler  | `Authorization: Bearer {jobsSecret}`                                                                                                            | `jobs`     |

A visitor token is issued by `POST widget/conversations` to an anonymous visitor, and stops working after 30 days unused. An invalid identity token is refused with 401.

A customer **can see** a conversation they opened, one they were added to as a participant, and one shared with a company the caller's identity lists in `orgs`. A conversation they cannot see answers 404. Routes marked **author** also require the caller to be the conversation's own contact, and answer 403 otherwise.

## Widget [#widget]

| Method  | Path                                                            | Who                                        | What it does                                                                                                                                                                                           |
| ------- | --------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`   | `widget/session`                                                | Anyone                                     | The caller, their conversations and what the widget shows; `?inbox=`, `?locale=` and `?org=`, the organization the widget files under, which `resolveTier` is asked about for the priorities on offer. |
| `GET`   | `widget/form`                                                   | Anyone                                     | A form token, `{ token, minSeconds }`, for a visitor's first message; see [Keep bots out of the widget](/docs/widget/bot-protection).                                                                  |
| `POST`  | `widget/conversations`                                          | Customer, or a visitor on a `public` inbox | Opens a conversation; see [the body](#opening-a-conversation).                                                                                                                                         |
| `GET`   | `widget/conversations/{id}`                                     | Can see                                    | The conversation, its public messages and its attachments.                                                                                                                                             |
| `POST`  | `widget/conversations/{id}/messages`                            | Can see                                    | Adds a message. From the author, `attachments` takes up to 20 ids of uploads started below; each file must be in storage (409 otherwise) and on no message yet (400 otherwise).                        |
| `PATCH` | `widget/conversations/{id}`                                     | Author                                     | Shares it with the company, or marks it resolved.                                                                                                                                                      |
| `POST`  | `widget/conversations/{id}/rating`                              | Author                                     | Rates it `good` or `bad`, with an optional comment.                                                                                                                                                    |
| `POST`  | `widget/conversations/{id}/seen`                                | Can see                                    | Marks it read by its author.                                                                                                                                                                           |
| `POST`  | `widget/conversations/{id}/attachments`                         | Author                                     | Starts an upload and answers a presigned form; 400 without `storage` or on an inbox with `uploads: false`.                                                                                             |
| `POST`  | `widget/conversations/{id}/attachments/{attachmentId}/complete` | Author                                     | Marks the upload done, as a file of the opening message. A file for a later message is sent with it instead.                                                                                           |
| `GET`   | `widget/conversations/{id}/attachments/{attachmentId}`          | Can see                                    | Redirects to a presigned download URL.                                                                                                                                                                 |
| `GET`   | `widget/help`                                                   | Anyone                                     | Searches the host's documentation through `help`; `?q=` and `?locale=`.                                                                                                                                |
| `GET`   | `widget/events`                                                 | Customer                                   | An event stream of changes to the conversations the caller can see, internal notes left out. 404 without [`realtime`](/docs/reference/configuration#conversations).                                    |

A conversation in `widget/session` and `widget/conversations/{id}` carries its `kind`, `chat` or `ticket`, `typing`, true while an agent writes a reply, `respondBy`, the time the team aims to answer by while nobody has, and `ratingScore`. `widget/session` says with `realtime` whether `widget/events` is there, carries with `captcha` the adapter's `client` when this caller must solve it (`null` otherwise), lists under the inbox the `priorities` this caller may pick with their descriptions (`null` for a visitor or a tier the inbox does not offer them to), and carries `priorityLabels` and `ratingScale`.

`POST widget/conversations/{id}/rating` takes `{ rating: 'good' | 'bad', comment? }` on the default scale and `{ score: 1..5, comment? }` when `ratingScale` is 5; the other shape is refused with 400.

### Opening a conversation [#opening-a-conversation]

The body of `POST widget/conversations`, which `helpdesk.createConversation(request, input)` takes as well:

<TypeTable
  type="{
  inbox: { type: 'string', required: true, description: <>A key of <code>inboxes</code>. 400 otherwise.</> },
  type: { type: 'string', required: true, description: <>One of <code>types</code>. 400 otherwise.</> },
  body: { type: 'string', required: true, description: '1 to 20,000 characters.' },
  subject: { type: 'string', description: <>Up to 200 characters. Unset, taken from the first line of <code>body</code>.</> },
  kind: { type: &#x22;'chat' | 'ticket'&#x22;, description: <>How the customer started it. Unset, a conversation with a <code>subject</code> is a ticket and one without is a chat. Email always arrives as a ticket.</> },
  context: { type: 'object', description: <>What the widget captured. See <a href=&#x22;#context&#x22;>context</a>.</> },
  segment: { type: 'string', description: <>A <code>value</code> of the inbox's <code>qualify</code> options. 400 otherwise.</> },
  priority: { type: 'string', description: <>One of the priorities the inbox offers this customer, by their tier; 400 otherwise, and always for a visitor. Unset, the inbox's <code>defaultPriority</code>.</> },
  orgId: { type: 'string', description: <>One of the caller's <code>orgs</code>; files the conversation under that company. 403 otherwise.</> },
  sharedWithCompany: { type: 'boolean', description: 'Shares it with that company.' },
  name: { type: 'string', description: &#x22;An anonymous visitor's name, up to 200 characters.&#x22; },
  email: { type: 'string', description: &#x22;An anonymous visitor's address, up to 320 characters. Required for them (400 otherwise).&#x22; },
  website: { type: 'string', description: <>A honeypot. When an anonymous visitor fills it, nothing is stored and <code>conversation</code> is <code>null</code>.</> },
  formToken: { type: 'string', description: <>From <code>GET widget/form</code>. Required for an anonymous visitor unless <code>botProtection</code> is <code>false</code>: 400 <code>Form check failed</code> without one at least <code>minSeconds</code> and at most a day old.</> },
  captchaToken: { type: 'string', description: <>The captcha's answer, when <code>captcha</code> is configured for this caller: 400 <code>Captcha required</code> without it, <code>Captcha failed</code> when <code>verify</code> refuses it.</> },
}"
/>

An unidentified caller on an inbox that is not `public` gets 401. A blocked contact or address gets 404. Over the rate limit, 429.

#### context [#context]

<TypeTable
  type="{
  url: { type: 'string', description: 'http or https, up to 2,000 characters.' },
  title: { type: 'string', description: 'Up to 500 characters.' },
  userAgent: { type: 'string', description: 'Up to 500 characters.' },
  viewport: { type: 'string', description: 'Up to 50 characters.' },
  locale: { type: 'string', description: &#x22;Up to 20 characters. An anonymous visitor's contact takes it as its locale.&#x22; },
  appVersion: { type: 'string', description: 'Up to 100 characters.' },
  host: { type: 'Record<string, string>', description: 'The host context: up to 20 keys of up to 100 characters, values up to 500.' },
  utm: { type: 'Record<string, string>', description: 'Up to 20 keys of up to 100 characters, values up to 200.' },
  errors: { type: 'string[]', description: 'Up to 20 strings of up to 2,000 characters.' },
  referrer: { type: 'string', description: 'Up to 2,000 characters.' },
  landingPage: { type: 'string', description: 'Up to 2,000 characters.' },
}"
/>

## Agent [#agent]

Every route here requires an agent. The `agent/deals`, `agent/canned` and `agent/overview` routes answer 404 when [`features`](/docs/reference/configuration#crm) switched their section off. A `viewer` may call every `GET`, and of the rest only `PATCH agent/me`, `POST agent/notifications/seen` and the `agent/views` routes; anything else answers 403. Once any agent has the `supervisor` role, `PUT agent/settings`, `DELETE agent/agents/{id}` and `GET agent/audit` answer 403 to everyone else.

Every request that is not a `GET` and succeeded is written to [`audit_log`](/docs/database/tables#audit_log) with the agent, the route, the id in its path and the names of the fields it sent. `POST agent/conversations/{id}/typing` and `POST agent/notifications/seen` are not.

| Method   | Path                                                  | What it does                                                                                                                                                                                                                                                                                                                                                                         |
| -------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`    | `agent/me`                                            | The agent, and the configuration the agent UI needs; `aiFeatures` says which [AI switches](/docs/ai/suggestions#choose-what-the-ai-does) are on, all `false` without `ai`, and `auditLog` whether this agent may read the audit log, and `uploads` whether there is a `storage` adapter to attach files with.                                                                        |
| `PATCH`  | `agent/me`                                            | Sets the agent's away time.                                                                                                                                                                                                                                                                                                                                                          |
| `GET`    | `agent/settings`                                      | The settings agents change: `confirmation`, the widget's confirmation text per language; `ai`, the AI switches; `recipients`, the agent ids chosen to receive emails about new requests, empty for the default; and `closed`, the days closed, each `{ date, name? }`.                                                                                                               |
| `PUT`    | `agent/settings`                                      | Changes them. Any field may be left out; `ai` takes any of `classify`, `title`, `summary`, `duplicates`, `draft` and `rewrite` as booleans and keeps the others, `recipients` replaces the list, up to 200 ids, and `closed` replaces the closed days, up to 366, each a `YYYY-MM-DD` date with an optional name.                                                                    |
| `GET`    | `agent/overview`                                      | The Statistics page: volume, response and resolution times with their 90th percentiles, SLA attainment and missed targets, ratings and tags, by inbox, agent, tier, priority and channel.                                                                                                                                                                                            |
| `GET`    | `agent/audit`                                         | The audit log, newest first: `?limit=` entries, 100 by default and at most. `?agentId=` and `?target=` narrow it, and `?before=` and `?beforeId=`, an entry's `createdAt` and `id`, page back from that entry; `?before=` alone skips every entry at that time. Each entry carries `targetName` and, for a conversation, `targetReference`; both are `null` once the target is gone. |
| `GET`    | `agent/unread`                                        | The count of waiting conversations.                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `agent/tags`                                          | The most used conversation tags.                                                                                                                                                                                                                                                                                                                                                     |
| `GET`    | `agent/agents`                                        | The team.                                                                                                                                                                                                                                                                                                                                                                            |
| `DELETE` | `agent/agents/{id}`                                   | Removes an agent from the team.                                                                                                                                                                                                                                                                                                                                                      |
| `GET`    | `agent/conversations`                                 | The inbox list, filtered by the query string, with the counts of all, the agent's own and unassigned conversations under the same filters.                                                                                                                                                                                                                                           |
| `POST`   | `agent/conversations`                                 | Opens a conversation in the agent's name; see [the body](#opening-a-conversation-as-an-agent).                                                                                                                                                                                                                                                                                       |
| `POST`   | `agent/conversations/bulk`                            | Changes several conversations at once; with `reply` or `note`, writes that text to each as a reply or an internal note. A reply assigns each unassigned conversation to the agent, unless the same request set `assigneeId`.                                                                                                                                                         |
| `GET`    | `agent/conversations/{id}`                            | One conversation with its messages, contact, company and timeline.                                                                                                                                                                                                                                                                                                                   |
| `PATCH`  | `agent/conversations/{id}`                            | Changes its status, priority, tier, type, inbox, title, assignee, company, tags or snooze.                                                                                                                                                                                                                                                                                           |
| `POST`   | `agent/conversations/{id}/messages`                   | Adds a reply or an internal note. A reply on an unassigned conversation assigns it to the agent who replied. `attachments` takes up to 20 ids from the route below; each file must be in storage (409 otherwise) and on no message yet (400 otherwise).                                                                                                                              |
| `POST`   | `agent/conversations/{id}/attachments`                | Starts an upload and answers a presigned form. The file shows once a message is sent with its id.                                                                                                                                                                                                                                                                                    |
| `POST`   | `agent/conversations/{id}/typing`                     | Shows the customer that the agent is writing a reply, for a few seconds; the agent UI repeats it while the agent types.                                                                                                                                                                                                                                                              |
| `GET`    | `agent/events`                                        | An event stream of every change, internal notes included. 404 without [`realtime`](/docs/reference/configuration#conversations).                                                                                                                                                                                                                                                     |
| `POST`   | `agent/conversations/{id}/participants`               | Adds a contact to it.                                                                                                                                                                                                                                                                                                                                                                |
| `POST`   | `agent/conversations/{id}/merge`                      | Merges it into another.                                                                                                                                                                                                                                                                                                                                                              |
| `POST`   | `agent/conversations/{id}/draft`                      | An AI draft reply, or a rewrite of the agent's text. 403 while the team has turned that switch off.                                                                                                                                                                                                                                                                                  |
| `POST`   | `agent/conversations/{id}/triage`                     | Runs AI triage now.                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`   | `agent/conversations/{id}/suggestion`                 | Accepts or dismisses the AI suggestion.                                                                                                                                                                                                                                                                                                                                              |
| `GET`    | `agent/conversations/{id}/attachments/{attachmentId}` | Redirects to a presigned download URL.                                                                                                                                                                                                                                                                                                                                               |
| `GET`    | `agent/notifications`                                 | The agent's notifications from the last 30 days: assignments, mentions, customer replies on their conversations, and service-level warnings and breaches (`sla-warning`, `sla-breach`, with the target in `who`).                                                                                                                                                                    |
| `POST`   | `agent/notifications/seen`                            | Marks them read.                                                                                                                                                                                                                                                                                                                                                                     |
| `GET`    | `agent/views`                                         | Saved views.                                                                                                                                                                                                                                                                                                                                                                         |
| `POST`   | `agent/views`                                         | Saves a view.                                                                                                                                                                                                                                                                                                                                                                        |
| `PATCH`  | `agent/views/{id}`                                    | Renames a view.                                                                                                                                                                                                                                                                                                                                                                      |
| `DELETE` | `agent/views/{id}`                                    | Deletes a view.                                                                                                                                                                                                                                                                                                                                                                      |
| `GET`    | `agent/canned`                                        | Canned replies.                                                                                                                                                                                                                                                                                                                                                                      |
| `POST`   | `agent/canned`                                        | Adds a canned reply.                                                                                                                                                                                                                                                                                                                                                                 |
| `PATCH`  | `agent/canned/{id}`                                   | Replaces a canned reply's title, body and language.                                                                                                                                                                                                                                                                                                                                  |
| `DELETE` | `agent/canned/{id}`                                   | Deletes a canned reply.                                                                                                                                                                                                                                                                                                                                                              |
| `GET`    | `agent/contacts`                                      | Contacts.                                                                                                                                                                                                                                                                                                                                                                            |
| `POST`   | `agent/contacts`                                      | Adds a contact.                                                                                                                                                                                                                                                                                                                                                                      |
| `GET`    | `agent/contacts/{id}`                                 | One contact with its timeline.                                                                                                                                                                                                                                                                                                                                                       |
| `PATCH`  | `agent/contacts/{id}`                                 | Changes a contact, or blocks it.                                                                                                                                                                                                                                                                                                                                                     |
| `POST`   | `agent/contacts/{id}/merge`                           | Merges another contact into this one.                                                                                                                                                                                                                                                                                                                                                |
| `DELETE` | `agent/contacts/{id}`                                 | Deletes a contact and what they wrote.                                                                                                                                                                                                                                                                                                                                               |
| `GET`    | `agent/companies`                                     | Companies.                                                                                                                                                                                                                                                                                                                                                                           |
| `POST`   | `agent/companies`                                     | Adds a company.                                                                                                                                                                                                                                                                                                                                                                      |
| `GET`    | `agent/companies/{id}`                                | One company with its timeline.                                                                                                                                                                                                                                                                                                                                                       |
| `PATCH`  | `agent/companies/{id}`                                | Changes a company.                                                                                                                                                                                                                                                                                                                                                                   |
| `GET`    | `agent/deals`                                         | Deals.                                                                                                                                                                                                                                                                                                                                                                               |
| `POST`   | `agent/deals`                                         | Adds a deal.                                                                                                                                                                                                                                                                                                                                                                         |
| `PATCH`  | `agent/deals/{id}`                                    | Changes a deal.                                                                                                                                                                                                                                                                                                                                                                      |
| `DELETE` | `agent/deals/{id}`                                    | Deletes a deal.                                                                                                                                                                                                                                                                                                                                                                      |
| `POST`   | `agent/activities`                                    | Logs a note, call or meeting.                                                                                                                                                                                                                                                                                                                                                        |

The inbox list reads `inbox`, `status`, `assignee`, `q`, `tag`, `priority`, `days`, `tier`, `channel` and `sort` from the query string; `sort` is `waiting`, `priority` or `sla`, the nearest deadline first.

### Opening a conversation as an agent [#opening-a-conversation-as-an-agent]

The body of `POST agent/conversations`, which `helpdesk.openConversation()` takes as well with `agentUserId` added:

<TypeTable
  type="{
  inbox: { type: 'string', required: true, description: <>A key of <code>inboxes</code>. 400 otherwise.</> },
  type: { type: 'string', required: true, description: <>One of <code>types</code>. 400 otherwise.</> },
  subject: { type: 'string', required: true, description: '1 to 200 characters.' },
  body: { type: 'string', required: true, description: &#x22;The agent's message, 1 to 20,000 characters; the customer is emailed it.&#x22; },
  contactId: { type: 'string', description: 'The contact to write to. 400 when unknown.' },
  email: { type: 'string', description: <>Instead of <code>contactId</code>: the address to write to. A contact that holds it is used, else one is made with <code>name</code>.</> },
  name: { type: 'string', description: 'Up to 200 characters, for a contact made from the address.' },
  companyId: { type: 'string | null', description: &#x22;The company to file it under; the contact's own unless set.&#x22; },
  priority: { type: 'string', description: <>Unset, the inbox's <code>defaultPriority</code>.</> },
  tier: { type: 'string | null', description: <>One of <code>tiers</code>. Unset, <code>resolveTier</code> decides.</> },
  tags: { type: 'string[]', description: 'Up to 50.' },
  assigneeId: { type: 'string | null', description: 'An active agent. Unset, the opening agent.' },
}"
/>

A conversation opened this way has the channel `agent`, starts `pending` with the customer's turn, has no response target, and raises `conversation.created` with the agent's message. A blocked contact answers 404.

## Rate [#rate]

| Method | Path   | Caller     | What it does                                                                              |
| ------ | ------ | ---------- | ----------------------------------------------------------------------------------------- |
| `GET`  | `rate` | Email link | An HTML page that asks the customer to confirm the rating in the link, with a button.     |
| `POST` | `rate` | Email link | Records the rating and answers an HTML page. A `bad` rating opens the conversation again. |

The links come in a [`customer-reply`](/docs/email/messages#customer-reply). A forged or expired link answers 404. A conversation already rated answers 200 and records nothing; one that is no longer resolved, or was merged, answers 409. With `ratingScale: 5` the link names a score from 1 to 5, and a score of 1 or 2 opens the conversation again.

## Health [#health]

| Method | Path     | Caller | What it does                                                                                                                    |
| ------ | -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `GET`  | `health` | Anyone | `{ ok: true }` once the database answered one query, `{ ok: false }` with 503 otherwise. For your status page or load balancer. |

## Inbound [#inbound]

| Method | Path      | Caller     | What it does                                                                                                                                                                                                                                                                                                                                      |
| ------ | --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `inbound` | Mail relay | Takes one raw message (`message/rfc822`) as the body and checks DKIM. Signed mail from the conversation's contact or a participant is added to the conversation that its `In-Reply-To` or `References`, or a `+{reference}` recipient address, points to. Any other mail opens a conversation in `inboundInbox`. Answers 202 with `{ ok: true }`. |

Without `inboundWebhookSecret`, or with another bearer token, it answers 401. The message is refused with 400 when empty, and with 413 over 25 MiB or when its headers exceed 64 KiB. Mail from a blocked address, and unsigned automated mail (an out-of-office or a bounce), is accepted with 202 and not stored; signed automated mail that answers a conversation is stored there as an internal system note. A message already stored, by Message-ID, is accepted and ignored. Signed mail from a sender over their hourly budget answers 429; unsigned mail over budget is dropped quietly.

## Jobs [#jobs]

| Method | Path   | Caller    | What it does                                      |
| ------ | ------ | --------- | ------------------------------------------------- |
| `POST` | `jobs` | Scheduler | Runs `helpdesk.runJobs()` and answers its report. |

Without `jobsSecret`, or with another bearer token, it answers 401.

`runJobs({ budgetMs }: { budgetMs?: number } = {})`, with `budgetMs` 20,000 by default, wakes due snoozes, queues due reminders, queues due SLA notices, applies `retentionDays`, then works through due jobs until the budget is spent. It returns:

<TypeTable
  type="{
  woken: { type: 'number', required: true, description: 'Snoozed conversations reopened.' },
  reminders: { type: 'number', required: true, description: 'Reminders queued.' },
  sla: { type: 'number', required: true, description: 'SLA warnings and breaches queued.' },
  retention: { type: 'number', required: true, description: 'Conversations deleted by retention.' },
  jobs: { type: 'number', required: true, description: 'Jobs completed.' },
  failed: { type: 'number', required: true, description: 'Jobs that threw.' },
  errors: { type: 'string[]', required: true, description: <>The steps that failed: <code>wake</code>, <code>reminders</code>, <code>sla</code>, <code>retention</code>.</> },
}"
/>

A failed job runs again after 2, 4, 8 and 16 minutes, and is kept with its last error after the fifth attempt. The job kinds are on [Database](/docs/database/tables#job).

## Origins and CORS [#origins-and-cors]

* A mutation is any method but `GET`, on any route but `rate`, `inbound` and `jobs`. A `POST` mutation must send `Content-Type: application/json` (415 otherwise). `health` takes only `GET`.
* A mutation that carries an `Origin` header must come from the origin of `adminUrl`, or, on a `widget/*` route, from an origin in an inbox's `allowedOrigins`. Otherwise it answers 403.
* `widget/*` responses to an allowed origin carry CORS headers: methods `GET, POST, PATCH, OPTIONS`, headers `content-type, x-helpdesk-visitor, x-helpdesk-identity`, max age 600 seconds. Agent routes never do.
* `OPTIONS` on any path answers 204.

## Errors [#errors]

| Status | Body                                                                                                                                                                                                                              |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `{ error: 'Invalid request', issues }` for a body that fails validation, where `issues` lists the paths of the invalid fields; `{ error: 'Invalid JSON' }` for a body that does not parse; otherwise `{ error }`.                 |
| 404    | `{ error: 'Not found' }` for an unknown route or a record the caller may not see.                                                                                                                                                 |
| 413    | `{ error: 'Too large' }` for a JSON body over 256 KiB or an inbound message over 25 MiB; `{ error: 'File too large' }` for an upload over `maxAttachmentBytes`; `{ error: 'Headers too large' }` for inbound headers over 64 KiB. |
| 500    | `{ error: 'Internal error' }`; the error is logged.                                                                                                                                                                               |
| Other  | `{ error, ids? }`, where `ids` lists the conversations a bulk change failed on.                                                                                                                                                   |

## Limits [#limits]

| Limit                                                                        | Value                                                                                                    |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Anonymous conversations, messages and uploads, per IP address (IPv6 per /64) | `anonymousRateLimit` an hour, 20 by default                                                              |
| Signed-in customer's conversations, messages and uploads                     | `customerRateLimit` an hour, 60 by default                                                               |
| Receipts                                                                     | 200 an hour                                                                                              |
| New-conversation notices to agents                                           | 200 an hour, each for host users and for everyone else                                                   |
| AI triage                                                                    | 100 an hour, each for host users and for everyone else                                                   |
| Inbound email per sender address                                             | 30 an hour, signed and unsigned counted apart                                                            |
| Inbound email per sender domain                                              | 100 an hour, signed and unsigned counted apart; a signed reply from someone on the thread is not counted |
| Message length                                                               | 20,000 characters                                                                                        |
| JSON body                                                                    | 256 KiB                                                                                                  |
| Inbound message                                                              | 25 MiB                                                                                                   |

Over a customer limit, the route answers 429. Over the receipt, notice and triage caps, the conversation is stored and the email or triage is skipped.
