Better Helpdesk
Reference

HTTP 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, Run jobs and retention.

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. inbound, jobs and rate are the routes meant for callers outside the package.

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

CallerHow the handler knows themRoutes
Customeridentify(request); else, when identityTokenSecret is set, an x-helpdesk-identity token signed with it; else an x-helpdesk-visitor tokenwidget/*
Agentidentify(request) returning isAgent: true. 401 without an identity, 403 when it is not an agent, 403 on a change from a vieweragent/*
AnyoneNothinghealth
Email linkThe signature in the query stringrate
Mail relayAuthorization: Bearer {inboundWebhookSecret}inbound
SchedulerAuthorization: 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

MethodPathWhoWhat it does
GETwidget/sessionAnyoneThe 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.
GETwidget/formAnyoneA form token, { token, minSeconds }, for a visitor's first message; see Keep bots out of the widget.
POSTwidget/conversationsCustomer, or a visitor on a public inboxOpens a conversation; see the body.
GETwidget/conversations/{id}Can seeThe conversation, its public messages and its attachments.
POSTwidget/conversations/{id}/messagesCan seeAdds 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).
PATCHwidget/conversations/{id}AuthorShares it with the company, or marks it resolved.
POSTwidget/conversations/{id}/ratingAuthorRates it good or bad, with an optional comment.
POSTwidget/conversations/{id}/seenCan seeMarks it read by its author.
POSTwidget/conversations/{id}/attachmentsAuthorStarts an upload and answers a presigned form; 400 without storage or on an inbox with uploads: false.
POSTwidget/conversations/{id}/attachments/{attachmentId}/completeAuthorMarks the upload done, as a file of the opening message. A file for a later message is sent with it instead.
GETwidget/conversations/{id}/attachments/{attachmentId}Can seeRedirects to a presigned download URL.
GETwidget/helpAnyoneSearches the host's documentation through help; ?q= and ?locale=.
GETwidget/eventsCustomerAn event stream of changes to the conversations the caller can see, internal notes left out. 404 without realtime.

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

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

Prop

Type

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

Prop

Type

Agent

Every route here requires an agent. The agent/deals, agent/canned and agent/overview routes answer 404 when features 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 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.

MethodPathWhat it does
GETagent/meThe agent, and the configuration the agent UI needs; aiFeatures says which AI switches 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.
PATCHagent/meSets the agent's away time.
GETagent/settingsThe 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? }.
PUTagent/settingsChanges 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.
GETagent/overviewThe 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.
GETagent/auditThe 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.
GETagent/unreadThe count of waiting conversations.
GETagent/tagsThe most used conversation tags.
GETagent/agentsThe team.
DELETEagent/agents/{id}Removes an agent from the team.
GETagent/conversationsThe inbox list, filtered by the query string, with the counts of all, the agent's own and unassigned conversations under the same filters.
POSTagent/conversationsOpens a conversation in the agent's name; see the body.
POSTagent/conversations/bulkChanges 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.
GETagent/conversations/{id}One conversation with its messages, contact, company and timeline.
PATCHagent/conversations/{id}Changes its status, priority, tier, type, inbox, title, assignee, company, tags or snooze.
POSTagent/conversations/{id}/messagesAdds 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).
POSTagent/conversations/{id}/attachmentsStarts an upload and answers a presigned form. The file shows once a message is sent with its id.
POSTagent/conversations/{id}/typingShows the customer that the agent is writing a reply, for a few seconds; the agent UI repeats it while the agent types.
GETagent/eventsAn event stream of every change, internal notes included. 404 without realtime.
POSTagent/conversations/{id}/participantsAdds a contact to it.
POSTagent/conversations/{id}/mergeMerges it into another.
POSTagent/conversations/{id}/draftAn AI draft reply, or a rewrite of the agent's text. 403 while the team has turned that switch off.
POSTagent/conversations/{id}/triageRuns AI triage now.
POSTagent/conversations/{id}/suggestionAccepts or dismisses the AI suggestion.
GETagent/conversations/{id}/attachments/{attachmentId}Redirects to a presigned download URL.
GETagent/notificationsThe 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).
POSTagent/notifications/seenMarks them read.
GETagent/viewsSaved views.
POSTagent/viewsSaves a view.
PATCHagent/views/{id}Renames a view.
DELETEagent/views/{id}Deletes a view.
GETagent/cannedCanned replies.
POSTagent/cannedAdds a canned reply.
PATCHagent/canned/{id}Replaces a canned reply's title, body and language.
DELETEagent/canned/{id}Deletes a canned reply.
GETagent/contactsContacts.
POSTagent/contactsAdds a contact.
GETagent/contacts/{id}One contact with its timeline.
PATCHagent/contacts/{id}Changes a contact, or blocks it.
POSTagent/contacts/{id}/mergeMerges another contact into this one.
DELETEagent/contacts/{id}Deletes a contact and what they wrote.
GETagent/companiesCompanies.
POSTagent/companiesAdds a company.
GETagent/companies/{id}One company with its timeline.
PATCHagent/companies/{id}Changes a company.
GETagent/dealsDeals.
POSTagent/dealsAdds a deal.
PATCHagent/deals/{id}Changes a deal.
DELETEagent/deals/{id}Deletes a deal.
POSTagent/activitiesLogs 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

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

Prop

Type

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

MethodPathCallerWhat it does
GETrateEmail linkAn HTML page that asks the customer to confirm the rating in the link, with a button.
POSTrateEmail linkRecords the rating and answers an HTML page. A bad rating opens the conversation again.

The links come in a 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

MethodPathCallerWhat it does
GEThealthAnyone{ ok: true } once the database answered one query, { ok: false } with 503 otherwise. For your status page or load balancer.

Inbound

MethodPathCallerWhat it does
POSTinboundMail relayTakes 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

MethodPathCallerWhat it does
POSTjobsSchedulerRuns 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:

Prop

Type

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.

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

StatusBody
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

LimitValue
Anonymous conversations, messages and uploads, per IP address (IPv6 per /64)anonymousRateLimit an hour, 20 by default
Signed-in customer's conversations, messages and uploadscustomerRateLimit an hour, 60 by default
Receipts200 an hour
New-conversation notices to agents200 an hour, each for host users and for everyone else
AI triage100 an hour, each for host users and for everyone else
Inbound email per sender address30 an hour, signed and unsigned counted apart
Inbound email per sender domain100 an hour, signed and unsigned counted apart; a signed reply from someone on the thread is not counted
Message length20,000 characters
JSON body256 KiB
Inbound message25 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.

On this page