> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chatbridge.algosmiths.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Changelog

> What's changed in the ChatBridge public API, newest first

<Update label="2026-08-26" description="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.

    | Tool                    | Scope           | What it does                                                                                                       |
    | ----------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------ |
    | `list_ticket_pipelines` | `ticket.view`   | Pipelines with their ordered stages — how the agent discovers the `pipeline` and `stage` ids everything else takes |
    | `list_tickets`          | `ticket.view`   | Filter by pipeline, stage, assignee, type, severity, priority, SLA breach, or contact                              |
    | `get_ticket`            | `ticket.view`   | One ticket in full, including SLA deadlines and affected contacts                                                  |
    | `create_ticket`         | `ticket.manage` | Open a ticket — only `title` is required                                                                           |
    | `update_ticket`         | `ticket.manage` | Stage moves, reassignment, priority/severity/outcome, tags, due date                                               |
    | `comment_on_ticket`     | `ticket.manage` | Add an internal note to the ticket thread                                                                          |

  * 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.

  <Warning>
    `comment_on_ticket` writes an **internal** note. It is never delivered to the customer on WhatsApp —
    use `send_message` or `send_template` for that.
  </Warning>

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-08-21" description="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:

    ```json theme={null}
    {
      "header": { "1": { "source": "ticket", "field": "id" } },
      "body": {
        "1": { "source": "field",  "field": "name" },
        "2": { "source": "ticket", "field": "stage" },
        "3": { "source": "static", "value": "within 24 hours" }
      }
    }
    ```
  * `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.

  <Note>
    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.
  </Note>

  <Warning>
    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.
  </Warning>

  **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.
</Update>

<Update label="2026-08-21" description="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`.

  <Note>
    `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`.
  </Note>

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-08-20" description="Connect an AI agent without pasting an API key (MCP OAuth)">
  **Added**

  * **OAuth 2.1 (authorization code + PKCE) for the [MCP server](/mcp).** 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.

  <Note>
    **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.
  </Note>

  <Warning>
    **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.
  </Warning>

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-08-19" description="MCP connection guide; corrected expired-trial behavior">
  **Added**

  * A [**MCP server guide**](/mcp): 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.

  <Warning>
    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.
  </Warning>
</Update>

<Update label="2026-08-18" description="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](/mcp).
  * Two new scopes: `macro.view` (list and run) and `macro.manage` (create/edit/delete, dashboard-only
    for now).

  <Warning>
    **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.
  </Warning>

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-08-18" description="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.

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-08-17" description="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.

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-08-17" description="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](/mcp) 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.

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-08-10" description="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.

  <Note>
    `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.
  </Note>

  <Warning>
    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.
  </Warning>
</Update>

<Update label="2026-08-09" description="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.

  <Warning>
    **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.
  </Warning>
</Update>

<Update label="2026-08-05" description="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.
</Update>

<Update label="2026-08-04" description="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.

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-07-29" description="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.

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-07-27" description="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.

  <Note>
    `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.
  </Note>
</Update>

<Update label="2026-07-27" description="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.
</Update>

<Update label="2026-07-27" description="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.
</Update>

<Update label="2026-07-25" description="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.

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-07-24" description="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.

  <Warning>
    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.)
  </Warning>
</Update>

<Update label="2026-07-23" description="Contacts gain first_message_at">
  **Added**

  * Contact responses now include **`first_message_at`** — `null` until the contact's first real message
    exists, set once it does.

  <Note>
    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.
  </Note>
</Update>

<Update label="2026-07-16" description="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.
</Update>

<Update label="2026-07-11" description="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.
</Update>

<Update label="2026-07-08" description="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.
</Update>

<Update label="2026-07-08" description="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.
</Update>

<Update label="2026-07-08" description="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.
</Update>

<Update label="2026-07-08" description="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.
</Update>

<Update label="2026-07-08" description="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.
</Update>

<Update label="2026-07-07" description="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.
</Update>

<Update label="2026-07-07" description="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.
</Update>

<Update label="2026-07-07" description="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.

  <Note>
    `/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.
  </Note>
</Update>

<Update label="2026-07-07" description="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.
</Update>

<Update label="2026-07-07" description="Phase 2b: read endpoints go live">
  **Added**

  * Read-only endpoints for contacts, conversations, templates, team, and catalogue.
  * The `team.view` scope.
</Update>

<Update label="2026-07-07" description="Phase 2a: API key scopes & rate limiting">
  **Added**

  * Per-key scopes and a flat 60 req/min rate limit, independent of plan.
</Update>
