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 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-Keyon 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
401withWWW-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
{ "ok": true, "data": { "conversations": [], "count": 0, "next_before": null } }
{
"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
countplus a cursor: passnext_beforeback asbeforefor the next (older) page; poll new messages withafter= the newestcreated_atyou stored (next_after); sync CRM/Help Desk records withupdated_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) andNivox-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), ideally with its own key. - Glue: a
message.receivedwebhook 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
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 }] }
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 }
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 }
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? }
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 }
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).
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.
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
{
"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
{
"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
- 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. - Copy the signing secret (
whsec_…) — shown once; New secret rotates it. - Send test delivers a
webhook.testevent 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
{
"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: 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.
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));
});
}
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 (usemessage.created_atand 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.