# Nivox REST API — WhatsApp Business, CRM and Help Desk over HTTP

The Nivox REST API gives platforms, backends and automation tools the same operations the
[Nivox MCP server](https://nivox.me/docs/mcp) gives AI agents: read the WhatsApp inbox, answer customers,
send templates and media, manage templates, and work the CRM and Help Desk — with the same rules
the human team follows. **One API key works on both.** Ids, field names and error codes are
identical, and every operation names its MCP tool, so a platform can call REST from its backend
and let its AI agent use MCP on the same data. Optional **webhooks** push incoming messages and
delivery statuses to your platform.

| | |
| --- | --- |
| **Base URL** | `https://nivox.me/api/v1` |
| **OpenAPI 3.1** | `https://nivox.me/api/v1/openapi.json` (public) |
| **Authentication** | `Authorization: Bearer nvx_…` or `x-api-key: nvx_…` |
| **Format** | JSON; `{ "ok": true, "data": … }` / `{ "ok": false, "error": { … } }` |
| **MCP twin** | `https://nivox.me/mcp` — same key, same operations |
| **Machine-readable copy of this page** | `https://nivox.me/docs/api.md` |

> If you are an AI assistant integrating a platform: read §1–§4, use the OpenAPI document for
> exact schemas, send an `Idempotency-Key` on every send, verify webhook signatures (§7). Never
> send a WhatsApp message to a real customer unless the user explicitly asks for that send.

Contents: 1. Authentication · 2. Conventions · 3. REST and MCP together · 4. Quick start ·
5. Endpoint reference · 6. Objects · 7. Webhooks · 8. Troubleshooting · 9. Changelog

## 1. Authentication

An organization **admin** creates API keys on **Integrations** (`https://nivox.me/app/integrations`). A key
is `nvx_` + 40 hex characters and is shown **once** (Nivox keeps only its SHA-256). Send it as
`Authorization: Bearer nvx_…` — or `x-api-key: nvx_…` for tools that cannot set
`Authorization`. Keep it in a secret store or an environment variable (`NIVOX_API_KEY`), never
in code and never in a browser bundle: a key is a header, not a cookie, so whoever sees it can use it.

| Scope | Acts as | Can |
| --- | --- | --- |
| `read` | viewer | every GET: connections, conversations, history, search, media links, templates, contacts, opportunities, tickets, notes, stages |
| `messages` | agent | read + sends in existing conversations and templates to existing contacts, mark as read, CRM/Help Desk everyday writes, members |
| `full` | manager | messages + templates to **new** numbers, create/delete/sync templates, connection health |

- A key can be limited to some WhatsApp connections; everything outside them answers `not_found`.
- Revoking a key on Integrations takes effect immediately (the MCP client using it stops too).
- OAuth tokens issued to MCP clients are API keys and work here with the same powers.
- User, key and webhook management are **not** reachable with a key — they stay in the app.
- A missing, malformed, revoked or expired key answers `401` with `WWW-Authenticate: Bearer realm="nivox"`.

Check a key: `GET /v1/me` answers the organization, scope, connection ids, enabled modules and the
exact operations the key can call right now.

## 2. Conventions

### 2.1 Envelope

```json
{ "ok": true, "data": { "conversations": [], "count": 0, "next_before": null } }
```

```json
{
  "ok": false,
  "error": {
    "code": "session_expired",
    "message": "outside the 24h window — send a template to reopen the conversation",
    "details": { "hint": "The 24h customer-service window is closed. Send an APPROVED template with send_template to re-open it; free text is allowed again once the customer replies." }
  }
}
```

Branch on `error.code` (stable), never on `message` (human text; it may name the MCP tool).

### 2.2 Errors

| Code | HTTP | Meaning / what to do |
| --- | --- | --- |
| `invalid_arguments` | 400 | A field is missing or wrong — the message names it. Fix the request. |
| `invalid_json` | 400 | The body is not a JSON object. |
| `unauthorized` | 401 | Missing, malformed, revoked or expired key. Do not retry. |
| `forbidden` | 403 | Scope too low (`details.requiredRole`). Ask an admin for a higher-scope key. |
| `module_disabled` | 403 | The CRM or Help Desk module is off for the organization. |
| `not_found` | 404 | Wrong id — or another organization's / another connection's (the same answer on purpose). |
| `route_not_found` | 404 | No such method + path. Check `openapi.json`. |
| `method_not_allowed` | 405 | The path exists with other methods (see the `Allow` header). |
| `session_expired` | 409 | The 24-hour window is closed: send an APPROVED template. |
| `account_inactive` | 409 | The WhatsApp connection is not active; sends are refused. |
| `template_not_approved` | 409 | The template exists but is not APPROVED. |
| `duplicate` | 409 | `POST /v1/contacts`: that WhatsApp identity already has a contact. |
| `no_stages` | 409 | The organization has no stage for that entity (all were deleted in the app). |
| `already_in_kind` | 409 | `convert`: the contact already is that kind. |
| `no_media` | 409 | The message has no downloadable media (yet). |
| `idempotency_in_progress` | 409 | A request with the same Idempotency-Key is still running — retry after `Retry-After`. |
| `payload_too_large` | 413 | Request body over 1 MB. |
| `too_large` | 413 | Media file over the limit for its type. |
| `unsupported_template` | 422 | Template with a media/location header (not supported yet). |
| `fetch_failed` | 422 | Nivox could not download the media `url`. |
| `idempotency_conflict` | 422 | The Idempotency-Key was used for a different request — use a new key. |
| `rate_limited` | 429 | Reserved for future limits — honour `Retry-After`. |
| `internal_error` | 500 | Unexpected; retry with backoff (reuse the Idempotency-Key). |
| `graph_error` | 502 | Meta refused (`details.details` carries Meta's code, e.g. a template parameter). Retry only if transient. |

### 2.3 Ids, timestamps and pagination

- Ids are UUIDs returned by earlier calls. Phones are display values (digits with country code);
  the identity of a conversation is its id.
- Timestamps are ISO 8601 UTC. Lists answer `count` plus a cursor: pass `next_before` back as
  `before` for the next (older) page; poll new messages with `after` = the newest `created_at` you
  stored (`next_after`); sync CRM/Help Desk records with `updated_after`.
- GET and DELETE read arguments from the query string (`?limit=20&unread_only=true`); POST and
  PATCH read a JSON object body. Path ids always win over body fields. Unknown fields are ignored.

### 2.4 Idempotency

Send `Idempotency-Key: <1–255 printable ASCII characters>` on POST requests (a UUID per logical
operation). Within 24 hours, repeating the **same** request with the same key does not run it again:
the first answer is replayed byte for byte with `Idempotent-Replayed: true`. The same key with a
**different** request answers `422 idempotency_conflict`; while the first is still running,
`409 idempotency_in_progress`. Answers of 500 or above are not stored (a retry runs again). Keys are
scoped to the API key.

### 2.5 Limits

- Request body ≤ 1 MB; list pages ≤ 100 (search ≤ 50); media by URL: image 10 MB, audio 20 MB,
  video/document 25 MB, sticker 2 MB (public https only, no private addresses).
- Meta's own messaging limits apply per number (see `/v1/connections/{connection_id}/health`).
- No per-key rate limit is enforced today; clients must still handle `429` + `Retry-After`.
- Webhooks: up to 10 endpoints per organization.

### 2.6 Request ids, versioning, CORS

- Every response carries `X-Request-Id` (yours, if you sent one) and `Nivox-Api-Version`.
- The major version is in the path (`/v1`). Within v1, changes are additive: new endpoints, new
  optional fields, new error codes — parse defensively.
- CORS is open (`*`) for tooling, but do not call the API from a browser with a key.

## 3. REST and MCP together

Every REST operation is an MCP tool behind a second transport: the same validation, the same scope
and module gates, the same pipeline and the same audit trail. The reference below names the tool of
each endpoint; the OpenAPI document carries it as `x-mcp-tool`.

- **Backend → REST.** Deterministic flows (order updates, CRM sync, reports) call the API and receive
  webhooks.
- **AI agent → MCP** at `https://nivox.me/mcp` ([setup](https://nivox.me/docs/mcp)), ideally with its own key.
- **Glue:** a `message.received` webhook is a natural trigger for your agent; the ids in the
  payload work directly in MCP tools (`list_messages`, `get_contact`, `send_text`…).
- Messages sent by an integration carry `sent_via: "api_key"`; the Nivox audit trail tells REST calls
  (`api.*`) from MCP calls (`mcp.*`).

## 4. Quick start

```bash
export NIVOX_API_KEY="nvx_..."          # from Integrations
BASE=https://nivox.me/api/v1

curl -s $BASE/me -H "Authorization: Bearer $NIVOX_API_KEY"                                    # who am I, what can I call
curl -s $BASE/connections -H "Authorization: Bearer $NIVOX_API_KEY"                           # my WhatsApp numbers
curl -s "$BASE/conversations?unread_only=true" -H "Authorization: Bearer $NIVOX_API_KEY"      # inbox triage
curl -s "$BASE/conversations/$CONVERSATION_ID/messages" -H "Authorization: Bearer $NIVOX_API_KEY"

# reply while window.open is true
curl -s -X POST $BASE/conversations/$CONVERSATION_ID/messages -H "Authorization: Bearer $NIVOX_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" -d '{"type":"text","text":"Oi! Já verifico para você."}'

curl -s -X POST $BASE/conversations/$CONVERSATION_ID/read -H "Authorization: Bearer $NIVOX_API_KEY" -H "Content-Type: application/json" -d '{}'
```

## 5. Endpoint reference

All paths are relative to `https://nivox.me/api`. Parameters marked *(required)* are required.

### Discovery

#### `GET /v1`

Public index: name, version and the documentation, OpenAPI and MCP URLs. No key needed.

#### `GET /v1/openapi.json`

This API as an OpenAPI 3.1 document (paths, request bodies from the real tool schemas, response
schemas, webhook payloads). No key needed. Import it into Postman, Insomnia or a client generator.

#### `GET /v1/me`

The calling key. **Scope:** any.

**Response `data`:** `{ api_key: { id, name, scope, role, connection_ids }, organization: { id, name }, modules: { crm, helpdesk }, mcp_endpoint, operations: [{ method, path, operation_id, mcp_tools }] }`

```bash
curl -s https://nivox.me/api/v1/me -H "Authorization: Bearer $NIVOX_API_KEY"
```

### Connections

#### `GET /v1/connections`

The WhatsApp Business numbers this key can operate. Start here.

**Scope:** `read` · **MCP tool:** `list_connections`




**Response `data`:** `{ connections: Connection[], scope }`


```bash
curl -s https://nivox.me/api/v1/connections -H "Authorization: Bearer $NIVOX_API_KEY"
```

#### `GET /v1/connections/{connection_id}/health`

Latest Meta health snapshot: messaging limits, quality rating, display-name status, payment method (`payment.status` ok | issue), template counts, 7-day volumes and 30-day cost. Refreshed daily.

**Scope:** `full` · **MCP tool:** `get_connection_health`

| Path parameter | Type | Description |
| --- | --- | --- |
| `connection_id` | uuid | The connection. |



**Response `data`:** `{ connection_id, phone, status, registered, quality_rating, health, health_checked_at }`



### Conversations

#### `GET /v1/conversations`

Inbox listing, most recent activity first. `window.open` says whether free text is allowed (24 h since the customer last wrote).

**Scope:** `read` · **MCP tool:** `list_conversations`


| Query parameter | Type | Description |
| --- | --- | --- |
| `connection_id` | uuid | WhatsApp connection (GET /v1/connections). Optional when the key sees a single connection. |
| `unread_only` | boolean | Only conversations with unread customer messages. |
| `open_window_only` | boolean | Only conversations where free text is allowed now. |
| `query` | string ≤ 80 | Case-insensitive match on the contact name or on phone digits. |
| `limit` | integer 1–100 | Page size (default 30). |
| `before` | ISO timestamp | Cursor: `next_before` of the previous page. |


**Response `data`:** `{ conversations: Conversation[], count, next_before }`


```bash
curl -s "https://nivox.me/api/v1/conversations?unread_only=true&limit=20" -H "Authorization: Bearer $NIVOX_API_KEY"
```

#### `GET /v1/conversations/lookup`

The conversation(s) of a phone number (digits with country code). Brazilian ninth-digit variants are matched.

**Scope:** `read` · **MCP tool:** `find_contact`


| Query parameter | Type | Description |
| --- | --- | --- |
| `phone` | string 8–20 (required) | e.g. `5511999998888`. |
| `connection_id` | uuid | WhatsApp connection (GET /v1/connections). Optional when the key sees a single connection. |


**Response `data`:** `{ phone, conversations: Conversation[], hint? }`


```bash
curl -s "https://nivox.me/api/v1/conversations/lookup?phone=5511999998888" -H "Authorization: Bearer $NIVOX_API_KEY"
```

#### `GET /v1/conversations/{conversation_id}`

One conversation with its contact and 24-hour window state.

**Scope:** `read` · **MCP tool:** `get_conversation`

| Path parameter | Type | Description |
| --- | --- | --- |
| `conversation_id` | uuid | The conversation. |



**Response `data`:** `{ conversation: Conversation, connection: { id, phone, status } }`



#### `GET /v1/conversations/{conversation_id}/messages`

Message history, chronological (oldest first) within the page. Default: the latest 30. Page backwards with `before`; poll forward with `after`.

**Scope:** `read` · **MCP tool:** `list_messages`

| Path parameter | Type | Description |
| --- | --- | --- |
| `conversation_id` | uuid | The conversation. |

| Query parameter | Type | Description |
| --- | --- | --- |
| `limit` | integer 1–100 | Page size (default 30). |
| `before` | ISO timestamp | Only messages created before this (`next_before`). |
| `after` | ISO timestamp | Only messages created after this (`next_after`), oldest first. |


**Response `data`:** `{ conversation_id, contact_name, window_open: Window, messages: Message[], count, has_more, next_before, next_after }`


```bash
curl -s "https://nivox.me/api/v1/conversations/$CONVERSATION_ID/messages?limit=50" -H "Authorization: Bearer $NIVOX_API_KEY"
```

#### `POST /v1/conversations/{conversation_id}/messages`

Send into this conversation. Same body as `POST /v1/messages` (the `type` table below) without `conversation_id`, which the path fixes.

**Scope:** `messages` · **MCP tool:** `send_text · send_template · send_media · send_buttons · send_interactive · send_reaction`

| Path parameter | Type | Description |
| --- | --- | --- |
| `conversation_id` | uuid | The conversation. |



**Response `data`:** see `POST /v1/messages`. **201** (a reaction answers **200**).


```bash
curl -s -X POST https://nivox.me/api/v1/conversations/$CONVERSATION_ID/messages -H "Authorization: Bearer $NIVOX_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"type":"text","text":"Olá! Seu pedido saiu para entrega."}'
```

#### `POST /v1/conversations/{conversation_id}/read`

Zero the unread counter in the team inbox and send WhatsApp read receipts (blue ticks); `typing: true` also shows "typing…".

**Scope:** `messages` · **MCP tool:** `mark_read`

| Path parameter | Type | Description |
| --- | --- | --- |
| `conversation_id` | uuid | The conversation. |


| Body field | Type | Description |
| --- | --- | --- |
| `typing` | boolean | Also show the typing indicator for a few seconds. |

**Response `data`:** `{ conversation_id, unread_count, receipt_sent }`



### Messages

#### `POST /v1/messages`

One endpoint for every kind of send, picked by `type`. Free text, media, buttons, interactive messages and reactions need the 24-hour window to be open (`session_expired` otherwise); a **template** works any time and is the only way to reach a number with no conversation yet (`phone`, scope `full`).

**Scope:** `messages` · **MCP tool:** `send_text · send_template · send_media · send_buttons · send_interactive · send_reaction`



| Body field | Type | Description |
| --- | --- | --- |
| `type` | string (required) | `text` · `template` · `media` · `buttons` · `interactive` · `reaction`. |
| `conversation_id` | uuid | Required for every type except `template` with `phone`. |
| `text` | string 1–4096 | `text`: the message. WhatsApp formatting: `*bold*`, `_italic_`, `~strike~`. |
| `preview_url` | boolean | `text`: render link previews (default true). |
| `reply_to_wamid` | string | `text`, `media`, `buttons`, `interactive`: quote this customer message. |
| `template_name` | string (required) | `template`: an APPROVED template (GET /v1/templates). |
| `language` | string | `template`: pt_BR, en_US… Optional when the template has one language. |
| `body_params` | string[] ≤ 20 | `template`: values for {{1}}, {{2}}… of the body, in order. |
| `header_param` | string ≤ 60 | `template`: value for the header {{1}}. |
| `phone` | string | `template`: destination digits with country code, instead of `conversation_id`. A NEW number needs scope `full`. |
| `connection_id` | uuid | `template` with `phone`: which number sends (optional with one connection). |
| `media_type` | string (required) | `media`: image · video · audio · document · sticker. |
| `url` | https URL (required) | `media`: public file, fetched by Nivox (image 10 MB, audio 20 MB, video/document 25 MB, sticker 2 MB). |
| `caption` | string ≤ 1024 | `media`: caption (not for audio/sticker). |
| `filename` | string ≤ 120 | `media`: file name shown for documents. |
| `mime` | string ≤ 100 | `media`: override the MIME type the URL reports. |
| `body` | string 1–1024 (required) | `buttons`: the message text. |
| `buttons` | array ≤ 3 (required) | `buttons`: `[{ "id": "yes", "title": "Sim" }]` (title ≤ 20). |
| `header` | string ≤ 60 | `buttons`: header text. |
| `footer` | string ≤ 60 | `buttons`: footer text. |
| `interactive` | object (required) | `interactive`: a raw Meta interactive object (list, cta_url, flow…). |
| `target_wamid` | string (required) | `reaction`: the message to react to. |
| `emoji` | string ≤ 8 (required) | `reaction`: one emoji; `""` removes the reaction. |

**Response `data`:** `{ message_id, wamid, conversation_id, status: "sent", type }` (templates add `template` and the filled `text`); a reaction answers `{ conversation_id, wamid, emoji }`. **201** (reaction **200**). `conversation_id` may differ from the one you sent to when Meta canonicalized the number.

Send an `Idempotency-Key` with every send (§2.4). Delivery and read arrive later as `message.status` webhooks (§7) or by re-reading the messages.

```bash
curl -s -X POST https://nivox.me/api/v1/messages -H "Authorization: Bearer $NIVOX_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"type":"template","phone":"5511999998888","template_name":"order_shipped","language":"pt_BR","body_params":["4821"]}'
```

#### `GET /v1/messages/search`

Case-insensitive substring search over message text across the conversations this key can see.

**Scope:** `read` · **MCP tool:** `search_messages`


| Query parameter | Type | Description |
| --- | --- | --- |
| `query` | string 2–80 (required) | Text to find. |
| `connection_id` | uuid | WhatsApp connection (GET /v1/connections). Optional when the key sees a single connection. |
| `conversation_id` | uuid | Restrict to one conversation. |
| `limit` | integer 1–50 | Page size (default 20). |


**Response `data`:** `{ messages: Message[], count }`



#### `GET /v1/messages/{message_id}/media`

A 1-hour signed download URL for the image, audio, video or document of a message whose `media.available` is true. Download it; do not store the link.

**Scope:** `read` · **MCP tool:** `get_media_url`

| Path parameter | Type | Description |
| --- | --- | --- |
| `message_id` | uuid | The message. |



**Response `data`:** `{ message_id, url, mime, bytes, expires_in_seconds }` — `409 no_media` while the file is not available.



### Templates

#### `GET /v1/templates`

Message templates of a connection (cached copy of Meta’s list; the first call on a never-synced connection pulls it from Meta). Only APPROVED templates can be sent.

**Scope:** `read` · **MCP tool:** `list_templates`


| Query parameter | Type | Description |
| --- | --- | --- |
| `connection_id` | uuid | WhatsApp connection (GET /v1/connections). Optional when the key sees a single connection. |
| `status` | string | `APPROVED` (default) · `PENDING` · `REJECTED` · `PAUSED` · `all`. |
| `include_components` | boolean | Include Meta’s raw components (buttons, examples). |


**Response `data`:** `{ connection_id, synced_at, templates: Template[], count }`



#### `POST /v1/templates`

Submit a new text template to Meta for approval (minutes to hours). Body variables are {{1}}, {{2}}… and each needs an example.

**Scope:** `full` · **MCP tool:** `create_template`



| Body field | Type | Description |
| --- | --- | --- |
| `connection_id` | uuid | WhatsApp connection (GET /v1/connections). Optional when the key sees a single connection. |
| `name` | string (required) | Lowercase letters, digits and underscores. |
| `language` | string (required) | pt_BR, en_US, es_ES… |
| `category` | string (required) | `UTILITY` · `MARKETING` · `AUTHENTICATION`. |
| `body_text` | string 1–1024 (required) | The body with {{n}} placeholders. |
| `body_examples` | string[] ≤ 20 | One example per {{n}}, in order (Meta requires them). |
| `header_text` | string ≤ 60 | Optional text header. |
| `footer_text` | string ≤ 60 | Optional footer. |

**Response `data`:** `{ connection_id, id, name, status }` **201**



#### `POST /v1/templates/sync`

Refresh the template cache of a connection from Meta (statuses, new templates).

**Scope:** `full` · **MCP tool:** `sync_templates`



| Body field | Type | Description |
| --- | --- | --- |
| `connection_id` | uuid | WhatsApp connection (GET /v1/connections). Optional when the key sees a single connection. |

**Response `data`:** `{ connection_id, synced, synced_at }`



#### `DELETE /v1/templates/{name}`

Delete a template (every language of that name) on Meta. Irreversible.

**Scope:** `full` · **MCP tool:** `delete_template`

| Path parameter | Type | Description |
| --- | --- | --- |
| `name` | string | Template name. |

| Query parameter | Type | Description |
| --- | --- | --- |
| `connection_id` | uuid | WhatsApp connection (GET /v1/connections). Optional when the key sees a single connection. |


**Response `data`:** `{ connection_id, name, deleted: true }`



### Contacts, notes, stages and members

Needs the CRM **or** the Help Desk module (`403 module_disabled` otherwise).

#### `GET /v1/contacts`

People the organization knows (leads and clients), created automatically from WhatsApp traffic.

**Scope:** `read` · **Module:** CRM or Help Desk · **MCP tool:** `list_contacts`


| Query parameter | Type | Description |
| --- | --- | --- |
| `kind` | string | `lead` · `client`. |
| `stage_key` | string | Lead stage key (GET /v1/stages). |
| `client_status` | string | `active` · `inactive`. |
| `owner_user_id` | uuid | A member id (GET /v1/members). |
| `query` | string ≤ 80 | Name, phone, e-mail or company. |
| `updated_after` | ISO timestamp | Delta sync: only records changed after this. |
| `limit` | integer 1–100 | Page size (default 30). |


**Response `data`:** `{ contacts: Contact[], count }`



#### `POST /v1/contacts`

Create a contact for someone who has not written on WhatsApp yet.

**Scope:** `messages` · **Module:** CRM or Help Desk · **MCP tool:** `create_contact`



| Body field | Type | Description |
| --- | --- | --- |
| `display_name` | string ≤ 120 | Name (this or `phone` is required). |
| `phone` | string ≤ 20 | Digits with country code. |
| `email` | string ≤ 254 | E-mail. |
| `company` | string ≤ 120 | Company. |
| `tags` | string[] ≤ 20 | Tags (≤ 40 characters each). |
| `kind` | string | `lead` (default) · `client`. |
| `stage_key` | string | Lead stage; default = the first stage. |
| `client_status` | string | `active` · `inactive` (clients). |
| `owner_user_id` | uuid | A member id (GET /v1/members). |

**Response `data`:** `{ contact: Contact }` **201** — `409 duplicate` when that WhatsApp identity already has a contact.



#### `GET /v1/contacts/{contact_id}`

Everything about one person in one call: the contact and stage, their conversations on the connections this key can see, open opportunities, open tickets and the last 10 notes.

**Scope:** `read` · **Module:** CRM or Help Desk · **MCP tool:** `get_contact`

| Path parameter | Type | Description |
| --- | --- | --- |
| `contact_id` | uuid | The contact (every Conversation carries `contact_id`). |



**Response `data`:** `{ contact: Contact, conversations: [{ id, connection_id, phone, unread_count, last_message_at, window_open }], open_opportunities: Opportunity[] | null, open_tickets: Ticket[] | null, notes: Note[] }` — a module that is off comes back `null`.



#### `PATCH /v1/contacts/{contact_id}`

Correct or enrich a contact. A name set here is never overwritten by the WhatsApp profile name.

**Scope:** `messages` · **Module:** CRM or Help Desk · **MCP tool:** `update_contact`

| Path parameter | Type | Description |
| --- | --- | --- |
| `contact_id` | uuid | The contact. |


| Body field | Type | Description |
| --- | --- | --- |
| `display_name` | string ≤ 120 | Name. |
| `phone` | string ≤ 20 | Digits with country code. |
| `email` | string ≤ 254 | E-mail. |
| `company` | string ≤ 120 | Company. |
| `tags` | string[] ≤ 20 | Replaces the whole tag list. |
| `owner_user_id` | uuid | A member id (GET /v1/members). |
| `clear_owner` | boolean | Leave the contact with no owner. |

**Response `data`:** `{ contact: Contact }`



#### `POST /v1/contacts/{contact_id}/move`

Leads: move to another stage (a `won` stage converts the lead). Clients: set the status.

**Scope:** `messages` · **Module:** CRM or Help Desk · **MCP tool:** `move_contact`

| Path parameter | Type | Description |
| --- | --- | --- |
| `contact_id` | uuid | The contact. |


| Body field | Type | Description |
| --- | --- | --- |
| `stage_key` | string | Lead stage key (leads only). |
| `client_status` | string | `active` · `inactive` (clients only). |

**Response `data`:** `{ contact: Contact }`



#### `POST /v1/contacts/{contact_id}/convert`

Turn a lead into a client, or a client back into a lead.

**Scope:** `messages` · **Module:** CRM or Help Desk · **MCP tool:** `convert_contact`

| Path parameter | Type | Description |
| --- | --- | --- |
| `contact_id` | uuid | The contact. |


| Body field | Type | Description |
| --- | --- | --- |
| `kind` | string (required) | `lead` · `client`. |
| `stage_key` | string | Lead stage when converting back to a lead. |
| `client_status` | string | `active` · `inactive`. |

**Response `data`:** `{ contact: Contact }` — `409 already_in_kind` when it already is that kind.



#### `GET /v1/contacts/{contact_id}/notes`

Internal notes about a contact, newest first.

**Scope:** `read` · **Module:** CRM or Help Desk · **MCP tool:** `list_notes`

| Path parameter | Type | Description |
| --- | --- | --- |
| `contact_id` | uuid | The contact. |

| Query parameter | Type | Description |
| --- | --- | --- |
| `limit` | integer 1–100 | Page size (default 20). |


**Response `data`:** `{ notes: Note[], count }`



#### `POST /v1/contacts/{contact_id}/notes`

Add an internal note (never sent to WhatsApp), optionally attached to an opportunity or a ticket.

**Scope:** `messages` · **Module:** CRM or Help Desk · **MCP tool:** `add_note`

| Path parameter | Type | Description |
| --- | --- | --- |
| `contact_id` | uuid | The contact. |


| Body field | Type | Description |
| --- | --- | --- |
| `body` | string 1–4000 (required) | The note. |
| `opportunity_id` | uuid | Attach to this opportunity. |
| `ticket_id` | uuid | Attach to this ticket. |

**Response `data`:** `{ note: Note }` **201**



#### `GET /v1/stages`

The organization’s stage vocabulary per entity, in board order. `key` is what every move takes; `kind` is `open`, `won` or `lost`.

**Scope:** `read` · **Module:** CRM or Help Desk · **MCP tool:** `list_stages`


| Query parameter | Type | Description |
| --- | --- | --- |
| `entity` | string | `lead` · `opportunity` · `ticket`; omit for all three. |


**Response `data`:** `{ stages: Stage[], count }`



#### `GET /v1/members`

Team members who can own a record or be assigned a ticket — id and display name only.

**Scope:** `messages` · **Module:** CRM or Help Desk · **MCP tool:** `list_members`




**Response `data`:** `{ members: [{ user_id, display_name }], count }`



### Opportunities

Needs the CRM module.

#### `GET /v1/opportunities`

Deals in the sales pipeline, most recently updated first.

**Scope:** `read` · **Module:** CRM · **MCP tool:** `list_opportunities`


| Query parameter | Type | Description |
| --- | --- | --- |
| `stage_key` | string | Opportunity stage key. |
| `contact_id` | uuid | Deals of one contact. |
| `owner_user_id` | uuid | A member id (GET /v1/members). |
| `open_only` | boolean | Only deals neither won nor lost. |
| `query` | string ≤ 80 | Title match. |
| `updated_after` | ISO timestamp | Delta sync. |
| `limit` | integer 1–100 | Page size (default 30). |


**Response `data`:** `{ opportunities: Opportunity[], count }`



#### `POST /v1/opportunities`

Open a deal for a contact.

**Scope:** `messages` · **Module:** CRM · **MCP tool:** `create_opportunity`



| Body field | Type | Description |
| --- | --- | --- |
| `contact_id` | uuid (required) | The contact. |
| `title` | string 1–120 (required) | What is being sold. |
| `value_cents` | integer ≥ 0 | Integer cents: R$ 1.500,00 is `150000`. |
| `currency` | string (3) | ISO code, BRL default. |
| `stage_key` | string | Default = the first stage. |
| `owner_user_id` | uuid | A member id (GET /v1/members). |
| `expected_close_on` | YYYY-MM-DD | Expected close date. |

**Response `data`:** `{ opportunity: Opportunity }` **201**



#### `GET /v1/opportunities/{opportunity_id}`

One deal with its contact.

**Scope:** `read` · **Module:** CRM · **MCP tool:** `get_opportunity`

| Path parameter | Type | Description |
| --- | --- | --- |
| `opportunity_id` | uuid | The opportunity. |



**Response `data`:** `{ opportunity: Opportunity, contact: Contact | null }`



#### `PATCH /v1/opportunities/{opportunity_id}`

Change title, value, currency, expected close date or owner.

**Scope:** `messages` · **Module:** CRM · **MCP tool:** `update_opportunity`

| Path parameter | Type | Description |
| --- | --- | --- |
| `opportunity_id` | uuid | The opportunity. |


| Body field | Type | Description |
| --- | --- | --- |
| `title` | string 1–120 | Title. |
| `value_cents` | integer ≥ 0 | Value in cents. |
| `currency` | string (3) | ISO code. |
| `expected_close_on` | YYYY-MM-DD | Expected close date. |
| `owner_user_id` | uuid | A member id (GET /v1/members). |
| `clear_owner` | boolean | Leave the deal with no owner. |

**Response `data`:** `{ opportunity: Opportunity }`



#### `POST /v1/opportunities/{opportunity_id}/move`

Advance or close a deal. A `won` stage stamps the close date and turns the contact into a client; `lost` records `lost_reason`.

**Scope:** `messages` · **Module:** CRM · **MCP tool:** `move_opportunity`

| Path parameter | Type | Description |
| --- | --- | --- |
| `opportunity_id` | uuid | The opportunity. |


| Body field | Type | Description |
| --- | --- | --- |
| `stage_key` | string (required) | Opportunity stage key. |
| `lost_reason` | string ≤ 200 | Only for a lost stage. |

**Response `data`:** `{ opportunity: Opportunity, contact_converted }`



### Tickets

Needs the Help Desk module.

#### `GET /v1/tickets`

Support tickets, most recently updated first.

**Scope:** `read` · **Module:** Help Desk · **MCP tool:** `list_tickets`


| Query parameter | Type | Description |
| --- | --- | --- |
| `stage_key` | string | Ticket stage key. |
| `priority` | string | `low` · `normal` · `high` · `urgent`. |
| `assignee_user_id` | uuid | Tickets of one member. |
| `contact_id` | uuid | Tickets of one contact. |
| `conversation_id` | uuid | Tickets opened from one conversation. |
| `open_only` | boolean | Only tickets neither resolved nor closed. |
| `query` | string ≤ 80 | Subject match. |
| `updated_after` | ISO timestamp | Delta sync. |
| `limit` | integer 1–100 | Page size (default 30). |


**Response `data`:** `{ tickets: Ticket[], count }`



#### `POST /v1/tickets`

Open a ticket — from a conversation (the contact is resolved for you) or for a contact.

**Scope:** `messages` · **Module:** Help Desk · **MCP tool:** `create_ticket`



| Body field | Type | Description |
| --- | --- | --- |
| `conversation_id` | uuid | The WhatsApp conversation it came from (preferred). |
| `contact_id` | uuid | When there is no conversation. |
| `subject` | string 1–160 (required) | Subject. |
| `description` | string ≤ 8000 | The problem, in detail. |
| `priority` | string | `low` · `normal` (default) · `high` · `urgent`. |
| `stage_key` | string | Default = the first ticket stage. |
| `assignee_user_id` | uuid | A member id (GET /v1/members). |

**Response `data`:** `{ ticket: Ticket }` **201**



#### `GET /v1/tickets/{ticket_id}`

One ticket with its contact.

**Scope:** `read` · **Module:** Help Desk · **MCP tool:** `get_ticket`

| Path parameter | Type | Description |
| --- | --- | --- |
| `ticket_id` | uuid | The ticket. |



**Response `data`:** `{ ticket: Ticket, contact: Contact | null }`



#### `PATCH /v1/tickets/{ticket_id}`

Change subject, description, priority or the linked conversation.

**Scope:** `messages` · **Module:** Help Desk · **MCP tool:** `update_ticket`

| Path parameter | Type | Description |
| --- | --- | --- |
| `ticket_id` | uuid | The ticket. |


| Body field | Type | Description |
| --- | --- | --- |
| `subject` | string 1–160 | Subject. |
| `description` | string ≤ 8000 | Description. |
| `priority` | string | `low` · `normal` · `high` · `urgent`. |
| `conversation_id` | uuid | Link the ticket to this conversation. |

**Response `data`:** `{ ticket: Ticket }`



#### `POST /v1/tickets/{ticket_id}/move`

Move a ticket: `won` = resolved, `lost` = closed; back to an open stage clears both.

**Scope:** `messages` · **Module:** Help Desk · **MCP tool:** `move_ticket`

| Path parameter | Type | Description |
| --- | --- | --- |
| `ticket_id` | uuid | The ticket. |


| Body field | Type | Description |
| --- | --- | --- |
| `stage_key` | string (required) | Ticket stage key. |

**Response `data`:** `{ ticket: Ticket }`



#### `POST /v1/tickets/{ticket_id}/assign`

Assign a ticket to a member, or unassign it.

**Scope:** `messages` · **Module:** Help Desk · **MCP tool:** `assign_ticket`

| Path parameter | Type | Description |
| --- | --- | --- |
| `ticket_id` | uuid | The ticket. |


| Body field | Type | Description |
| --- | --- | --- |
| `assignee_user_id` | uuid | Member id (GET /v1/members). |
| `unassign` | boolean | Leave the ticket unassigned. |

**Response `data`:** `{ ticket: Ticket }`



## 6. Objects

Every object has all its fields present (`null` rather than absent); snake_case throughout.

**Connection** — `id`, `phone`, `verified_name`, `business`, `status` (`active` accepts sends),
`registered`, `flow` (`classic` · `coexistence`), `quality_rating`, `health_checked_at`,
`templates_synced_at`, `connected_at`.

**Conversation**

```json
{
  "id": "3fbb9c0a-0349-4363-a3ff-da21b273be0c",
  "connection_id": "f8a7d953-5b66-4cd8-b4e3-03168aa7add2",
  "contact_id": "79db5fd9-1fe5-4f9d-bbe2-6988253b1e71",
  "contact": { "name": "Maria Souza", "phone": "5511988887777", "wa_id": "5511988887777", "opted_out": false },
  "unread_count": 1,
  "last_message": { "preview": "Meu pedido já saiu?", "type": "text", "at": "2026-09-14T18:22:31.120Z", "direction": "inbound" },
  "window": { "open": true, "expires_at": "2026-09-15T18:22:31.120Z", "can_send_free_text": true, "last_inbound_at": "2026-09-14T18:22:31.120Z" },
  "created_at": "2026-09-01T12:00:00.000Z"
}
```

**Message**

```json
{
  "id": "0d8f6c1e-8b7a-4c1e-9f5a-2c1d3e4f5a6b",
  "wamid": "wamid.HBgNNTUxMTk4ODg4Nzc3NxUCABIYFjNFQjBDMEI4",
  "conversation_id": "3fbb9c0a-0349-4363-a3ff-da21b273be0c",
  "direction": "inbound",
  "type": "text",
  "status": "received",
  "text": "Meu pedido já saiu?",
  "created_at": "2026-09-14T18:22:31.120Z",
  "author": "Maria Souza"
}
```

`status`: `sending` · `sent` · `delivered` · `read` · `failed` (outbound) · `received` (inbound).
Optional fields: `author` (inbound), `sent_via` (`user` | `api_key`, outbound), `reply_to_wamid`,
`reactions`, `template`, `media` (`{ mime, bytes, status, available, filename }` — download with
`GET /v1/messages/{message_id}/media`), `location`, `contacts`, `interactive`, `button`, `order`
(Meta's shapes), `error` (`{ code, message }` on failed sends).

**Template** — `id`, `name`, `language`, `status`, `category`, `header` (`{ format, text, variables }` or
null), `body` (`{ text, variables }`), `footer`, `buttons`, `components` (with `include_components`).

**Contact** — `id`, `kind` (`lead` · `client`), `name`, `name_source`, `phone`, `wa_id`, `email`,
`company`, `tags`, `stage` (`{ key, name, kind }`, leads), `client_status`, `owner_user_id`, `source`,
`stage_changed_at`, `converted_at`, `created_via`/`updated_via` (`user` | `api_key`), `created_at`, `updated_at`.

**Opportunity** — `id`, `contact_id`, `title`, `value_cents`, `currency`, `stage`, `owner_user_id`,
`expected_close_on`, `won_at`, `lost_at`, `lost_reason`, `is_open`, `stage_changed_at`, `created_via`,
`updated_via`, `created_at`, `updated_at`.

**Ticket** — `id`, `number`, `contact_id`, `conversation_id`, `subject`, `description`, `stage`,
`priority`, `assignee_user_id`, `resolved_at`, `closed_at`, `is_open`, `stage_changed_at`, `created_via`,
`updated_via`, `created_at`, `updated_at`.

**Note** — `id`, `contact_id`, `opportunity_id`, `ticket_id`, `body`, `author_via`, `author_user_id`,
`created_at`. **Stage** — `key`, `name`, `entity`, `kind` (`open` · `won` · `lost`), `position`, `is_default`.

## 7. Webhooks

Optional. Instead of polling, an organization admin registers an HTTPS endpoint on
**Integrations → Webhooks** and Nivox POSTs signed events to it.

### 7.1 Setup

1. **Integrations → Webhooks → New webhook**: the public `https://` URL (no IP addresses, no
   localhost or internal domains, no credentials in the URL), a description, the events, and
   optionally which WhatsApp connections.
2. Copy the **signing secret** (`whsec_…`) — shown once; **New secret** rotates it.
3. **Send test** delivers a `webhook.test` event right away and shows the HTTP status. **Pause** stops
   new events for the endpoint; **Show deliveries** lists the last 14 days, with **Redeliver**.

### 7.2 Events

| Event | Fires when |
| --- | --- |
| `message.received` | A customer message arrived and was stored (media mirrored when possible). |
| `message.sent` | An outbound message was accepted by Meta — from the inbox, the REST API or an MCP agent. |
| `message.status` | An outbound message became `delivered`, `read` or `failed`. |
| `webhook.test` | Sent by **Send test** only. |

Reactions, template status changes and CRM/Help Desk events are not webhooks in v1.

### 7.3 Payload

```json
{
  "id": "5a0f4d8e-2f7b-4f6c-9d0e-1b2c3d4e5f60",
  "type": "message.received",
  "created_at": "2026-09-14T18:22:31.180Z",
  "api_version": "v1",
  "org_id": "0b8e1c7a-5d2f-4e39-9a61-3c4d5e6f7a8b",
  "data": {
    "connection": { "id": "f8a7d953-5b66-4cd8-b4e3-03168aa7add2", "phone": "+55 11 99999-0000" },
    "conversation": { "id": "3fbb9c0a-0349-4363-a3ff-da21b273be0c", "contact_id": "79db5fd9-1fe5-4f9d-bbe2-6988253b1e71", "…": "the Conversation object" },
    "message": { "id": "0d8f6c1e-8b7a-4c1e-9f5a-2c1d3e4f5a6b", "direction": "inbound", "type": "text", "status": "received", "text": "Meu pedido já saiu?", "…": "the Message object" }
  }
}
```

`data.conversation` and `data.message` are exactly the objects of §6. `message.sent` has the same
`data`. `message.status` adds `data.status` (`delivered` · `read` · `failed`) and `data.error`
(`{ code, message }` for failed, else `null`), and `data.message.status` is the status that fired.

### 7.4 Headers

| Header | Value |
| --- | --- |
| `webhook-id` | The event id (= payload `id`), identical on every retry — deduplicate on it. |
| `webhook-timestamp` | Unix seconds when this attempt was signed. |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` (space-separated list). |
| `X-Nivox-Event` | The event type. |
| `X-Nivox-Delivery` | The delivery id (one per endpoint and event). |
| `X-Nivox-Attempt` | 1, 2, 3… |
| `User-Agent` | `Nivox-Webhooks/1.0` |

### 7.5 Verifying the signature

Nivox signs with [Standard Webhooks](https://www.standardwebhooks.com): the signature is
`base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}"))`, where `key` is the
base64-decoded part of the secret after `whsec_`. Verify the **raw bytes** you received (not
re-serialized JSON) and refuse timestamps more than 5 minutes away. Libraries that do this:
`standardwebhooks` (npm, PyPI, Go, Ruby…) and Svix.

```js
import crypto from 'node:crypto';

export function verifyNivoxWebhook(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'] ?? '';
  if (!id || !timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
  return signatures.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    return version === 'v1' && signature?.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  });
}
```

```python
import base64, hashlib, hmac, time

def verify_nivox_webhook(raw_body: bytes, headers, secret: str) -> bool:
    msg_id, ts = headers.get("webhook-id"), headers.get("webhook-timestamp")
    if not msg_id or not ts or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(e.split(",", 1)[1], expected)
               for e in headers.get("webhook-signature", "").split() if e.startswith("v1,"))
```

### 7.6 Delivery, retries and ordering

- Answer **2xx within 10 seconds** and do the work afterwards. Any other status, a timeout, a network
  error or a **redirect** (redirects are never followed — register the final URL) is a failure.
- Retries: +1 min, +5 min, +30 min, +2 h, +6 h, +12 h — **7 attempts** over about 20 h 36 min; then
  the delivery is `failed` (redeliver it from Integrations). An endpoint with 10 or more consecutive
  failures for 3 days is disabled automatically ("Disabled after failures"; enable it again after fixing).
- **At-least-once, unordered:** the same event can arrive twice (deduplicate on `webhook-id`) and events
  can arrive out of order (use `message.created_at` and the status ranks sent < delivered < read).
- Deliveries and their payloads are kept for 14 days. Nivox resolves your hostname on every attempt and
  never delivers to a private address.

## 8. Troubleshooting

| Symptom | Cause / fix |
| --- | --- |
| `401 unauthorized` | Wrong header, revoked or expired key, or a session/JWT token instead of `nvx_…`. |
| `403 forbidden` on sends | A `read` key. Ask an admin for `messages` (or `full` for new numbers and templates). |
| `403 module_disabled` | CRM / Help Desk off for the organization; a Nivox operator enables it. |
| `404 not_found` for an id you just listed | The key is limited to other connections, or the id is another organization's. |
| `404 route_not_found` | Method or path typo — paths start with `/v1`; compare with `openapi.json`. |
| `409 session_expired` | The 24-hour window closed: send a template; free text resumes when the customer replies. |
| `502 graph_error` | Meta refused: read `error.details` (template parameters, number not on WhatsApp, payment method…). |
| A retry sent the message twice | Send an `Idempotency-Key` with every POST. |
| Webhook never arrives | Endpoint paused or disabled, URL not public https, a redirect, a non-2xx answer or > 10 s — check **Show deliveries** and **Send test**. |
| Signature does not match | Verify the raw request bytes with the current secret (rotating invalidates the old one). |

## 9. Changelog

- **1.0.0** (2026-09-14) — first release: 37 operations mirroring the MCP server 0.3.2, Idempotency-Key,
  OpenAPI 3.1, outbound webhooks (`message.received`, `message.sent`, `message.status`) with Standard
  Webhooks signatures, retries and a delivery log.
