Send a WhatsApp message with Node.js
This page sends a WhatsApp message from Node.js with the built-in fetch, then receives the reply in a small HTTP server. Nothing here needs a package from npm.
What you need
- An account on Threadwire.
- A linked number. In your panel, click "Add instance" and scan the QR code with WhatsApp on the phone (Settings → Linked devices). Then copy the Instance ID from the instance's page.
- An API key. Make it under Developers → API keys → New key (admins only).
- Node.js 18 or later. The sample is an ES module: a
.mjsfile, or"type": "module"inpackage.json. It reads the key from an environment variable,THREADWIRE_KEY.
An instance is one WhatsApp number linked to Threadwire. It is linked as a device, the same way WhatsApp Web is linked, so this is not Meta's official API. WhatsApp API without Meta approval says what that means.
Send the message
Send the first message to yourself. Write "hi" to your linked number from your own WhatsApp on another phone, then send to that phone. Only one phone? Use the linked number's own number as to.
// Node.js 18 or later, in an ES module (.mjs, or "type": "module")
const response = await fetch('https://threadwire.net/api/v1/messages', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.THREADWIRE_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ instance_id: 'YOUR_INSTANCE_ID', to: 'YOUR_PHONE', text: 'Hello from Threadwire' }),
});
if (!response.ok) throw new Error(await response.text());
const { data: message } = await response.json();
console.log(message.id, message.status);
Authorization: your API key as a bearer token, sent on every request.instance_id: the Instance ID you copied.to: that phone's number in international format, like201012345678: country code, no+needed, no leading 0.text: the message, up to 4,096 characters.
Read the answer
The answer comes at once, and the sample takes the message out of data:
202 {"data": {"id": "01K…", "status": "queued", "phone": "201012345678", "body": "Hello from Threadwire", "…": "…"}}
id: keep it.GET /v1/messages/{id}returns the message with its status at that moment.status:queuedmeans the message was accepted and waits its turn. Soawaitgives you a queued message, not a delivered one.phoneandbody: who the message is for, and its text.
A queued message goes out the way a person would send it. Its status then moves to sent, delivered and read, or to failed with the reason in error.
You wrote first, so this message is a reply, and it arrives in seconds. A first message to someone who never wrote to your number is the slowest kind. Such messages are spaced out, capped per day and not sent at night, so let people write first. How sending protects your number has the figures.
Receive the reply
Under Developers → Webhooks (admins only), set your endpoint's URL: a public https:// address. The signing secret starts with whsec_, and is shown when you save the URL. Every reply and status change is then posted to your endpoint as JSON:
{"id": "evt_01K…", "type": "message.received", "created_at": "2026-10-01T10:00:00+00:00",
"data": {"id": "01K…", "instance_id": "01K…", "phone": "201012345678", "body": "Hi!", "…": "…"}}
A reply arrives as message.received, and a delivery update as message.status.
Every event is signed per the Standard Webhooks spec. This endpoint, from the webhooks guide, is whole. It reads the raw body, checks the signature, answers 200 at once, and drops repeats. It listens on port 3000.
// Node.js 18 or later, no packages. Run: THREADWIRE_WEBHOOK_SECRET=whsec_… node endpoint.mjs
import crypto from 'node:crypto';
import http from 'node:http';
const key = Buffer.from(process.env.THREADWIRE_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
const handled = new Set(); // in production: your database or cache
http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
// The raw body, exactly as it arrived: the signature covers these bytes.
const body = Buffer.concat(chunks);
const id = req.headers['webhook-id'];
const timestamp = req.headers['webhook-timestamp'];
// 1. Verify: sent in the last 5 minutes, and signed with your secret.
const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key)
.update(`${id}.${timestamp}.`).update(body).digest('base64'));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const signed = (req.headers['webhook-signature'] ?? '').split(' ').some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!fresh || !signed) {
res.writeHead(401).end();
return;
}
// 2. Answer 200 at once, before any slow work.
res.writeHead(200).end();
// 3. Drop repeats: an event sent again keeps its webhook-id.
if (handled.has(id)) return;
handled.add(id);
const event = JSON.parse(body);
console.log(event.type);
if (event.type === 'message.received') {
console.log(`${event.data.phone}: ${event.data.body}`);
}
});
}).listen(3000);
Press "Send test event" on the Webhooks page, and a webhook.test event goes to your endpoint. Then write to your linked number from another phone: a message.received event arrives.
Using Express? Webhooks and events has the same check as one function.
Good to know
- Retries are safe with an
Idempotency-Keyheader. Add'Idempotency-Key': 'order-1042-shipped'toheaders: any unique string of yours. A retry with the same key returns the first message instead of sending a second. - An error has a
code. The sample throws the answer's text, which is JSON. It holds amessagein plain words and a stablecode, such asvalidation_failed, for your program. - Retry only what can pass. Retry
429,502and503, afterRetry-Afterwhen present.401,403,404and422need a fix first. See what to retry. - Events can arrive out of order. Use each event's
created_atand the message's own times.
Next
- Sending messages: files, voice notes, polls and the rest, as fields on the same request.
- Webhooks and events: every event, retries, and polling.
- The API reference: every endpoint, and the errors.
- Tools and code samples: the Postman collection, and this request in other languages.