Core resources
Human handoff
Everything that happens after a chatbot escalates to a person: who is on duty, who gets the conversation, how long the customer waits, and what the operator can do with it. Built so another application — a CRM, a helpdesk, your own dashboard — can be the agent console while Bolcho runs the queue.
The shape of it
Your app owns the people. Bolcho owns the queue. You push a roster of operators and a heartbeat; Bolcho decides who gets the next conversation, measures how long customers waited, returns work when someone disappears, and keeps an audit trail. Your app renders the inbox and calls the actions below.
0 — Turn it on
handoffEnabled on the assistant (PATCH /agents/{id}) is the master switch, and it is off by default. It gates every route to a person: the assistant's own escalation, the widget's "Talk to a person", a "Talk to a human" Home button, and a handoff step in a flow. With it off, a customer who asks is told plainly that they cannot be put through — never parked in a queue nobody is watching.
With it on, the chat brain gains a request_handoff tool and escalates the moment somebody asks for a person, or when it cannot help. This is the path most escalations take: a Home button and a flow step both require the visitor to be somewhere specific, whereas asking in plain language works at any point in the conversation, in any language.
GET /agents/{id}/handoff-readiness returns every precondition as a checked list — plan, switch, roster, who is on duty, whether any route exists, and whether your server URL is subscribed to the right events. It is what the dashboard renders, and it is the fastest way to find out why an integration looks silent.{
"ready": false,
"destination": "webhook",
"checks": [
{ "id": "plan", "label": "Your plan includes live handoff", "ok": true },
{ "id": "enabled", "label": "Handoff is turned on for this chatbot", "ok": true },
{ "id": "team", "label": "Somebody can answer", "ok": true, "detail": "3 people on the roster." },
{ "id": "on_duty", "label": "Somebody is on duty", "ok": false, "severity": "warning" },
{ "id": "trigger", "label": "A customer can ask for a person", "ok": true },
{ "id": "destination", "label": "Your system is set up to receive handoffs", "ok": false,
"detail": "No server URL yet, so handoffs have nowhere to go." }
]
}1 — Register your operators
POST /inbox/operators/sync replaces the roster. Bolcho cannot know who was hired or who is on shift, so this is the one thing it must be told. Operators absent from a sync are marked unavailable, never deleted — a partial sync degrades to "nobody is available" rather than a wiped team and a broken audit trail. Then call POST /inbox/operators/heartbeat every ~30s from each open inbox.
2 — Choose how conversations are routed
Per chatbot, via PATCH /agents/{id}:
- manual — lands in a shared queue for someone to claim (the default)
- round_robin — the eligible operator who has gone longest without one
- least_busy — the eligible operator holding the fewest
Eligible means available, heartbeat fresh, covers this chatbot, and under maxConcurrent — all four. If nobody qualifies the conversation stays in the queue and the customer is told honestly; Bolcho will never invent an assignee to make a queue look tidy.
3 — Set the clocks
handoffSlaSec is how long a customer may wait for their first human reply before the conversation is flagged breached and raised in the queue. The clock stops on the first delivered reply, not on assignment — a claimed-then-ignored conversation is exactly the failure this measures. handoffAbandonSec marks a conversation abandoned after that much customer silence while still waiting; null means never.
Office hours
handoffHours on the assistant decides what an escalating customer is told. The handoff is never blocked — someone asking for a person has already decided the bot cannot help, and refusing to escalate strands them twice. What changes is the message and the expectation.
{
"handoffEnabled": true,
"handoffDestination": "webhook",
"handoffRouting": "round_robin",
"handoffSlaSec": 300,
"handoffAbandonSec": 1800,
"handoffAwayMessage": "We're closed until 9am — leave a message and we'll reply first thing.",
"handoffBusyMessage": "Everyone is with another customer. We'll be with you shortly.",
"handoffHours": {
"timezone": "Asia/Kolkata",
"days": { "mon": [{ "from": "09:00", "to": "18:00" }] },
"holidays": ["2026-12-25"]
}
}Acting as one of your operators
Your app authenticates with ONE workspace API key but has many agents behind it. Send x-voxa-actor: <externalId> on every inbox action so Bolcho knows who is claiming, replying, transferring and resolving — otherwise every action is anonymous and ?mine=true returns everyone's work. Add x-voxa-actor-name to make the audit timeline readable. Both headers are ignored for dashboard (JWT) callers, so a logged-in user cannot act as somebody else.
GET /public/chatbot/conversations/{id}/messages) returns a handoff object: either {state:"assigned", agentName, agentAvatarUrl} so the widget can say "Priya joined", or {state:"waiting", position, estimatedWaitSec}. The estimate is the median time-to-first-reply over recent conversations and is null when there is no history — an invented wait reads as a promise.Being told a customer is waiting
Two server messages carry the queue to you, so you never poll for it. Set a Server URL on the assistant and make sure Handoff requested and Customer replied are ticked under Server messages. Both are on by default for new assistants, and turning handoffEnabled on with a webhook destination subscribes them if they are missing — assistants created before those events existed keep whatever they were made with, and nothing is delivered for a type that is not ticked. That is the single most common reason an integration looks silent.
conversation-handoff fires once, on the transition into waiting — not on every message afterwards. Routing has already run, so assignedTo tells you who it went to (null means it is unassigned and anyone may claim it).
{
"message": {
"type": "conversation-handoff",
"timestamp": "2026-08-19T08:20:48.869Z",
"agent": { "id": "…", "name": "Support Assistant" },
"conversation": {
"id": "6e556ba1-…",
"channel": "widget",
"status": "handoff",
"handoffAt": "2026-08-19T08:20:48.812Z",
"customer": "Asha",
"leadPhone": "+919876543210",
"leadEmail": null
}
}
}conversation-message fires when the customer says something more while one of your people owns the conversation, so their screen updates without polling.
{
"message": {
"type": "conversation-message",
"timestamp": "2026-08-19T08:21:06.131Z",
"agent": { "id": "…", "name": "Support Assistant" },
"conversation": { "id": "6e556ba1-…", "channel": "widget", "status": "handoff" },
"message": { "role": "user", "content": "are you there?" }
}
}Verifying it really came from us
When a signing secret is set we send x-voxa-signature: an HMAC-SHA256, hex-encoded, over the raw request bytes. Compute it over the body before any JSON parsing — re-serialising changes the bytes and the check will never match — and compare in constant time. The type is also in x-voxa-message, so you can route before parsing.
timestamp is far from now.Catching up on what you missed
GET /inbox/changes?since= returns everything that changed after a timestamp, oldest first, with a nextSince cursor to store. Poll it every few minutes as a safety net — a deploy, a restart or thirty seconds of unreachability is enough to lose a handoff otherwise, and the customer is left waiting in a queue you were never told about. It also surfaces abandoned, which the main list cannot show.
How many people you may register
Seats are a plan limit. GET /entitlements returns limits.maxOperators alongside usage.operators, so you can render "3 of 5 agents" without hard-coding anyone's pricing. Going over returns 402 with code: "quota_exceeded", the limit and what you sent — and the whole sync is refused rather than trimmed, so you never end up believing more people are staffing the queue than really are. On a plan without live handoff every roster call returns 403.
/inbox/changes agents:readWhat changed since…
Everything updated after a timestamp, oldest first — the recovery path for a webhook that never arrived. Store `nextSince` and pass it back next time.
Query parameters
| since* | string | ISO timestamp of the last change you processed. |
| limit | number | 1–200. Default 100. |
curl "https://api.bolchoai.in/v1/inbox/changes?since=2026-08-19T08:00:00Z" -H "Authorization: Bearer $BOLCHO_API_KEY"Response
{
"data": [
{ "id": "…", "channel": "widget", "status": "handoff", "assignedTo": null, "waitingSince": "…", "updatedAt": "…" }
],
"nextSince": "2026-08-19T08:21:06.131Z",
"hasMore": false
}/inbox/operators/sync agents:writeSync the operator roster
Replace the workspace's operators. Anyone absent is marked unavailable rather than deleted.
Body
| operators* | object[] | The full roster. |
| operators[].externalId* | string | This person's id in YOUR system. Stable — it is what assignment is recorded against. |
| operators[].name* | string | Internal name, shown to your own staff. |
| operators[].displayName | string | Shown to the CUSTOMER when they join a chat. Falls back to `name`. |
| operators[].avatarUrl | string | Shown to the customer alongside displayName. |
| operators[].email | string | Informational. |
| operators[].available | boolean | Are they taking conversations right now? |
| operators[].maxConcurrent | number | Live conversations they may hold at once. Default 5. |
| operators[].agentIds | string[] | Chatbots they cover. Empty or omitted = all of them. |
| operators[].skills | string[] | Free-form capabilities, for skills-based routing later. |
curl -X POST https://api.bolchoai.in/v1/inbox/operators/sync -H "Authorization: Bearer $BOLCHO_API_KEY" -H "Content-Type: application/json" \
-d '{"operators":[{"externalId":"u_123","name":"Priya Sharma","displayName":"Priya","available":true,"maxConcurrent":3}]}'Response
{
"data": [
{ "externalId": "u_123", "name": "Priya Sharma", "displayName": "Priya", "available": true, "online": true, "openConversations": 2, "maxConcurrent": 3, "agentIds": [], "skills": [] }
],
"total": 1
}/inbox/operators/heartbeat agents:writeHeartbeat / set availability
Called on a timer (~30s) while an operator has their inbox open, and whenever they flip Available/Away. An unknown externalId is auto-registered rather than rejected.
Body
| externalId* | string | Who is checking in. |
| available | boolean | Omit to send a pure heartbeat without changing their status. |
curl -X POST https://api.bolchoai.in/v1/inbox/operators/heartbeat -H "Authorization: Bearer $BOLCHO_API_KEY" -H "Content-Type: application/json" \
-d '{"externalId":"u_123","available":true}'Response
{ "online": 3 }/inbox/operators agents:readList operators
The roster with who is genuinely online and how loaded each person is. `available` is what they said; `online` is whether their heartbeat is still fresh.
curl https://api.bolchoai.in/v1/inbox/operators -H "Authorization: Bearer $BOLCHO_API_KEY"/inbox/operators/{externalId} agents:writeRemove an operator
Hard-delete. Prefer omitting them from a sync, which keeps the audit trail intact.
Path parameters
| externalId* | string | Their id in your system. |
curl -X DELETE https://api.bolchoai.in/v1/inbox/operators/$EXTERNALID \
-H "Authorization: Bearer $BOLCHO_API_KEY"Response
{ "ok": true }/inbox agents:readList conversations
Conversations needing a person, newest activity first, with a last-message preview.
Query parameters
| mine | boolean | Only the caller's own — resolved from `x-voxa-actor`. |
| status | string | `handoff`, `open`, `resolved` or `abandoned`. |
curl https://api.bolchoai.in/v1/inbox -H "Authorization: Bearer $BOLCHO_API_KEY" -H "x-voxa-actor: u_123"/inbox/{id}/claim agents:writeClaim a conversation
Take it. Race-safe: two operators claiming together means one gets a 409 naming who won, rather than both silently believing they have it.
Path parameters
| id* | uuid | Conversation id. |
curl -X POST https://api.bolchoai.in/v1/inbox/$ID/claim \
-H "Authorization: Bearer $BOLCHO_API_KEY"Response
{ "ok": true, "assignedTo": "u_123" }/inbox/{id}/release agents:writeRelease back to the queue
Only the operator holding it may release it.
Path parameters
| id* | uuid | Conversation id. |
curl -X POST https://api.bolchoai.in/v1/inbox/$ID/release \
-H "Authorization: Bearer $BOLCHO_API_KEY"Response
{ "ok": true, "assignedTo": null }/inbox/{id}/transfer agents:writeTransfer to a colleague
Hand it to a named operator, keeping the customer with a person. Distinct from release, which drops them back into an anonymous queue and loses who was dealing with it.
Path parameters
| id* | uuid | Conversation id. |
Body
| toExternalId* | string | The operator to hand it to. |
curl -X POST https://api.bolchoai.in/v1/inbox/c_123/transfer -H "Authorization: Bearer $BOLCHO_API_KEY" -H "Content-Type: application/json" \
-H "x-voxa-actor: u_123" -d '{"toExternalId":"u_456"}'Response
{ "ok": true, "assignedTo": "u_456", "assignedToName": "Rahul Verma" }/inbox/{id}/reply agents:writeReply to the customer
Delivered to the customer's channel first, then persisted — so a transcript never claims you said something they did not receive. On WhatsApp, outside Meta's 24-hour window the reply is stored and reported as undelivered with the reason.
Path parameters
| id* | uuid | Conversation id. |
Body
| content* | string | What to say. |
curl -X POST https://api.bolchoai.in/v1/inbox/$ID/reply \
-H "Authorization: Bearer $BOLCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "string"
}'Response
{ "id": "m_1", "role": "assistant", "content": "…", "delivered": true, "deliveryFailedReason": null }/inbox/{id}/resolve agents:writeResolve
Mark it done. Only the operator holding it may resolve it.
Path parameters
| id* | uuid | Conversation id. |
curl -X POST https://api.bolchoai.in/v1/inbox/$ID/resolve \
-H "Authorization: Bearer $BOLCHO_API_KEY"Response
{ "ok": true }/inbox/{id}/reopen agents:writeHand back to the bot
Return the conversation to the assistant and clear the assignment.
Path parameters
| id* | uuid | Conversation id. |
curl -X POST https://api.bolchoai.in/v1/inbox/$ID/reopen \
-H "Authorization: Bearer $BOLCHO_API_KEY"Response
{ "ok": true }/inbox/{id}/notes agents:writeAdd a private note
Never reaches the customer and never enters the transcript — notes are events, not messages, so they cannot leak into an export, an analysis, or a WhatsApp send.
Path parameters
| id* | uuid | Conversation id. |
Body
| body* | string | The note. |
curl -X POST https://api.bolchoai.in/v1/inbox/$ID/notes \
-H "Authorization: Bearer $BOLCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "string"
}'Response
{ "ok": true }/inbox/{id}/timeline agents:readConversation timeline
Everything that happened that was not a message: assignment, transfers, releases, notes, tags, SLA breaches, abandonment — in order, with who did it.
Path parameters
| id* | uuid | Conversation id. |
curl https://api.bolchoai.in/v1/inbox/c_123/timeline -H "Authorization: Bearer $BOLCHO_API_KEY"Response
{
"data": [
{ "type": "auto_assigned", "actorName": "Priya Sharma", "body": "Routed to Priya Sharma", "createdAt": "2026-08-04T09:12:44.000Z" },
{ "type": "transferred", "actorName": "Priya Sharma", "body": "Transferred to Rahul Verma", "data": { "from": "u_123", "to": "u_456" } },
{ "type": "note", "actorName": "Priya Sharma", "body": "Customer already called twice about this." },
{ "type": "sla_breached", "actorName": "System", "body": "No human reply after 412s (target 300s)", "data": { "waitedSec": 412, "targetSec": 300 } }
],
"total": 4
}/inbox/canned-responses agents:readList saved replies
Workspace-wide entries plus any scoped to this chatbot.
Query parameters
| agentId | uuid | Narrow to one chatbot's replies plus the shared ones. |
curl https://api.bolchoai.in/v1/inbox/canned-responses \
-H "Authorization: Bearer $BOLCHO_API_KEY"Response
{ "data": [ { "id": "cr_1", "shortcut": "hours", "title": "Opening hours", "body": "We're open 9-6, Monday to Saturday." } ], "total": 1 }/inbox/canned-responses agents:writeSave a reply
Upserts on `shortcut`, which is unique per workspace — saving under a name already in use replaces it rather than erroring.
Body
| shortcut* | string | Typed after "/" in the composer. Lower-cased; a leading slash is stripped. |
| title* | string | Shown in the picker. |
| body* | string | The text inserted. |
| agentId | uuid | Scope to one chatbot. Omit for workspace-wide. |
| sortOrder | number | Order in the picker. |
curl -X POST https://api.bolchoai.in/v1/inbox/canned-responses \
-H "Authorization: Bearer $BOLCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"shortcut": "string",
"title": "string",
"body": "string",
"agentId": "00000000-0000-0000-0000-000000000000",
"sortOrder": 123
}'/inbox/canned-responses/{id} agents:writeDelete a saved reply
Remove it.
Path parameters
| id* | uuid | Saved reply id. |
curl -X DELETE https://api.bolchoai.in/v1/inbox/canned-responses/$ID \
-H "Authorization: Bearer $BOLCHO_API_KEY"Response
{ "ok": true }/inbox/presence/{agentId} agents:writeReport presence (legacy)
Reports a COUNT of available operators for one assistant. Superseded by the operator roster, which knows WHO is available and can therefore route; kept working for integrations that only ever pushed a number. If you sync operators, this is maintained for you.
Path parameters
| agentId* | uuid | Assistant id. |
Body
| available* | number | How many operators are at their desk. 0 = nobody. |
curl -X POST https://api.bolchoai.in/v1/inbox/presence/$AGENTID \
-H "Authorization: Bearer $BOLCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"available": 123
}'Response
{ "ok": true }