Skip to main content
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.
Changed
  • 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.
comment_on_ticket writes an internal note. It is never delivered to the customer on WhatsApp — use send_message or send_template for that.
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
Added
  • 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, and POST /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:
  • source is one of:
    • static — the literal value.
    • 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 as 21 Aug 2026 in 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.
Validation is strict, and a bad mapping is a 400 rather than a silently mangled message: component keys other than header/body, placeholder numbers outside 1–32, unknown source/field values, non-string literals, and literals over 512 characters are all rejected. Anything that resolves to nothing (no assignee, no due date, a contact with no company on file) sends as an em dash, and every value is whitespace-flattened — WhatsApp rejects parameters containing newlines, tabs, or four or more consecutive spaces.
Fixed
  • Three already-released endpoints were missing from the API Reference and now have their pages: GET /macros/ and POST /macros/{id}/run/ (released 2026-08-18) and POST /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_contacts array ([{id, name, phone}]), and POST /tickets/ / PATCH /tickets/{id}/ accept affected_contact_ids with 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 /mcp accepts either credential.
  • Standard discovery documents so a client can find the flow by itself: GET /.well-known/oauth-protected-resource and GET /.well-known/oauth-authorization-server. A 401 from /mcp now includes a WWW-Authenticate header pointing at the first of these.
  • GET /oauth/mcp/authorize, POST /oauth/mcp/token (authorization_code and refresh_token grants), and POST /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, and macro.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.
For client authors: clients are pre-registered by us, so there is no dynamic client registration — the missing registration_endpoint in the authorization-server metadata is the machine-readable signal. Contact us to register a client_id and redirect URI. Other constraints worth building against: PKCE is S256 only (plain is refused), redirect_uri must match your registered value exactly, authorization codes are single-use with a 2-minute lifetime, and refresh tokens rotate — each refresh invalidates both the old refresh token and the access token it replaces. Access tokens last 8 hours, so refresh on schedule rather than treating a 401 as fatal.
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 into queued_for_approval, dry_run, and the error shapes to expect.
Fixed
  • The Authentication page said expired-trial API keys become read-only — that GET requests keep working while writes return 403. 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.
If you built a read-only degraded mode against the old wording, it never worked. On an expired trial with no active plan, every /api/v1/ and /mcp request returns 403 until the workspace is reactivated from the dashboard — there is no reads-still-work state to fall back to. The dashboard itself keeps read access, so your team can still see their data and reactivate; only key-authenticated API traffic is cut off.
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 ordered actions list.
  • 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_macro tool exposes the same thing to a connected agent. See the MCP guide.
  • Two new scopes: macro.view (list and run) and macro.manage (create/edit/delete, dashboard-only for now).
A macro is a shortcut, never a privilege escalation. Running one requires macro.view plus the underlying scope of every action in the bundle — a macro that tags, assigns, and sends needs the tag, assign, and send scopes too. Missing ones are named in the error.
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 unless status is REJECTED, 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
  • TicketStage objects returned by the Ticket Pipelines & Stages endpoints gain notify_template_id and notify_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/ or PATCH /api/v1/ticket-pipelines/{pipeline_id}/stages/{stage_id}/ via notify_template_id: it must be a template id in your workspace with APPROVED status (400 otherwise). An empty string or null clears it; omitting the key on PATCH leaves 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 same Authorization: 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, and run_macro (added 2026-08-18).
  • dry_run: true on send_message and send_template — reports whether the call would send or queue, and the autonomy level that decided it, with no side effects.
  • dry_run: true on 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 — and username, 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 as to, as well as a phone number. ChatBridge translates it into Meta’s recipient addressing parameter for you.
  • GET /api/v1/contacts/?search= also matches username (with or without a leading @) and bsuid.
  • Phone-number listings gain business_username / business_username_status — your own claimed WhatsApp username, mirrored from the business_username_updates webhook.
Changed
  • For a contact who has hidden their phone number, receiver_id and phone contain 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.
