Skip to content
Get an API key

Get started

TypeScript 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

Shell

npm install @nodium.io/whatsapp # the librarynpm install -g @nodium.io/cli # the nodium command (optional)export NODIUM_KEY=nod_… # Settings › API keys in the console

A first message

TypeScript

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

TypeScript

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 onceconst horizon = nodium.as('crm-4187') // adds Nodium-Tenant to every callawait 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

TypeScript

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

TypeScript

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.

TypeScript

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

TypeScript

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