# Use Gauge with AI tools (/ai-tools)
Coding assistants write better integrations when they read the real docs instead of guessing.
These docs are published in forms AI tools can read directly.
## Give your assistant the docs [#give-your-assistant-the-docs]
| | |
| --- | --- |
| [`/llms.txt`](/llms.txt) | An index of every page with a one-line summary, following the [llms.txt](https://llmstxt.org) convention. Point an assistant here first. |
| [`/llms-full.txt`](/llms-full.txt) | Every guide in one plain-text file. Paste it, or attach it, as context. |
| [`/openapi/v2.json`](/openapi/v2.json) | The exact OpenAPI 3.1 document: every endpoint, schema, scope and error. |
| **Copy page** | The button at the top of every page copies it as Markdown. **Open** sends it to ChatGPT or Claude. |
A prompt that works well:
> Read https://docs.gauge.chat/llms.txt and https://docs.gauge.chat/openapi/v2.json. Using plain
> `fetch` (no SDK), write a Node function that sends a WhatsApp message with the Gauge API, with an
> `Idempotency-Key`, and retries on 429 using `Retry-After`.
## Keep keys out of prompts [#keep-keys-out-of-prompts]
Never paste an API key or a webhook signing secret into an AI tool. Use an environment variable
(`GAUGE_API_KEY`) in the code it writes, and set the value yourself.
## Coming next: a Gauge MCP server [#coming-next-a-gauge-mcp-server]
The [Model Context Protocol](https://modelcontextprotocol.io) lets AI assistants call tools
directly. A Gauge MCP server is planned. It will be generated from the same OpenAPI document as this
reference, and will be **read-only by default**: an assistant could look up conversations, contacts
and connections with your key, and could only send when you grant it a key with write scopes.
Until then, the files above give an assistant everything it needs to write the integration with
you.
# Authentication and keys (/authentication)
Every request is authenticated with an API key, sent as a bearer token:
```http
GET /backend/v2/me HTTP/1.1
Host: app.gauge.chat
Authorization: Bearer pulse_k_live_9c2Rk1...
```
A request without a key, or with an unknown, revoked or expired one, gets `401 unauthorized`.
## Who can create keys [#who-can-create-keys]
- **The workspace must be on a paid plan.** The API is available on **Growth** and **Scale**. On
Free, every request answers `402 plan_required` ([Plans](/conventions/plans)).
- **The member must be allowed to manage API keys.** Keys are created in **Developers → API keys**
by a member whose role includes the API keys permission.
## Keys belong to a person [#keys-belong-to-a-person]
A key is not a free-floating credential. It acts **as the member who created it**:
- It can only do what that member can do in the console. A key never sees a conversation its
member could not open, so a key made by an Agent who sees only their own conversations sees only
those.
- Contacts are the exception the console already makes: every member with contact access sees the
whole workspace's contacts, and so does their key.
- If the member is deactivated or removed, **every key they made stops working at once** with
`401 key_owner_inactive`. Create a new key under an active member and swap it in.
Create integration keys from a member who will stay, such as an owner or a dedicated integration
seat, and keep a note of who owns which key. The **API keys** list shows each key's creator.
## Scopes [#scopes]
A key carries a list of scopes; each endpoint needs one. The scope a route needs is shown on every
[reference](/reference) page (`x-scopes`). A key's power is **its scopes and its member's role,
together**: a scope the member's role cannot use grants nothing.
| Scope | Lets the key | Needs this in the member's role |
| --- | --- | --- |
| `conversations:read` | List and open conversations the member can see | Conversations · view |
| `conversations:write` | Change status, assignee and tags; mark read | Conversations · view, create, edit |
| `messages:read` | Read message history and delivery status, and download a message's files | Conversations · view |
| `messages:write` | Send messages and templates as the member | Conversations · view, create |
| `contacts:read` | List, search and open contacts | Contacts · view |
| `contacts:write` | Create, edit, upsert, tag and merge contacts. Erasing one (`DELETE`) also needs the member to hold the erasure right (Owner or Admin) | Contacts · view, create, edit |
| `connections:read` | See connected channels and what each can send | Conversations · view |
| `templates:read` | See approved WhatsApp templates | Conversations · view |
| `media:read` | Get short-lived links to files you uploaded with `POST /v2/media` | Conversations · view |
| `media:write` | Upload files to attach to messages | Conversations · view, create |
| `webhooks:read` | List webhook endpoints and their delivery state | Settings · view |
| `webhooks:write` | Create, change, test and rotate webhook endpoints; redeliver events | Settings · view, edit |
| `events:read` | List and read the last 30 days of events | Settings · view |
**Write includes read.** `messages:write` also grants `messages:read`.
**Wildcards.** `*:read` grants every area's `:read` scope, and `*:write` grants every area scope.
They are capped at use to exactly what the member holds, so a wildcard can never reach further than
the member's role.
**Spending scopes.** `sms-send` and `voice-dial` are reserved for SMS and calls over the API,
which open after a compliance review. They are granted **only by name**: no wildcard, and no key
created as "full access", ever includes them.
**You cannot grant what you do not have.** When you create a key, the console offers only the
scopes your own role can use. Asking for more over the console API answers
`400 validation_failed` with "Your role cannot grant: …".
When a key calls a route without the scope it needs, the answer is `403 insufficient_scope`, and
`error.details.required` names the missing scope.
Keys made for `/v1` carry v1 scopes such as `contacts:rw` or `support:read`. They keep working on
`/v1`. A v1 `:rw` scope also counts as `:write` on `/v2`.
## Environments [#environments]
Every key is **live** or **test**, fixed when it is created and visible in its prefix:
| | Live key | Test key |
| --- | --- | --- |
| Prefix | `pulse_k_live_` | `pulse_k_test_` |
| Reaches | Your real workspace data | Sandbox data only |
| `GET /v2/me` `key.environment` | `live` | `test` |
### Test keys [#test-keys]
A test key works only on endpoints that support the sandbox. Elsewhere it answers
`403 forbidden` with `reason: sandbox_key_not_supported`; nothing it does can reach a real
customer. Test keys are accepted by `/v2/me`, `/v2/connections`, `/v2/templates` and the
[Support API](/support).
**Test keys are refused on every messaging and webhook route**: contacts, conversations,
messages, media, `/v2/webhook-endpoints` and `/v2/events`. There is no sandbox send: a send always
reaches a real customer, so it needs a live key. To try a send safely, send to your own number or
address on a live key.
Keys are issued as `pulse_k_live_…` and `pulse_k_test_…` today. A `gauge_live_` / `gauge_test_`
prefix is planned; when it ships, existing keys keep working unchanged.
## Pinning a key to an App [#pinning-a-key-to-an-app]
If you use the [Support API](/support), you can pin a key to one **App** (a support project). A
pinned key sees only that App's data: on `/v2/connections`, only the App's chat line. On `/v2` a
key may be pinned to **one** App; a key pinned to several answers `403 forbidden` with
`reason: key_pinned_to_projects`. `GET /v2/me` returns the pin as `key.pinnedAppId`.
A pinned key can read `/v2/me` and `/v2/connections`, and manage the webhook endpoints and events of
its own App. It is **refused on the messaging routes** (contacts, conversations, messages, media,
templates) with `403 forbidden` and `reason: key_pinned_to_projects`: a contact or a conversation
belongs to no single App. Use an unpinned key for messaging.
## IP allowlist [#ip-allowlist]
Give a key a list of up to 20 IPv4 or IPv6 addresses or CIDR ranges (`203.0.113.7`,
`198.51.100.0/24`, `2001:db8::/32`), and it works only from those addresses. A request from
anywhere else answers `403 forbidden` with `reason: ip_not_allowed`. An empty list means any
address.
## Expiry [#expiry]
A key can expire after 30, 60, 90 or 365 days, or never. After its expiry it answers
`401 unauthorized`. Expiring keys are a good default for anything that is not a long-lived server
integration.
## Rotating a key [#rotating-a-key]
Rotate (**roll**) a key from its row in **Developers → API keys**. Rolling issues a new secret for
the same key: same name, scopes, pin, allowlist and usage history. You choose how long the old
secret keeps working alongside the new one:
| Overlap | Use it when |
| --- | --- |
| 0 hours | The key leaked. The old secret stops at once. |
| 1 hour | You can redeploy within the hour. |
| 24 hours | A normal scheduled rotation. |
| 72 hours | Several systems share the key and take time to update. |
During the overlap both secrets authenticate. Deploy the new one, then let the old one lapse.
To stop a key for good, **revoke** it. Revocation is immediate and cannot be undone.
## Keeping keys safe [#keeping-keys-safe]
- Keep keys on your server. Never ship one in a browser bundle, a mobile app, or a public repository.
- One key per integration, so you can rotate or revoke one without breaking the others.
- Grant the fewest scopes that work, and add an IP allowlist for fixed servers.
- Every request a key makes is in **Developers → Logs** for 14 days, by key, status and request id.
## Authentication errors [#authentication-errors]
| Status | `error.code` | `reason` | Meaning |
| --- | --- | --- | --- |
| 401 | `unauthorized` | | No key, or an unknown, revoked, expired or rolled-out key |
| 401 | `key_owner_inactive` | `key_owner_inactive` | The key's member is no longer active |
| 402 | `plan_required` | | The workspace is not on Growth or Scale |
| 403 | `workspace_suspended` | | The workspace is suspended; contact Gauge support |
| 403 | `forbidden` | `ip_not_allowed` | The request came from outside the key's allowlist |
| 403 | `forbidden` | `sandbox_key_not_supported` | A test key on an endpoint without sandbox support |
| 403 | `forbidden` | `key_pinned_to_projects` | A pinned key on an endpoint that cannot honour the pin |
| 403 | `insufficient_scope` | | The key, or its member's role, lacks the route's scope |
The full list is in [Errors](/conventions/errors).
# Changelog (/changelog)
Breaking changes only ship behind a new [`Gauge-Version`](/conventions/versioning) date and are
announced here first, with a `Deprecation` and `Sunset` period.
## 21 October 2026: start WhatsApp Cloud conversations with a template [#21-october-2026-start-whatsapp-cloud-conversations-with-a-template]
Additive, on API version `2026-10-21`.
- `POST /v2/messages` with `to` or `contactId` and an approved `template` now **starts a WhatsApp
Cloud conversation**: Gauge creates or reuses the contact and the conversation and sends the
template. The connection reports `startConversations: true` when templates are enabled.
[Start a conversation with a template](/messaging/templates#start-a-conversation-with-a-template).
- Free text to someone on WhatsApp Cloud with no conversation answers `422` with reason
`outside_24h_window` (it answered `cannot_start_conversations` before).
- New `422` reasons, checked before anything is written: `template_not_approved`,
`template_language_not_found`, `template_variable_mismatch` (previously a template that was not
approved, or a missing variable, was refused without a `reason`; extra variables were ignored and
are now refused).
## 21 October 2026: messaging and webhooks on `/v2` [#21-october-2026-messaging-and-webhooks-on-v2]
Additive, on API version `2026-10-21`.
**New**
- [Messaging](/messaging): contacts (with upsert by channel identity, tags, merge and GDPR erasure),
conversations (filters, status, assignment, team, topic, tags, notes, read), messages (text,
templates, buttons and lists, files, quoted replies, metadata), media uploads, and templates.
- `POST /v2/messages` answers `201` when sent during the request and **`202` with `status: queued`**
when dispatched in the background; the `Idempotency-Key` also de-duplicates the send beyond 24 hours.
- WhatsApp Web [warm-up caps](/channels/whatsapp-web#send-caps) (`429`, `whatsapp_web_minute_cap` /
`whatsapp_web_daily_cap`) and the `whatsapp_web_unofficial` warning on every send.
- `GET /v2/messages/{id}/attachments/{index}`: a signed download of any file on a message.
- `503 media_unavailable` on file uploads and downloads where a deployment has no public storage.
- [Message events](/webhooks/events): `message.received`, `message.sent`,
`message.status_updated` (including `sent` for queued messages), `conversation.created`,
`conversation.updated`, `contact.created`, `contact.updated`, delivered by the owner's visibility.
- [Webhooks from code](/webhooks#manage-endpoints-from-code): `/v2/webhook-endpoints` and the 30-day
event log `/v2/events`, with redelivery (20 an hour per workspace, shared with the console).
- The `Gauge-Signature` header (`t=,v1=`) and `Gauge-Event-Id`, `Gauge-Event-Type`,
`Gauge-Delivery-Attempt`, `Gauge-Webhook-Id` on every delivery; the `x-pulse-*` headers stay.
**Changed**
- An endpoint failing every delivery for 3 days is [switched off](/webhooks/delivery#endpoints-that-keep-failing-are-switched-off)
and its owner told.
- A delivery body is capped at 64 KB; a longer message is truncated with a `fetchUrl`.
- Test keys and App-pinned keys are refused on the messaging routes.
## 21 October 2026: API version `2026-10-21` [#21-october-2026-api-version-2026-10-21]
The first dated version of `/v2`.
**New**
- `GET /v2/me`: who a key is, its scopes, environment and rate-limit budget.
- `GET /v2/connections` and `GET /v2/connections/{id}`: connected channels with their effective
API capabilities, and a ban-risk `warning` on WhatsApp Web.
- `GET /v2/openapi.json`: a generated OpenAPI 3.1 document with shared `Error` and `Page` schemas,
an `operationId` and tags on every operation, and the webhook catalogue.
- One [error envelope](/conventions/errors) and a closed list of error codes on `/v1` and `/v2`.
- `X-Request-Id` on every response, and [bring your own](/conventions/request-ids) UUID or ULID.
- [Idempotency keys](/conventions/idempotency), required on every `/v2` POST.
- [Rate limits](/conventions/rate-limits) per key and per workspace, with `RateLimit-*` headers.
- [Scopes](/authentication#scopes) by area (`messages:write`, `contacts:read`, …), capped by the
member's role when a key is created and when it is used.
- Per-key [IP allowlists](/authentication#ip-allowlist), and a request log in **Developers → Logs**.
**Changed**
- API access requires the Growth or Scale plan (`402 plan_required` otherwise).
- A key stops working when its member leaves the workspace (`401 key_owner_inactive`).
- `/v1/conversations` and `/v1/contacts` now respect the member's own and team visibility, as the
console does.
- Webhook `*` subscriptions receive the public catalogue only, and payloads carry only each event's
documented fields.
## Coming next [#coming-next]
- **SMS and voice** over the API, after a compliance review.
- **`@silquetech/gauge-sdk`**, the official Node SDK.
# Email (/channels/email)
`kind: "email"` is a connected inbox (for example `support@yourcompany.com`). Mail sent to it
arrives in Gauge as conversations; replies go out from it and thread correctly in the customer's
mail client.
## What it can do [#what-it-can-do]
| | |
| --- | --- |
| Text | Yes |
| Attachments | Not yet over the API |
| Start a conversation | Yes: address the recipient by email address |
| Campaigns | Yes |
| Delivery | `immediate`, or `queued` when your workspace uses the newer email pipeline (see `capabilities.delivery`) |
## Threading [#threading]
Replies keep the email thread: Gauge sets the standard `In-Reply-To` and `References` headers, so
the customer sees one conversation in their mail client, and their answer comes back into the same
Gauge conversation.
## Who the mail is from [#who-the-mail-is-from]
How a reply is sent depends on how the inbox is connected:
| Connected as | From | Replies come back via | Signed by |
| --- | --- | --- | --- |
| **A mailbox** (IMAP/SMTP, Gmail or Microsoft 365) | The mailbox itself, with the connection's display name. A copy lands in its Sent folder. | The mailbox (Gauge reads it) | Your mail provider, as for any mail you send |
| **A forwarding address** (no mailbox) | Gauge's shared sending address; your inbox address is the `Reply-To` | The `Reply-To` | Gauge's sending domain, not yours |
Gauge does not yet sign mail with **your** domain's DKIM key. If recipients must see your own
domain as the sender, connect the inbox as a mailbox. Either way the threading headers are kept, so
the customer sees one conversation.
# Channels and capabilities (/channels)
A **connection** is one connected channel account: a WhatsApp number, an email inbox, a web chat
widget, an Instagram account. You connect channels in the console (**Channels**); the API reads
them and sends through them.
Channels are not interchangeable. WhatsApp only allows a free-text message inside a 24-hour window,
Instagram cannot carry an attachment from Gauge yet, a web chat visitor cannot be messaged until
they open the chat. Gauge does not hide those differences behind a send that quietly drops
something. It tells you up front, and refuses clearly when you ask for something a channel cannot do.
## Read a connection's capabilities [#read-a-connections-capabilities]
`GET /v2/connections` and `GET /v2/connections/{id}` return each connection's **effective**
capabilities: what it can do over the API right now, with your workspace's settings applied. This is
the source of truth. Check it instead of hard-coding a channel's abilities.
```json
"capabilities": {
"sendText": true,
"sendMedia": false,
"sendTemplates": false,
"sendInteractive": false,
"quotedReplies": false,
"startConversations": true,
"broadcast": false,
"delivery": "immediate"
}
```
| Field | Meaning |
| --- | --- |
| `sendText` | Can send a text message at all |
| `sendMedia` | Can attach a file (image, document, audio, video) |
| `sendTemplates` | Can send an approved WhatsApp template |
| `sendInteractive` | Can send buttons, lists or quick replies |
| `quotedReplies` | Can send a reply that quotes an earlier message |
| `startConversations` | Can send with `to` or `contactId` (a new or existing person) rather than only into an existing `conversationId` |
| `broadcast` | Can be used for campaigns (bulk sends) |
| `delivery` | `immediate`: sent during the request. `queued`: accepted, sent just after; follow `message.status_updated`. `none`: cannot send. |
`flags` lists the workspace settings the capabilities were computed from, and `warning` is set when
a channel carries a risk you must accept (WhatsApp Web).
Asking a connection for something its capabilities say it cannot do answers
`422 unsupported_channel_action`. Nothing is sent, and nothing is silently dropped.
## At a glance [#at-a-glance]
The typical capabilities of each channel. Some depend on features being enabled for your workspace,
so the connection's own `capabilities` always win.
| Channel | `kind` | Status | Text | Media | Templates | Start a conversation | Delivery |
| --- | --- | --- | --- | --- | --- | --- | --- |
| [WhatsApp Cloud API](/channels/whatsapp-cloud) | `whatsapp_cloud` | | Yes | When enabled | When enabled | With a template, when templates are enabled | Queued when media is enabled, else immediate |
| [WhatsApp Web](/channels/whatsapp-web) | `whatsapp_web` | | Yes | Yes | No | Yes | Queued |
| [Email](/channels/email) | `email` | | Yes | No | No | Yes | Immediate or queued |
| [Web chat](/channels/web-chat) | `webchat` | | Yes | No | No | No | Immediate |
| [Instagram](/channels/instagram-messenger) | `instagram` | | Yes | No | No | No | Immediate or queued |
| [Messenger](/channels/instagram-messenger) | `messenger` | | Yes | No | No | No | Immediate or queued |
| [SMS](/channels/sms) | `sms` | | | | | | |
| [Voice](/channels/voice) | `voice` | | | | | | |
Reading connections and sending through them ([messaging endpoints](/messaging)) are both
. `GET /v2/connections/{id}` also returns `limits`: the reply window in hours
(`sessionWindowHours`), whether a template is required outside it, the WhatsApp Web warm-up
`sendCaps`, and the largest file it accepts (`maxMediaBytes`).
## The 24-hour window and templates [#the-24-hour-window-and-templates]
WhatsApp (Cloud API) lets a business send **free-form** messages only within **24 hours of the
customer's last message**. This is WhatsApp's rule, not Gauge's, and it protects customers from
unsolicited messages.
- **Inside the window** (the customer wrote in the last 24 hours): send any text, media or
interactive message the connection supports.
- **Outside the window**: you can only send an approved **message template**, a pre-written
message Meta has reviewed, with placeholders for your values. A free-text send answers
`422 unsupported_channel_action` with reason **`outside_24h_window`**.
- **Each customer reply reopens the window** for another 24 hours.
The window is not a field on the conversation; compute it from the conversation's `lastInboundAt`
and the connection's `limits.sessionWindowHours`, or just send and handle the `422`.
Someone who has never written to you has no window at all, so on WhatsApp Cloud **a template is
how you start a conversation**. With templates enabled, the connection reports
`startConversations: true`: send a template with `to` (a phone number) or `contactId`, and Gauge
finds or creates the contact and the conversation, sends the template, and the customer's reply
lands in that same conversation. Free text to someone with no conversation answers `422` with
reason `outside_24h_window`. Templates are created and submitted for approval in the console
(**Templates**); the API lists them and sends them. See
[Start a conversation with a template](/messaging/templates#start-a-conversation-with-a-template).
Instagram and Messenger have Meta's own reply window where it is enforced for your workspace
(`limits.sessionWindowHours` says so); a send outside it answers `422` with reason
`outside_session_window`, and there is no template to reopen it. The other channels have no
window, but each has its own reach limits, listed on its page.
# Instagram and Messenger (/channels/instagram-messenger)
`kind: "instagram"` is an Instagram professional account; `kind: "messenger"` is a Facebook Page.
Both are connected through Meta.
## What they can do [#what-they-can-do]
| | |
| --- | --- |
| Text | Yes, into conversations the customer started |
| Attachments | Not yet |
| Start a conversation | No: Meta only reveals who someone is after they message you |
| Campaigns | No |
| Delivery | `immediate`, or `queued` when your workspace uses the newer Meta send path (see `capabilities.delivery`) |
Meta applies its own reply window to Instagram and Messenger. Where Gauge enforces it for your
workspace, the connection's `limits.sessionWindowHours` says how long it is, and a send outside it
is refused **before** anything reaches Meta: `422 unsupported_channel_action` with reason
`outside_session_window`. Unlike WhatsApp there is no template to reopen it; wait for the customer
to write again.
A send Meta itself refuses (the person blocked the account, or Meta rejects the message) is not an
error on your request: the message is created, then reported `failed` with Meta's reason in a
[`message.status_updated`](/webhooks/events#messagestatus_updated) event and on
`GET /v2/messages/{id}`.
# SMS (/channels/sms)
Gauge workspaces can already send and receive SMS from the console on their Gauge numbers. **SMS
over the API is not available yet.**
Sending SMS from code costs money per message and reaches people's phones, so it opens only after
a compliance review covering consent records, do-not-contact lists, quiet hours in the recipient's
time zone, and spending caps.
When it ships:
- it will need the **`sms-send`** scope, granted only by name: no wildcard or "full access" key will
ever include it ([Scopes](/authentication#scopes));
- an SMS you send will not have to open an inbox conversation, so notifications do not land in your
team's queue;
- `sms.received` and `sms.status_updated` webhook events will report replies and delivery.
Follow the [changelog](/changelog).
# Voice (/channels/voice)
Gauge's phone system (numbers, calls, recordings, transcripts, voicemail) works today in the
console. **Voice over the API is not available yet.**
The plan, in order:
1. **Read**: list numbers, calls and voicemails; fetch a call's transcript; get a short-lived
signed link to a recording. Webhook events `call.completed`, `call.recording_ready` and
`voicemail.received`.
2. **Act**: place a call that rings one of your team and then the customer. This needs the
**`voice-dial`** scope, granted only by name, and opens after the same compliance review as SMS
(consent, do-not-call lists, recording notices, spending caps).
Follow the [changelog](/changelog).
# Web chat (/channels/web-chat)
`kind: "webchat"` is the Gauge chat widget, on your website or inside your app. Messages arrive
live in the visitor's open chat.
## What it can do [#what-it-can-do]
| | |
| --- | --- |
| Text | Yes, into a conversation the visitor started |
| Quick-reply buttons and lists | When enabled for your workspace (`sendInteractive`) |
| Attachments | Not yet over the API |
| Start a conversation | No: a visitor is not addressable until they open the chat |
| Delivery | `immediate` |
## Putting the chat in your product [#putting-the-chat-in-your-product]
To put the widget on your site or in your app, signed in as your own user, use the
[Support API](/support): [Quick start: web app](/support/quickstart-web-app),
[mobile WebView](/support/quickstart-webview) or [API only](/support/quickstart-api).
# WhatsApp Cloud API (/channels/whatsapp-cloud)
`kind: "whatsapp_cloud"` is a number connected through Meta's official **WhatsApp Business
Platform (Cloud API)**. It is the right choice for anything automated or at volume: it is what
WhatsApp built for businesses, and it is the channel Meta will not ban you for using as intended.
## What it can do [#what-it-can-do]
| | |
| --- | --- |
| Text | Yes, inside the 24-hour window |
| Templates | Yes, when templates are enabled for your workspace (`sendTemplates`) |
| Media | Yes, when WhatsApp Cloud media is enabled for your workspace (`sendMedia`) |
| Interactive (buttons, lists) | When enabled for your workspace (`sendInteractive`) |
| Quoted replies | When interactive messages and media are enabled for your workspace (`quotedReplies`) |
| Start a conversation | Yes, **with an approved template** (`startConversations`, when templates are enabled). Free text to someone with no conversation answers `422`, reason `outside_24h_window` |
| Campaigns | Yes |
| Delivery | `queued` (`202`) when media is enabled for your workspace, else `immediate` (`201`) |
Read the connection's `capabilities` for what is enabled for you; the table is the full feature
set.
## Start a conversation [#start-a-conversation]
WhatsApp only lets a business message someone first with an approved template. Send one with `to`
(an E.164 number) or `contactId`:
```bash
curl https://app.gauge.chat/backend/v2/messages \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"to": "+447700900123",
"template": {
"name": "appointment_reminder",
"language": "en_GB",
"variables": { "1": "Layla", "2": "Tuesday 22 October", "3": "10:30" }
}
}'
```
Gauge creates the contact and the conversation if they do not exist yet (or reuses the ones that
do), sends the template, and answers `202` with the `conversationId`. When the customer replies,
the 24-hour window opens and you can send free text into that conversation. The template must be
`approved`, in the `language` you name, with exactly its variables; see
[Templates](/messaging/templates#start-a-conversation-with-a-template).
## The 24-hour window [#the-24-hour-window]
Free-form messages are allowed only within 24 hours of the customer's last message. Outside it,
a free-text send answers `422 unsupported_channel_action` with reason `outside_24h_window`: send an
approved template instead. Details: [The 24-hour window and templates](/channels#the-24-hour-window-and-templates).
## Delivery status [#delivery-status]
A queued send answers `202` with `status: queued`: Gauge accepted the message and hands it to
WhatsApp right after the request. WhatsApp's progress arrives as
[`message.status_updated`](/webhooks/events#messagestatus_updated) events, each with `status` one of
`sent`, `delivered`, `read` or `failed`. A `failed` event carries `error: { "reason": "…" }`, the
reason in plain words (for example "This number is not on WhatsApp."); every other status has
`error: null`. The same history is at `GET /v2/messages/{id}/status-history`.
## Costs [#costs]
Meta charges for some WhatsApp messages, by category and country. Gauge passes those fees through
at cost, with no markup, exactly as for messages sent from the console.
## Recipient numbers [#recipient-numbers]
Send to numbers in international (E.164) format: `+447700900123`. Gauge normalises them for
WhatsApp.
# WhatsApp Web (/channels/whatsapp-web)
A WhatsApp Web connection runs on an ordinary WhatsApp account (a phone number using the WhatsApp
or WhatsApp Business app), linked to Gauge the way WhatsApp Web is linked to a browser. It is
**not** Meta's official Business Platform.
**WhatsApp may restrict or permanently ban an account that sends automated or high-volume
messages**, messages people did not ask for, or messages many recipients report or block. A ban
takes the number off WhatsApp, usually with its chat history, and Gauge cannot reverse it.
By sending through a WhatsApp Web connection over the API, **you accept that risk, and you are
responsible for your send volume, your pacing and your recipients' consent.** For automation,
notifications or anything at volume, use the [WhatsApp Cloud API](/channels/whatsapp-cloud).
Every WhatsApp Web connection says so in the API itself. Its `warning` field is never null:
```json
"warning": {
"code": "whatsapp_web_ban_risk",
"message": "WhatsApp Web connections run on a personal WhatsApp account, not the official Business API. WhatsApp may restrict or ban an account that sends automated or high-volume messages. You are responsible for your send volume and limits on this connection."
}
```
The same warning appears in the console when a key is created and on the connection itself, and
every `POST /v2/messages` on a WhatsApp Web connection answers with
`"warnings": ["whatsapp_web_unofficial"]`.
## What it can do [#what-it-can-do]
| | |
| --- | --- |
| Text | Yes, with no 24-hour window |
| Media | Yes |
| Quoted replies | Yes |
| Templates | No (templates are a Cloud API feature) |
| Start a conversation | Yes |
| Campaigns | **No.** Bulk sending is not available on WhatsApp Web, by design |
| Delivery | `queued`: every send answers `202` |
## Using it responsibly [#using-it-responsibly]
These lower the risk. None of them removes it.
- **Answer, do not broadcast.** Replying to people who wrote to you is the safest use. Cold
messages to numbers that never contacted you are the riskiest.
- **Pace your sends.** Spread messages out; do not fire hundreds in a burst. Gauge applies its own
per-connection [send caps](#send-caps) on WhatsApp Web, and you should stay well under them.
- **Only message people who expect it**, and stop when someone asks you to.
- **Keep the phone healthy.** The linked phone must stay online and signed in; if it logs out, the
connection stops until it is re-linked.
- **Move volume to the Cloud API.** If a WhatsApp Web number is becoming important to your
business, migrate it to the official platform.
## Send caps [#send-caps]
A newly linked number warms up: Gauge caps how much it sends while the account is young, and lifts
the cap as it ages. The caps count **every** outbound message on the connection (console and API
alike, queued ones included), per minute and per UTC day:
| Warm-up tier | Account age | Per minute | Per day |
| --- | --- | --- | --- |
| 0 | from linking | 1 | 30 |
| 1 | 3 days | 3 | 120 |
| 2 | 7 days | 8 | 400 |
| 3 | 14 days | 15 | 1,000 |
The connection's current tier and caps are in `limits.sendCaps` on
[`GET /v2/connections/{id}`](/channels): `{ "perMinute": 1, "perDay": 30, "warmupTier": 0 }`.
A send over a cap is **refused, not delayed**: `429 rate_limited` with reason
`whatsapp_web_minute_cap` or `whatsapp_web_daily_cap`, `details.limit`, and a `Retry-After` header
(seconds until the minute, or the UTC day, resets). Nothing is queued. Wait and retry with the same
`Idempotency-Key`.
# Errors (/conventions/errors)
Every error from `/v1` and `/v2` has the same shape:
```json
{
"error": {
"code": "validation_failed",
"message": "Invalid query parameters.",
"details": [{ "path": "query.limit", "message": "Number must be less than or equal to 100" }],
"requestId": "4f6c1d2e-8a3b-4c5d-9e0f-1a2b3c4d5e6f"
}
}
```
| Field | |
| --- | --- |
| `code` | One of the codes below. **Branch on this**, never on `message`. |
| `message` | A sentence for a human. It may be reworded at any time. |
| `reason` | Optional. A finer sub-code for some refusals, such as `ip_not_allowed`. |
| `details` | Optional. For `validation_failed`, a list of `{ path, message }`, one per bad field. |
| `requestId` | The same id as the `X-Request-Id` header. Quote it to support. |
A `5xx` never carries a stack trace or internal detail: its message is always "Something went wrong
on our side. Quote the request id if you contact support."
## Codes [#codes]
The list is closed: a new code is an additive change, and renaming one is a breaking change that
needs a new [version](/conventions/versioning).
| HTTP | `code` | When | What to do |
| --- | --- | --- | --- |
| 400 | `validation_failed` | A parameter, header or body field is invalid. `details` names each one. Also: a missing `Idempotency-Key` on a POST, an invalid cursor, an unknown `Gauge-Version`. | Fix the request. Do not retry unchanged. |
| 401 | `unauthorized` | No key, or an unknown, revoked or expired one. | Check the key. |
| 401 | `key_owner_inactive` | The key's member is no longer active in the workspace. | Create a new key under an active member. |
| 402 | `plan_required` | The workspace is not on Growth or Scale. | Upgrade in **Settings → Billing**. |
| 403 | `forbidden` | Refused for a reason in `reason`: `ip_not_allowed`, `sandbox_key_not_supported`, `key_pinned_to_projects`. | See [Authentication](/authentication#authentication-errors). |
| 403 | `insufficient_scope` | The key, or its member's role, lacks the route's scope. `details.required` names it. | Add the scope, or use another key. |
| 403 | `workspace_suspended` | The workspace is suspended. | Contact Gauge support. |
| 404 | `not_found` | The resource does not exist, or this key cannot see it. | Check the id. Gauge does not tell you which. |
| 409 | `conflict` | The request clashes with the current state, or an identical request is still running (`reason: idempotency_in_flight`). | Re-read the resource, or retry shortly. |
| 409 | `idempotency_mismatch` | An `Idempotency-Key` was reused with a different body. | Use a new key for a new request. |
| 413 | `payload_too_large` | The body or file is over the limit. | Send less. |
| 422 | `unsupported_channel_action` | The channel cannot do this, such as media on a channel without media, or free text outside WhatsApp's 24-hour window. | Check the connection's `capabilities`. See [Channels](/channels). |
| 429 | `rate_limited` | Over the [rate limit](/conventions/rate-limits). | Wait `Retry-After` seconds, then retry. |
| 500 | `internal` | Our fault. | Retry with backoff. If it persists, contact support with the request id. |
`404` is also the answer for another workspace's ids and for anything outside the key's visibility.
Gauge never confirms that a resource exists to a caller who may not see it.
## Retrying safely [#retrying-safely]
| Retry? | Codes |
| --- | --- |
| Yes, after a wait | `429 rate_limited` (honour `Retry-After`), `500 internal`, network errors and timeouts |
| Yes, shortly | `409 conflict` with `reason: idempotency_in_flight` |
| No, fix first | Every other `4xx` |
Send POSTs with an [`Idempotency-Key`](/conventions/idempotency) and every retry is safe: a request
that already succeeded replays its first response instead of running twice.
```js
async function gauge(path, init = {}, attempt = 0) {
const res = await fetch(`https://app.gauge.chat/backend${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.GAUGE_API_KEY}`, ...init.headers },
});
if (res.ok) return res.status === 204 ? null : res.json();
const { error } = await res.json();
const retryable = res.status === 429 || res.status >= 500 || error.reason === 'idempotency_in_flight';
if (retryable && attempt < 4) {
const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000 + Math.random() * 250));
return gauge(path, init, attempt + 1);
}
throw Object.assign(new Error(error.message), { code: error.code, reason: error.reason, requestId: error.requestId });
}
```
```python
import os, random, time
import requests
def gauge(method, path, attempt=0, **kwargs):
headers = {"Authorization": f"Bearer {os.environ['GAUGE_API_KEY']}", **kwargs.pop("headers", {})}
res = requests.request(method, f"https://app.gauge.chat/backend{path}", headers=headers, timeout=15, **kwargs)
if res.ok:
return None if res.status_code == 204 else res.json()
error = res.json()["error"]
retryable = res.status_code == 429 or res.status_code >= 500 or error.get("reason") == "idempotency_in_flight"
if retryable and attempt < 4:
wait = float(res.headers.get("Retry-After") or 2 ** attempt)
time.sleep(wait + random.random() / 4)
return gauge(method, path, attempt + 1, headers=headers, **kwargs)
raise RuntimeError(f"{error['code']}: {error['message']} (request {error['requestId']})")
```
# Idempotency (/conventions/idempotency)
Networks fail in the worst place: after Gauge sent the message, before you got the answer. An
**idempotency key** makes the retry safe. Send the same key again and Gauge returns the first
answer instead of doing the work twice.
**Every `POST` on `/v2` requires an `Idempotency-Key` header.** A POST without one answers
`400 validation_failed`, naming the header.
```bash
curl https://app.gauge.chat/backend/v2/messages \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5d1f8c2a-7e3b-4a9d-b6c0-2f8e1a4d7c93" \
-d '{"connectionId":"…","to":"+447700900123","text":"Hello"}'
```
## Choosing a key [#choosing-a-key]
- 1 to 255 visible ASCII characters (no spaces).
- **One key per distinct operation.** A UUID generated when you decide to do the thing works, as
does an id from your own system that names the operation: `order-1042-confirmation`.
- Generate it **before** the first attempt and reuse it for every retry of that operation. A new
key on each retry defeats the point.
## What happens [#what-happens]
Keys are scoped to the API key, the method and the path, and are kept for **24 hours**.
| You send | Gauge answers |
| --- | --- |
| A key it has not seen | Runs the request and stores the answer |
| The same key and the same body, after the first finished | The **stored** status and body, with `Idempotent-Replayed: true` |
| The same key and the same body, while the first is still running | `409 conflict`, `reason: idempotency_in_flight`. Retry shortly. |
| The same key with a **different** body | `409 idempotency_mismatch`. A new request needs a new key. |
| The same key after the first attempt **failed** | Runs again: a failed attempt releases its key, so an honest retry is not stuck behind an error |
A replay is visible in **Developers → Logs**, where it is marked as a replay.
## Sending messages [#sending-messages]
For `POST /v2/messages`, the idempotency key is also how a message is identified as "the same send".
Gauge derives the message's own de-duplication reference from your API key and the
`Idempotency-Key`, and stores it on the message. So:
- **Within 24 hours**, a retry replays the first answer exactly: the same status (`201` or `202`)
and body, with `Idempotent-Replayed: true`.
- **After 24 hours**, the replay record is gone, but the message still carries the reference: a
retry with the same key answers that **same message** (with its current status, so `201` once it
has been sent) and never sends a second one.
The reference is per API key: the same `Idempotency-Key` on two different keys is two sends.
# Conventions (/conventions)
Every `/v2` endpoint shares the same contract. Build your client around these once and every new
endpoint behaves the way you expect.
| Convention | In one line |
| --- | --- |
| [Versioning](/conventions/versioning) | `/v2` in the path; pin a dated `Gauge-Version` for additive changes. |
| [Errors](/conventions/errors) | One envelope; branch on `error.code`, never on the message. |
| [Request ids](/conventions/request-ids) | Every response has `X-Request-Id`; quote it to support. |
| [Idempotency](/conventions/idempotency) | Every POST needs an `Idempotency-Key`; retries replay the first answer. |
| [Pagination](/conventions/pagination) | Opaque cursors, `{ data, nextCursor, prevCursor }`, up to 100 per page. |
| [Rate limits](/conventions/rate-limits) | Per key and per workspace, by plan; `RateLimit-*` headers, `429` with `Retry-After`. |
| [Plans](/conventions/plans) | The API is included on Growth and Scale. |
Requests and responses are JSON (`Content-Type: application/json`), in UTF-8. Timestamps are
ISO 8601 in UTC (`2026-10-21T09:12:44.000Z`). Ids are opaque strings, today UUIDs; do not parse them.
# Pagination (/conventions/pagination)
Every list endpoint answers with the same envelope:
```json
{
"data": [{ "id": "…" }, { "id": "…" }],
"nextCursor": "c1_WyIyMDI2LTEwLTIxVDA5OjEyOjQ0LjAwMFoiLCI5YjJm…",
"prevCursor": null
}
```
Lists are **newest first**, ordered by creation time and then id, so a page never skips or repeats a
row even when new ones arrive while you are paging.
| Query parameter | |
| --- | --- |
| `limit` | Page size, 1 to 100. Default 25. |
| `after` | A `nextCursor` from the previous page: continue to older items. |
| `before` | A `prevCursor`: go back to newer items. Send `after` or `before`, not both. |
`nextCursor` is `null` on the last page; `prevCursor` is `null` on the first.
**Cursors are opaque.** Pass them back exactly as you got them. Do not build, parse or store them
long-term; their format can change. A string that is not a cursor Gauge issued answers
`400 validation_failed`.
## Fetch every page [#fetch-every-page]
```js
async function* listAll(path) {
let after = null;
do {
const url = new URL(`https://app.gauge.chat/backend${path}`);
url.searchParams.set('limit', '100');
if (after) url.searchParams.set('after', after);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.GAUGE_API_KEY}` } });
const page = await res.json();
if (!res.ok) throw new Error(`${page.error.code} (${page.error.requestId})`);
yield* page.data;
after = page.nextCursor;
} while (after);
}
for await (const connection of listAll('/v2/connections')) console.log(connection.displayName);
```
```python
def list_all(path):
after = None
while True:
params = {"limit": 100, **({"after": after} if after else {})}
res = requests.get(
f"https://app.gauge.chat/backend{path}",
headers={"Authorization": f"Bearer {os.environ['GAUGE_API_KEY']}"},
params=params,
timeout=15,
)
page = res.json()
if not res.ok:
raise RuntimeError(f"{page['error']['code']} ({page['error']['requestId']})")
yield from page["data"]
after = page["nextCursor"]
if not after:
return
for connection in list_all("/v2/connections"):
print(connection["displayName"])
```
Paging through a large list spends one request per page against your
[rate limit](/conventions/rate-limits). Use `limit=100` for bulk reads.
# Plans (/conventions/plans)
| | Free | Growth | Scale |
| --- | --- | --- | --- |
| API access | Not included | Included | Included |
| Requests per minute, workspace | | 600 | 3,000 |
| Requests per minute, per key | | 300 | 1,500 |
Rate limits are [provisional](/conventions/rate-limits).
On Free, every API request answers:
```json
{
"error": {
"code": "plan_required",
"message": "The Gauge API is available on the Growth and Scale plans. Upgrade in Settings → Billing to use this key.",
"requestId": "…"
}
}
```
with status `402`. Upgrade in **Settings → Billing**, and the same keys start working at once.
- **Trials count.** A workspace trialling Scale has API access for the trial.
- **Payment problems do not cut you off at once.** A subscription that is past due keeps API access
through the same grace period as the rest of the product.
- **There is no separate API bill or quota.** API traffic counts against the plan's normal limits
and usage, exactly as work done in the console does. Messages you send over the API cost what
they cost in the console (for example, Meta's own WhatsApp fees, passed through at cost).
# Rate limits (/conventions/rate-limits)
The limits below are the launch values and may change before the API leaves preview. Read the live
values from the `RateLimit-*` headers or `GET /v2/me` rather than hard-coding them.
## The limits [#the-limits]
Each request spends one token from **two buckets**: one for the key and one for the whole workspace.
A request goes through only when both have a token.
| Plan | Workspace | Each key |
| --- | --- | --- |
| Growth | 600 requests per minute | 300 per minute |
| Scale | 3,000 requests per minute | 1,500 per minute |
| Free | The API is not included | |
Each key gets **half** the workspace budget, so one runaway integration cannot starve the others.
Buckets refill continuously, not at the top of the minute: a key on Growth gets a token back every
200 ms.
API traffic is not metered or billed separately. It counts against your plan's normal limits and
usage, like work done in the console.
## Headers [#headers]
Every `/v2` response that reached the limiter carries:
```http
RateLimit-Limit: 300
RateLimit-Remaining: 287
RateLimit-Reset: 3
```
| Header | |
| --- | --- |
| `RateLimit-Limit` | Requests per minute in the tighter of your two buckets |
| `RateLimit-Remaining` | Tokens left in that bucket |
| `RateLimit-Reset` | Seconds until that bucket is full again |
`GET /v2/me` returns both buckets in `rateLimit`, including `workspaceLimit` and
`workspaceRemaining`.
## When you hit it [#when-you-hit-it]
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 2
RateLimit-Limit: 300
RateLimit-Remaining: 0
RateLimit-Reset: 60
```
```json
{
"error": {
"code": "rate_limited",
"message": "Rate limit reached. Retry after 2 s.",
"details": { "limit": 300, "workspaceLimit": 600 },
"requestId": "…"
}
}
```
Wait `Retry-After` seconds, then retry. A refused request spends no token, but requests to a route
the key cannot call still count: the limit is checked before the scope.
## Staying under [#staying-under]
- Page with `limit=100` instead of many small pages.
- Use [webhooks](/webhooks) instead of polling for changes.
- Spread batch jobs over time, and give each integration its own key.
- Back off on `429` with jitter, so many workers do not retry in the same instant.
Gauge also applies a per-IP safety limit in front of the API, to protect the platform from floods.
Normal integrations never meet it.
# Request ids (/conventions/request-ids)
Every response from the API, success or error, carries a request id:
```http
HTTP/1.1 200 OK
X-Request-Id: 4f6c1d2e-8a3b-4c5d-9e0f-1a2b3c4d5e6f
```
The same id is in the body of every error, as `error.requestId`. Gauge's own logs are keyed on it,
so with the id support can find your exact request in seconds; without it, they are searching by
time.
**Log it** next to your own record of the call, at least for errors.
## Bring your own id [#bring-your-own-id]
If you already trace requests with your own id, send it as `X-Request-Id` and Gauge uses it instead
of minting one, end to end. It must be a **UUID** or a **ULID**; anything else is ignored and Gauge
mints a fresh UUID.
```bash
curl https://app.gauge.chat/backend/v2/me \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "X-Request-Id: 01JAXQ7M3R9K5V2T8N4P6W1Y0Z"
```
## Find a request in the console [#find-a-request-in-the-console]
**Developers → Logs** lists every `/v2` request for the last 14 days: method, route, status,
latency, the key that made it, and whether it was an idempotent replay. Paste a request id into the
search to jump to it.
# Versioning (/conventions/versioning)
## Two layers [#two-layers]
**The path** carries the major version: `/v2/...`. A new major version is a new path, and the old
one keeps working. `/v1` is frozen: it gets security fixes only, and new work lands on `/v2`.
**The `Gauge-Version` header** carries a dated version for changes inside `/v2`:
```http
Gauge-Version: 2026-10-21
```
- **Leave it out** and you get the current version. Fine while you build.
- **Send it** in production to pin the behaviour your code was written against.
- An **unknown** value answers `400 validation_failed` rather than silently using another version,
so a typo cannot change behaviour without you noticing.
- Every `/v2` response echoes the version it followed in its own `Gauge-Version` header.
Published versions: **`2026-10-21`** (current).
## What counts as a breaking change [#what-counts-as-a-breaking-change]
These are **additive** and can ship at any time, without a new version. Write your client to
tolerate them:
- a new endpoint, or a new optional request field or query parameter;
- a new field in a response object;
- a new value in an enum we document as extensible (event names, `error.reason`, connection `kind`);
- a new webhook event type.
These are **breaking** and only ever ship behind a new `Gauge-Version` date or a new path:
- removing or renaming a field, an endpoint or an `error.code`;
- changing a field's type or meaning;
- making an optional request field required.
## Deprecation [#deprecation]
Before an endpoint is retired, its responses carry the standard headers:
```http
Deprecation: @1798761600
Sunset: Sun, 01 Aug 2027 00:00:00 GMT
Link: ; rel="deprecation"
```
Nothing is deprecated today. Every deprecation is also announced in the [changelog](/changelog).
# Glossary (/glossary)
| Term | Meaning |
| --- | --- |
| **Workspace** | Your Gauge account: the tenant. Everything a key can reach belongs to one workspace. |
| **Member** | A person with a seat in the workspace. A key always acts as a member. |
| **Role** | What a member may do and see. A key can never do more than its member's role. |
| **API key** | A secret that authenticates requests as one member, within its scopes. `pulse_k_live_…` or `pulse_k_test_…`. |
| **Scope** | A permission on a key, by area: `messages:write`, `contacts:read`. |
| **Live / test** | A key's environment. Test keys reach sandbox data only. |
| **Connection** | One connected channel account: a WhatsApp number, an inbox, a Facebook Page. Not the same as a *channel* (the type) or an *identity* (the person's address). |
| **Channel** | The kind of connection: WhatsApp Cloud API, WhatsApp Web, email, web chat, Instagram, Messenger, SMS, voice. In the API, a connection's `kind`. |
| **Capabilities** | What a connection can do over the API right now: text, media, templates, starting conversations. |
| **Contact** | A person, merged across channels. |
| **Identity** | One way to reach a contact: a channel plus the id on it, such as a WhatsApp number. |
| **Conversation** | A contact on one connection: a thread with a status, an assignee and tags. |
| **Message** | One item in a conversation: inbound or outbound text, media, template or interactive. |
| **Template** | A WhatsApp message pre-approved by Meta, the only kind allowed outside the 24-hour window. |
| **24-hour window** | WhatsApp's rule: free-form messages only within 24 hours of the customer's last message. |
| **App** | A product of yours connected to Gauge Support (in older docs and the API, a *project*). |
| **Integration** | One way into an App: in-app chat, a WhatsApp number, an email address. It has a key such as `customer` or `salon`. Not an API key. |
| **Webhook endpoint** | Your URL that receives events, with its subscriptions and signing secret. |
| **Event** | Something that happened, delivered to webhooks: `support.ticket.created`. Has a stable `id`. |
| **Delivery** | One attempt to POST an event to an endpoint. An event can have several. |
| **Idempotency key** | A header that makes a retried POST safe: the same key never does the work twice. |
| **Request id** | The id of one API request, in `X-Request-Id`. Quote it to support. |
| **Cursor** | An opaque marker for the next or previous page of a list. |
# The Gauge API (/)
Gauge is a revenue inbox: a business's WhatsApp, Instagram, Messenger, email, web chat and phone
calls land in one priority-sorted queue. The Gauge API gives your code the same access a member of
the team has in the console. It can see the connected channels, send and read messages, keep
contacts in step with your own system, and receive events the moment they happen.
Create a key, make your first call, send a WhatsApp message and receive replies by webhook.
Bearer keys, scopes, live and test environments, IP allowlists, expiry and rotation.
Every /v2 endpoint and webhook event, generated from the API's own OpenAPI document.
The event catalogue, signature verification, retries, replay and inbound message forwarding.
## What you can build [#what-you-can-build]
- **Reply from your own product.** Read a conversation and answer it from your CRM, booking system
or back office, on the channel the customer used.
- **Forward inbound messages.** Receive every new customer message at your endpoint, signed, and
route it wherever it needs to go.
- **Keep contacts in sync.** Create and update contacts from your system of record, so the team sees
the same customer you do.
- **Embed support in your app.** The [Support API](/support) puts Gauge's chat, tickets and
callbacks inside your own web or mobile app, signed in as your user.
## What is available today [#what-is-available-today]
The API is versioned by path. `/v2` is the current version and is being built out area by area.
This table is the honest state of each area; every page in these docs carries the same marks.
| Area | Status | Where |
| --- | --- | --- |
| Who a key is (`GET /v2/me`) | | [Reference](/reference) |
| Connections and their capabilities | | [Channels](/channels) |
| Webhooks: signed delivery, retries, replay, test events | | [Webhooks](/webhooks) |
| Support API (`/v1/support`): customers, chat, tickets, callbacks | | [Support API](/support) |
| Contacts, conversations, messages, media, templates | | [Messaging](/messaging) |
| Message events (`message.received` and friends) | | [Events](/webhooks/events) |
| SMS and voice | | [Channels](/channels) |
**Coming soon** means the area is planned and not yet available. Watch the [changelog](/changelog).
## The basics [#the-basics]
| | |
| --- | --- |
| Base URL | `https://app.gauge.chat/backend` |
| Version | `/v2` in the path, plus an optional dated `Gauge-Version` header ([Versioning](/conventions/versioning)) |
| Authentication | `Authorization: Bearer ` ([Authentication](/authentication)) |
| Format | JSON in, JSON out, UTF-8 |
| Errors | One envelope, `{ "error": { "code", "message", "requestId" } }` ([Errors](/conventions/errors)) |
| Plans | Growth and Scale. The API is not included on Free ([Plans](/conventions/plans)) |
There is no Gauge SDK yet. Every example in these docs is plain HTTP in curl, Node (`fetch`) and
Python (`requests`), so it works with whatever HTTP client you already use. An official Node SDK,
`@silquetech/gauge-sdk`, will follow the v2 endpoints.
## Using an AI assistant? [#using-an-ai-assistant]
These docs are published for AI tools as well: [`/llms.txt`](/llms.txt) indexes every page,
[`/llms-full.txt`](/llms-full.txt) has all of it in one file, and every page has a **Copy page**
button. See [Use Gauge with AI tools](/ai-tools).
# Contacts (/messaging/contacts)
A contact is a person, merged across channels. Contacts are workspace-wide: a key with contact
access sees every contact, the same list the console shows. Every contact endpoint needs a live key
that is not pinned to an App.
| Endpoint | Scope | |
| --- | --- | --- |
| `GET /v2/contacts` | `contacts:read` | List and search contacts |
| `POST /v2/contacts` | `contacts:write` | Create a contact (`201`) |
| `GET /v2/contacts/{id}` | `contacts:read` | One contact |
| `PATCH /v2/contacts/{id}` | `contacts:write` | Update fields or custom fields |
| `PUT /v2/contacts/by-identity/{channel}/{externalId}` | `contacts:write` | Create (`201`) or update (`200`) by channel identity |
| `GET`, `POST /v2/contacts/{id}/identities` | `contacts:read`, `contacts:write` | List or add a handle (`{ "channel", "externalId" }`) |
| `DELETE /v2/contacts/{id}/identities/{identityId}` | `contacts:write` | Remove a handle |
| `POST /v2/contacts/{id}/tags`, `DELETE /v2/contacts/{id}/tags/{tag}` | `contacts:write` | Add tags (`{ "tags": [...] }`), remove one |
| `POST /v2/contacts/{id}/merge` | `contacts:write` | Merge `sourceId` into this contact |
| `DELETE /v2/contacts/{id}` | `contacts:write` + erasure right | Erase the contact (irreversible) |
## The contact object [#the-contact-object]
```json
{
"id": "2b9d4f61-7a3c-4e8b-9d0f-1a2b3c4d5e60",
"displayName": "Layla Haddad",
"email": "layla@example.com",
"phone": "+447700900123",
"title": null,
"companyId": null,
"companyName": null,
"lifecycleStage": "customer",
"isVip": false,
"tags": ["vip"],
"customFields": { "plan": "gold" },
"identities": [
{ "id": "8c1d…", "channel": "whatsapp_cloud", "externalId": "447700900123", "connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f" },
{ "id": "9d2e…", "channel": "email", "externalId": "layla@example.com", "connectionId": null }
],
"createdAt": "2026-10-20T16:02:11.903Z",
"updatedAt": "2026-10-21T09:28:40.112Z"
}
```
## Search [#search]
`GET /v2/contacts` takes, all optional: `q` (name, email, phone or company contains), `email`,
`phone` (any format, matched on digits), `tag`, `company`, `updatedSince`, `createdSince`,
`channel` + `externalId` (the contact holding that handle), and `sort` (`-createdAt`, the default,
`createdAt`, `updatedAt` or `-updatedAt`).
## Create and update [#create-and-update]
`POST /v2/contacts` takes `displayName`, `email`, `phone`, `country` (for a national phone number:
`US`, `CA` or `GB`), `title`, `companyId` or `companyName` (found or created), `customFields` and
`tags`. It needs at least a `displayName`, an `email` or a `phone`. An email or phone another
contact already holds answers `409 conflict`.
`PATCH /v2/contacts/{id}` takes the same fields except `email` and `tags` (add handles and tags
with their own endpoints).
## Sync from your system: upsert by identity [#sync-from-your-system-upsert-by-identity]
The simplest way to keep Gauge in step with your CRM or database is to **upsert** each customer by
something both systems know, such as their phone number on a WhatsApp connection or their email
address:
```bash
curl -X PUT "https://app.gauge.chat/backend/v2/contacts/by-identity/whatsapp_cloud/+447700900123" \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "displayName": "Layla Haddad", "customFields": { "plan": "gold" } }'
```
The body takes `displayName`, `title`, `companyId` or `companyName`, `customFields` and `country`.
The handle in the path is normalized the way the inbox files it (a phone to E.164). If a contact
holds that handle, it is updated and the answer is `200`; otherwise a contact is created with that
handle and the answer is `201`. Run it as often as you like: the result is the same.
## Erase a contact [#erase-a-contact]
`DELETE /v2/contacts/{id}` is the GDPR erasure: the contact's handles are deleted and their
messages redacted. It **cannot be undone**. It needs `contacts:write` **and** the key's member must
hold the erasure right (Owner or Admin); otherwise it answers `403`. Pass an optional `?reason=` for
the audit log. The answer counts what was erased:
```json
{ "id": "2b9d…", "erased": true, "erasedAt": "2026-10-21T10:00:00.000Z", "identitiesDeleted": 2, "messagesRedacted": 14 }
```
## Events [#events]
Creating, renaming, merging and changing a contact's handles or fields raise
[`contact.created` and `contact.updated`](/webhooks/events#contacts) webhooks, as in the console.
# Conversations (/messaging/conversations)
A conversation is one contact on one connection: the thread an agent works in the inbox. It has a
status, an assignee, tags, and its messages.
| Endpoint | Scope | |
| --- | --- | --- |
| `GET /v2/conversations` | `conversations:read` | A page of conversations, most recent activity first |
| `GET /v2/conversations/{id}` | `conversations:read` | One conversation |
| `PATCH /v2/conversations/{id}` | `conversations:write` | Change status, assignee, team, topic, tags or priority |
| `POST /v2/conversations/{id}/read` | `conversations:write` | Mark it read for the key's member (`200`) |
| `GET /v2/conversations/{id}/messages` | `messages:read` | Its messages, newest first |
| `POST /v2/conversations/{id}/notes` | `conversations:write` | Add an internal note the customer never sees (`201`) |
Every one needs a live key that is not pinned to an App. Lists use the standard
[pagination](/conventions/pagination). A key sees only conversations its member can see in the
console (their own, their team's, the unassigned queue, or all): anything else is a `404`, on the
list and on a direct read alike.
## The conversation object [#the-conversation-object]
```json
{
"id": "c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c",
"status": "open",
"snoozedUntil": null,
"channel": "whatsapp_cloud",
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"contact": { "id": "2b9d4f61-7a3c-4e8b-9d0f-1a2b3c4d5e60", "displayName": "Layla Haddad" },
"assigneeId": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b",
"teamId": null,
"topicId": null,
"appId": null,
"integrationId": null,
"tags": ["refund"],
"priority": 0,
"unread": false,
"needsReply": true,
"lastMessageAt": "2026-10-21T09:28:40.112Z",
"lastInboundAt": "2026-10-21T09:28:40.112Z",
"createdAt": "2026-10-20T16:02:11.903Z",
"updatedAt": "2026-10-21T09:28:40.112Z"
}
```
`status` is `open`, `snoozed` or `resolved`. `unread` is relative to the key's member.
## Filters [#filters]
`GET /v2/conversations` takes, all optional: `status` and `channel` (comma-separated lists, e.g.
`status=open,snoozed`), `connectionId`, `assignee` (`me`, `none` or a member id), `teamId`,
`topicId`, `appId`, `integrationId`, `contactId`, `tag`, `unread` (`true`/`false`),
`updatedSince` (ISO time), `priorityMin`, `priorityMax`, and `sort` (`-lastMessageAt`, the default,
`lastMessageAt`, `createdAt` or `-createdAt`).
`GET /v2/conversations/{id}/messages` takes `direction` (`inbound`/`outbound`), and
`includeNotes` / `includeSystem` (`false` by default).
## Change a conversation [#change-a-conversation]
`PATCH /v2/conversations/{id}`, with any of:
| Field | |
| --- | --- |
| `status` | `open`, `snoozed` or `resolved` |
| `snoozedUntil` | With `status: snoozed`: when to wake it (a future ISO time). Omit to snooze until the customer replies. |
| `assigneeId` | A member id, or `null` to unassign |
| `teamId` | Move it to a team |
| `topicId` | A topic id, or `null` to clear |
| `tags` | The **whole** tag set (replaces). Or use `addTags` / `removeTags` to change a few; not both. |
| `priority` | 0 to 1000 |
| `visitorDetails` | App conversations: visitor detail values by field key |
```bash
curl -X PATCH https://app.gauge.chat/backend/v2/conversations/c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "resolved", "addTags": ["refunded"] }'
```
The answer is the conversation after the change. Fields are applied in the order of the table; if
one is refused (for example an assignee the member may not assign to), the rest are not applied
and the error says which.
## Leave a note for the team [#leave-a-note-for-the-team]
`POST /v2/conversations/{id}/notes` · requires `Idempotency-Key`
```bash
curl https://app.gauge.chat/backend/v2/conversations/c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c/notes \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: note-order-1042-refunded" \
-d '{ "body": "Refund of £42.00 issued in Stripe (re_3Q…)." }'
```
`body` is plain text up to 8,192 characters; `@[Name](memberId)` mentions notify that member.
`messageId` pins the note to one message of the conversation.
## Events [#events]
A status change (open, snooze, resolve) and an assignment, team or topic change made through the
API raise the same [`conversation.updated`](/webhooks/events#conversationupdated) webhook as one
made in the console. Tag and priority changes do not raise a webhook.
# Messaging (/messaging)
The [API reference](/reference) is generated from the running API's own specification and lists
every field of every operation below.
## The model [#the-model]
| Resource | What it is |
| --- | --- |
| **Connection** | One connected channel account: a WhatsApp number, an inbox. [Live](/channels). |
| **Contact** | A person, merged across channels. One contact can have a WhatsApp number, an email address and an Instagram handle. |
| **Identity** | One way to reach a contact: a channel plus the id on it, such as `whatsapp` + `+447700900123`. |
| **Conversation** | A contact on one connection: a thread with a status, an assignee and tags. |
| **Message** | One item in a conversation: inbound or outbound, text, media, template or interactive. |
| **Media** | A file you upload with `POST /v2/media` to send. A file already on a message (one a customer sent, or one you sent) is downloaded through that message. |
| **Template** | An approved WhatsApp message template. |
## Endpoints [#endpoints]
| Endpoint | Scope | |
| --- | --- | --- |
| `POST /v2/messages` | `messages:write` | Send a message |
| `GET /v2/messages/{id}` | `messages:read` | One message |
| `GET /v2/messages/{id}/status-history` | `messages:read` | Its delivery history |
| `GET /v2/messages/{id}/attachments/{index}` | `messages:read` | A short-lived download link for a file on a message |
| `GET /v2/conversations` | `conversations:read` | List conversations |
| `GET /v2/conversations/{id}` | `conversations:read` | One conversation |
| `PATCH /v2/conversations/{id}` | `conversations:write` | Status, assignee, team, topic, tags, priority |
| `POST /v2/conversations/{id}/read` | `conversations:write` | Mark read |
| `GET /v2/conversations/{id}/messages` | `messages:read` | A conversation's messages |
| `POST /v2/conversations/{id}/notes` | `conversations:write` | Add an internal note |
| `GET /v2/contacts`, `POST /v2/contacts` | `contacts:read`, `contacts:write` | List, create |
| `GET`, `PATCH /v2/contacts/{id}` | `contacts:read`, `contacts:write` | One contact |
| `DELETE /v2/contacts/{id}` | `contacts:write` + the member's erasure right | Erase a contact (GDPR, irreversible). See [Contacts](/messaging/contacts#erase-a-contact) |
| `PUT /v2/contacts/by-identity/{channel}/{externalId}` | `contacts:write` | Create or update by channel identity |
| `GET`, `POST /v2/contacts/{id}/identities`, `DELETE …/identities/{identityId}` | `contacts:read`, `contacts:write` | A contact's handles |
| `POST /v2/contacts/{id}/tags`, `DELETE …/tags/{tag}` | `contacts:write` | Tag, untag |
| `POST /v2/contacts/{id}/merge` | `contacts:write` | Merge another contact into this one |
| `POST /v2/media` | `media:write` | Get an upload URL |
| `GET /v2/media/{id}` | `media:read` | Get a short-lived download URL |
| `GET /v2/templates`, `GET /v2/templates/{id}` | `templates:read` | WhatsApp templates |
## Which keys can call them [#which-keys-can-call-them]
Every messaging endpoint needs a **live** key that is **not pinned to an App**. A test key gets
`403 forbidden` with reason `sandbox_key_not_supported`, and a key pinned to an App gets
`403 forbidden` with reason `key_pinned_to_projects`: these endpoints read and write real customer
data that belongs to no single App. The one exception is `GET /v2/templates`, which a test key may
read. See [Test keys](/authentication#test-keys).
## Visibility [#visibility]
A key sees what its member sees in the console. For conversations and messages, that is the
member's own, their team's, or all, depending on their role. Contacts are visible workspace-wide to
anyone with contact access, as in the console. See [Keys belong to a person](/authentication#keys-belong-to-a-person).
## Sent as you, marked as the API [#sent-as-you-marked-as-the-api]
A message sent with a key is sent as the key's member and appears in the console like any other
reply, marked as sent through the API. API sends do not count as a person taking over from the AI
assistant.
# Media (/messaging/media)
Files never pass through your API request body. You ask Gauge for an upload URL, upload the file
straight to storage, then send a message that refers to it.
### Ask for an upload URL [#ask-for-an-upload-url]
`POST /v2/media` · scope `media:write` · requires `Idempotency-Key` · live, unpinned key
```json
{ "contentType": "application/pdf", "sizeBytes": 183402, "filename": "invoice-1042.pdf", "connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f" }
```
| Field | |
| --- | --- |
| `contentType` | One of `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `video/mp4`, `video/3gpp`, `audio/ogg`, `audio/opus`, `audio/webm`, `audio/mpeg`, `audio/mp4`, `audio/aac`, `audio/amr`, `application/pdf`. Anything else answers `400`. |
| `sizeBytes` | The exact size. It is signed into the upload URL: storage refuses a body of any other length. |
| `filename` | Optional, up to 200 characters. |
| `connectionId` | Optional but recommended: the file is checked against that connection's limits now rather than at send time. |
`201` answers the media with a presigned upload:
```json
{
"id": "5d1c7e2a-9b3f-4a6d-8e0c-2f4a6b8d0e1f",
"contentType": "application/pdf",
"sizeBytes": 183402,
"filename": "invoice-1042.pdf",
"status": "pending",
"createdAt": "2026-10-21T09:30:00.000Z",
"expiresAt": "2026-10-21T09:45:00.000Z",
"upload": {
"url": "https://…",
"method": "PUT",
"headers": { "Content-Type": "application/pdf", "Content-Length": "183402" },
"expiresAt": "2026-10-21T09:45:00.000Z"
},
"download": null
}
```
### Upload the file [#upload-the-file]
`PUT` the bytes to `upload.url` with **exactly** `upload.headers`, within 15 minutes. The URL is
valid for that one file only.
```bash
curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" -H "Content-Length: 183402" --data-binary @invoice-1042.pdf
```
### Send it [#send-it]
Use the media `id` as `mediaId` in [`POST /v2/messages`](/messaging/messages), on a connection whose
`capabilities.sendMedia` is `true`. `text` becomes the caption. A send before the upload landed
answers `400` with reason `media_not_uploaded`.
## Limits [#limits]
| | Largest file |
| --- | --- |
| Any upload (no `connectionId`) | 16 MB |
| WhatsApp Cloud and WhatsApp Web | 16 MB; photos on WhatsApp Cloud 5 MB |
| Every other channel that sends files | 7 MB |
Over the limit answers `413 payload_too_large` with reason `file_too_large` and `details.maxBytes`.
A connection that cannot send files answers `422 unsupported_channel_action` with reason
`media_not_supported`. Each connection states its own limit as `limits.maxMediaBytes` on
[`GET /v2/connections/{id}`](/channels).
## Download a file you uploaded [#download-a-file-you-uploaded]
`GET /v2/media/{id}` · scope `media:read`
Once the file has landed, `status` is `uploaded` and `download` is a signed link valid for
5 minutes. Fetch the file when you need it rather than storing the link. Until then `download` is
`null`. A key sees the files its member uploaded, or every file when its member sees every
conversation.
Files on messages (what a customer sent you, or any file on a message) are downloaded through the
message: [`GET /v2/messages/{id}/attachments/{index}`](/messaging/messages#download-a-messages-file).
That is the link a webhook's `media[].url` carries.
## When file storage is not available [#when-file-storage-is-not-available]
File uploads and downloads need storage the internet can reach. Where a deployment has none
configured yet, `POST /v2/media`, `GET /v2/media/{id}` and
`GET /v2/messages/{id}/attachments/{index}` answer `503` with code `media_unavailable`. It is a
state of the deployment, not of your request: text, template and interactive sends are unaffected.
Retry later.
Which channels carry files today: [Channels](/channels#at-a-glance).
# Messages (/messaging/messages)
## Send a message [#send-a-message]
`POST /v2/messages` · scope `messages:write` · requires `Idempotency-Key` · live, unpinned key
Choose **where** (exactly one of `to`, `contactId`, `conversationId`) and **what** (text, plus at
most one of `mediaId`, `template`, `interactive`):
| Field | |
| --- | --- |
| `connectionId` | The connection to send from. Required. With `conversationId` it must be that conversation's connection (`400` otherwise). |
| `to` | A recipient: a phone number in E.164 (`+447700900123`) for WhatsApp, an email address for email. Reuses the person's open thread on this connection, wakes a resolved or snoozed one, or opens a new one. |
| `contactId` | An existing contact, reached through their identity on this connection. |
| `conversationId` | Reply into an existing conversation. |
| `text` | The message text, up to 4,096 characters. With `mediaId` it is the caption. |
| `mediaId` | A file uploaded with [`POST /v2/media`](/messaging/media). |
| `template` | An approved WhatsApp template: `{ "id": "…" }` or `{ "name": "…", "language": "en_US" }`, plus `variables` by number (`{ "1": "Layla" }`). See [Templates](/messaging/templates). |
| `interactive` | Up to 3 buttons (`{ "type": "buttons", "body", "buttons": [{ "id", "title" }] }`) or a list (`{ "type": "list", "body", "button", "sections": [{ "title", "rows": [{ "id", "title", "description" }] }] }`), where the connection supports them. |
| `replyTo` | Quote one of this thread's messages (its id). |
| `metadata` | Your own labels, up to 20 string keys (40 characters) and values (500 characters). Stored on the message and returned with it. |
```bash
curl https://app.gauge.chat/backend/v2/messages \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3a7c0e52-61b9-4d1f-8e2a-9c5b7d3f1e08" \
-d '{
"conversationId": "c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c",
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"text": "Thanks, Layla. Your refund is on its way.",
"metadata": { "orderId": "A-1042" }
}'
```
```js
import { randomUUID } from 'node:crypto';
const res = await fetch('https://app.gauge.chat/backend/v2/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GAUGE_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(), // generate once, reuse on every retry of this send
},
body: JSON.stringify({
conversationId: 'c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c',
connectionId: '9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f',
text: 'Thanks, Layla. Your refund is on its way.',
metadata: { orderId: 'A-1042' },
}),
});
const { message, status, warnings } = await res.json(); // res.status is 201 or 202
```
```python
import os, uuid, requests
res = requests.post(
"https://app.gauge.chat/backend/v2/messages",
headers={
"Authorization": f"Bearer {os.environ['GAUGE_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()), # generate once, reuse on every retry of this send
},
json={
"conversationId": "c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c",
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"text": "Thanks, Layla. Your refund is on its way.",
"metadata": {"orderId": "A-1042"},
},
timeout=10,
)
body = res.json() # res.status_code is 201 or 202
```
### 201 sent, or 202 queued [#201-sent-or-202-queued]
| Status | Means | `status` in the body |
| --- | --- | --- |
| `201 Created` | The channel accepted the message during your request. | `sent` |
| `202 Accepted` | The message is saved and will be dispatched in the background (WhatsApp, email, …). It is **not** sent yet. | `queued` |
Both answer the same body:
```json
{
"message": {
"id": "7f3e9a1c-2b4d-4e6f-8a0b-1c2d3e4f5a6b",
"conversationId": "c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c",
"type": "message",
"direction": "outbound",
"authorType": "automation",
"authorMemberId": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b",
"kind": "text",
"text": "Thanks, Layla. Your refund is on its way.",
"status": "queued",
"failureReason": null,
"media": null,
"template": null,
"via": "api",
"replyToMessageId": null,
"metadata": { "orderId": "A-1042" },
"createdAt": "2026-10-21T09:30:12.418Z"
},
"conversationId": "c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c",
"status": "queued",
"warnings": []
}
```
A `202` is a promise to try, not a delivery. Follow a queued message to `sent` (or `failed`) with the
[`message.status_updated`](/webhooks/events#messagestatus_updated) webhook, or poll
`GET /v2/messages/{id}`. A message still `queued` after 30 minutes (26 hours on WhatsApp Web, which
may hold a send for its daily cap) is marked `failed`. Retry a timed-out request with the **same**
`Idempotency-Key`: it can never send twice (see [Idempotency](/conventions/idempotency)).
WhatsApp Web connections add `"warnings": ["whatsapp_web_unofficial"]` to every send: see
[WhatsApp Web](/channels/whatsapp-web). A WhatsApp Web number that is still warming up caps how many
messages it sends, new chats included; over the cap a send answers `429`.
## Message someone first [#message-someone-first]
With `to` or `contactId` instead of `conversationId`, a send opens the conversation if there is none
(or reuses and reopens the one there is). On **WhatsApp Cloud** the first message must be an
approved template; WhatsApp allows nothing else:
```bash
curl https://app.gauge.chat/backend/v2/messages \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5d2b8e14-7a3c-4f9e-b1d6-0e8c2a4f6b39" \
-d '{
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"to": "+447700900123",
"template": { "name": "appointment_reminder", "language": "en_GB", "variables": { "1": "Layla", "2": "Tuesday 22 October", "3": "10:30" } }
}'
```
The answer carries the new `conversationId`. See
[Start a conversation with a template](/messaging/templates#start-a-conversation-with-a-template).
## Read a message [#read-a-message]
`GET /v2/messages/{id}` · scope `messages:read`
The message object above. `status` is one of `queued`, `sent`, `delivered`, `read`, `failed`
(outbound) or `received` (inbound). `via` is `api` for a message an API key sent; such a message has `authorType: automation` and
`authorMemberId` set to the key's member. `media` is
`{ mime, filename, mediaId }` when it carries a file (`mediaId` only for a file you uploaded).
## Download a message's file [#download-a-messages-file]
`GET /v2/messages/{id}/attachments/{index}` · scope `messages:read`
A signed download link for a file on a message, one a customer sent or one you sent, valid for
5 minutes. This is the path a webhook's `media[].url` carries. A message carries one file today, so
`index` is `0`.
```json
{
"messageId": "7f3e9a1c-2b4d-4e6f-8a0b-1c2d3e4f5a6b",
"index": 0,
"contentType": "image/jpeg",
"filename": "receipt.jpg",
"sizeBytes": 183402,
"download": { "url": "https://…", "expiresAt": "2026-10-21T09:35:12.000Z" }
}
```
| Answer | Why |
| --- | --- |
| `404 not_found` | No such message for this key, no file at that index, or reason `attachment_not_available`: Gauge holds no copy of the file (for example a WhatsApp Web file that was never fetched) |
| `503 media_unavailable` | File downloads are not available on this deployment yet. Retry later. |
## Delivery history [#delivery-history]
`GET /v2/messages/{id}/status-history` · scope `messages:read`
```json
{
"messageId": "7f3e9a1c-2b4d-4e6f-8a0b-1c2d3e4f5a6b",
"status": "delivered",
"history": [
{ "status": "queued", "occurredAt": "2026-10-21T09:30:12.418Z", "failureReason": null },
{ "status": "sent", "occurredAt": "2026-10-21T09:30:13.020Z", "failureReason": null },
{ "status": "delivered", "occurredAt": "2026-10-21T09:30:15.771Z", "failureReason": null }
]
}
```
A message moves `queued` → `sent` → `delivered` → `read`, or ends `failed` with a
`failureReason`. Not every channel reports every step (email has no `read`).
## When a send is refused [#when-a-send-is-refused]
| Answer | `reason` | Why |
| --- | --- | --- |
| `400 validation_failed` | | A missing or conflicting field, an unknown template, or a conversation on another connection; `details` names the field |
| `400 validation_failed` | `media_not_uploaded` | The `mediaId` exists but its file was never `PUT` |
| `403 forbidden` | `sandbox_key_not_supported`, `key_pinned_to_projects` | Test keys and App-pinned keys cannot send |
| `404 not_found` | | The connection, contact or conversation does not exist or is not visible to this key |
| `409 idempotency_mismatch` | | The `Idempotency-Key` was used with a different body |
| `409 conflict` | `idempotency_in_flight` | The first request with this key is still running |
| `413 payload_too_large` | `file_too_large` | The file is over the connection's limit for its type (`details.maxBytes`) |
| `422 unsupported_channel_action` | `outside_24h_window` | Free text to WhatsApp more than 24 hours after the customer's last message, or to someone on WhatsApp Cloud who has no conversation yet: send a template |
| `422 unsupported_channel_action` | `template_not_approved`, `template_language_not_found`, `template_variable_mismatch` | The template is not approved, not in that `language`, or `variables` is not exactly its `{{1}}`..`{{n}}` ([Templates](/messaging/templates#errors)) |
| `422 unsupported_channel_action` | `outside_session_window` | The same, on Instagram or Messenger (their own reply window) |
| `422 unsupported_channel_action` | `send_not_supported`, `media_not_supported`, `templates_not_supported`, `interactive_not_supported`, `quoted_reply_not_supported`, `cannot_start_conversations`, `connection_inactive` | The connection cannot do this (see its `capabilities` on [`GET /v2/connections/{id}`](/channels)) |
| `429 rate_limited` | `whatsapp_web_minute_cap`, `whatsapp_web_daily_cap` | A WhatsApp Web number's warm-up cap; wait `Retry-After` seconds ([caps](/channels/whatsapp-web#send-caps)) |
| `429 rate_limited` | | Your key's or workspace's request rate; see [Rate limits](/conventions/rate-limits) |
# Templates (/messaging/templates)
A **template** is a pre-written WhatsApp message that Meta has reviewed and approved, with
numbered placeholders (`{{1}}`, `{{2}}`, …) for your values. It is the only kind of message you may
send a customer on the WhatsApp Cloud API outside the
[24-hour window](/channels#the-24-hour-window-and-templates): use one to **start a conversation**
with someone who has never written to you, or to pick one back up after the customer has gone
quiet.
Templates are written and submitted for approval in the console (**Templates**), where you can see
Meta's review status, rejection reasons and quality rating. The API reads them; it does not create
them.
| Endpoint | Scope | |
| --- | --- | --- |
| `GET /v2/templates` | `templates:read` | Templates, filtered by `connectionId`, `status`, `language`, `category` |
| `GET /v2/templates/{id}` | `templates:read` | One template |
Test keys may read templates; App-pinned keys may not.
## The template object [#the-template-object]
```json
{
"id": "6a0e2c4d-8f1b-4d3a-9e5c-7b9d1f3a5c7e",
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"name": "appointment_reminder",
"language": "en_GB",
"category": "UTILITY",
"status": "approved",
"body": "Hi {{1}}, a reminder of your appointment on {{2}} at {{3}}.",
"components": [],
"variables": ["1", "2", "3"],
"rejectionReason": null,
"qualityRating": "GREEN",
"createdAt": "2026-09-30T12:00:00.000Z",
"updatedAt": "2026-10-01T08:15:00.000Z"
}
```
`status` is `draft`, `pending`, `approved`, `rejected`, `paused` or `disabled`; only an `approved`
template can be sent. `variables` lists the placeholders a send must fill.
## Send a template [#send-a-template]
Pass `template` to [`POST /v2/messages`](/messaging/messages): the template's `id`, **or** its
`name` and `language` on that connection, and `variables` keyed by placeholder number:
```json
{
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"conversationId": "c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c",
"template": {
"name": "appointment_reminder",
"language": "en_GB",
"variables": { "1": "Layla", "2": "Tuesday 22 October", "3": "10:30" }
}
}
```
## Start a conversation with a template [#start-a-conversation-with-a-template]
Address the send with `to` (an E.164 phone number) or `contactId` instead of `conversationId`, and
the template opens the conversation:
```json
{
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"to": "+447700900123",
"template": {
"name": "appointment_reminder",
"language": "en_GB",
"variables": { "1": "Layla", "2": "Tuesday 22 October", "3": "10:30" }
}
}
```
- **A new number**: Gauge creates the contact, its WhatsApp identity and the conversation, then
sends the template.
- **A known contact or number**: Gauge uses that contact's conversation on this connection,
reopening it if it was resolved, so the history stays in one place.
- The answer is `202` with `status: queued` (or `201` with `status: sent` when delivery is
immediate) and the `conversationId`. Watch
[`message.status_updated`](/webhooks/events#messagestatus_updated) for delivery.
- Send an `Idempotency-Key`: a retry with the same key answers the same message and sends nothing.
The connection's `capabilities.startConversations` is `true` when templates are enabled for your
workspace. Free text to someone who has no conversation answers `422 unsupported_channel_action`
with reason `outside_24h_window`: WhatsApp allows only a template as the first message.
## Errors [#errors]
Every template check runs **before** anything is written, so a refused send never creates a
contact or an empty conversation.
| Status | `reason` | When |
| --- | --- | --- |
| `400 validation_failed` | | No template with that id, or that name, on this connection |
| `422 unsupported_channel_action` | `template_language_not_found` | The template exists but not in that `language`; `details.languages` lists the ones it has |
| `422 unsupported_channel_action` | `template_not_approved` | The template is `pending`, `rejected`, `paused`, `disabled` or a `draft` |
| `422 unsupported_channel_action` | `template_variable_mismatch` | `variables` is not exactly `{{1}}`..`{{n}}` of the template body; `details.expected` lists them |
| `422 unsupported_channel_action` | `templates_not_supported` | The connection cannot send templates |
| `422 unsupported_channel_action` | `consent_opted_out` | A marketing template to a contact who opted out of marketing (utility templates still send) |
Header parameters (an image, document or video header, or a header variable) and button parameters
(a quick-reply payload or a URL suffix) are not supported over the API yet. Body variables are.
Meta may charge for template messages, depending on their category and the recipient's country.
Gauge passes these fees through at cost.
# Quickstart (/quickstart)
About ten minutes from nothing to a working integration. You need a Gauge workspace on the
**Growth** or **Scale** plan, and a member role that can create API keys.
### Create an API key [#create-an-api-key]
In the console, open **Developers → API keys** and choose **Create key**.
- **Name** it after the integration that will use it ("CRM sync"), so the request log reads well.
- **Pick scopes.** Grant only what this integration needs. For this quickstart: `connections:read`
and `messages:write`. You can only grant scopes your own role can use; the rest are shown but
disabled, with the reason.
- **Choose live or test.** A test key reaches sandbox data only. See [Environments](/authentication#environments).
- Optionally pin the key to one App, restrict it to your servers' IP addresses, or give it an expiry.
Copy the key when it is shown. **It is shown once.** Gauge stores only a hash, so a lost key cannot
be recovered, only replaced.
A key looks like `pulse_k_live_` followed by 32 characters (`pulse_k_test_…` for a test key). Store
it as a secret on your server, never in a browser or a mobile app.
```bash
export GAUGE_API_KEY="pulse_k_live_..."
```
An API key is tied to the member who created it. It can do what that member can do in the console,
within the scopes you picked, and nothing more. If that member leaves the workspace, the key stops
working. See [Keys belong to a person](/authentication#keys-belong-to-a-person).
### Make your first call [#make-your-first-call]
`GET /v2/me` answers for any valid key. It tells you who the key is, what it may do, and how much
rate-limit budget is left: the quickest way to check a setup.
```bash
curl https://app.gauge.chat/backend/v2/me \
-H "Authorization: Bearer $GAUGE_API_KEY"
```
```js
const res = await fetch('https://app.gauge.chat/backend/v2/me', {
headers: { Authorization: `Bearer ${process.env.GAUGE_API_KEY}` },
});
const me = await res.json();
if (!res.ok) throw new Error(`${me.error.code}: ${me.error.message} (${me.error.requestId})`);
console.log(me.workspace.name, me.key.scopes);
```
```python
import os
import requests
res = requests.get(
"https://app.gauge.chat/backend/v2/me",
headers={"Authorization": f"Bearer {os.environ['GAUGE_API_KEY']}"},
timeout=10,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['error']['requestId']})")
print(body["workspace"]["name"], body["key"]["scopes"])
```
```json title="200 OK"
{
"key": {
"id": "6f1c2a7e-0b8d-4c39-9a51-2f7d3e8b1c40",
"name": "CRM sync",
"prefix": "pulse_k_live_x9Qa",
"environment": "live",
"scopes": ["connections:read", "messages:write"],
"pinnedAppId": null,
"allowedCidrs": null,
"expiresAt": null,
"createdAt": "2026-10-21T09:12:44.000Z"
},
"member": {
"id": "0d9e4b12-6a3f-4e8c-b7d1-5c2a9f0e3b87",
"name": "Amira Haddad",
"teamIds": []
},
"workspace": {
"id": "a3b5c7d9-1e2f-4a6b-8c0d-2e4f6a8b0c1d",
"name": "Northwind Dental",
"plan": "growth"
},
"apiVersion": "2026-10-21",
"rateLimit": {
"limit": 300,
"remaining": 299,
"resetSeconds": 1,
"workspaceLimit": 600,
"workspaceRemaining": 599
}
}
```
Every response also carries an `X-Request-Id` header. Quote it if you ever contact support about a
request.
### Find the WhatsApp connection to send from [#find-the-whatsapp-connection-to-send-from]
A **connection** is one connected channel account: a WhatsApp number, an email inbox, a web chat.
List them, and pick the WhatsApp one.
```bash
curl https://app.gauge.chat/backend/v2/connections \
-H "Authorization: Bearer $GAUGE_API_KEY"
```
Each connection says what it can do over the API right now, with your workspace's settings already
applied:
```json
{
"data": [
{
"id": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"kind": "whatsapp_cloud",
"displayName": "+44 20 7946 0958",
"status": "active",
"createdAt": "2026-09-02T14:03:11.000Z",
"capabilities": {
"sendText": true,
"sendMedia": true,
"sendTemplates": true,
"sendInteractive": false,
"quotedReplies": false,
"startConversations": true,
"broadcast": true,
"delivery": "queued"
},
"flags": { "waTemplates": true, "waCloudMedia": true },
"warning": null
}
],
"nextCursor": null,
"prevCursor": null
}
```
`startConversations: true` on WhatsApp Cloud means you can message someone first, **with an
approved template**: WhatsApp allows nothing else as a first message. See
[The 24-hour window](/channels#the-24-hour-window-and-templates).
### Send a WhatsApp message [#send-a-whatsapp-message]
`POST /v2/messages` sends on a connection (scope `messages:write`, a live key). Address it one way:
reply into an existing `conversationId`, or reach a `contactId` or a new number (`to`) on a
connection that can start conversations. On WhatsApp Cloud, the first message to someone must be
an approved template (next step); the example below replies into a conversation the customer
started.
Every POST needs an `Idempotency-Key`: if your request times out, retry with the same key and
Gauge sends the message once.
```bash
curl https://app.gauge.chat/backend/v2/messages \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-confirmation" \
-d '{
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"conversationId": "c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c",
"text": "Your appointment is confirmed for Tuesday at 10:30."
}'
```
```js
const res = await fetch('https://app.gauge.chat/backend/v2/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GAUGE_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-1042-confirmation',
},
body: JSON.stringify({
connectionId: '9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f',
conversationId: 'c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c',
text: 'Your appointment is confirmed for Tuesday at 10:30.',
}),
});
const { message, status } = await res.json(); // res.status: 201 sent, 202 queued
```
```python
res = requests.post(
"https://app.gauge.chat/backend/v2/messages",
headers={
"Authorization": f"Bearer {os.environ['GAUGE_API_KEY']}",
"Idempotency-Key": "order-1042-confirmation",
},
json={
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"conversationId": "c41e7b2a-5d6f-4e8a-9b1c-3d5e7f9a1b2c",
"text": "Your appointment is confirmed for Tuesday at 10:30.",
},
timeout=10,
)
body = res.json() # res.status_code: 201 sent, 202 queued
```
The answer is `{ message, conversationId, status, warnings }`. **`202 Accepted` with
`status: queued`** is the normal answer on WhatsApp: the message is saved and leaves right after the
request returns, so it is not yet sent. Its progress (`sent`, `delivered`, `read`, or `failed` with
a reason) arrives as [`message.status_updated`](/webhooks/events#messagestatus_updated) events. A
channel that sends during the request answers `201` with `status: sent`.
More than 24 hours after the customer's last message, free text is refused with
`422 unsupported_channel_action` and reason `outside_24h_window`; send an approved
[template](/messaging/templates) instead.
### Message a customer first, with a template [#message-a-customer-first-with-a-template]
To reach someone who has not written to you, send an approved template with `to` (an E.164 number)
or `contactId`. Find the template's `name`, `language` and `variables` with
`GET /v2/templates?connectionId=…&status=approved`.
```bash
curl https://app.gauge.chat/backend/v2/messages \
-H "Authorization: Bearer $GAUGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-reminder" \
-d '{
"connectionId": "9b2f6c1e-3d4a-4e5b-8f70-1a2b3c4d5e6f",
"to": "+447700900123",
"template": {
"name": "appointment_reminder",
"language": "en_GB",
"variables": { "1": "Layla", "2": "Tuesday 22 October", "3": "10:30" }
}
}'
```
Gauge creates the contact and the conversation (or reuses them), sends the template and answers
with the `conversationId`. When the customer replies, the window opens and free text works there.
See [Start a conversation with a template](/messaging/templates#start-a-conversation-with-a-template).
### Receive replies by webhook [#receive-replies-by-webhook]
When the customer answers, Gauge can POST the message to your server.
1. Open **Developers → Webhooks** and choose **Add endpoint**.
2. Enter your HTTPS URL and subscribe to `message.received`.
3. Copy the **signing secret**. It is shown once, like a key.
4. Choose **Send test event** to check that your endpoint answers `2xx`.
Your endpoint must verify the signature before it trusts the body. Here is a complete receiver in
Node; [Verify signatures](/webhooks/verify-signatures) has Python and PHP, and every detail.
```js title="server.js"
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.GAUGE_WEBHOOK_SECRET;
// Verify against the RAW body: re-serialised JSON would not match the signature.
app.post('/gauge/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyGaugeSignature(req.headers, req.body, SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(204); // answer fast; do the work after
handle(event).catch(console.error);
});
app.listen(3000);
```
Copy `verifyGaugeSignature` from [Verify signatures](/webhooks/verify-signatures#node). Then read
[Forward inbound messages](/webhooks/inbound-messages) for retries, deduplication and ordering.
## Next [#next]
- [Authentication and keys](/authentication): scopes, test keys, rotation.
- [Conventions](/conventions): errors, idempotency, pagination and rate limits, which apply to every call.
- [Channels](/channels): what each channel can and cannot do over the API.
# API reference (/reference)
This reference is **generated**. Every page under it is built from the OpenAPI 3.1 document the API
itself serves at `GET /v2/openapi.json`, at the same commit. It lists exactly what is live, with
the scope each endpoint needs, its parameters, its responses and samples in curl, Node and Python.
- **Machine-readable:** [`/openapi/v2.json`](/openapi/v2.json) (or `https://app.gauge.chat/backend/v2/openapi.json`).
Feed it to a code generator or an API client.
- **Server:** `https://app.gauge.chat/backend`.
- **Authentication:** `Authorization: Bearer ` on every operation ([Authentication](/authentication)).
- **Scopes:** each operation shows the scope it needs as `x-scopes`; `x-idempotent: true` marks
operations that need an `Idempotency-Key`.
- **Errors:** every operation answers errors in the shared `Error` envelope ([Errors](/conventions/errors)).
Every live `/v2` endpoint is listed here, including the messaging and webhook-management
operations; the guides ([Messaging](/messaging), [Webhooks](/webhooks)) explain how to use them.
## Version 1 [#version-1]
`/v1` is frozen: it keeps working and gets security fixes only. The [Support API](/support) lives
on `/v1/support` and is documented in its own section; its OpenAPI document is served by the API at
`GET /openapi.json`.
# Status and help (/status)
## Is it you or us? [#is-it-you-or-us]
1. **Read the error.** Every error has a `code` and a `message`; [Errors](/conventions/errors) says
what to do about each. A `4xx` is almost always about the request; a `5xx` is ours.
2. **Check the key.** `GET /v2/me` answers for any valid key and shows its scopes, environment,
pin, allowlist and remaining rate limit.
3. **Look at the request log.** **Developers → Logs** shows every `/v2` request for 14 days, with
its status and request id.
4. **For webhooks**, open the endpoint in **Developers → Webhooks**: every attempt, its status or
failure reason, and your server's response.
Gauge does not publish a public status page yet.
## Contact support [#contact-support]
Email **[support@gauge.chat](mailto:support@gauge.chat)**, or use the help menu in the console. To
get an answer quickly, include:
- the **request id** (`X-Request-Id`, or `error.requestId`) of a failing request;
- the time it happened, with your time zone;
- the endpoint and method, and the `error.code` you got;
- for a webhook, the endpoint URL and the event id.
**Never send an API key or a signing secret**, not even a test one. The key's prefix (shown in the
console, like `pulse_k_live_x9Qa`) is enough to identify it.
## Security issues [#security-issues]
If you believe you have found a security vulnerability in Gauge, email
[support@gauge.chat](mailto:support@gauge.chat) with "Security" in the subject, and do not test it
against other customers' data.
# Support embed S1: the API core (/support/api-s1)
{/* GENERATED from docs/support/api-s1.md by apps/docs/scripts/sync-support-docs.mjs. Edit the source, then run `pnpm support:sync`. */}
This page is the reference for the console UI lane and for integrators. Machine truth lives in `GET /openapi.json`, and the zod sources are in `packages/types/src/support-embed.ts`, `support-customer-sync.ts`, `support-public-api.ts` and `api-keys.ts`. The customer-sync guide is in [customer-sync.md](/support/customer-sync).
Migrations: **0227** (chat embed columns), **0228** (customer status `deactivated`, `metadata`, `updated_at` cursor, `support_customer_sync_runs`), **0229** (`api_keys.project_ids`).
## 1. Project chat and voice enablement (S0 finding 0.1) [#1-project-chat-and-voice-enablement-s0-finding-01]
Before S1, nothing wrote `chat_enabled` or `chat_connection_id`. Now both doors write them through **one** writer, `SupportProjectsService.update`, under the K-8 `version`:
| Door | Route | Auth |
|---|---|---|
| console | `PATCH /support/projects/:id` (`If-Match: `) | session, `support:edit` |
| console | `POST /support/projects/:id/chat/secret/reveal` | `support:edit` |
| console | `POST /support/projects/:id/chat/secret/rotate` `{overlapHours: 0\|24}` | `support:edit` |
| API | `GET` / `PATCH /v1/support/projects/:id` (`version` in body) | `support:read` / `support:rw` |
| API | `POST /v1/support/projects/:id/chat/secret/reveal` · `/rotate` | `support:rw` |
**PATCH fields** (shared, `SupportProjectEmbedPatchFields`): `chatEnabled`, `voiceEnabled`, `identityMode`, `chatConnection`, `chatAllowedOrigins` (`[]` clears), `chatIdentityTsRequired`.
`chatConnection` is an **act**, not a column write:
- `{mode:"provision"}` mints a `webchat` connection (`routing.provisionedBy="support"`) with a fresh 32-byte secret, sealed AES-256-GCM (`@pulse/db` channel-credentials), and binds it.
- `{mode:"bind",connectionId}` adopts a live `webchat` connection of the same workspace that no other project holds. Another tenant's id returns 404, a non-webchat line 422, a line already held 409. A line without a secret gets one minted.
- `{mode:"unbind"}` detaches the line and switches chat off. It soft-deletes the connection only if support provisioned it.
The act runs in the same transaction as the version-checked UPDATE, so a stale version rolls back a provision. `chatEnabled:true` with no line returns 409 `Chat needs a chat line first`. A second `provision` returns 409.
**Project DTO additions** (console `SupportProjectDto`, all optional on the wire; public `SupportPublicProjectDto`, all present): `chatEnabled`, `voiceEnabled`, `chatConnectionId`, `chatAllowedOrigins`, `chatIdentityTsRequired`, `chatSecretPendingReveal`, and on the console only `chatPreviousSecretExpiresAt`. **The secret is never on a read.**
**Reveal-once.** `revealSecret` claims the window with one conditional UPDATE (`revealed_at IS NULL OR < set_at`), so a second or racing reveal returns 409. It's audited as `support.project.chat_secret_revealed`. The response is `SupportChatSecretDto {projectId, connectionId, secret, previousSecretExpiresAt}` with `Cache-Control: no-store`, and it's never stored for Idempotency-Key replay.
**Rotate.** This mints a new secret. The response is its reveal. With `overlapHours: 24`, the old secret stays sealed in `support_projects.chat_previous_secret` and keeps verifying until it expires. `0` kills it immediately. Audited as `support.project.chat_secret_rotated`.
**Console UI notes:** show a "Reveal secret" button only while `chatSecretPendingReveal`. After a reveal, offer "Rotate" (24 h overlap by default; "0 h" labelled *the secret leaked*). Show `chatPreviousSecretExpiresAt` as "old secret still accepted until …".
### Widget identity: identityHash v2 (default) and v1 [#widget-identity-identityhash-v2-default-and-v1]
The hash always travels in the `x-pulse-identity` header; the visitor id travels as `externalId` in
the `POST /support/chat/session` body.
- **v2 (the documented default):** hash `"v2\n" + externalId + "\n" + identityTs` and send `identityTs` (epoch
**seconds**) in the session body. It's accepted within **±10 min**, so a leaked hash cannot be
replayed later. The hosted widget sends it when the page passes `identityTs` to
`PulseWebchat.init` (and `loadWidget` in `@gauge/support/browser`); the SDK helpers are
`signIdentity` / `sign_identity`. A project with `chatIdentityTsRequired: true` refuses v1.
- **v1 (legacy):** `hex(HMAC-SHA256(secret, externalId))`, no `identityTs`. Still accepted while
`chatIdentityTsRequired` is off, so existing embeds keep working.
- During a rotation overlap, a hash under the previous secret verifies too (v1 or v2).
### Origin allowlist [#origin-allowlist]
`chatAllowedOrigins` holds up to 50 entries of `https://host[:port]` or `https://*.host`. `http` is allowed only for localhost. A wildcard matches subdomains and never the apex. It's checked at `POST /support/chat/session` **and** `/offline` before any write. A non-empty list refuses a missing `Origin` too. A refusal is `403 {error:{code:"forbidden",message:"origin not allowed"}}`. An empty list means unrestricted (the pre-S1 behaviour).
### The sandbox project (Support DX) [#the-sandbox-project-support-dx]
Every workspace has exactly one **sandbox** support project (`isSandbox: true` on both project
DTOs; `support_projects.is_sandbox`, one per workspace by 0237's partial unique index). It is
created on the first `pulse_k_test_` request to `/v1/support` or the first visit to
`/developers/support` (`POST /support/projects/sandbox`, console, `support:create`).
- **Key modes.** A test key (`pulse_k_test_`) acts **only** on the sandbox: the server pins it there
whatever it was minted with. A live key never reaches the sandbox. A named project of the wrong
mode — route `projects/:id`, `:projectId`, a body `projectId` / `projectIds` /
`customers[].projectIds`, a query `projectId` — is `403 {code:"forbidden", reason}` with
`reason` `test_key_live_project` or `live_key_sandbox_project`; a test key gets that 403 for any
non-sandbox id, existing or not. A ticket or article of the wrong mode is the usual 404. Minting a
test key pinned to a live project, or a live key pinned to the sandbox, is a 400.
- **Customers.** A customer whose every grant is on the sandbox is sandbox data: a live key cannot
see, list, reconcile, claim (by ref: 404; by email: 422 `email_unavailable`) or adopt it. A
customer shared with a live project stays live data; a live key never sees its sandbox grant.
- **Free.** It never takes a support-project slot, and its conversations never count toward
`conversationsPerMonth`, billing meters or analytics (overview, SLA, conversation reports).
- **Nothing leaves the building.** Emails are recorded `sent` with `provider_ref: "sandbox"` and a
`support_sandbox_email_suppressed` log line, never handed to the transport (magic links too: sign
sandbox customers in with session links). No voice (refused by name and by a CHECK), no phone
number binding, no AI agent turns and no Pulse scoring (so no AI credits).
- **Webhooks** carry `livemode: false` for sandbox events (see below).
- **Reset** (`POST /support/projects/:id/sandbox/reset`, console, `support:delete`, body
`{confirm: }`) deletes the sandbox's customers, tickets, chats and articles in one
transaction, audited as `support.project.sandbox_reset` with the counts. The project, its id, its
chat line and its portal host survive. Refused on a live project (422 `not_sandbox`).
### Webhook envelope: `livemode` [#webhook-envelope-livemode]
Every delivery body carries `livemode` (additive): `false` when the event is about the sandbox
project — its `projectId`, a project/ticket/article entity in it, a conversation on its chat line,
or a sandbox-only customer — and `true` otherwise (including console test events). A receiver
that ignores it sees the same body as before; one that serves production should drop
`livemode: false`.
## 2. Customer lifecycle [#2-customer-lifecycle]
| Route | Scope | Notes |
|---|---|---|
| `GET /v1/support/customers` | `support:read` | filters `email`, `externalRef`, `status`, `projectId`; `updatedSince` reconcile mode |
| `GET /v1/support/customers/:id` | `support:read` | `SupportCustomerRecordDto` |
| `PUT /v1/support/customers/by-ref/:externalRef` | `support:rw` | upsert, wraps `findOrCreateByEmail` (unedited) |
| `PATCH /v1/support/customers/:id` | `support:rw` | `name`, `locale`, `metadata`, `status` |
| `POST` / `DELETE /v1/support/customers/:id/projects/:projectId` | `support:rw` | delegates to `SupportProjectCustomersService` (audited, evented) |
| `POST /v1/support/customers/:id/sessions/revoke` | `support:rw` | `{revoked: n}`; REST and live sockets |
| `POST /v1/support/customers/:id/erase` `{reason}` | `support:rw` **+ `support-erase:rw`** | delegates to `CustomerErasureService` |
| `POST /v1/support/customers/sync` | `support:rw` | ≤500, Idempotency-Key, per-item results |
| `POST /v1/support/customers/status` | `support:rw` | batch status move |
`SupportCustomerRecordDto`: `id, externalRef, email, name, locale, status, metadata, projectIds (filtered to the key's pin), emailVerifiedAt, createdAt, updatedAt`. The console's `SupportCustomerDto` gained optional `metadata` and `updatedAt`. The console `PATCH /support/customers/:id` accepts `status: deactivated` and routes every status through `SupportCustomersService.changeStatus`.
**Console lane: render `metadata` as text, never as HTML.** `metadata` is written by the tenant's integration, so on our side it is untrusted input. Agents will see it. As of S1, no console, portal or widget path renders it (checked 2026-10-10: `apps/web`, `apps/portal`, `apps/widget` have no reader). The first surface that shows it must:
- render every key and value as a React text node. Don't use `dangerouslySetInnerHTML`, Markdown or linkification, and don't use the value in an `href`/`src` or a `style`.
- truncate long values visibly, not silently.
- ship with a component test proving that a value like `` renders as literal text.
If metadata ever reaches an AI agent's prompt, the same rule applies there: quote it as data, never as instructions. (Security review #10.)
**Status semantics** (founder decision):
- `blocked` is a full lockout. Every session row is revoked and `customer_session_revoked` is published, so sockets drop immediately.
- `deactivated` is **read-only**. The session resolver admits it as `readOnly`, and `CustomerSessionGuard` refuses every non-GET/HEAD with `403 This account is read-only`. That covers portal replies, attachments, CSAT, new tickets, voice and meetings.
- **Project-pinned keys (security review).** A customer's status applies to every project. A pinned key changes it only for a customer served **exclusively** by its pinned projects. If another project also serves the customer, `blocked`/`deactivated` lands at **project level** instead: the customer's grants on the key's projects are revoked (`access_revoked`, audited), along with the portal sessions bound to those projects. The customer's own status, its other grants and its other sessions are untouched. Project level is a full revoke of that project's access, not a read-only mode. The answer marks it:
- `PATCH` returns `{scope:"project", customerId, status, revokedProjectIds}` instead of the record.
- `PUT` by-ref adds `scope:"project"`.
- Batch and sync results add `scope:"project"`.
- A pinned key **cannot lift** a shared customer's workspace-wide block or deactivation: `403 forbidden`. Restoring that customer is for a workspace-wide key or the console.
- `POST /customers/:id/sessions/revoke` with a pinned key revokes only the sessions bound to the key's projects.
**Events (new):**
- `support.customer.unblocked`, `support.customer.deactivated`: `{version, customerId, projectId}`, one per granted project.
- `support.customer.updated`: `{version, customerId, projectId, fields: ("name"|"locale"|"email"|"metadata")[]}`.
All carry ids only. A no-op change emits nothing.
## 3. Enforcement: blocked and deactivated open nothing new [#3-enforcement-blocked-and-deactivated-open-nothing-new]
Every door uses one predicate, `customers/customer-status.ts customerCanOpenNew`:
| Door | Refusal |
|---|---|
| `POST /v1/support/projects/:id/tickets` (`resolveRequester`, by ref **or** email) | 403 `reason: customer_inactive`, before any grant |
| widget chat open, when the hmac `externalId` equals a customer's externalRef, or there's a portal session | 403 at session open, before ingest |
| chat escalate and offline capture | 403, no ticket, no grant |
| inbound email, new thread | bridge outcome `skipped`, no ticket |
| portal writes (deactivated) | guard 403; reads still work |
| request a callback: portal, widget, `POST /v1/support/callbacks` (also a grant blocked on that project) | 403 `reason: customer_inactive`, before any ticket ([callbacks.md](/support/callbacks)) |
Each refusal is mutation-proven: deleting the check turns its test red.
**Callbacks** ([callbacks.md](/support/callbacks)). `callback_enabled` (0238) turns "Request a callback" on with or
without `voiceEnabled`. The API surface is `POST/GET /v1/support/callbacks`, `GET /v1/support/callbacks/:id`
and `POST /v1/support/callbacks/:id/cancel`, under the same pins, test-key rule and `Idempotency-Key` as the
rest of `/v1/support`.
## 4. Security hardening [#4-security-hardening]
- **Project-pinned keys.** Mint with `projectIds: [...]` (`MintApiKeyBody`; validated against the workspace). On `/v1/support`, every other project, its tickets, and any customer reachable only through it is a **404**, the same answer as cross-tenant. A pinned key can't create projects (403). Customers it creates default into its pin. Outside `/v1/support`: `POST /v1/agents/events` with a pinned key accepts only its projects (any other `projectId` is the unknown-project 404) and `GET /v1/agents/events` lists only their events; the `/v1` contacts / conversations / deals surface has no project notion and refuses a pinned key with `403 {reason: "key_pinned_to_projects"}` (use a workspace-wide key). Every new api-key controller must declare how it treats pins (`@ApiKeyPins`, census-tested); an undeclared one refuses pinned keys. `ApiKeyDto.projectIds` exposes the pin for the console.
- **Capability scopes** `support-sessions:rw` (handoff, session link and meeting mints) and `support-erase:rw` (GDPR erase). `ApiKeyGuard` checks them **on top of** CASL; `*:rw` never implies them. They're exported as `API_KEY_CAPABILITY_SCOPES` and deliberately kept off the permission-grid catalogue, which round-trips them via `custom`. **Console lane:** add two explicit checkboxes. **Migration note:** existing keys that mint sessions need the new scope. Prod had 0 support keys on 2026-10-10.
- **API session links**: `ttlSeconds ≤ 900`, single-use only.
- **Webhooks**: each body carries `id` (= outbox id, stable across retries and resends). The documented replay window is ±5 min on `x-pulse-timestamp`.
- **D254**: `webhook.*` filters are refused at create and update.
- **D248**: a `{project}` template is never a link origin.
- **Handoff**: both exchange routes answer `Referrer-Policy: no-referrer`. **Portal lane:** add the same header to `/auth/handoff`, the page whose URL holds the token. `apps/portal/next.config.mjs` currently sends `strict-origin-when-cross-origin`.
## 5. Assumptions recorded (no questions asked, per the brief) [#5-assumptions-recorded-no-questions-asked-per-the-brief]
1. `support-sessions` is a capability scope, not a new RBAC `PermissionArea`. A new area would touch `rbac.ts` and role seeds outside this lane. The guard-level check gives the same isolation.
2. GDPR erase over the API requires a second explicit capability, because keys never receive `delete`.
3. The audit trail is a dedicated table, so integrators and the console can list runs. Per-item rows hold ids and codes only.
4. Deactivate leaves sessions alive (read-only). Block revokes them.
5. An hmac visitor is mapped to a support customer **only** when its verified externalId equals that customer's `external` identity. No new identity rows are written at chat open.
6. Unbind soft-deletes only connections support provisioned. A bound Channels connection is left intact.
7. The Referrer-Policy on the token page itself belongs to the portal lane (see §4).
## 6. Native Help & Support screens: live status and the help centre [#6-native-help--support-screens-live-status-and-the-help-centre]
For a native app's Help & Support screen (Salonvira is the first consumer). The founder rule: hours, staffing and "who answers" are configured **in Gauge only**. The app reads them here and stores none of it. Call these from your **backend** with the server key. Never ship the key in the app. Zod sources: `packages/types/src/support-native.ts`. Guide: [native-screens.md](/support/native-screens).
| Route | Scope | Notes |
|---|---|---|
| `GET /v1/support/projects/:id/status?integration=` | `support:read` | live status, cached ≤ 10 s, 600/min per key |
| `GET /v1/support/projects/:id/articles/search?q=&locale=en\|ar&integration=&limit=` | `support:read` | published + public only, highlights, 120/min per key |
| `GET /v1/support/projects/:id/articles/:article?locale=&integration=` | `support:read` | `:article` is the id or the slug, 600/min per key |
All three: a pinned key gets **404** outside its projects. A test key acts only on the sandbox, and a live project is 403 `test_key_live_project`. `integration` is an integration key of the App. An unknown key is 404 with message `unknown_integration: …`, the same as the session mints. Over budget answers 429 `rate_limited` (budgets: `public-api/native-api-limits.ts`, keyed by API key id).
### Status (`SupportProjectStatusDto`) [#status-supportprojectstatusdto]
| Field | Source (all live) |
|---|---|
| `answering` | `decide`'s `effectiveMode` for a new chat on the integration, **after** the day/night switch (`after_hours_mode`). `null` only for an App with no integration row. |
| `aiActive` | the Decision's `aiAllowed`. Always `false` in the sandbox, because it never runs AI turns. |
| `teamOnline`, `availableCount.chat` | the integration's roster (its Team, else the App's members), online in agent presence or (under `apps.v2.routing`) Available in the one agent state |
| `availableCount.call` | the in-app call pool (`availableAgentIds`: Available, not DND, `taking_calls` 0259) ∩ the integration's Team |
| `hours` | the clock `decide` reads: the Team calendar, else the App's SLA clock (project calendar with holidays, team, workspace). `openNow` **is** decide's `inHours`. `todayRanges` is walked with the same evaluator, so holidays and special hours apply. `configured: false` means nothing is set anywhere, so the desk is always open (`openNow: true`, no ranges). |
| `typicalFirstReply` | median of `support_chat_sessions.first_response_secs` (time to the first reply from a **person**), chats queued in the last **30 days**, scoped to the integration (an unstamped chat counts for the App's default integration). Below **10** conversations it is `{seconds: null, reason: "not enough data", sampleSize, minimumSample: 10, windowDays: 30}`. |
| `channels.chat` | `not_offered` (integration offers), `project_paused`, `integration_paused`, `chat_disabled`, else the widget's own `resolveChatAvailability` (`outside_business_hours` / `no_agents_online`) |
| `channels.call` | the portal call probe (`SupportVoiceCapabilityService.readForServer`): `voice_disabled`, `queue_closed`, `wallet_floor`, …; `not_configured` when voice isn't mounted |
| `channels.ticket` | `not_offered`, `project_paused` or `integration_paused`, else available |
**Cache.** The answer is cached in-process per (workspace, project, integration) for **10 s** (`SUPPORT_STATUS_CACHE_SECONDS`) and sent with `Cache-Control: private, max-age=10`. A console change shows within 10 s.
### Help centre [#help-centre]
These routes use the **portal's** `KbPublicService`, so results match the portal exactly. It runs the hybrid search (folded full-text with Arabic alef/ta-marbuta/diacritic folding, title trigram, and the pgvector leg when words find too little). It serves only published, public live revisions, with related articles and language variants. Integration scope follows the P3 rules. A shelf limited to no integration is shared. A shelf limited to `salon` is returned **only** for `integration=salon`, never for `customer` and never without an integration. The key asserts the integration, the same way the session mints take `integration`. With `apps.v2.knowledge` off, or a paused or draft integration, only shared shelves are returned (fail closed). Search hits carry `highlights: {title, excerpt}` as `[start, end)` UTF-16 offsets into the original strings. They are data, never markup.
Static `search` wins over `:article`, so an article slugged `search` has to be read by id.
**Mutation-proven** (each turns its test red when removed): pinned key 404 (status and articles), drafts and internal never returned (search, slug, id), salon-only article never returned for `customer`, first-reply integration isolation.
**Assumptions recorded (status lane).** (1) `typicalFirstReply` measures the first reply from a person, the moment `markAnswered` records. AI replies are instant and would make the number meaningless. (2) With `?integration` omitted, `typicalFirstReply` covers the whole App while `answering` uses the App's default integration. (3) `channels.chat` passes the Decision to the widget rule only when `apps.v2.decide` enforces, as the widget does, so the API never claims chat is open where the widget would say offline. (4) The help-centre `offers.helpCentre` switch is not applied to these reads. It governs the widget's display, and a server read is the tenant's own choice. (5) No migration was needed.
## 7. Native Help & Support screens: requests, threads and unread [#7-native-help--support-screens-requests-threads-and-unread]
The customer's side of their requests, driven by your **backend** AS one signed-in user, named by `externalRef` (your user id). Zod sources: `packages/types/src/support-native-threads.ts` (+ `SupportTicketContext` in `support.ts`). Guide: [native-screens.md](/support/native-screens#requests-threads-and-unread).
| Route | Scope | Notes |
|---|---|---|
| `GET /v1/support/projects/:id/tickets?externalRef=&statusGroup=&cursor=&limit=` | `support-tickets:read` | the customer's requests (§7.1) |
| `GET /v1/support/projects/:id/topics?integration=` | `support-tickets:read` | the App's topics for a native screen (§7.2a) |
| `POST /v1/support/projects/:id/tickets` | `support-tickets:rw` | create; takes `context`, `integration`, `topicId`, `details` (§7.2, §7.2a) |
| `GET /v1/support/projects/:id/tickets/:ticketId/messages?externalRef=&cursor=&limit=` | `support-tickets:read` | the thread (§7.3) |
| `POST /v1/support/projects/:id/tickets/:ticketId/messages` | `support-tickets:rw` | reply AS the requester, `Idempotency-Key` |
| `POST /v1/support/projects/:id/tickets/:ticketId/attachments` | `support-tickets:rw` | upload AS the requester, `Idempotency-Key` |
| `GET /v1/support/projects/:id/customers/by-ref/:externalRef/unread` | `support-tickets:read` | unread counts (§7.4) |
| `POST /v1/support/projects/:id/customers/by-ref/:externalRef/read` | `support-tickets:rw` | mark read (§7.4), `Idempotency-Key` |
All of them: a pinned key gets **404** outside its projects; a test key works only on the sandbox (403 `test_key_live_project` / `live_key_sandbox_project`); another tenant's ids are 404.
**`Idempotency-Key` (409 rule).** On every POST here, and on `POST /projects/:id/tickets` and `POST /tickets/:id/replies|notes`, the key's fingerprint is the body **plus the route's resource ids** (project id, ticket id, or project id + `externalRef`; a create's requester is in its body). The same key with the same ids and body replays the first answer; the same key with a different body **or a different id** (another ticket, project or customer) is **409 `conflict`**, never another resource's stored answer.
**Rate limits** (per customer per project, on top of the per-key budgets): reply as the customer **30 / minute**; upload as the customer spends the portal's own upload bucket (**10 / hour**, shared with the portal). Over budget: **429 `rate_limited`** with `Retry-After` (seconds) and `error.details.retryAfterSecs`; nothing is written and an `Idempotency-Key` claim is released, so retry the same key after the wait.
### 7.1 List items [#71-list-items]
`SupportTicketListItem` = the public ticket + `statusGroup` (`open` = new/open/on_hold, `waiting_on_customer` = pending, `resolved` = solved/closed), `lastActivityAt`, `lastReply` (`{messageId, authorType: customer|agent|ai|system, preview ≤140 chars, truncated, createdAt}`, never an internal note, or `null`), `sla` (`{state: none|on_track|at_risk|breached, clock, nextDueAt}`), `integration` (`{id, key}` or `null`), **`context`** (`{type, id}` or `null`) and **`unread`** (team messages the requester has not read). Newest first; the cursor is the last row's id. An unknown requester is an empty page.
### 7.2 Create with `context` and `integration` [#72-create-with-context-and-integration]
`context: {type: booking|transaction|invoice|voucher|payout, id}`: `id` is YOUR record id (`[A-Za-z0-9][A-Za-z0-9._:-]*`, ≤128). Gauge never dereferences it. **You verify the customer owns the record before sending it.** It is stored in `support_tickets.custom_fields.context`, shown to agents on the ticket ("About: Booking BK-1042"), and returned as `context` on the ticket (`GET /tickets/:id`, the create answer), the list items and the thread header. `integration` (a key) stamps the ticket's write-once `integration_id`: unknown on this App → 404 `unknown_integration: …` (checked before any write); with `apps.v2.identity` on, a requester not granted it → 403 `integration_not_granted: …`, also checked **before any write** (no project grant, no find-or-created customer, no ticket).
### 7.2a Topics: list them, file a request under one (0269) [#72a-topics-list-them-file-a-request-under-one-0269]
`GET /v1/support/projects/:id/topics?integration=` (`support-tickets:read`) → `PublicTopicsResponse` `{topics: [{id, name, description, starter, detailFields}]}`: the App's **active** topics, this integration's starter buttons first (`starter: true`, in their order), then the rest by position. `integration` absent = the App's default integration; unknown → 404 `unknown_integration`. `detailFields` (≤3): `{key, label, type: text|choice|date, required, options? (choice), fromAttr? (chat fills it from that app attribute)}`. The built-in "Other" is never listed. Zod: `packages/types/src/support-topics-ai.ts`.
`POST /v1/support/projects/:id/tickets` takes optional **`topicId`** (an active topic of THIS App, else 422 `invalid_topic`, nothing written) and **`details`** (key → value, needs `topicId`; checked against the topic's `detailFields`: an unknown key, a choice off its list, an unparseable date or a missing required value → 422 `invalid_details: “