Authentication-category templates (one-tap, zero-tap, copy-code) still require a real phone number. Sending one to a BSUID returns 403 with an actionable error rather than an opaque Meta failure.
Scheduled broadcasts
Added
  • POST /api/v1/broadcasts/{id}/confirm/ accepts an optional scheduled_at (a future ISO 8601 datetime). With it, the broadcast moves to the new scheduled status instead of sending immediately, and fires at that time. A past or unparsable value returns 400 and leaves the draft untouched.
  • POST /api/v1/broadcasts/{id}/unschedule/ (broadcast.manage) reverts a scheduled broadcast to draft before it fires — 409 in any other status.
  • Broadcast list and detail responses gain scheduled_at (nullable), and status gains the scheduled value.
The recipient list is re-resolved at fire time, not snapshotted when you schedule. Whoever matches the broadcast’s audience filters when it fires is who receives it — so a contact who picks up a matching tag in the meantime will be included, and one who loses it will not. Once the scheduler flips a broadcast to running it can no longer be stopped.
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_status documents the snoozed value, contact and conversation shapes document custom_fields, and phone numbers document code_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 in resolved, pending, converted or spam is moved back to open automatically. snoozed is unaffected — it returns to open on 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 PATCH setting conversation_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 under whatsapp_business_account_id that POST /api/v1/messages/ would reject. The read side served the Business Manager id (whenever the connecting user had granted Meta’s business_management scope), while the write side has always resolved that field against the WABA id. A caller following the documented flow — take whatsapp_business_account_id from GET /api/v1/phone-numbers/, send it as whatsapp_business_account_id on POST /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/ and GET/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 existing contact.view / contact.edit scopes — no new scope to grant.
  • A custom_fields object now appears on contact and conversation responses, and can be written on POST /api/v1/contacts/, PATCH /api/v1/contacts/{receiver_id}/, and PATCH /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, filterable custom_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: number accepts a numeric string, boolean accepts true/false/yes/no/1/0, date requires YYYY-MM-DD, select must be one of the defined options, and multi_select accepts 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.
Fixed
  • 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 errors array.
Custom Contact/Conversation Fields (additive)
Added
  • GET/POST /api/v1/custom-fields/ and GET/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_fields now 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}/ and PATCH /api/v1/contacts/{receiver_id}/ previously returned 400 for conv_status: "snoozed" — the value the dashboard UI was already sending was never actually a valid choice on the API. It is now, alongside a new snoozed_until field.
  • Snoozing requires a future snoozed_until in the same request. A background task returns conv_status to open and clears snoozed_until once it passes. Setting conv_status to anything else clears snoozed_until automatically.
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/OPTIONS were 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.
If your integration relied on reads continuing after a trial lapsed, it stops at 403 — for every /api/v1/ and /mcp request — until the workspace is reactivated. Your team keeps read access in the dashboard itself, so nobody is locked out of their own data; only key-authenticated API traffic is cut off. (The Authentication page described the old read-only behavior until 2026-08-19; it was wrong for that whole period.)
Contacts gain first_message_at
Added
  • Contact responses now include first_message_atnull until 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 optional template_variables object (shared with the dashboard BroadcastView via _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 gain template_variables and confirmed_at fields.
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 the ticket.view/ticket.manage read/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 as qa.review).
Security
  • A broadcast can never be sent in a single unguarded API call — POST /broadcasts/ only ever creates a DRAFT; sending requires a separate POST /broadcasts/{id}/confirm/ call requiring broadcast.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= on GET /contacts/, /conversations/, /conversations/{id}/messages/ is now capped at 200.
  • Every error response body is now normalized to {"error": ...}.
Removed
  • Dropped the template.submit scope — 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 to POST /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. IsAuthenticated added 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. ReadOnlyForExpiredTrial is 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_fields now accepted on POST/PATCH /api/v1/tickets/ (and internal equivalents).
  • reopened_count, last_customer_at, escalated_at, escalated_to now returned on every ticket representation.
  • New outgoing webhook event: ticket.reopened — fires when a terminal-stage ticket auto-reopens.
Changed
  • POST /api/v1/tickets/{id}/escalate/ now also stamps escalated_at/escalated_to on the ticket.
  • SLA auto-escalation via TicketSLAPolicy.escalation_after_hours/escalate_to is 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/.
Docs note: this page mirrors the Changelog section of 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 the 200-vs-404 difference 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.
Security
  • 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.view scope.
Phase 2a: API key scopes & rate limiting
Added
  • Per-key scopes and a flat 60 req/min rate limit, independent of plan.