Your AI agent can now work the helpdesk over MCP
Added
-
The MCP server gains six ticketing tools, taking it to fifteen. An external agent can now triage
end to end: read a WhatsApp conversation, open a ticket for it, move it through your pipeline, and
leave the team an internal note — without a human relaying anything.
-
Field names match the REST ticket endpoints exactly, so anything already written against
/api/v1/tickets/translates directly.
- Ticket tools now return the underlying validation message when a call is rejected — for example
stage: Invalid pk "999999" - object does not exist.— instead of a generic failure. An agent can correct its own arguments rather than retrying blindly.
These six tools need the Ticketing add-on enabled for your organization. Without it they return a
tool error saying so, rather than vanishing from
tools/list — so your agent can tell “not
subscribed” apart from “wrong tool name”. Moving a ticket into a stage that notifies the contact still
sends that notification, exactly as a dashboard move would.Status-update templates can name the contact and the ticket
AddedFixed
-
Ticket stages now carry a variable mapping for their notify template, so a status update can say
“Hi Priya, ticket 4821 is now In progress” instead of being limited to static copy. Stage responses
include
notify_template_variables, andPOST /ticket-pipelines/{id}/stages//PATCH /ticket-pipelines/{id}/stages/{stage_id}/accept it. -
The shape is the one broadcasts already use — keyed by component, then by placeholder number:
-
sourceis one of:static— the literalvalue.field— a contact field, resolved per recipient:name,phone,email,company. On a tracker ticket every affected contact gets their own name, from one stage move.ticket— a field of the ticket that moved, identical for everyone on the fan-out:id,title,type,stage,pipeline,priority,severity,assignee,outcome,created_date,due_date. Choice fields render as their labels (“Sev1 — Critical”); dates as21 Aug 2026in your workspace timezone.
Omitting the key on
PATCH leaves the mapping unchanged; {}, null or "" clears it. Clearing
notify_template_id clears the mapping along with it, so a stage never keeps a mapping for a
notification it no longer sends.- Three already-released endpoints were missing from the API Reference and now have their pages:
GET /macros/andPOST /macros/{id}/run/(released 2026-08-18) andPOST /broadcasts/{id}/unschedule/(released 2026-08-09). The endpoints themselves are unchanged — only their documentation was missing.
Tracker tickets: link every affected contact to one ticket
Added
- Tickets can now carry the full set of contacts an issue affects — think an outage, a delayed batch,
or a recalled product tracked as one ticket instead of dozens. Ticket responses include a new
affected_contactsarray ([{id, name, phone}]), andPOST /tickets//PATCH /tickets/{id}/acceptaffected_contact_idswith the contact ids to link. - Stage notifications reach everyone. If a stage has a notify template configured, moving the ticket into that stage now messages the primary contact and every affected contact on WhatsApp — one stage move updates all affected customers at once.
- Changes to the linked list show up in the ticket’s activity feed as
affected_contacts_changed.
affected_contact_ids replaces the whole list on each write — send the complete set of ids you
want linked, not a delta. Omitting the key on PATCH leaves the list unchanged. Contacts must belong
to your workspace; anything else is a 400.Per-contact protections still apply to the fan-out: contacts who opted out and contacts with no
WhatsApp conversation are skipped individually, and one failed send never blocks the others.
Connect an AI agent without pasting an API key (MCP OAuth)
Added
- OAuth 2.1 (authorization code + PKCE) for the MCP server. Instead of creating an API key
and pasting it into your AI client, you now approve the client in the ChatBridge dashboard and it
receives its own token.
POST /mcpaccepts either credential. - Standard discovery documents so a client can find the flow by itself:
GET /.well-known/oauth-protected-resourceandGET /.well-known/oauth-authorization-server. A401from/mcpnow includes aWWW-Authenticateheader pointing at the first of these. GET /oauth/mcp/authorize,POST /oauth/mcp/token(authorization_codeandrefresh_tokengrants), andPOST /oauth/mcp/revoke.- On the consent screen you choose exactly what the agent gets, from
chat.view,chat.send_message,chat.assign,contact.view,contact.edit,template.view, andmacro.view— you can grant less than the client asked for. - Settings → API Keys → Connected AI agents: everything connected, what it can do, when it last acted, and a one-click disconnect that takes effect on the agent’s very next call.
API keys are not deprecated. For a server-side agent, a cron job, or CI — anywhere there’s no browser
to complete a consent flow — a
cb_live_... key is still the right answer. OAuth exists for interactive
clients, where asking a person to handle a long-lived secret by hand is the worse option.You can only grant permissions you hold yourself. If you’re a member with read-only access, the send
permission appears greyed out on the consent screen rather than failing after you approve. An API key
also cannot start a consent flow, so a scoped key can’t be used to mint a broader, longer-lived
credential.
MCP connection guide; corrected expired-trial behavior
Added
- A MCP server guide: how to connect Claude Code, claude.ai / Claude Desktop, or any MCP
client to
POST /mcp, the nine tools and the scope each needs, how the autonomy gate turns a send intoqueued_for_approval,dry_run, and the error shapes to expect.
- The Authentication page said expired-trial API keys become read-only — that
GETrequests keep working while writes return403. That has been wrong since 2026-07-24, when the trial gate was tightened so an API key is refused entirely on an expired trial, reads included. The page now says so.
Macros — run a saved bundle of actions
Added
GET /api/v1/macros/(macro.view) — list the workspace’s macros, the one-click action bundles configured in Settings → Macros: id, name, description, and the orderedactionslist.POST /api/v1/macros/{id}/run/(macro.view) — run one against a conversation or ticket with{"chat_id": ...}or{"ticket_id": ...}. Returns{"ran": [{"type", "detail"}, ...]}.- The MCP
run_macrotool exposes the same thing to a connected agent. See the MCP guide. - Two new scopes:
macro.view(list and run) andmacro.manage(create/edit/delete, dashboard-only for now).
Execution is all-or-nothing. Database actions apply in authored order inside a single transaction with
message sends deferred to the end, so if anything fails — a closed 24-hour window, a missing referenced
object — nothing is applied and the error body says which step failed. You never get a
half-applied macro.
Template rejection reason
Added
- Template objects now include
rejected_reason— Meta’s reason for rejecting the template, persisted from the status webhook and from template sync. It’s an empty string unlessstatusisREJECTED, and it’s cleared again once the template is approved or resubmitted. Read-only.
Editing and resubmitting a rejected template is dashboard-only for now. The API surfaces why Meta
rejected it, so you can alert on it, but the fix has to happen in the ChatBridge UI.
Ticket stages gain contact notifications
Added
TicketStageobjects returned by the Ticket Pipelines & Stages endpoints gainnotify_template_idandnotify_template_name(both nullable). When set, moving a ticket into that stage sends the named APPROVED template to the ticket’s contact on WhatsApp.- Set it on
POST /api/v1/ticket-pipelines/{pipeline_id}/stages/orPATCH /api/v1/ticket-pipelines/{pipeline_id}/stages/{stage_id}/vianotify_template_id: it must be a template id in your workspace withAPPROVEDstatus (400otherwise). An empty string ornullclears it; omitting the key onPATCHleaves it unchanged.
The send happens worker-side after the stage transition commits, so a successful
PATCH means the move
was recorded, not that the message is already delivered. Contacts who have opted out are always skipped.MCP server — connect your own AI agent
Added
- A hosted remote MCP server at
POST /mcp(streamable HTTP, stateless — one JSON-RPC 2.0 message per request;initialize,ping,tools/list,tools/call). It authenticates with the sameAuthorization: Bearer cb_live_...workspace API key as/api/v1/, honours the same scope vocabulary per tool, and shares the same per-key rate-limit bucket. See the MCP guide to connect one. - Nine tools, each wrapping the equivalent REST endpoint one-to-one:
read_conversations,read_messages,send_message,send_template,update_conversation,search_contacts,update_contact,get_templates, andrun_macro(added 2026-08-18). dry_run: trueonsend_messageandsend_template— reports whether the call would send or queue, and the autonomy level that decided it, with no side effects.dry_run: trueon outgoing-webhook response actions: the action is validated and logged but not sent. Non-breaking — omitting it sends, as before.
The send tools deliberately do not give an external agent a way around your workspace’s settings.
Below the
autonomous autonomy level they return status: "queued_for_approval" and park the message in
the dashboard’s approval queue rather than delivering it — a successful result, not an error. Treat it as
“submitted for review” and stop, or your agent will retry and pile up duplicate drafts. Every tool call is
recorded in the workspace audit log.WhatsApp usernames & business-scoped user IDs
Added
- Contact responses gain two nullable fields:
bsuid— Meta’s durable business-scoped user ID for this user×business pair, captured automatically from inbound webhooks — andusername, the contact’s WhatsApp @handle when they’ve adopted one. Usernames are display-only and are never an addressing key. POST /api/v1/messages/accepts a BSUID asto, as well as a phone number. ChatBridge translates it into Meta’srecipientaddressing parameter for you.GET /api/v1/contacts/?search=also matchesusername(with or without a leading@) andbsuid.- Phone-number listings gain
business_username/business_username_status— your own claimed WhatsApp username, mirrored from thebusiness_username_updateswebhook.
- For a contact who has hidden their phone number,
receiver_idandphonecontain the BSUID string rather than a phone number.
receiver_id is still the stable handle for URL paths and dedup, but store bsuid alongside it if you
sync contacts into your own system: it survives username changes and phone-visibility changes, and is
regenerated only when the user changes their phone number.Scheduled broadcasts
Added
POST /api/v1/broadcasts/{id}/confirm/accepts an optionalscheduled_at(a future ISO 8601 datetime). With it, the broadcast moves to the newscheduledstatus instead of sending immediately, and fires at that time. A past or unparsable value returns400and leaves the draft untouched.POST /api/v1/broadcasts/{id}/unschedule/(broadcast.manage) reverts ascheduledbroadcast todraftbefore it fires —409in any other status.- Broadcast list and detail responses gain
scheduled_at(nullable), andstatusgains thescheduledvalue.
Custom Fields reach the API Reference
Fixed
- The Custom Fields endpoints (
GET/POST /api/v1/custom-fields/,GET/PATCH/DELETE /api/v1/custom-fields/{id}/, released 2026-07-27) now appear in the API Reference with their own nav group — they were previously missing from the generated reference even though the endpoints were live. - The reference schemas caught up with earlier releases:
conversation_statusdocuments thesnoozedvalue, contact and conversation shapes documentcustom_fields, and phone numbers documentcode_verification_status. Documentation only — no API behavior changed.
An inbound customer reply reopens a closed conversation
Changed
conversation_status(conv_status) is no longer changed only by explicit writes. When a contact sends an inbound message, a conversation sitting inresolved,pending,convertedorspamis moved back toopenautomatically.snoozedis unaffected — it returns toopenon its own timer only.- Affected responses:
GET/PATCH /api/v1/conversations/,GET/PATCH /api/v1/contacts/, and anything else embedding a conversation’s status. No request format changed; no field was added or removed. - There is no grace window: the reopen happens on any inbound message, however soon after the status was
set. A
PATCHsettingconversation_status: "resolved"therefore does not guarantee the conversation is still resolved when you read it back, if the contact replied in between.
If you poll conversations and treat
resolved as terminal — syncing closures into your own CRM, or driving
an agent that only reads open conversations — expect conversations to leave resolved with no API call from
you. Reconcile on the value you read rather than caching the one you wrote. The reopen is recorded in the
conversation activity trail with no actor, which is how you can tell it from an agent’s manual reopen.whatsapp_business_account_id now consistently means the WABA id (bugfix)
Fixed
GET /api/v1/phone-numbers/returned a value underwhatsapp_business_account_idthatPOST /api/v1/messages/would reject. The read side served the Business Manager id (whenever the connecting user had granted Meta’sbusiness_managementscope), while the write side has always resolved that field against the WABA id. A caller following the documented flow — takewhatsapp_business_account_idfromGET /api/v1/phone-numbers/, send it aswhatsapp_business_account_idonPOST /api/v1/messages/— got a validation error whenever the two ids differed. Both sides now use the WABA id, so the documented flow works.- Affected responses:
GET /api/v1/phone-numbers/and any payload embedding a business account. No request format changed, and no field was added or removed —POST /api/v1/messages/accepted only the WABA id before this change and still does.
If you stored the
whatsapp_business_account_id value returned before this release and it looked different
from the WABA id shown in Meta Business Manager, it was a Business Manager id and should be re-fetched. If
the two matched (the case when business_management was never granted), nothing changes for you.Custom contact & conversation fields
Added
GET/POST /api/v1/custom-fields/andGET/PATCH/DELETE /api/v1/custom-fields/{id}/— CRUD for your workspace’s custom-field schema (name,label,field_type,applies_to,options). Types:text,number,boolean,date,select,multi_select. Uses the existingcontact.view/contact.editscopes — no new scope to grant.- A
custom_fieldsobject now appears on contact and conversation responses, and can be written onPOST /api/v1/contacts/,PATCH /api/v1/contacts/{receiver_id}/, andPATCH /api/v1/conversations/{receiver_id}/. Values are validated against your defined schema. - Not gated behind ticketing or any billing add-on.
custom_fields merges on PATCH rather than replacing — sending one key leaves the others intact.
This is deliberately unlike tags, which is full-replace. To clear a single field, set it explicitly
rather than omitting it.Bulk contact import stores defined custom fields as custom fields
Changed
POST /api/v1/contacts/bulk/— a per-row key whose name matches one of the workspace’s custom-field definitions (GET /api/v1/custom-fields/) is now stored in the contact’s typed, filterablecustom_fields, instead of being flattened into the freeform profile-attribute blob alongside genuinely unknown keys. Values are coerced to the field’s declared type and validated against it:numberaccepts a numeric string,booleanacceptstrue/false/yes/no/1/0,daterequiresYYYY-MM-DD,selectmust be one of the defined options, andmulti_selectaccepts those options separated by;.- Keys matching no definition are unaffected and still stored as freeform profile attributes, so existing integrations that send arbitrary extra keys keep working unchanged.
- An empty-string value for a custom-field key no longer writes an empty value over the field; the key is omitted from the row entirely.
- A value that fails coercion or type validation no longer fails the whole row. The contact is still
created from its remaining fields, the offending field is dropped, and an entry naming the field appears
in the response’s
errorsarray.
Custom Contact/Conversation Fields (additive)
Added
GET/POST /api/v1/custom-fields/andGET/PATCH/DELETE /api/v1/custom-fields/{id}/— CRUD for a per-workspace custom-field schema (name/label/field_type/applies_to/options) for Contacts and Conversations.contact.view(GET) /contact.edit(POST/PATCH/DELETE). Field types:text,number,boolean,date,select,multi_select.custom_fieldsnow appears on contact/conversation responses; writes are validated against the workspace’s schema and merge into the existing value rather than replacing it wholesale.
conv_status gains a working snoozed value
Fixed
PATCH /api/v1/conversations/{receiver_id}/andPATCH /api/v1/contacts/{receiver_id}/previously returned400forconv_status: "snoozed"— the value the dashboard UI was already sending was never actually a valid choice on the API. It is now, alongside a newsnoozed_untilfield.- Snoozing requires a future
snoozed_untilin the same request. A background task returnsconv_statustoopenand clearssnoozed_untilonce it passes. Settingconv_statusto anything else clearssnoozed_untilautomatically.
The snooze timer is timer-only: an inbound reply does not wake a snoozed conversation early. This is
the one status the 2026-08-04 auto-reopen behavior deliberately leaves alone.
Security: expired-trial API keys blocked entirely, including reads
Security
- An expired trial now blocks the REST API completely for API keys — reads included. Previously
GET/HEAD/OPTIONSwere exempt from trial gating unconditionally, so an expired-trial or cancelled-subscription workspace’s key could keep reading indefinitely while writes were correctly refused. The read exemption now applies only to dashboard (cookie/token) requests.
Contacts gain first_message_at
Added
- Contact responses now include
first_message_at—nulluntil the contact’s first real message exists, set once it does.
This exists because
conv_status defaults to "open" for every contact at creation, whether or not a
conversation has ever happened — so UI or automation keyed on conv_status alone shows freshly created
and freshly imported contacts as “Open”. Check first_message_at to tell a real open conversation from
a contact who has simply never written in. Non-breaking additive field.Per-contact broadcast variables (additive)
Added
POST /api/v1/broadcasts/now accepts an optionaltemplate_variablesobject (shared with the dashboardBroadcastViewvia_create_broadcast_draft_for_workspace). It maps each template placeholder to a contact field (name/phone/email/company, resolved per recipient at send time) or a static value — e.g.{"body": {"1": {"source": "field", "field": "name"}, "2": {"source": "static", "value": "20%"}}}. Non-breaking: omitting it preserves the prior behaviour (Meta receives no per-variable parameters). The broadcast create/detail responses gaintemplate_variablesandconfirmed_atfields.
Broadcasts go live
Added
GET/POST /api/v1/broadcasts/(broadcast.view/broadcast.manage),GET/DELETE /api/v1/broadcasts/{id}/(broadcast.view/broadcast.manage),POST /api/v1/broadcasts/{id}/confirm/(broadcast.manage),GET /api/v1/broadcasts/preview/(broadcast.view).- New scope pair
broadcast.view/broadcast.manage— matches theticket.view/ticket.manageread/write split convention. Deliberately not added to default permissions — a confirmed broadcast can reach thousands of recipients in one call, requiring an explicit workspace-owner grant (same reasoning asqa.review).
- A broadcast can never be sent in a single unguarded API call —
POST /broadcasts/only ever creates aDRAFT; sending requires a separatePOST /broadcasts/{id}/confirm/call requiringbroadcast.manage. - The opt-out compliance shield is inherited for free — both dashboard and API confirm paths call the identical shared helper.
Two documented GAPs fixed: pagination cap, error-envelope normalization
Fixed
?limit=onGET /contacts/,/conversations/,/conversations/{id}/messages/is now capped at 200.- Every error response body is now normalized to
{"error": ...}.
- Dropped the
template.submitscope — it was dead vocabulary, never checked by any endpoint.
Four new endpoints closing surface gaps
Added
POST /api/v1/templates/(template.create) — the scope existed with no endpoint since Phase 2b.GET /api/v1/account-health/(waba.view) — aggregated phone/template quality + recent alerts.GET /api/v1/phone-numbers/(waba.view) — closes the “how does a caller know what to pass toPOST /messages/” discovery gap.GET/POST /api/v1/contacts/{receiver_id}/notes/(note.view/note.create) — same “scope with no endpoint” gap as templates.
Security: unauthenticated requests no longer crash with a 500
Security
- Fixed: every
/api/v1/endpoint returned an unhandled 500 for an unauthenticated request instead of a clean 401.IsAuthenticatedadded as the first permission class on all view declarations.
Trial/billing gating now enforced on the public API
Security
- Fixed a billing-bypass: an expired-trial or cancelled-subscription workspace could still write via its
API key.
ReadOnlyForExpiredTrialis now included on all/api/v1/view permission declarations.
Ticket lifecycle: merge, sub-tickets, watchers, auto-reopen & SLA auto-escalation
Added
POST /api/v1/tickets/{id}/merge/(ticket.manage) — marks a ticket a duplicate of another, workspace-scoped target, auto-closes into the pipeline’s first terminal stage.parent_ticket,duplicate_of,watchers/watcher_ids,outcome,custom_fieldsnow accepted onPOST/PATCH /api/v1/tickets/(and internal equivalents).reopened_count,last_customer_at,escalated_at,escalated_tonow returned on every ticket representation.- New outgoing webhook event:
ticket.reopened— fires when a terminal-stage ticket auto-reopens.
POST /api/v1/tickets/{id}/escalate/now also stampsescalated_at/escalated_toon the ticket.- SLA auto-escalation via
TicketSLAPolicy.escalation_after_hours/escalate_tois now live.
Phase 3b (partial): OpenAPI spec goes live
Added
- Interactive API reference (this site) and the underlying OpenAPI 3.0 spec at
GET /api/v1/schema/.
public_api.md in the main repo, which is the
engineering-side source of truth — keep the two in sync rather than editing only one.Phase 3a: Ticketing & Flows endpoints go live
Added
- Three new scopes:
ticket.view,ticket.manage,flow.view. - Full ticket CRUD, comments, activity log, and escalation.
- Ticket pipeline and stage management.
- Read-only WhatsApp Flows endpoints: list, detail, metrics, submissions.
Security fixes from a code review and pentest
Security
- API keys are now rejected outright on every internal (non-
/api/v1/) endpoint. A key — including a read-only one — previously inherited the workspace owner’s full permissions on ChatBridge’s internal dashboard routes, because those routes authorize by workspace permission rather than by key scope. Found by live exploitation during a pentest. - Fixed a cross-tenant contact-existence oracle on
GET /api/v1/conversations/{receiver_id}/.... The lookup for the path parameter matched contacts platform-wide instead of scoping to the calling key’s own workspace, so the200-vs-404difference revealed whether a given phone number was a contact of any ChatBridge tenant.
/api/v1/ itself was never affected by the first issue — the public API has always enforced key scopes
correctly. The exposure was that a key could reach internal routes it was never meant to touch. For the
second, no message content or contact data was ever returned across tenants; only the existence signal
leaked, and it is now scoped to the key’s own workspace.Phase 3: write endpoints go live
Added
- Create/update/delete contacts, bulk contact import.
- Update conversation status, priority, tags, and assignment.
- Send WhatsApp messages.
- Message sending enforces the same plan limits, opt-out/compliance shield, and 24-hour session window as the dashboard.
Phase 2b: read endpoints go live
Added
- Read-only endpoints for contacts, conversations, templates, team, and catalogue.
- The
team.viewscope.
Phase 2a: API key scopes & rate limiting
Added
- Per-key scopes and a flat 60 req/min rate limit, independent of plan.

