# Nodium > Connected in minutes. Nodium carries the messages, you decide. Connect your WhatsApp number or your clients' numbers through Meta's official API: REST, SDK, signed webhooks, templates, Flows, broadcasts. Free to start. Nodium is published by By-Tech.io (Paris, France). Contact: contact@nodium.io ## Pricing - Build — €0 forever: Your WhatsApp number, connected by you. No card. - 3,000 messages a month - 1 WhatsApp number, connected in two minutes - 1 GB of media, 7 days of retention - The whole API, the console and the journal - Signed webhooks, templates, Flows and broadcasts - Beyond that: Launch, €19 a month - Launch — €19 per month: Connect your clients' numbers, each through its own link. Everything included on Nodium's side. - 100,000 messages a month - 3 numbers included, €8 per extra number - 20 GB of media, 30 days of retention - Unlimited clients, one connect link each - Beyond the quota: €1 per 1,000 messages, up to the cap you set - Scale — €249 per month: For a fleet of clients, with priority email support. - 1,000,000 messages a month - 50 numbers included, €4 per extra number - 200 GB of media, 90 days of retention - Beyond the quota: €0.50 per 1,000 messages - Moving from Build to Launch changes nothing in your code: same key, same webhooks. Meta bills its fees separately, per number. - Details: https://nodium.io/en/pricing ## Pages (English) - [WhatsApp API for developers and SaaS platforms — Nodium](https://nodium.io/en/home): Connect your WhatsApp number or your clients' numbers through Meta's official API: REST, SDK, signed webhooks, templates, Flows, broadcasts. Free to start. - [WhatsApp Cloud API for developers, multi-client — Nodium](https://nodium.io/en/whatsapp-api): Connect your customers' WhatsApp numbers and send and receive over REST: templates per customer, the 24-hour window handled, broadcasts, signed webhooks. - [WhatsApp Business API gateway: webhooks, templates — Nodium](https://nodium.io/en/product): Meta's official WhatsApp Cloud API with signed webhooks, templates, Flows and broadcasts, plus a REST API and a TypeScript SDK to send and receive from your code. - [WhatsApp API for SaaS, agencies and product teams — Nodium](https://nodium.io/en/use-cases): Software publishers connecting their customers' WhatsApp, agencies delivering for theirs, product teams adding WhatsApp to their app. - [WhatsApp API pricing: free plan, then from €19 — Nodium](https://nodium.io/en/pricing): Build is free forever: 3,000 messages and 1 number. Launch from €19 a month, Scale from €249. Meta bills its fees per number, with no markup from Nodium. - [WhatsApp REST API, OpenAPI and webhooks — Nodium](https://nodium.io/en/api): A REST API under /api/v1: a data envelope, stable error codes, signed webhooks, multi-client keys, OpenAPI 3.1, a TypeScript SDK and llms.txt. - [WhatsApp API for AI agents: llms.txt, AGENTS.md — Nodium](https://nodium.io/en/agents): Claude Code, Cursor, Codex: your coding agent reads Nodium as plain text. llms.txt, AGENTS.md, OpenAPI and a ready-to-paste prompt to connect WhatsApp. - [Security and GDPR: encrypted, isolated, EU-hosted — Nodium](https://nodium.io/en/security): Secrets encrypted with AES-256-GCM, signed webhooks, per-client isolation, configurable retention, erasure by API, hosting in the European Union. - [Contact the Nodium team — WhatsApp API](https://nodium.io/en/contact): Write to the Nodium team about a WhatsApp API integration, a custom plan or your data. - [Mentions légales — Nodium](https://nodium.io/en/legal/notice): Éditeur, directeur de la publication, hébergement et propriété intellectuelle du site Nodium. - [Terms of Service — Nodium](https://nodium.io/en/legal/terms): The terms of use of Nodium, the API gateway to WhatsApp and Muse: account and keys, responsibility for content, channel fees, data processing. - [Privacy — Nodium](https://nodium.io/en/legal/privacy): What data Nodium processes, why, with which providers, for how long, and how to exercise your rights. ## Pages (français) - [API WhatsApp pour développeurs et éditeurs SaaS — Nodium](https://nodium.io/fr/home): Branchez votre numéro WhatsApp ou ceux de vos clients par l'API officielle de Meta : REST, SDK, webhooks signés, modèles, Flows, diffusions. Gratuit pour démarrer. - [API WhatsApp Cloud pour développeurs, multi-clients — Nodium](https://nodium.io/fr/whatsapp-api): Branchez le WhatsApp de vos clients, envoyez et recevez par une API REST : modèles par client, fenêtre de 24 heures tenue, diffusions, webhooks signés. - [Passerelle API WhatsApp Business : webhooks, modèles — Nodium](https://nodium.io/fr/product): L'API WhatsApp Cloud officielle de Meta, avec webhooks signés, modèles, Flows et diffusions, une API REST et un SDK TypeScript pour envoyer et recevoir. - [API WhatsApp pour éditeurs SaaS, agences et produits — Nodium](https://nodium.io/fr/use-cases): Éditeurs qui branchent le WhatsApp de leurs clients, agences qui livrent pour les leurs, équipes produit qui ajoutent WhatsApp à leur application. - [Tarifs de l'API WhatsApp : gratuit, puis dès 19 € — Nodium](https://nodium.io/fr/pricing): Build gratuit pour toujours : 3 000 messages et 1 numéro. Launch dès 19 € par mois, Scale dès 249 €. Meta facture ses frais par numéro, sans marge de Nodium. - [API REST WhatsApp, OpenAPI et webhooks — Nodium](https://nodium.io/fr/api): Une API REST sous /api/v1 : enveloppe data, codes d'erreur stables, webhooks signés, clés multi-clients, OpenAPI 3.1, SDK TypeScript et llms.txt. - [API WhatsApp pour agents IA : llms.txt, AGENTS.md — Nodium](https://nodium.io/fr/agents): Claude Code, Cursor, Codex : votre agent de code lit Nodium en texte simple. llms.txt, AGENTS.md, OpenAPI et le prompt prêt à copier pour brancher WhatsApp. - [Sécurité et RGPD : chiffré, cloisonné, hébergé en UE — Nodium](https://nodium.io/fr/security): Secrets chiffrés en AES-256-GCM, webhooks signés, cloisonnement par client, conservation paramétrable, effacement par l'API, hébergement dans l'Union européenne. - [Contacter l'équipe Nodium — API WhatsApp](https://nodium.io/fr/contact): Écrivez à l'équipe Nodium : une intégration de l'API WhatsApp, une offre sur mesure, vos données. - [Mentions légales — Nodium](https://nodium.io/fr/legal/notice): Éditeur, directeur de la publication, hébergement et propriété intellectuelle du site Nodium. - [Conditions d'utilisation — Nodium](https://nodium.io/fr/legal/terms): Les conditions d'utilisation de Nodium, passerelle API vers WhatsApp et Muse : compte et clés, responsabilité du contenu, frais de canal, sous-traitance des données. - [Confidentialité — Nodium](https://nodium.io/fr/legal/privacy): Quelles données Nodium traite, pourquoi, avec quels prestataires, combien de temps, et comment exercer vos droits. ## Developers - [For AI agents](https://nodium.io/en/agents): how a coding agent integrates Nodium, the prompt to give it - [AGENTS.md](https://nodium.io/agents.md): the integration sheet for coding agents, in one file - [Full text for LLMs](https://nodium.io/llms-full.txt): this summary, AGENTS.md and the whole API guide - [API documentation](https://nodium.io/en/docs/api): guide and reference, in English - [API for LLMs](https://nodium.io/docs/api/llms.txt): every published route, in one file - [OpenAPI](https://nodium.io/docs/api/openapi.json): the machine-readable description - [Webhook events](https://nodium.io/docs/api/webhook-events.json): fields and an example of each event - [Status](https://nodium.io/status.json): the state of the API, machine-readable - TypeScript SDK: `npm i @nodium.io/whatsapp` · CLI: `npx @nodium.io/cli` - MCP server: https://nodium.io/api/v1/mcp — operate WhatsApp from an agent with your API key (guide: https://nodium.io/en/docs/api/guide/mcp) REST base URL: `https://nodium.io/api/v1` (Bearer API key, `{ data }` / `{ error }` envelope). One key per account acts for every client: the optional `Nodium-Tenant` header names the client, by id or by your own `externalId`. Each client links its own WhatsApp number through a hosted connect link (`POST /api/v1/tenants/{id}/connect-links`). A WhatsApp number is required, yours or your customer's: Nodium does not provide numbers. Rate limit: 600 calls per minute per client (per key when pinned), 6,000 per key. API key roles: admin, agent (send-only), member (read-only). Webhooks are signed with HMAC-SHA256 (`X-Nodium-Signature`), one subscription for all clients. Each app is published as a hosted MCP server for Muse at `/api/v1/apps/{id}/mcp`, with OAuth 2.1 handled by Nodium. Nodium never answers for you: it carries messages and tool calls, your code decides the reply. --- # Nodium — Agent Skill Nodium is an API gateway that connects a developer's backend to conversational surfaces: the official WhatsApp Cloud API (Meta Tech Provider) and Muse, Meta's AI assistant (each app is published as a hosted MCP server). Nodium never answers for you: it carries messages, your backend decides the reply. One account, one key, one client per end customer (plus your own company), HTTPS only. Full reference: `https://nodium.io/en/docs/api` · every route in one file: `https://nodium.io/docs/api/llms.txt` · OpenAPI 3.1: `https://nodium.io/docs/api/openapi.json` · everything in one file: `https://nodium.io/llms-full.txt` · status: `https://nodium.io/status.json`. ## SDK ``` npm i @nodium.io/whatsapp # Node 20+, ESM and CommonJS, typed npx @nodium.io/cli listen # forward webhooks to localhost while you build ``` ```ts import { Nodium } from '@nodium.io/whatsapp' import { verifyWebhook } from '@nodium.io/whatsapp/server' const nodium = new Nodium(process.env.NODIUM_KEY!) await nodium.messages.send({ channelId, to: '+33612345678', type: 'text', text: 'Hello', idempotencyKey: 'order-4187' }) // One client among yours: nodium.as('').messages.send(…) ``` Errors are `NodiumError` (`code`, `status`, `requestId`, `retry`): branch on `code`, never on the message. `verifyWebhook(rawBody, headers, secret)` checks the signature and returns the event (it throws `NodiumSignatureError` otherwise); `receiveFlowRequest` / `respondToFlow` serve a dynamic Flow. ## MCP server Operate WhatsApp from an agent through the Model Context Protocol, with the same key: `https://nodium.io/api/v1/mcp` (Streamable HTTP, POST only, no session). ``` claude mcp add --transport http nodium https://nodium.io/api/v1/mcp --header "Authorization: Bearer $NODIUM_KEY" ``` Each tool calls the matching route with your key (`send_message`, `list_conversations`, `list_messages`, `list_templates`, `read_journal`…), so the key's role decides what the agent may do and lists only the tools it can use. The only writes are `send_message`, `mark_read` and `update_contact`: broadcasts, templates, clients and connect links stay with the API. Every tool takes an optional `tenant` (client id or `externalId`). Results are the content of `data` as JSON text; refusals come back with `isError: true` and the API's `error.code`. Guide: `https://nodium.io/en/docs/api/guide/mcp`. ## Base URL and envelope ``` https://nodium.io/api/v1 ``` - Success: `{ "data": ... }`. - Refusal: `{ "error": { "status": 409, "code": "window_closed", "message": "...", "detail": "...", "param": null, "doc_url": "...", "requestId": "..." } }`. Test `error.code` (stable), never the text. `param` names the refused field of the body or query (`null` when it is not about one field); `doc_url` points at the explanation of the code in the guide (`null` for a code it does not list). Quote `requestId` when asking for help. - Every list answers `items`. A paginated list (`limit`, then send `nextCursor` back as `cursor` until it is `null`) also gives `nextCursor`, `hasMore` and, on its first page when it counts, `total`. A list returned whole (a bounded set) gives `items` only. - Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. ## Authentication `Authorization: Bearer `. - **The account key** (`nod_…`): one kind of key, owned by your account and created in the console (Settings › API keys) or with `POST /keys`. It acts as itself (messages carry its label), with one role. A key never creates a key more powerful than itself. The secret is shown once. - **The client it acts on**: the client the key is pinned to (`clientId` at creation), otherwise the one named by `Nodium-Tenant: `, otherwise your own company ("My company"). A client outside your account answers `404`, never `403`. A key pinned to one client cannot name another. - **Roles**: `admin` = full (webhooks, templates, channels, broadcasts, apps, keys, clients); `agent` = **send-only** (read, send, react, contacts); `member` = read-only. Each route states the role it needs. Routes that cover every client (`/tenants`, `GET /channels?scope=account`) need an `admin` key that is not pinned (`all_clients_required`). ## Resources ### Conversations and messages - `GET /conversations` — list (cursor; `channelId`, `contactId`, `phone`: the contact with this number, written any way, 6 digits at least) - `GET /conversations/:id` — one conversation, with its `window` (`open`, `expiresAt`, `minutesLeft`) - `GET /conversations/:id/messages` — history (cursor) - `POST /messages` — **the one way to send**, by channel and person (`channelId`, `to` or `recipient`, `type`, `idempotencyKey`); Nodium finds or opens the conversation (see below) - `POST /media` — upload a file (multipart: `file`); returns a `mediaId` valid 7 days, sent with `POST /messages` - `POST /conversations/:id/read` — read receipt - `POST /conversations/:id/typing` — typing indicator - `GET /messages/:id` — one message as it stands (status, `errorInfo`, ids) - `GET /messages/:id/media` — download a received file ### Contacts - `GET /contacts` — list (cursor, `q`, `consent`, `blocked`, `channel`) - `GET /contacts/export` — the same filters, as a CSV file (`admin`, 10,000 rows at most) - `POST /contacts` — create - `GET /contacts/:id` - `PATCH /contacts/:id` — fields, marketing consent - `DELETE /privacy/contacts/:id` — erase a person, for good ### Templates A template belongs to the client you act on and is deployed to the WhatsApp accounts of its numbers, including numbers it connects later (Meta reviews it in each). To send one, its name is enough: Nodium picks the approved version of the client. One button can open a WhatsApp Flow (`{ "type": "FLOW", "text", "flowId", "screen" }`): the only way to open a Flow outside the 24-hour window. - `GET /templates` · `GET /templates/:id` — `status` is for the client you act on; `statusCounts` and `clients` give the state per client - `POST /templates` — draft, for the client you act on; nothing reaches Meta before `deploy` - `PATCH /templates/:id` — edit while no client holds it at Meta · `DELETE /templates/:id` - `POST /templates/:id/deploy` — deposit it at every client it targets; only does what is left to do, safe to repeat - `POST /templates/:id/refresh` — ask Meta for the state at each client - `POST /templates/import` — import the templates already at Meta ### Broadcasts - `POST /broadcasts/preview` — who a broadcast would reach - `POST /broadcasts` — one template to many contacts; refused (`422 broadcast_exceeds_messaging_limit`) when it exceeds the number's messaging tier per 24 h - `GET /broadcasts` · `GET /broadcasts/:id` — `status`: `sending` · `paused` · `finished` · `cancelled`; a recipient is `sent`/`failed` only once its message is settled - `POST /broadcasts/:id/cancel` - `POST /broadcasts/:id/resume` — continue a broadcast the circuit breaker paused (sends refused over and over for an account reason: payment, quality, rate limit, token, plan allowance); `409 broadcast_not_paused` otherwise ### Channels and numbers - `GET /channels` — the channels of the client you act on (WhatsApp numbers); `?scope=account` for every client - `POST /channels` — create a test channel - `GET /channels/:id` — one number: quality, sending tier, last health check - `PATCH /channels/:id` — switch on or off, rename · `DELETE /channels/:id` - `POST /channels/:id/check` — check a WhatsApp number - `GET /whatsapp/signup/:channelId` — follow a number connected through Meta's signup: display number, `wa.me` link, history import progress on a coexistence number (`admin`) - `POST /channels/:id/diagnostic` — why a number does not receive or send: eight checks, each with what to do and who can fix it (`admin`, 6 per minute per number) - `GET /channels/:id/profile` · `PATCH /channels/:id/profile` — the business profile read at Meta (`admin`): `about`, `description`, `address`, `email`, `websites[]`, `vertical`; only the fields you send change - `GET /phone-numbers` · `GET /phone-numbers/:id` — numbers as Meta sees them - `POST /phone-numbers/:id/pin` — change the two-step verification PIN Numbers are connected by the customer through Meta's Embedded Signup, from a hosted connect link (see Clients). A channel with `paymentMissing: true` has a WhatsApp account without a payment method at Meta (error 131042 on paid sends). Coexistence (the WhatsApp Business app and the API on the same number) is supported. ### Webhooks A subscription belongs to the client the call acts on. One created on "My company" also receives the events of every client of your account; every payload carries the originating client's id in `workspace_id`. - `GET /webhooks` · `POST /webhooks` (`url`, `events[]` or `["*"]`, `label`, `channelIds[]`) → the secret, shown once - `PATCH /webhooks/:id` — url, events, label, `channelIds`, `isActive` (pause/resume) · `DELETE /webhooks/:id` `channelIds` limits a subscription to some numbers (empty: all). An event that belongs to no number (a template status, a consent change) is not delivered to a limited subscription. - `POST /webhooks/:id/test` — one signed test call now - `POST /webhooks/:id/rotate-secret` — new secret, old one still signs for 24h - `GET /webhooks/deliveries` — delivery log - `POST /webhooks/deliveries/:id/redeliver` — same payload, same event `id` ### Apps (Muse / MCP) An app is your API published to assistants. Nodium calls your API directly, as its OpenAPI describes it — you write no endpoint for it. Every account has one app from sign-up. - `GET /apps` · `POST /apps` (`name`, `description`, `apiBaseUrl`, `mcpEnabled`) - `GET /apps/:id` · `PATCH /apps/:id` (`apiBaseUrl`, `testCredential`, `mcpTools`, `status`, `authType`, `authConfig`, Muse listing fields…) · `DELETE /apps/:id` - `POST /apps/:id/tools/import` — OpenAPI 3 (JSON, `url` or `document`) → tools; `mode` `replace`|`merge`, `dryRun`. Reads are enabled, writes are not - `PATCH /apps/:id/tools/:name` — enable or disable a tool - `POST /apps/:id/tools/:name/test` — call a tool for real with your test credential (never billed) - `GET /apps/:id/review-kit` — the Muse review file (`format=json|markdown`) - `GET /apps/:id/connections` · `DELETE /apps/:id/connections/:connId` - `GET /apps/:id/invocations` — tool call journal · `GET /apps/:id/stats` ### Clients A client is one of your customers: its own numbers, contacts, conversations and templates, isolated from the others. "My company" is your own, born with the account (not listed). - `GET /tenants` · `POST /tenants` (`name`, `externalId`, `locale`, `timezone`) → `{ tenant }` - `GET /tenants/:id` · `PATCH /tenants/:id` · `DELETE /tenants/:id` — `:id` is the client id or your `externalId` - `POST /tenants/:id/connect-links` — hosted connect link (`redirectUrl`, `expiresInDays`) - `GET /tenants/:id/channels` - `GET /connect-links` — the hosted links of the account, never their address (`admin`; `state`, `client`, cursor) · `DELETE /connect-links/:id` — an unused link stops working ### Keys - `GET /keys` · `POST /keys` (`label`, `role`, `clientId` to pin it, `expiresInDays` 1–3650, absent = never expires) → `{ key, token }`, the token shown once; past its date a key answers 401 `api_key_expired` - `GET /keys/current` — the key making the call: `{ id, accountId, role, pinned, expiresAt }` (any role) - `DELETE /keys/:id` — revoke ### Sandbox Try WhatsApp without a number of your own: send `join ` from your phone to the shared Nodium number (the `waLink` opens it), and your messages arrive in "My company" like on a real number. Only linked phones receive, inside the 24-hour window, no templates, with a daily cap (`sandbox_phone_not_linked`, `sandbox_daily_limit`). - `GET /sandbox` — number, `joinMessage`, `waLink`, linked phones, limits - `POST /sandbox/code` — a new code (if it leaked) - `DELETE /sandbox/phones/:id` — unlink a phone (`id` from `GET /sandbox`) ### WhatsApp Flows A Flow is a form shown inside WhatsApp; it lives at Meta and Nodium keeps its versions. A draft can be edited, a published Flow only deprecated or duplicated. Answers arrive as `message.received` (form reply), or through `GET /flow-responses`. - `GET /flows` · `POST /flows` (`name`, `categories[]`, `flowJson`, `channelId` — the number whose WhatsApp account hosts the Flow) · `GET /flows/:id` · `PATCH /flows/:id` · `DELETE /flows/:id` (draft only) - `POST /flows/sync` — read the client's Flows back from Meta - `GET /flows/:id/versions` · `POST /flows/:id/versions` (`flowJson`, validated by Meta: read `validationErrors`) · `GET /flows/:id/versions/:versionId` - `GET /flows/:id/preview` — Meta's preview link - `POST /flows/:id/publish` — final · `POST /flows/:id/deprecate` · `POST /flows/:id/duplicate` - `GET /flows/:id/data-endpoint` · `PUT` (`url`) → the signing `secret`, shown once · `DELETE` — a dynamic Flow: Meta calls Nodium, which relays to your `url` - `POST /flows/:id/data-endpoint-test` — sends your `url` a signed `INIT` call (`test: true`) and returns status, duration and your answer - `GET /flows/encryption` · `POST /flows/encryption` (`channelId`, optional) — whether the client's encryption key is registered at Meta, per number, and registering it - `GET /flows/:id/invocations` — the last calls Meta made (no content) - `GET /flow-responses` — what contacts entered (`flowId` for one Flow, `flowToken`, `from`, `to`, cursor); `GET /flows` gives each Flow's `responses` count ### Files and statistics - `GET /media` — the files Nodium keeps (`kind`, `from`, `to`, cursor) · `DELETE /media/:id` — remove the copy (`admin`; the message stays) - `GET /stats/delivery` — sent, delivered, read, replied, failed, skipped per day and per template or broadcast (`from`, `to`, `source`, `channelId`; 366 days at most) ### Journal - `GET /journal` — messages, webhook deliveries, tool calls and API calls in time order (`kind`, `client`, `status=failed`, `direction`, `channel`, `q`, `from`, `to`, cursor). Successful reads of the API (`GET` below 400) are left out unless `reads=1`; the first page tells how many in `hiddenReads` · `GET /journal/:kind/:id` — one line, with the error explained - `GET /events/stream` — the same lines live, same `reads` rule (Server-Sent Events; reconnect with `Last-Event-ID`) ### Event log The webhook is a doorbell, the event log is the truth: every event Nodium emits is also written to a numbered log, kept 30 days. - `GET /events` (`admin`) — `after` (a `seq`), `limit` (≤ 500, default 100) → `{ data: [envelope + seq], next_after }`. Without `after`, `data` is empty and `next_after` is the head: store it as your first cursor, then loop while `next_after` moves. Entries come in order, none skipped; deduplicate on `id` (the same as in webhook deliveries). A key that sees every client reads them all (or the one `Nodium-Tenant` names); a pinned key, one client. A cursor older than the log answers `410 cursor_expired`. ### Other - `GET /search` — find a contact, a template or a number by `q` (2 to 100 characters) - `GET /usage` — what has been consumed, month by month - `GET /billing/summary` — this month: active clients, messages and tool calls against the allowance, estimated total - `GET /api-requests` — your recent calls (30 days, no bodies) - `GET /health` — is everything working - `GET https://nodium.io/status.json` — public status (outside `/api/v1`) ## Sending a message One call sends everything, by **channel and person** — never a conversation id: ``` POST /api/v1/messages channelId string REQUIRED which number speaks (GET /channels) to | recipient string REQUIRED the person: a phone number in E.164 (`to`), or their WhatsApp user id `wa:…` (`recipient`) — one of the two idempotencyKey string REQUIRED your unique key, 8–200 chars (or the `Idempotency-Key` header, same value if both are given) type string REQUIRED text | interactive | location | contacts | template | image | video | audio | document | reaction replyToId string optional quote an earlier message of this conversation (Nodium id) queue boolean optional return 202 "queued" and send within a minute scheduledAt string optional ISO 8601, at least 1 minute ahead → 202 "scheduled" ``` Each `type` takes its own fields (a field that does not belong is refused): ``` text text ≤ 4,096 characters interactive form (+ text) buttons (1–3), list (≤ 10 rows), location_request or flow location location latitude, longitude, name?, address? contacts contacts one contact card: name.formatted_name required template templateName (+ templateLanguage) | templateId, variables, buttons image… mediaId, caption?, voice? the id from POST /media; caption ≤ 1,024 (none for audio) reaction messageId, emoji emoji "" removes; no message is created ``` Nodium finds the conversation for (channel, person), or opens it — a person it has never seen becomes a contact. Returns `{ conversationId, message, duplicate }` (`message` is `null` for a reaction). Same `idempotencyKey` twice with the same request = the first message returned with `duplicate: true`, never a second send; the same key with different content (number, recipient, type, text, template values…) = `409 idempotency_key_conflict`, nothing sent (`queue` and `scheduledAt` are not compared). **A refused send is still HTTP 200/201**: test `message.status` (`failed`); `message.error` is the provider's reason and `message.errorInfo` = `{ code, title, action, actor }` explains it (`actor`: `you` | `client` | `nodium` | `wait`; `null` when it did not fail). `GET /messages/:id` re-reads a message later. **The 24h window**: free-form types (everything but `template` and `reaction`) are refused with `window_closed` (409) more than 24 hours after the person last wrote — and to anyone who never did. Send `type: "template"` with `templateName` (or `templateId`), `variables` (`{ "1": "Karim" }` or `{ "client_name": "Karim" }`), and optionally `buttons`. **Files**: `POST /media` first (multipart `file`, 4 MB at most) → `mediaId`, valid for 7 days and sendable to many people; then `POST /messages` with `type` set to the returned `kind`. Transient refusals from Meta (429, 5xx) are retried by Nodium after 30 s, 2 min and 10 min before `message.failed`. ## Webhooks ### Events `message.received`, `message.imported`, `message.echoed`, `message.edited`, `message.revoked`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `conversation.opened`, `contact.opted_in`, `contact.opted_out`, `template.status_changed`, `channel.connected`, `channel.connect_failed`, `channel.health_changed`, `history.progress`. Subscribe to `*` for all. `message.imported` (the history of a coexistence number) is written to the event log only: it never rings. Payload: `{ "id": "evt_…", "event": "...", "workspace_id": "...", "external_id": "...", "occurred_at": "...", "data": { ... } }` (`external_id`: your id for the client, `null` for "My company"). `message.received` carries `message_id`, `conversation_id`, `contact_id`, `channel_id`, `provider_message_id`, `from`, `profile_name`, `type`, `text`, `reply` (button/list choice), `location`, `media` (`url`, `mime_type`, `filename`), `window_expires_at` (end of the 24 h window), `blocked` (a blocked contact: stored and announced, nothing should answer), and on a reaction `reaction_to` and `emoji`; a change of number arrives as `type: "system"` (`previous_number`, `new_number`). `message.sent` and `message.failed` carry the whole content, the `idempotency_key` and `to`, whoever sent it; `message.failed` adds `error_code` and `error_info` (`{ code, title, action, actor }`). `channel.connected` carries `phone_number_id`. ### Signature Headers: `X-Nodium-Event`, `X-Nodium-Delivery`, `X-Nodium-Signature: t=,v1=`. Verify `HMAC-SHA256(secret, ".")` on the raw body, before JSON parsing. During a rotation two `v1=` values appear (newest first) — accept if any matches. Reject timestamps older than 5 minutes. Deduplicate on the payload `id` (stable across retries and redeliveries). ### Delivery - Answer `2xx` within 10 seconds. - 6 attempts: after 1 min, 5 min, 30 min, 2 h, 12 h; then `failed`. - Ordered per conversation (an event waits for older events of the same conversation). - `isActive: false` pauses (deliveries wait). Nodium pauses a subscription on its own after 50 consecutive failures with nothing delivered for 24 h (`disabledReason`); setting `isActive: true` resumes it. ## MCP (Muse) Each app with `mcpEnabled: true` is served at: ``` POST /api/v1/apps/:id/mcp JSON-RPC 2.0 (Streamable HTTP): initialize, tools/list, tools/call GET /api/v1/apps/:id/mcp 405 (no server stream) DELETE /api/v1/apps/:id/mcp end the session (Mcp-Session-Id) ``` Responses are raw JSON-RPC (no `{ data }` envelope). Every call needs a Bearer token issued by Nodium for that app; without it: `401` with `WWW-Authenticate: Bearer resource_metadata="…"`. OAuth 2.1, run by Nodium: - `GET /.well-known/oauth-authorization-server` - `GET /.well-known/oauth-protected-resource/api/v1/apps/:id/mcp` - `POST /api/oauth/register` — dynamic client registration - `GET|POST /api/oauth/authorize` — PKCE S256 + `resource`, consent screen - `GET /api/oauth/callback` — register it as redirect URI at **your** OAuth provider when the app's `authType` is `oauth2` - `POST /api/oauth/token` · `POST /api/oauth/revoke` End-user sign-in to your service (`authType`): `api_key`, `oauth2` (your provider, PKCE, `authConfig.authorizeUrl`, `tokenUrl`, `clientId`, `scopes`) or `none`. A `tools/call` becomes a direct call to your API: the tool's `operation` (`method`, `path`) under your `apiBaseUrl`, with the arguments placed as its OpenAPI describes, and the end user's own credential placed per `authConfig` (`Authorization: Bearer ` with `oauth2`, the user's key with `api_key`, nothing with `none`) — the assistant never has more rights than the user. The call leaves through the same filter as webhooks (HTTPS, no private address), 30 seconds and 1 MB at most; a non-2xx answer becomes an MCP error result. Every call is logged (`GET /apps/:id/invocations`) and counts against your allowance. ## Clients (multi-tenant) 1. `POST /tenants` with your `externalId` (replay → `409 tenant_exists` with the existing id). 2. `POST /tenants/:id/connect-links` → `url` of a hosted page (`/connect/`), single use, 7 days by default (30 max). Your customer connects their number without a Nodium account, is guided to add a payment method at Meta (recommended, skippable), and lands on `redirectUrl?status=connected&channel_id=…` (or `status=cancelled`). The `channel.connected` webhook fires too. 3. Act inside the client with `Nodium-Tenant: ` and your key, or with a key pinned to it (`POST /keys` with `clientId`). The plan follows connected WhatsApp numbers and volume, for the whole account (clients are free). Build (free): 3,000 messages a month and 1 number; at the allowance it blocks sends with `402 quota_exceeded` (reception continues) and refuses one more number with `402 plan_limit`. Launch and Scale include more messages and numbers, then bill extra numbers and blocks of 1,000 messages, up to the overage cap the account sets. Meta bills its fees to each customer's WhatsApp Business account, with no markup from Nodium. ## Key constraints - **Idempotency key** required on every send. - **Text limits**: 4,096 characters for a text, 1,024 for a file caption — refused at once (`text_too_long`, `caption_too_long`, 422), nothing stored. - **24h window** enforced on free text (`window_closed`, 409). Only a message from the customer reopens it; a template never does. - **Rate limits**: 600 calls/min per client (per key when the key is pinned), 6,000/min per key (`429 rate_limited`, `Retry-After`); 30 free-text replies (text and files)/min per key and per client; templates are not limited by it. - **Marketing opt-out**: a contact who opted out (or Meta error 131050) gets `consent_missing` on marketing templates. - **HTTPS only** for webhook and app URLs. ## Error codes (selection) The full list is in the reference (`/en/docs/api/guide/responses`). - `window_closed` — 24h window closed, send a template - `idempotency_key_required` · `idempotency_key_conflict` (same key, different content) - `no_recipient` · `not_whatsapp` · `contact_blocked` · `consent_missing` - `template_required` · `template_not_approved` · `template_variables_invalid` · `template_holes_unfilled` · `template_not_applicable` · `template_locked` · `template_category_locked` · `template_edit_refused` · `template_exists` - `file_missing` · `file_too_large` · `empty_file` · `unsupported_file_type` · `file_type_refused` · `caption_unsupported` - `channel_required` · `unknown_channel` · `ambiguous_channel` · `no_channel` - `webhook_url_not_https` - `plan_limit` · `quota_exceeded` · `rate_limited` - `tenant_required` · `tenant_invalid` · `tenant_not_found` · `tenant_exists` · `all_clients_required` - `link_not_found` · `link_used` · `link_expired` · `link_closed` - `sandbox_unavailable` · `sandbox_phone_not_linked` · `sandbox_daily_limit` --- # The Nodium API guide ## Get started Source: https://nodium.io/en/docs/api/guide/start Nodium connects your backend to WhatsApp and Muse. From nothing to a first message sent and answered, in three calls. Nodium is a gateway: it carries messages between WhatsApp (and Muse, Meta's assistant) and your backend. It never answers for you — **your code decides every reply**. Inbound messages reach you by **webhook**; you answer through the **API**. - **Base address**: `https://nodium.io/api/v1`. Every call is JSON over HTTPS, authenticated with one key: `Authorization: Bearer nod_…`. - **Answers** come as `{ "data": … }` or `{ "error": { "code", … } }`. Branch on `error.code`, never on the text. - **Limits**: 600 calls per minute per client for a key that acts on every client of your account (6,000 per key at most); 600 per minute for a key pinned to one client. Reported in `X-RateLimit-*` headers. - **Reference**: every route, field and error code is in the **Reference** section, generated from the code. The OpenAPI document is at `/docs/api/openapi.json`, an index for agents at `/docs/api/llms.txt`. ### Try it without a number No number of your own, no Meta verification, no template to approve: the **sandbox** is a WhatsApp number shared by Nodium. Link your own phone to your account by sending it a code, then send and receive like on a real number — `message.received` included. Three calls, a few minutes. ```bash # 1. Your sandbox: the shared number and your join code curl https://nodium.io/api/v1/sandbox \ -H "Authorization: Bearer $NODIUM_KEY" # Open data.waLink on your phone (or send data.joinMessage, "join ", to # data.displayPhone): your phone is linked and the 24-hour window opens. # 2. Read the sandbox again: data.channelId is now set curl https://nodium.io/api/v1/sandbox \ -H "Authorization: Bearer $NODIUM_KEY" # 3. Write to your phone in free text, on the sandbox channel curl https://nodium.io/api/v1/messages \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "channelId": "", "to": "", "type": "text", "text": "Hello from Nodium", "idempotencyKey": "sandbox-hello-1" }' ``` Limits: only linked phones receive, only inside the 24-hour window, free text only (no templates), and a daily number of sends per account. The sandbox needs a key that sees every client of your account, and is open only when the server has enabled it (`data.available`). When you are ready, connect your own number (below) and send templates. ### Before the first call - Create an account: it comes with a first client, **My company**, and an app. Nothing else is required before the first call. - Create an API key in the console, under **Settings › API keys**, or with `POST /keys`. It is shown once. In TypeScript, install the SDK: `npm i @nodium.io/whatsapp`, then `new Nodium(process.env.NODIUM_KEY)` (see **TypeScript SDK**); the `nodium` command of `@nodium.io/cli` replays your webhooks to your own machine (see **Webhooks**). From any other language, call the API directly: the OpenAPI document carries a code sample for every route. - To send and receive on your own number, connect one through Meta's signup window, from the hosted connect link. The number belongs to you or your customer; Nodium does not provide numbers. With **coexistence**, the WhatsApp Business app keeps working on the same number. Until then, the sandbox above is enough to try everything in free text. - Register a webhook address (console, or `POST /webhooks`) so Nodium can tell you when a customer writes. - Building for many customers? Read **Your clients** first: each customer is a client of your account and connects their own number from a hosted link. ### Three calls, on your own number ```bash # 1. Which numbers can I send from? curl https://nodium.io/api/v1/channels \ -H "Authorization: Bearer $NODIUM_KEY" # 2. Write first to a customer: on WhatsApp, an approved template curl https://nodium.io/api/v1/messages \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "channelId": "", "to": "+33612345678", "type": "template", "templateName": "order_ready", "templateLanguage": "en", "variables": { "1": "Karim" }, "idempotencyKey": "order-1042-confirmation" }' # 3. The customer answers: you receive message.received on your webhook. # Reply in free text, within 24 hours: curl https://nodium.io/api/v1/messages \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "channelId": "", "to": "+33612345678", "type": "text", "text": "Yes, it is ready.", "idempotencyKey": "order-1042-reply-1" }' ``` That is the whole loop: list your channels, write first with an approved template, receive the answer by webhook, reply in free text. ### Where to look next - **TypeScript SDK** — `@nodium.io/whatsapp` and the `nodium` command: install, send, receive, Flows, errors. - **Concepts** — account, client, channel, conversation, contact, template, webhook: how they fit. - **Authentication** — the account key, roles, expiry, the client header, limits and quota. - **Responses and errors** — the `data` / `error` envelope, pagination. - **The 24-hour window** — the WhatsApp rule that decides what you may send. - **Sending** — idempotency keys, the queue, retries. - **Webhooks** — events, numbers, signatures, retries, ordering. - **Flows** — WhatsApp forms, static or backed by your own endpoint. - **Coexistence** — a number that keeps the WhatsApp Business app. - **Running your numbers** — statistics, media, contact export, business profile, connect links, search. - **Muse (MCP)** — publish your app as a hosted MCP server. - **Your clients** — one client per end customer, connected from a hosted link. ## TypeScript SDK Source: https://nodium.io/en/docs/api/guide/sdk Two npm packages: the library to call the API and receive its webhooks, and the nodium command to develop locally. The SDK wraps this API for TypeScript and JavaScript. It describes only routes that exist, with their types; from any other language, call the API directly — every route of the **Reference** carries a curl, Node.js and SDK sample. - `@nodium.io/whatsapp` — the library: every resource of the API (`messages`, `templates`, `flows`, `broadcasts`, `tenants`, `webhooks`, `events`…). No runtime dependency: the native `fetch` of Node 20+, Bun, Deno and browsers. ESM and CommonJS, types included. MIT licensed. - `@nodium.io/whatsapp/server` — what needs `node:crypto` and runs on your server only: verifying webhooks (`verifyWebhook`) and answering dynamic Flows (`receiveFlowRequest`, `respondToFlow`, `flowError`, `completeFlow`). Kept apart so the main entry also runs in browsers and edge functions. - `@nodium.io/cli` — the `nodium` command: replays your events to your local server, signed like production, and sends a message from the terminal. ### Install ```bash npm install @nodium.io/whatsapp # the library npm install -g @nodium.io/cli # the nodium command (optional) export NODIUM_KEY=nod_… # Settings › API keys in the console ``` ### A first message ```ts import { Nodium } from '@nodium.io/whatsapp' const nodium = new Nodium(process.env.NODIUM_KEY!) const [channel] = await nodium.channels.list() const { message } = await nodium.messages.send({ channelId: channel.id, to: '+33612345678', type: 'template', // a first message is always an approved template templateName: 'order_ready', variables: { 1: 'Karim' } }) if (message?.status === 'failed') console.error(message.errorInfo?.title) ``` - `messages.send` is the one way to send: `type` picks the kind (`text`, `template`, `interactive`, `location`, `contacts`, a file by `mediaId`) and its fields; `to` is a number in E.164 form, or `recipient` a `wa:…` id for a contact who shows no number. - It always carries an idempotency key: pass your own `idempotencyKey`, or the SDK draws one, fixed for the call, so a retry can never send twice. - **A refused send is not an exception**: the call resolves with `message.status === "failed"`, and `message.errorInfo` says what happened, what to do and who acts. ### Your clients ```ts const client = await nodium.tenants.create( { name: 'Agence Horizon', externalId: 'crm-4187' }, { connectLink: { redirectUrl: 'https://app.example.com/whatsapp' } } ) console.log(client.connectLink.url) // send it to your customer: shown once const horizon = nodium.as('crm-4187') // adds Nodium-Tenant to every call await horizon.messages.send({ channelId, to: '+33612345678', type: 'text', text: 'Hello' }) ``` Without `as()`, calls act on **My company** — or on the client a key is pinned to. `as()` takes Nodium's id or your own `externalId`. See **Your clients**. ### Receive webhooks ```ts import express from 'express' import { NodiumSignatureError, verifyWebhook } from '@nodium.io/whatsapp/server' app.post('/webhooks/nodium', express.raw({ type: 'application/json' }), (req, res) => { try { const event = verifyWebhook(req.body, req.headers, process.env.NODIUM_WEBHOOK_SECRET!) if (event.event === 'message.received') handle(event.external_id, event.data) res.sendStatus(200) // event.id is the same on every redelivery } catch (error) { if (error instanceof NodiumSignatureError) return res.sendStatus(400) throw error } }) ``` Verify the **raw** body, never a re-serialised one. During a secret rotation, pass both secrets as an array. Calls older than 5 minutes are refused. Events are typed: `NodiumEvent` is a union on `event`. See **Webhooks**. ### Answer a dynamic Flow ```ts import express from 'express' import { completeFlow, flowError, receiveFlowRequest, respondToFlow } from '@nodium.io/whatsapp/server' app.post('/whatsapp/flow', express.raw({ type: 'application/json' }), async (req, res) => { const call = receiveFlowRequest(req.body, req.headers, process.env.NODIUM_FLOW_SECRET!) // call.action: 'INIT' | 'data_exchange' | 'BACK' · call.screen · call.data · call.flowToken if (call.action === 'INIT') return res.json(respondToFlow({ screen: 'SLOTS', data: { days: await nextDays() } })) if (call.screen === 'SLOTS') { const slots = await freeSlots(call.data.date as string) if (!slots.length) return res.json(flowError('SLOTS', 'No slot left that day.')) return res.json(respondToFlow({ screen: 'CONFIRM', data: { slots } })) } res.json(completeFlow(call.flowToken, { booked: true })) // closes the Flow }) ``` Nodium decrypts Meta's calls and relays them signed with the secret of the Flow's address (`flows.setDataEndpoint`); your JSON answer is encrypted back for Meta. Answer within about 9 seconds. `completeFlow` closes the form; the answer then reaches your webhook as `message.received` with `reply.kind: "flow"`. See **Flows**. ### Pages, retries and errors - A list that takes `limit` and `cursor` returns `{ items, nextCursor, hasMore }` and has an `each()` that walks every page: `for await (const tenant of nodium.tenants.each())`. - 429 and 5xx are retried (`maxRetries`, default 2) after `Retry-After` or an exponential backoff. **A write without an idempotency key is never retried.** - Every refusal is a `NodiumError`: `status`, `code`, `detail`, `requestId`, `retryAfter`, `data`, and `retry` — `retry`, `retry_after`, `fix_and_retry`, `upgrade` or `do_not_retry`. `isAuthError()`, `isRateLimit()`, `isNotFound()`, `isNetworkError()` and `isPlanLimit` answer the usual questions. - Options: `new Nodium(key, { baseUrl, fetch, timeoutMs, maxRetries })` — defaults `https://nodium.io/api/v1`, the global `fetch`, 30 seconds, 2 retries. ```ts import { NodiumError } from '@nodium.io/whatsapp' try { await nodium.tenants.create({ name: 'Horizon', externalId: 'crm-4187' }) } catch (error) { if (!(error instanceof NodiumError)) throw error if (error.code === 'tenant_exists') return error.data.tenantId // branch on code, never on the text if (error.retry.action === 'retry_after') await sleep(error.retry.retryAfterMs!) if (error.isPlanLimit) notifyBilling(error.requestId) } ``` ### A route the SDK does not wrap yet ```ts // A route the SDK does not wrap yet: same key, same client header, same retries. const encryption = await nodium.as('crm-4187').request({ method: 'GET', path: '/flows/encryption' }) ``` `request()` returns the content of `data`. A write is retried only when you give it an `idempotencyKey`. ### Develop locally `npx @nodium.io/cli listen --forward http://localhost:3000/webhook --secret whsec_…` reads your event log and calls your local server with real, signed deliveries — no tunnel, no public address. See **Webhooks**. ### Versions The packages follow semantic versioning: a patch fixes, a minor adds, a major changes what exists. Their README on npm lists every method: `npmjs.com/package/@nodium.io/whatsapp` and `npmjs.com/package/@nodium.io/cli`. ## MCP server Source: https://nodium.io/en/docs/api/guide/mcp Operate WhatsApp from an AI agent: Claude Code, Cursor or your own agent calls Nodium through the Model Context Protocol, with your API key. Nodium serves its own MCP server at `https://nodium.io/api/v1/mcp`. An agent connected to it reads everything (numbers, conversations, contacts, templates, broadcasts, clients, the journal), sends messages one person at a time and keeps contacts up to date — as tools, without writing an HTTP client. It is the API, nothing more: **each tool calls the matching route with your key**, so roles, client isolation, quotas, rate limits and the request log apply exactly as they do to your code. Not to be confused with **Muse (MCP)**: there, Nodium serves *your* API to Meta's assistant, at `/api/v1/apps/{id}/mcp`, with OAuth for your end users. Here, *your* agent operates *Nodium*, with your account's key. ### Connect an agent ```bash # Claude Code claude mcp add --transport http nodium https://nodium.io/api/v1/mcp \ --header "Authorization: Bearer $NODIUM_KEY" # Acting for one of your clients by default (the tools can still name another) claude mcp add --transport http nodium-crm-4187 https://nodium.io/api/v1/mcp \ --header "Authorization: Bearer $NODIUM_KEY" --header "Nodium-Tenant: crm-4187" ``` ```json // .cursor/mcp.json, or the "mcpServers" block of any MCP client { "mcpServers": { "nodium": { "url": "https://nodium.io/api/v1/mcp", "headers": { "Authorization": "Bearer nod_…" } } } } ``` - **No mass or configuration writes.** Broadcasts, templates, clients and connect links are not tools: an agent reads messages written by anyone, and an instruction slipped into one of them must never trigger a send to everyone or change your account. Those stay with the API, called by your code. - **The key decides what the agent may do.** Give an agent that answers customers an `agent` key (read and send) and a reporting agent a `member` key (read-only). Tools the key cannot use are not even listed. - **A key pinned to one client** (`POST /keys` with `clientId`) confines the agent to that client, whatever it asks. - **Keep the key out of prompts and repositories**: put it in the environment of the agent, as above. ### The tools - **Numbers**: `list_channels`, `get_channel`, `diagnose_channel`. - **Conversations and messages**: `list_conversations`, `get_conversation`, `list_messages`, `get_message`, `send_message`, `mark_read`. - **Contacts**: `list_contacts`, `get_contact`, `update_contact`. - **Templates and Flows**: `list_templates`, `get_template`, `list_flows`. - **Broadcasts**: `list_broadcasts`, `get_broadcast`, `preview_broadcast` (who it would reach; sends nothing). - **Clients**: `list_clients`, `get_client`. - **Everything else**: `search`, `read_journal`, `get_billing_summary`. Each tool takes the fields of its route — its schema and description are drawn from this reference — plus an optional `tenant`: the client to act on, by Nodium id or your `externalId`. Without it, the `Nodium-Tenant` header of the connection applies, then the key's default client. Reads are marked read-only for the agent; writes are not. ### What the agent receives - A success returns the content of `data`, as JSON text. A refusal returns `isError: true` with the API's `{ "error": { "code", "message", … } }`: the agent branches on `code` like your code does (`window_closed` → send a template; `rate_limited` → wait). - `send_message` needs your own `idempotencyKey` (8 to 200 characters): the agent reuses it when it retries, never for a new message. - A long answer is cut at 100,000 characters with a note: ask for fewer items (`limit`) or the next page (`cursor`). ### Protocol - Streamable HTTP, revisions `2025-06-18` and `2025-03-26`: `initialize`, `ping`, `tools/list`, `tools/call`. POST only — no server stream, and no session (`Mcp-Session-Id` is never issued): every call carries the key. - Without a valid key: `401` with a JSON-RPC error. A call from a web page (an `Origin` other than Nodium's) is refused: a key never lives in a browser. - 600 calls per minute per address on the endpoint, then the usual limits of each route (600 per minute per client, 6,000 per key). ### Without an MCP client ```bash curl -X POST https://nodium.io/api/v1/mcp \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2025-06-18" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "send_message", "arguments": { "channelId": "4c1d…", "to": "+33612345678", "type": "text", "text": "Your order has shipped.", "idempotencyKey": "order-4187-shipped", "tenant": "crm-4187" } } }' # → { "jsonrpc": "2.0", "id": 1, # "result": { "isError": false, "content": [{ "type": "text", # "text": "{\"conversationId\":\"…\",\"message\":{\"status\":\"sent\",…},\"duplicate\":false}" }] } } ``` ## Concepts Source: https://nodium.io/en/docs/api/guide/concepts Seven words explain the whole API. - **Account** — you: the publisher or company that pays Nodium. Your plan, your API keys and the templates you write once belong to the account. - **Client** (also called a tenant or workspace) — one of your customers, isolated from the others: its own numbers, conversations, contacts, webhooks and Flows. **My company** is your own, the first one. `externalId` is your own identifier for a client, and works wherever a client id is expected. - **Channel** — a WhatsApp number connected to a client. `GET /channels` gives you the `channelId` every first message needs. - **Contact** — a person who wrote to, or was written to from, a channel. Identified by phone number; it carries the marketing consent (`optInAt`, `optOutAt`). - **Conversation** — one contact on one channel: the thread, with its 24-hour `window` and the delivery receipts of each message. - **Template** — a message pre-approved by Meta. It belongs to one client and is deployed to the WhatsApp accounts of its numbers; it is the only way to write first, or to write after the window closed. - **Webhook** — your subscription to events (`message.received`, `message.failed`…). One on **My company** receives the events of every client, each payload naming its client in `workspace_id`. ### How they fit ```text account (plan, API keys, templates) └─ client (My company, Optique Martin, …) ← header Nodium-Tenant ├─ channel a WhatsApp number │ └─ conversation one contact, 24-hour window │ └─ message in / out, status ├─ contact ├─ webhook subscription └─ Flows, broadcasts, media ``` ### Which client does a call act on? - A key belongs to the **account**. Each call acts on **one client**: the one the key is pinned to, otherwise the one named by the `Nodium-Tenant` header, otherwise **My company**. - Something that belongs to another client, or to another account, answers **404**, never 403. - What you define once for the account (keys, your plan) is read across all clients. What a client operates (numbers, messages, templates, webhooks, Flows) is read for the client the call acts on. ### A message's life - **In**: WhatsApp → Nodium → `message.received` on your webhook → your code decides. - **Out**: your code → `POST /messages` (a channel and a person, never a conversation id) → WhatsApp. Its status moves `queued` → `sent` → `delivered` → `read`, each step a webhook, or `failed` with the reason. - Nodium never sends anything you did not ask for, and never decides what to say. ## Authentication Source: https://nodium.io/en/docs/api/guide/auth One key for your account, one role. The key acts as itself. ```http Authorization: Bearer nod_a1b2c3d4e5_… ``` - **A key belongs to your account.** It acts on one client per call: the one it is pinned to, otherwise the one named by the `Nodium-Tenant` header, otherwise your own company (**My company**). Anything outside your account answers `404`, never `403`. - **A key acts as itself**, not as the person who created it. Messages it sends carry the key's label. Removing that person does not revoke the key. - **A key never creates a key more powerful than itself.** Keys are created in the console or with `POST /keys`; the secret is shown once, and Nodium keeps only its hash. A revoked key is refused on its very next call. - A key never expires unless you say so: `expiresInDays` (1 to 3650) in `POST /keys` fixes an end date, returned as `expiresAt`. Past it, the key answers `401` with `api_key_expired` and shows as inactive. Rotate by creating a new key, deploying it, then revoking the old one. ```bash curl https://nodium.io/api/v1/keys \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "label": "Billing system", "role": "agent", "expiresInDays": 90 }' # → data.key.expiresAt, data.token (shown once) # Past that date the key answers 401 with the code api_key_expired. ``` ### Roles - `admin` — **full**: everything below, plus webhooks, templates, channels, broadcasts, apps and keys. - `agent` — **send-only**: read, send messages, react, keep contacts up to date. Nothing else. - `member` — **read-only**: conversations, messages, contacts. - Each route of the reference states the role it needs. ### Choosing the client - Add `Nodium-Tenant: ` to any call to act on that client. Without it, the call acts on the key's client, or on My company. - Pin a key to one client with `clientId` in `POST /keys`: it can never leave that client, and a header naming another one answers `404`. - Only an `admin` key that is not pinned sees every client: it alone manages `/tenants` and `/keys`. - See **Your clients**. ### Limits 600 calls per minute per client for a key that acts on every client of your account (the client is named by `Nodium-Tenant`), with a ceiling of 6,000 per key; 600 per minute for a key pinned to one client. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. Above the limit you get `429` with `rate_limited` and a `Retry-After` header in seconds. Free-form messages (`POST /messages`, every `type` except `template`) have their own limit, 600 per minute per key and per client; templates do not. A key that acts on several clients has its own 600 for each of them. To send in volume without waiting on each call, use `queue: true` (see **Sending**). ### Quota Messages sent and received (templates included) are counted together across your whole account, from the plan's monthly allowance. Receipts and test channels do not count. The allowance resets on the 1st of the month at midnight UTC. On the free plan (Build) sends are blocked once it is used up: they answer `402` with `quota_exceeded`, without any earlier warning; messages you receive still come in. On a paid plan each extra block of 1,000 messages is billed, up to an overage cap you set in your billing space; at the cap sends stop with the same `402`. Phone numbers and stored media have their own allowance: a number beyond it is billed on a paid plan and refused (`402` with `plan_limit`) on Build, and media beyond the storage allowance is refused on Build (`402` with `storage_quota`). `GET /billing/summary` shows the month so far. Meta's own fees are never included: your clients pay them to Meta. ### Your calls, logged Every call made with a key over the last 30 days is listed in the console and by `GET /api-requests`: address, status, error code, duration, request id. No body, header or query string is kept. ### The journal `GET /journal` is one timeline of everything on your account: messages, webhook deliveries, tool calls from assistants and API calls, with the reason and a plain explanation when a line failed (Meta's error code, translated). Filter by `kind`, `client`, `status=failed` or period. `GET /events/stream` streams new lines as they happen (Server-Sent Events; it closes after about 50 seconds: reconnect with the last event id). ## Responses and errors Source: https://nodium.io/en/docs/api/guide/responses Every answer has one of two shapes. Test `error.code`, never the text. ### Success: data ```http HTTP/1.1 200 OK X-RateLimit-Limit: 600 X-RateLimit-Remaining: 598 X-RateLimit-Reset: 1790000060 { "data": { "items": [ { "id": "9c1f…", "status": "open" } ], "nextCursor": "MjAyNi0wOS0xOFQw…", "hasMore": true } } ``` ### Refusal: error ```http HTTP/1.1 422 Unprocessable Entity { "error": { "status": 422, "code": "recipient_invalid", "message": "`to` must be a number in international format (E.164), `recipient` a WhatsApp user id `wa:…`, and never both.", "detail": "`to` must be a number in international format (E.164), for example +33612345678.", "param": "to", "doc_url": "https://nodium.io/en/docs/api/guide/responses#recipient_invalid", "requestId": "a1b2c3d4" } } ``` - `code` is stable and documented below: test it, never the text. `message` is the same English sentence for a given code. - `detail` is the specific reason for this call, in English. A few refusals not translated yet still carry a French `detail`; `code` and `message` are always English. Send `Accept-Language: fr` to ask for French where a translation exists. For `rate_limited`, `quota_exceeded` and `plan_limit`, `detail` is `null`: `message` and `data` carry everything. - `param` names the field of the body or query that was refused (`to`, `type`, `location.latitude`, `variables.1`), or is `null` when the refusal is not about one field. It is set on the `422` refusals of sending, and on most of the other validated fields. - `doc_url` links to the line of this page that explains `code`; it is `null` for a code that is not listed below. - `requestId` identifies the call in our logs and in `GET /api-requests`: quote it if you need help. - Some refusals carry more facts in `data` — `template_holes_unfilled` lists the unfilled placeholders, `tenant_exists` gives the existing client id, `window_closed` the state of the window. ### Lists and pagination A list answers `data.items`. A list that pages takes `limit` and `cursor` and also returns `nextCursor` (`null` on the last page), `hasMore`, and `total` on the first page when the list counts. Pass `nextCursor` back as `cursor` until it is `null`; cursors are opaque, do not build them. A list returned whole (channels, API keys, Flows…) carries only `items`, and its description says how many it holds at most. ```js let cursor do { const url = new URL('https://nodium.io/api/v1/conversations') url.searchParams.set('limit', '100') if (cursor) url.searchParams.set('cursor', cursor) const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.NODIUM_KEY}` } }) const body = await res.json() if (body.error) throw new Error(`${body.error.code}: ${body.error.message}`) for (const conversation of body.data.items) handle(conversation) cursor = body.data.nextCursor } while (cursor) ``` Three routes keep a shape of their own: `GET /events` answers `data` and `next_after` (a position in the numbered event log, not an opaque cursor), `GET /templates` answers `templates` and `summary`, and `GET /webhooks` answers `events`, `subscriptions` and `deliveries`. ### Field names REST requests and responses use camelCase (`channelId`, `nextCursor`). Webhook payloads and entries of the event log use snake_case (`workspace_id`, `occurred_at`, `error_code`), and so does `next_after`: they are the same event envelopes, with a contract of their own. Within one family a field is always spelled the same way; the two families are not translated into each other. ### Error codes Every code below is an anchor of this page: the `doc_url` of a refusal points to its line. Every error code, with its meaning: https://nodium.io/en/docs/api/guide/responses ## The 24-hour window Source: https://nodium.io/en/docs/api/guide/window WhatsApp decides what you may send. Nodium tells you before Meta refuses. On WhatsApp you may send **free text** — plain messages, buttons, lists — only within 24 hours of the customer's last message. Outside that window, only an **approved template** goes through. This is Meta's rule; Nodium enforces it before sending, so a message never looks sent when it was not. - Every conversation carries `window`: `open`, `expiresAt`, `minutesLeft`. Read it before choosing between text and template. - Free text outside the window is refused with `window_closed` (409). Send a template instead: `POST /messages` with `type: "template"`. - A template never reopens the window. Only a message from the customer does. - Writing first to someone who never wrote to you is always outside the window: it must be a template. ### Marketing consent A customer can tap **Stop** under a marketing message. Nodium records it — also when Meta refuses a send for that reason (error 131050) — sets `optOutAt` on the contact, emits `contact.opted_out`, and refuses further **marketing** templates to them with `consent_missing`. Utility and authentication templates still go through. You can also set consent yourself with `PATCH /contacts/{id}`. ## Sending Source: https://nodium.io/en/docs/api/guide/sending Every send carries your own key against duplicates. For volume, queue. ### idempotencyKey Every send requires an idempotency key, 8 to 200 characters, chosen by you and unique per message — an order number and a step, for example. Give it as the `idempotencyKey` field of the body, or as the `Idempotency-Key` request header (the usual place for it); the same rules apply to both. Give it in one place, or in both with the same value: two different values are refused with `422 invalid_request`. Sending twice with the same key sends once: the second call returns the first message with `duplicate: true`, even if the window closed in between. Retry freely after a timeout — with the same request: the same key used for different content (another number, type, text or template values) is refused with `409 idempotency_key_conflict`, and nothing is sent. `queue` and `scheduledAt` do not count as content. Use a new key for a new message. ### What you can send - `POST /messages` — everything goes through this one call, by `channelId` and the person (`to`, a phone number in E.164 form, or `recipient`, their WhatsApp user id `wa:…`). Nodium finds their conversation, or opens it. `type` chooses: `text`; `interactive` (`form`: up to 3 buttons, a list of up to 10 rows, a location request, or a **Flow** that opens a WhatsApp form — see **Flows**); `location` (a pin); `contacts` (one contact card); `template` (an approved template, inside or outside the window); `image`, `video`, `audio`, `document` (a file, by `mediaId`); `reaction` (`messageId`, `emoji`). `replyToId` quotes an earlier message of the conversation. - `POST /media` — upload a file (multipart, 4 MB at most) and get a `mediaId`, valid for 7 days, that `POST /messages` cites. The same `mediaId` can be sent to many people. - `POST /conversations/{id}/read`, `/typing` — a read receipt, the typing indicator. - `POST /broadcasts` — one template to many contacts; `POST /broadcasts/preview` first, `POST /broadcasts/{id}/cancel` to stop it. The number's messaging tier is honoured, and a broadcast whose sends are refused over and over for an account reason is paused (`status: "paused"`): fix the cause, then `POST /broadcasts/{id}/resume`. ### queue and scheduledAt By default a send waits for WhatsApp to accept the message and returns its status. With `queue: true` it returns at once with HTTP `202` and `status: "queued"`; the message leaves within a minute. With `scheduledAt` (ISO 8601, at least one minute ahead) it is stored as `scheduled` and leaves at that time. Follow it with the `message.sent`, `message.delivered`, `message.read` and `message.failed` webhooks. ```bash curl https://nodium.io/api/v1/messages \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "channelId": "", "to": "+33612345678", "type": "template", "templateId": "", "variables": { "1": "Karim" }, "idempotencyKey": "campaign-42-contact-981", "queue": true }' # → 202 Accepted, "status": "queued". It leaves within a minute; # message.sent or message.failed tells you how it went. ``` ### Retries When Meta answers with a transient refusal (rate limit or server error), Nodium retries on its own after 30 seconds, 2 minutes and 10 minutes before marking the message `failed` and emitting `message.failed`. A permanent refusal fails at once, with the reason in `error`. ### A refused send is still HTTP 200 When WhatsApp refuses a message, the call itself succeeded: `POST /messages` answers `200` (or `201`) with `message.status: "failed"`, not an HTTP error. **Test `message.status`, not only the HTTP code.** `message.error` is the provider's reason, and `message.errorInfo` explains it: `title` says what happened, `action` what to do, and `actor` who acts — `you` (your code or console), `client` (the owner of the number: `action` is written to be passed on to them as is), `nodium`, or `wait` (the refusal is temporary). `errorInfo` is `null` when the message did not fail, or failed without a provider code. The `message.failed` webhook carries the same as `error_info`. ```json { "data": { "conversationId": "7c1e4a90-0000-4000-8000-000000000002", "message": { "id": "b1a4e7c0-0000-4000-8000-000000000001", "status": "failed", "error": "Meta refused: payment method missing (code 131042)", "errorInfo": { "code": 131042, "title": "Missing or declined payment method on your client's WhatsApp account: Meta blocks paid messages.", "action": "Add a valid payment method to your WhatsApp Business account in WhatsApp Manager, under payment methods. Messages will then go out normally.", "actor": "client" } }, "duplicate": false } } ``` To check a message later without listing its conversation, `GET /messages/{id}` returns it as it stands: its current `status`, `errorInfo` and ids. Prefer the webhooks to polling it. ### Statuses - `queued`, `scheduled` — waiting to leave. - `sending` — being handed to WhatsApp. - `sent` — WhatsApp accepted it. - `delivered`, `read` — reported back by WhatsApp. A later status never goes backwards. - `failed` — refused, now or later; `error` says why and `errorInfo` explains it. ### Template placeholders A template placeholder is numbered (`{{1}}`) or named (`{{client_name}}`). Fill it in `variables` by the same key: `{ "1": "Karim" }` or `{ "client_name": "Karim" }`. A placeholder left empty is refused with `template_holes_unfilled`. A template is written for the client the call acts on with `POST /templates`, then deployed to the WhatsApp accounts of that client's numbers with `POST /templates/{id}/deploy` (numbers connected later receive it on their own). Meta reviews it account by account: `POST /templates/{id}/refresh` re-reads the states, and every status or category change arrives as `template.status_changed`. To send, the name is enough: Nodium looks it up at the client the conversation belongs to and picks the version approved for the number that sends. ### Received media A received file comes with `media.url` in `message.received`; download it with `GET /messages/{id}/media` and your key. A voice note arrives as an audio file: your code listens to it or passes it on. ## Webhooks Source: https://nodium.io/en/docs/api/guide/webhooks Nodium calls you when something happens. Signed, retried, in order, logged. ### What you receive ```json { "id": "evt_5f1c0a9e2b7d4c3a8e6f1b2c3d4e5f60", "event": "message.received", "workspace_id": "…", "external_id": "crm-4187", "occurred_at": "2026-09-28T09:12:44.120Z", "data": { "message_id": "…", "conversation_id": "…", "contact_id": "…", "channel_id": "…", "channel_type": "whatsapp", "from": "+33612345678", "profile_name": "Karim", "type": "text", "text": "Hello, is my order ready?", "media": null, "window_expires_at": "2026-09-29T09:12:44.000Z" } } ``` - `id` is the event's own id: it stays the same when a delivery is retried or redelivered. **Deduplicate on it.** - Headers: `X-Nodium-Event` (the event name), `X-Nodium-Delivery` (this delivery), `X-Nodium-Signature`. - Answer with any `2xx` within 10 seconds. Do the work afterwards. - **Ordered per conversation**: an event waits until the older ones of the same conversation are delivered. Template and channel events do not wait. - `workspace_id` names the client the event comes from, `external_id` is your own id for it (`null` for **My company**). A subscription on **My company** receives every client's events; one created with `Nodium-Tenant` or a pinned key receives that client's only. - **The webhook is a doorbell, the event log is the truth.** Every event is also written to a numbered log, kept 30 days: `GET /events?after=` returns the same envelopes (plus `seq`), in order, none skipped. Start without `after` to get the head, then read from `next_after`. A delivery that never arrived costs you nothing. The history imported from a coexistence phone (`message.imported`) is in the log only: it never rings. ### Events Subscribe to the ones you need with `events` (`*` means all). Open an event to see its fields and a sample payload. `data.channel_id` tells which number it concerns. Every webhook event, with its fields and an example: https://nodium.io/docs/api/webhook-events.json ### Choose the numbers By default a subscription receives the events of every number of its client. Pass `channelIds` to `POST /webhooks` (or change it with `PATCH /webhooks/{id}`) to receive the events of those numbers only; an empty list means all numbers again. An event that belongs to no number — a template status, a consent change — is not delivered to a subscription limited to some numbers. A channel that is not yours is refused with `422`. ```bash # Only the events of two numbers curl https://nodium.io/api/v1/webhooks \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/hooks/nodium", "events": ["message.received", "message.failed"], "channelIds": ["", ""] }' ``` ### Verify the signature `X-Nodium-Signature: t=,v1=`. The signature is `HMAC-SHA256(secret, ".")`. During a secret rotation the header carries **two** `v1=` values, newest first — accept the call if **any** matches. Reject calls older than 5 minutes. With the TypeScript SDK, one call: `verifyWebhook(rawBody, headers, secret)` from `@nodium.io/whatsapp/server` checks the signature and the delay and returns the typed event (see **TypeScript SDK**). Without it: ```js import crypto from 'node:crypto' // rawBody: the request body exactly as received, before any JSON parsing. export function verifyNodium(rawBody, header, secret) { const parts = header.split(',') const t = parts.find(p => p.startsWith('t='))?.slice(2) const signatures = parts.filter(p => p.startsWith('v1=')).map(p => p.slice(3)) if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex') return signatures.some(sig => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) } ``` ```python import hmac, hashlib, time def verify_nodium(raw_body: bytes, header: str, secret: str) -> bool: parts = header.split(",") t = next((p[2:] for p in parts if p.startswith("t=")), None) signatures = [p[3:] for p in parts if p.startswith("v1=")] if not t or abs(time.time() - int(t)) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(sig, expected) for sig in signatures) ``` ### Develop locally without a tunnel A webhook address must be a public `https` URL, which your laptop is not. Instead of a tunnel, let the `nodium` command (`@nodium.io/cli`) fetch your events and call your local server: each call carries the body and the headers of a real delivery, signed with the secret you give, so your verification code runs as in production. ```bash export NODIUM_KEY=nod_… npx @nodium.io/cli listen \ --forward http://localhost:3000/webhook \ --secret whsec_… \ --events message.received,message.failed # 10:32:01 message.received 200 12 ms evt_5f1c0a9e… # 10:32:07 message.failed 500 4 ms evt_8c2d41b7… # Try it: send a message to your own phone, then answer it. npx @nodium.io/cli send --channel --to +33612345678 --text "Hello" ``` - It reads the event log (`GET /events`) from a cursor, so a dropped connection loses nothing. `Ctrl+C` stops it and prints the number to resume from (`--from`). The key needs the `admin` role. - It needs no webhook subscription: Nodium calls nothing, the command asks. `--secret` can be the secret of a subscription or any string your code also verifies with; `--events` filters, `--tenant` names one client. - A call your server answers with a non-`2xx` status, or not at all, is shown and not retried. `message.imported` is replayed only when `--events` names it, as it never rings in production. - Without the command, do the same in any language: read `GET /events?after=` in a loop and call your own handler. ### Retries, log, redelivery, pause - A call that fails or does not answer `2xx` is retried after 1 min, 5 min, 30 min, 2 h and 12 h — six attempts in all — then marked `failed`. - `GET /webhooks/deliveries` lists every delivery with the status your server answered and the error. That settles "we never received it". - `POST /webhooks/deliveries/{id}/redeliver` sends it again, same payload, same `id`. - `POST /webhooks/{id}/rotate-secret` issues a new secret; the old one keeps signing for 24 hours so you can deploy without missing a call. - `POST /webhooks/{id}/test` fires one signed test call now and tells you what came back. - `PATCH /webhooks/{id}` with `isActive: false` pauses a subscription: deliveries wait, and resume when you set it back to `true`. After 50 failed attempts in a row with nothing delivered for 24 hours, Nodium pauses it on its own and says why in `disabledReason`. ## Flows Source: https://nodium.io/en/docs/api/guide/flows WhatsApp forms: draw them in JSON, publish them at Meta, open them from a button in the conversation. A **Flow** is an interactive form that opens inside WhatsApp: several screens, fields, choices. It is drawn as JSON (Meta's Flow JSON), lives at Meta, and is opened by a button you send in a conversation. Nodium manages its life cycle and reads the answers; **what the form does is yours** — the JSON says it, or your own endpoint does. - Flows belong to a client: they live in one of its WhatsApp Business accounts, so the client needs a connected number (`no_channel` otherwise). If it has several accounts, name one with `wabaId`. - Reading (`GET`) is open to every role; creating, changing, publishing and deleting need `admin`. - `status`: `draft` (editable), `published` (sendable, final), `deprecated`, `blocked` or `throttled` (Meta flags an unhealthy endpoint). `mode`: `static`, or `dynamic` once a data endpoint is attached. ```bash # 1. Create a draft (without flowJson it starts from a four-screen quote request) curl https://nodium.io/api/v1/flows \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Booking", "categories": ["APPOINTMENT_BOOKING"] }' # → data.flow.id, data.flow.metaFlowId, data.validationErrors # 2. Save your own JSON as a new version; Meta validates it curl https://nodium.io/api/v1/flows//versions \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "flowJson": { "version": "7.2", "screens": [ … ] } }' # 3. Look at it as on a phone, then publish (final) curl https://nodium.io/api/v1/flows//preview -H "Authorization: Bearer $NODIUM_KEY" curl -X POST https://nodium.io/api/v1/flows//publish -H "Authorization: Bearer $NODIUM_KEY" # 4. Send it: a button in the conversation opens the form (inside the 24-hour window) curl https://nodium.io/api/v1/messages \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "channelId": "", "to": "+33612345678", "type": "interactive", "text": "Pick a slot for your visit.", "idempotencyKey": "visit-1042-flow", "form": { "kind": "flow", "flowId": "", "buttonLabel": "Book", "flowToken": "visit-1042" } }' # 5. The answer comes back as message.received (data.reply.kind = "flow"), # and stays readable with your token: curl "https://nodium.io/api/v1/flow-responses?flowToken=visit-1042" \ -H "Authorization: Bearer $NODIUM_KEY" ``` ### Create, version, preview, publish - `POST /flows` creates a draft at Meta with its first version. Without `flowJson` it starts from a four-screen quote request. `categories` are Meta's (`OTHER` by default). `POST /flows/sync` also pulls in Flows drawn directly in WhatsApp Manager. - `POST /flows/{id}/versions` saves a JSON (10 MB at most) as a new version, which Meta validates. Invalid JSON is saved too: `validationErrors` says what to fix. The same JSON as the latest version opens no new one. `GET /flows/{id}/versions` lists them (`v1`, `v2`…, without the JSON); `GET /flows/{id}/versions/{versionId}` returns one with its JSON. - `GET /flows/{id}/preview` returns the address of the page Meta renders — the form as on a phone, Android or iOS, light or dark. It expires after about thirty days; ask again. - `POST /flows/{id}/publish` is **final**: a published Flow can no longer be changed or unpublished. Meta refuses JSON that still has validation errors, and says why (`502 upstream_error`). - `POST /flows/{id}/duplicate` copies a Flow into a new draft: the way to change a published one. `PATCH /flows/{id}` renames it or changes its categories, even when published. `POST /flows/{id}/deprecate` retires a published Flow; `DELETE /flows/{id}` removes a draft only. ### Send a Flow In `POST /messages`, with `type: "interactive"`, pass `form.kind: "flow"` with the Flow's `flowId` (its `metaFlowId`) and a `buttonLabel` (20 characters at most). Optional: `flowToken` (your own token, returned with the answer), `screen` (a first screen other than the default), `data` (values for that screen) and `mode: "draft"` to try an unpublished Flow. Like every form, it counts as free text: the 24-hour window applies. ### Open a Flow from a template An interactive Flow message only goes out within the 24-hour window. To open a Flow for **someone who has not written** — or in a broadcast — put it on an approved template: a button `{ "type": "FLOW", "text", "flowId", "screen" }` (one per template, `screen` optional, ignored for a dynamic Flow). ```bash # 1. A template with a button that opens the Flow (flowId from GET /flows) curl https://nodium.io/api/v1/templates \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "service_reminder", "language": "en", "category": "UTILITY", "body": "Your car is due for its service, {{1}}. Pick a slot in a few taps.", "examples": { "1": "Karim" }, "buttons": [{ "type": "FLOW", "text": "Book a slot", "flowId": "", "screen": "SLOTS" }] }' # 2. Deploy it once the Flow is published, then send it — no 24-hour window needed curl https://nodium.io/api/v1/messages \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "channelId": "", "to": "+33612345678", "type": "template", "templateName": "service_reminder", "variables": { "1": "Karim" }, "buttons": [{ "index": 0, "type": "flow", "flowToken": "visit-1042" }] }' ``` - The Flow must be **published** when you deploy the template: Meta refuses a template that opens a draft. A deprecated Flow is refused at once (`422 template_buttons_invalid`). - A Flow lives in one WhatsApp account. In each account of the client, Nodium opens the Flow **of the same name**; an account without it reports the deployment as `failed`, with the reason. - At send time the Flow button value is optional: `flowToken` comes back with the answer, `data` fills the first screen. A broadcast sends the template without them. - The answer arrives exactly as for an interactive Flow: `message.received` with `data.reply.kind` = `flow`. ### Read the answers - When the contact submits, you receive `message.received` with `data.reply` of kind `flow`: `data.reply.data` holds what they filled in, field by field. - `GET /flow-responses` lists the answers, most recent first, with `flowToken`, `from` and `to` filters and cursor pagination. `flowToken` fetches the answer to one particular send. - The answers are read from the received messages, so they are kept as long as message content is (your plan's retention). Store what you need from the webhook. ### A dynamic Flow: your own endpoint A Flow can ask your server what to show on each screen (available slots, a price, a lookup). Attach your https address to a **draft**: Meta calls Nodium on every screen, Nodium decrypts the encrypted request and relays it to you as a signed POST; your JSON answer is encrypted and handed back to Meta. Nodium carries; it decides nothing. ```bash # Attach your address to a draft: the Flow becomes dynamic curl -X PUT https://nodium.io/api/v1/flows//data-endpoint \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/whatsapp/flow" }' # → data.dataEndpoint.metaUrl, and data.secret ("whsec_…", shown once) ``` - `PUT /flows/{id}/data-endpoint` needs a public https address (private addresses are refused, as for webhooks) and a draft. It also generates the client's encryption key and registers it on its numbers; `unregisteredChannels` lists the numbers that did not take it — call the route again once fixed. - The signing **secret is returned once, by this call**. Calling it again issues a new one, and the old one stops working at once. `GET /flows/{id}/data-endpoint` never returns it. - Your address receives the body below, signed like a webhook: verify `X-Nodium-Signature` (see **Webhooks**). You have about 9 seconds. - `GET /flows/encryption` tells, number by number, whether the client's encryption key is registered at Meta (`ready`, `missing`, `unknown`); `POST /flows/encryption` registers it on one number (`channelId`) or on all. `POST /flows/{id}/data-endpoint-test` sends your address a signed `INIT` call marked `test: true` and returns the status, the duration and your answer. - `DELETE /flows/{id}/data-endpoint` makes a draft static again. Duplicating a Flow does not copy the endpoint: attach it again on the copy. - `GET /flows/{id}/invocations` shows the last 100 calls of the last 30 days — action (`INIT`, `data_exchange`, `BACK`, `ping`), screen, the status your server answered, duration, error. The content exchanged is never kept. ```http POST https://api.example.com/whatsapp/flow Content-Type: application/json X-Nodium-Signature: t=1790000000,v1= { "source": "whatsapp_flow", "flow": { "id": "", "meta_flow_id": "" }, "data_exchange": { "version": "3.0", "action": "data_exchange", "screen": "SLOTS", "data": { "date": "2026-10-02" }, "flow_token": "visit-1042" }, "signature_valid": true, "received_at": "2026-09-29T10:00:00.000Z" } # You answer, within about 9 seconds: { "screen": "CONFIRM", "data": { "slots": [ { "id": "10", "title": "10:00" } ] } } ``` In TypeScript, `receiveFlowRequest` verifies the signature and returns `{ action, screen, data, flowToken, flow, test }`; `respondToFlow`, `flowError` and `completeFlow` build the answer (see **TypeScript SDK**): ```ts import express from 'express' import { completeFlow, flowError, receiveFlowRequest, respondToFlow } from '@nodium.io/whatsapp/server' app.post('/whatsapp/flow', express.raw({ type: 'application/json' }), async (req, res) => { const call = receiveFlowRequest(req.body, req.headers, process.env.NODIUM_FLOW_SECRET!) // call.action: 'INIT' | 'data_exchange' | 'BACK' · call.screen · call.data · call.flowToken if (call.action === 'INIT') return res.json(respondToFlow({ screen: 'SLOTS', data: { days: await nextDays() } })) if (call.screen === 'SLOTS') { const slots = await freeSlots(call.data.date as string) if (!slots.length) return res.json(flowError('SLOTS', 'No slot left that day.')) return res.json(respondToFlow({ screen: 'CONFIRM', data: { slots } })) } res.json(completeFlow(call.flowToken, { booked: true })) // closes the Flow }) ``` ## Coexistence Source: https://nodium.io/en/docs/api/guide/coexistence A number that keeps the WhatsApp Business app: what is written from the phone reaches you too. With **coexistence**, a business connects its number to Nodium **and** keeps using the WhatsApp Business app on its phone. `GET /channels?scope=account` tells which numbers do (`coexistence`). Both sides write in the same threads; Nodium carries what the phone does to you. ### What is written from the phone - `message.echoed` — the person holding the phone wrote to a contact from the app, not through the API. The payload carries `to`, `type`, `text` (or the caption), `media` and `sent_at`, and `sender` is always `phone`. - `message.edited` — a message was edited from the phone. `message_id` and `provider_message_id` are those of the **original** message; `text` is the text after the edit. - `message.revoked` — a message was deleted for everyone from the phone; same identifiers as the original. - These messages are stored as **outbound**, in the conversation, and signed by the person the client named as holding the phone. They do not reopen the 24-hour window: only a message from the customer does. ```json { "id": "evt_9a8b7c6d5e4f30211a2b3c4d5e6f7081", "event": "message.echoed", "workspace_id": "…", "external_id": "crm-4187", "occurred_at": "2026-09-29T09:59:58.900Z", "data": { "message_id": "…", "conversation_id": "…", "channel_id": "…", "to": "+33612345678", "sender": "phone", "type": "text", "text": "Received, see you tomorrow.", "sent_at": "2026-09-29T09:59:58.000Z" } } ``` ### How to tell them apart in messages - `origin` on a message is `phone` when it was written from the WhatsApp Business app; otherwise `null`. Use it to tell a person's message from one your code sent. - `imported` is `true` for a message taken from the phone's chat history, not received live. - Both appear in `GET /conversations/{id}/messages` and in the journal. ### History When the number is connected, Meta can hand over the conversations already on the phone, and the address book. Nodium imports them into the same conversations and contacts: read them through the API (`imported: true`). **No webhook is sent for imported history** — it is not news — so do not wait for one; list the conversations instead. Imported history never moves the 24-hour window backwards. ## Running your numbers Source: https://nodium.io/en/docs/api/guide/operate What happened to your sends, the files you hold, your contacts, the profile of a number, and finding things fast. ### Delivery statistics `GET /stats/delivery` counts what became of the messages you sent: `sent`, `delivered`, `read`, `replied`, `failed` and `skipped`, per day and per template (`source=template`, the default) or per broadcast (`source=broadcast`). The default period is the last 30 days; it cannot exceed 366 days. `previous` holds the same counts for the period of equal length just before, to compare. `channelId` narrows it to one number. - `sent` counts every message whose sending was attempted, failures included. `delivered` includes `read`. `replied` counts messages after which the contact wrote within 24 hours. - `skipped` counts the recipients a broadcast left out before sending (no consent, no number); it is 0 for templates. - A message is counted on the day it was sent (UTC), with its **current** status: the exact time of a read is not kept. ```bash curl "https://nodium.io/api/v1/stats/delivery?source=template&from=2026-09-01T00:00:00Z" \ -H "Authorization: Bearer $NODIUM_KEY" # → data.totals { sent, delivered, read, replied, failed, skipped }, # data.previous (the period of equal length just before), # data.days[] and data.groups[] (per template, or per broadcast) ``` ### Media - `GET /media` lists the files Nodium kept a copy of — images, videos, sounds, documents — most recent first. Filter by `kind` (`image`, `video`, `audio`, `document`) and by `from` / `to`; cursor pagination. - Download one with `GET /messages/{messageId}/media`. `DELETE /media/{id}` (the id of the **message** that carries the file) removes Nodium's copy; the message stays. It cannot be recovered afterwards: WhatsApp deletes received media after seven days. ### Contacts export `GET /contacts/export` returns the contacts of the client as a CSV file (UTF-8) — not wrapped in `data` — with the same filters as `GET /contacts` (`q`, `consent`, `blocked`, `channel`). At most 10,000 rows: narrow the filters beyond that. Columns: `id`, `name`, `phone`, `email`, `external_id`, `consent` (`in`, `out`, `unknown`), `blocked`, `conversations`, `created_at`, `updated_at`. ### Business profile of a number `GET /channels/{id}/profile` reads what people see when they open the business profile: `about`, `description`, `address`, `email`, up to two `websites`, `vertical` (business category) and `pictureUrl` (read-only). It is read from Meta on every call. `PATCH /channels/{id}/profile` changes the fields you send and leaves the others alone; `websites` is replaced as a whole, an empty `vertical` clears the category. The shared sandbox number cannot be changed. Meta's limits apply (`422 invalid_request`); a refusal from Meta is `502 upstream_error`. ```bash curl -X PATCH https://nodium.io/api/v1/channels//profile \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "about": "Opticians since 1987", "email": "contact@example.com", "websites": ["https://example.com"], "vertical": "HEALTH" }' ``` `GET /channels/{id}` returns one channel with its quality rating, messaging tier and the result of the last health check (`POST /channels/{id}/check`). ### Connect links `GET /connect-links` lists the hosted links of your account and where each stands; `DELETE /connect-links/{id}` revokes one that is still pending. Creating one is `POST /tenants/{id}/connect-links`: see **Your clients**. ### Search `GET /search?q=` (2 to 100 characters) finds contacts (name, phone number, email), templates (name), channels (name, number) and — for a key that sees every client — clients (name or your `externalId`). A handful per family: it is for finding one thing fast; the list routes return everything. ### The journal, filtered `GET /journal` also filters messages by `direction` (`inbound` or `outbound`) and by `channel`; the other kinds of lines have neither and are left out when you use them. ## Muse (MCP) Source: https://nodium.io/en/docs/api/guide/muse Publish your app in Muse as a hosted MCP server. Nodium runs the server and OAuth, and calls your API directly. An **app** is your software as Muse sees it: a name, a list of tools, and the address of your API. Every account already has one. With `mcpEnabled: true`, Nodium serves it at `https://nodium.io/api/v1/apps/{id}/mcp` — the URL your users paste into Muse. You write no server code: each tool is an operation of your own API, described by your OpenAPI document. ### Describe your API and pick the tools ```bash # 1. Your account already has an app. Point it at your API (admin key) curl -X PATCH https://nodium.io/api/v1/apps/ \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "apiBaseUrl": "https://api.example.com", "mcpEnabled": true }' # 2. Derive its tools from your OpenAPI 3 document curl https://nodium.io/api/v1/apps//tools/import \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/openapi.json", "dryRun": true }' # 3. The URL your users paste into Muse # https://nodium.io/api/v1/apps//mcp ``` - `POST /apps/{id}/tools/import` derives one tool per operation of an OpenAPI 3 document (JSON), by `url` or inline `document`. `mode: "merge"` keeps the tools you already have; `dryRun: true` shows the result without saving. `GET` and `HEAD` operations come **enabled**; every other operation comes **disabled** until you enable it. - `PATCH /apps/{id}/tools/{name}` enables or disables one tool. Or set `mcpTools` yourself with `PATCH /apps/{id}`: a `name`, a `description`, an `inputSchema` (JSON Schema of type `object`), an `operation` (`{ method, path }`), optionally `scope` (`read` or `write`) and `enabled`. - `PATCH /apps/{id}` also sets `apiBaseUrl` (https), `status` (`active` or `paused`) and how your users sign in to **your** API: `authType` `api_key`, `oauth2` (your provider, PKCE — register `https://nodium.io/api/oauth/callback` as redirect URI) or `none`. The import pre-fills it from your OpenAPI security scheme. ### Test a tool `POST /apps/{id}/tools/{name}/test` calls one tool for real with a test credential of yours (`testCredential` in `PATCH /apps/{id}`, write-only) and returns your API's answer. Test calls are marked as such in the journal and never billed. ### What Nodium runs - The MCP endpoint, Streamable HTTP: `POST /api/v1/apps/{id}/mcp` carries JSON-RPC (`initialize`, `tools/list`, `tools/call`); `GET` answers 405; `DELETE` ends the session. - OAuth 2.1 for Muse: discovery at `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource/api/v1/apps/{id}/mcp`, dynamic client registration (`POST /api/oauth/register`), authorization with PKCE S256 and a consent screen (`/api/oauth/authorize`), tokens (`POST /api/oauth/token`), revocation (`POST /api/oauth/revoke`). - A call without a Nodium token gets `401` with `WWW-Authenticate` pointing to that discovery document: the client starts the flow on its own. - Only enabled tools are offered to assistants. Each `tools/call` counts against your quota; past it on a plan that blocks, the tool answers with an error result instead of calling your API. ### What your API receives ```http # Muse asks "where is order 1042?": Nodium calls your API, as your OpenAPI describes it, # with that user's own token at your service. GET https://api.example.com/orders/1042 Authorization: Bearer Accept: application/json ``` - The call is the operation the tool stands for, on `apiBaseUrl`, with the user's own credential at your service — placed as your `authConfig` says (`Authorization: Bearer` with OAuth). The assistant never has more rights than that user. - Nodium refreshes an expired OAuth token; a connection that cannot be refreshed makes the tool ask the user to sign in again. - Answer JSON: Nodium turns it into the MCP result. A non-`2xx` answer becomes an error result. The call times out after 30 seconds and the body is capped at 1 MB. Private addresses are refused, like for webhooks. - Every call lands in `GET /apps/{id}/invocations`; `GET /apps/{id}/stats` sums them up, `GET /apps/{id}/connections` lists who is connected, and `DELETE /apps/{id}/connections/{connId}` cuts one off. - `GET /apps/{id}/review-kit` (`format=markdown` for a file) generates the file for Muse's directory review: listing, checklist and MCP address. ## Your clients Source: https://nodium.io/en/docs/api/guide/partners One client per end customer, one key to drive them all. Your customers never create a Nodium account. You publish software; each of your customers wants their own WhatsApp. Add each one as a **client** (`/tenants`): its own numbers, conversations and templates, isolated from the others. Your own company is the first client, **My company**; a direct business connects its number there. You drive every client with your account key. ```bash # 1. Add one of your customers as a client curl https://nodium.io/api/v1/tenants \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Optique Martin", "externalId": "cust_1042", "locale": "fr" }' # 2. A hosted connect link, to send to that customer curl https://nodium.io/api/v1/tenants/cust_1042/connect-links \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "redirectUrl": "https://app.example.com/settings/whatsapp" }' # → data.url: send it. Back on your side: # https://app.example.com/settings/whatsapp?status=connected&channel_id=… # 3. Act for that client: same key, one header curl https://nodium.io/api/v1/conversations \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Nodium-Tenant: cust_1042" ``` ### Clients - `POST /tenants` adds one. Pass your own `externalId`: it is unique in your account, so replaying the call answers `409 tenant_exists` with the existing id instead of creating a duplicate. - Everywhere a client id is expected, your `externalId` works too. - `GET /tenants`, `GET /tenants/{id}`, `PATCH /tenants/{id}` (name, `externalId`, locale, time zone), `DELETE /tenants/{id}` to close it. Each one carries its WhatsApp state, its latest connect link and its activity this month. - The `clients` limit of your plan counts every client of the account except My company: `402 plan_limit` beyond it. The free plan (Build) includes none, so adding a client — `POST /tenants` or a connect link for another client — needs Launch. A client is billed for a month once it is **active** (a message sent or received, or a tool call served). ### The hosted connect link - `POST /tenants/{id}/connect-links` returns a `url` to send to your customer. It opens a Nodium page where they connect their number in Meta's signup window — coexistence with the WhatsApp Business app included — and are invited to add a payment method at Meta. - The link works once and expires after 7 days by default (`expiresInDays`, 30 at most). The address is shown only in that response. - When they finish, they land on your `redirectUrl` with `status=connected&channel_id=…`, or `status=cancelled`. Confirm with `GET /tenants/{id}/channels`, or listen to the `channel.connected` webhook. - `GET /connect-links` lists every link of your account (`state`: `pending`, `used`, `expired`; filter with `state` or `client`). The address itself is never returned again — only a hash is kept — so a lost link is replaced by a new one. - `DELETE /connect-links/{id}` revokes a link that has not been used yet: it stops working at once and shows as `expired`. ### Acting for a client - Add `Nodium-Tenant: ` to any call with your account key. A client outside your account answers `404`. - Or issue a key pinned to one client: `POST /keys` with `clientId` (role `admin`, `agent` or `member`), shown once. It can never act on another client. - One webhook is enough: a subscription created without `Nodium-Tenant` (on your own company) receives the events of **every** client of your account. Every payload carries `workspace_id`, the client the event comes from. A subscription created with the header, or with a pinned key, receives that client's events only. - Each client has its own templates, deployed to its WhatsApp accounts (see **Sending**). ### Meta's fees Meta bills its message fees directly to each customer's WhatsApp Business account, with no markup from Nodium. Without a payment method there, the number still receives and answers free inside the 24-hour window, but a paid send is refused by Meta (error `131042`): the channel is flagged `paymentMissing`, and `message.failed` carries `error_code`, `reason: "payment_missing"` and a `hint` you can pass to your customer. The console also lists it under what needs your attention.