Send WhatsApp Messages with Node.js: Complete Tutorial
Node.js 18+ ships with native fetch. That means zero extra dependencies to send your first WhatsApp message. This tutorial walks you from API key to production-ready code: text messages, template sends, media attachments, an Express webhook endpoint with HMAC-SHA256 signature verification, TypeScript types, and a client class with exponential backoff retry logic.
TL;DR
Node.js 18+ has built-in fetch — you do not need axios. Send a WhatsApp message to a contact in five lines of code using the SendSeven REST API. This tutorial covers sending text messages, template messages, and media; building an Express.js webhook endpoint with HMAC-SHA256 signature verification using express.raw() and crypto.timingSafeEqual; TypeScript type definitions; and a WhatsAppClient class with exponential backoff retry. All code uses environment variables. No hardcoded keys.
Quick Answer: Minimal Working Example
If you want the shortest possible path to a delivered WhatsApp message, here it is. Node.js 18+ ships with native fetch — no npm install required beyond your API key and a contact ID.
// Requires Node.js 18+ (built-in fetch)
import 'dotenv/config';
const response = await fetch(`${process.env.S7_BASE_URL}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.S7_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
contact_id: 'contact_your_contact_id',
channel_id: 'channel_your_whatsapp_channel_id',
text: 'Hello from Node.js!'
})
});
const data = await response.json();
console.log(data);
That is it. One HTTP call, one JSON body. The rest of this tutorial turns that snippet into production-ready code.
Looking for the Python version? See Send WhatsApp Messages with Python: Complete Tutorial.
Prerequisites
Before you start, you need:
- Node.js 18 or higher. Version 18 introduced stable built-in
fetch. Runnode --versionto check. The tutorial uses ESM (importsyntax) and top-levelawait. If your project uses CommonJS (require), see the FAQ section at the end for the equivalent patterns. - A SendSeven account. The API authentication guide explains how to generate a key. Your key starts with
s7_api_. The API key glossary entry covers key management best practices. - A connected WhatsApp channel and at least one contact. Follow the WhatsApp setup guide to connect your WhatsApp Business number. Once connected, you will find your
channel_idin the channel settings and yourcontact_idin the Contacts section. - npm 7+. Check with
npm --version.
Install the two packages this tutorial needs:
npm init -y
npm install dotenv express
That is the complete dependency list. dotenv loads .env files. express handles the webhook endpoint in Step 4. Everything else — fetch, crypto, Buffer — is built into Node.js.
Project Setup
Create a .env file in your project root. This keeps credentials out of your source code and git history.
# .env
S7_API_KEY=s7_api_your_key_here
S7_BASE_URL=https://api.sendseven.com/api/v1
S7_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# IDs from your SendSeven dashboard
S7_CHANNEL_ID=channel_your_whatsapp_channel_id
Add .env to your .gitignore immediately:
echo ".env" >> .gitignore
To use ESM with top-level await, add "type": "module" to your package.json:
{
"name": "whatsapp-sendseven",
"version": "1.0.0",
"type": "module",
"dependencies": {
"dotenv": "^16.0.0",
"express": "^4.18.0"
}
}
TypeScript users: optional tsconfig
If you prefer TypeScript, install the type packages and create a minimal config:
npm install -D typescript @types/node @types/express tsx
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}
Run TypeScript files directly during development with npx tsx src/your-file.ts. The TypeScript type definitions for the SendSeven API are in the TypeScript types section below.
Step 1: Send a Text Message
Create src/send-text.js. This sends a plain text WhatsApp message to a contact using the POST /messages endpoint.
// src/send-text.js
import 'dotenv/config';
const BASE_URL = process.env.S7_BASE_URL;
const API_KEY = process.env.S7_API_KEY;
async function sendTextMessage(contactId, channelId, text) {
const response = await fetch(`${BASE_URL}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
contact_id: contactId,
channel_id: channelId,
text
})
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`API error ${response.status}: ${error.message ?? JSON.stringify(error)}`);
}
return response.json();
}
// Usage
const result = await sendTextMessage(
'contact_your_contact_id',
process.env.S7_CHANNEL_ID,
'Hello from Node.js! This is a test message.'
);
console.log('Message sent:', result);
Run it:
node src/send-text.js
A successful response looks like this:
{
"id": "msg_a1b2c3d4e5f6",
"conversation_id": "conv_8f3a2b1c",
"contact_id": "contact_your_contact_id",
"channel": "whatsapp",
"text": "Hello from Node.js! This is a test message.",
"direction": "outbound",
"status": "sent",
"created_at": "2026-02-27T10:00:00Z"
}
The message lands in your contact's WhatsApp in under a second. WhatsApp messages achieve a 98% open rate (Mobilesquared) — that response is not just delivered, it will almost certainly be read.
Step 2: Send a Template Message
WhatsApp requires pre-approved templates for any message sent outside the 24-hour service window. If a customer has not messaged you recently, or if you want to initiate a conversation, you must use a template.
Templates belong to one of four message categories, each priced separately as of July 2025 per-message pricing: Marketing, Utility, Authentication, and Service. Service messages — replies within the 24-hour window — are free and unlimited since November 2024. Template approval typically takes minutes via automated review, up to 24 hours for manual review.
Create a template in your SendSeven dashboard first, then send it via the API. The full guide to interactive messages is at WhatsApp Interactive Messages.
// src/send-template.js
import 'dotenv/config';
const BASE_URL = process.env.S7_BASE_URL;
const API_KEY = process.env.S7_API_KEY;
async function sendTemplateMessage({ templateId, contactId, channelId, language = 'en', variables = {} }) {
const response = await fetch(
`${BASE_URL}/whatsapp/templates/${templateId}/send`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
contact_id: contactId,
channel_id: channelId,
language,
variables
})
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Template send failed ${response.status}: ${error.message ?? JSON.stringify(error)}`);
}
return response.json();
}
// Example: order confirmation template
// Template body: "Hi {{1}}, your order #{{2}} has been confirmed!"
const result = await sendTemplateMessage({
templateId: 'tmpl_order_confirmation',
contactId: 'contact_your_contact_id',
channelId: process.env.S7_CHANNEL_ID,
language: 'en',
variables: {
body: ['Jane', 'ORD-98765'] // Positional variable substitution
}
});
console.log('Template sent:', result);
// Example with a quick reply button parameter
const resultWithButtons = await sendTemplateMessage({
templateId: 'tmpl_appointment_reminder',
contactId: 'contact_your_contact_id',
channelId: process.env.S7_CHANNEL_ID,
language: 'en',
variables: {
body: ['2pm tomorrow'],
buttons: [
{
index: 0, // Button position (0-indexed)
parameters: [
{ type: 'payload', payload: 'CONFIRM_APPT_12345' }
]
}
]
}
});
console.log('Template with button sent:', resultWithButtons);
The variables.body array maps positionally to the {{1}}, {{2}}, etc. placeholders in your template. The variables.buttons array lets you pass dynamic payloads to quick reply buttons — the payload is what your webhook receives when the user taps the button.
Step 3: Send Media
WhatsApp supports images, documents, audio, and video as message attachments. The file must be accessible via a public HTTPS URL — WhatsApp fetches it directly. Files behind authentication or on private networks will fail.
// src/send-media.js
import 'dotenv/config';
const BASE_URL = process.env.S7_BASE_URL;
const API_KEY = process.env.S7_API_KEY;
async function sendMediaMessage({ contactId, channelId, mediaUrl, mediaType = 'image', caption = '' }) {
const body = {
contact_id: contactId,
channel_id: channelId,
attachments: [
{
type: mediaType, // 'image' | 'document' | 'audio' | 'video'
url: mediaUrl
}
]
};
// Caption is supported for images and video, not for audio or documents
if (caption && (mediaType === 'image' || mediaType === 'video')) {
body.text = caption;
}
const response = await fetch(`${BASE_URL}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Media send failed ${response.status}: ${error.message ?? JSON.stringify(error)}`);
}
return response.json();
}
// Send an image with a caption
const imageResult = await sendMediaMessage({
contactId: 'contact_your_contact_id',
channelId: process.env.S7_CHANNEL_ID,
mediaType: 'image',
mediaUrl: 'https://cdn.example.com/product-photo.jpg',
caption: 'Here is the product photo you requested.'
});
console.log('Image sent:', imageResult);
// Send a PDF document (no caption for documents)
const docResult = await sendMediaMessage({
contactId: 'contact_your_contact_id',
channelId: process.env.S7_CHANNEL_ID,
mediaType: 'document',
mediaUrl: 'https://cdn.example.com/invoice-2026-001.pdf'
});
console.log('Document sent:', docResult);
Keep media files under WhatsApp's size limits: images and documents under 100 MB, audio under 16 MB, video under 16 MB. For larger files, link to a download page instead of embedding the media directly.
Step 4: Express.js Webhook Endpoint
Sending messages is half the picture. When a customer replies, a conversation closes, or a contact is updated, SendSeven fires a webhook to your endpoint. This step builds a production-ready Express webhook handler.
There is one non-obvious requirement: you must use express.raw(), not express.json(), for the webhook route. Signature verification works on the exact bytes sent over the wire. If Express parses the JSON first and hands you a JavaScript object, you have lost the original byte sequence. When you re-serialize it to compute the HMAC, the bytes may differ (key ordering, whitespace). The hashes will not match. Every verification attempt will fail.
Read more about the webhook system in the Webhooks Explained deep dive and the webhook setup guide.
// src/webhook-server.js
import 'dotenv/config';
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const PORT = process.env.PORT ?? 3000;
const WEBHOOK_SECRET = process.env.S7_WEBHOOK_SECRET; // whsec_...
// ──────────────────────────────────────────────────────────────────
// Signature verification middleware
// CRITICAL: express.raw() — NOT express.json()
// req.body will be a Buffer containing the raw request bytes
// ──────────────────────────────────────────────────────────────────
function verifySignature(req, res, next) {
const signatureHeader = req.headers['x-webhook-signature'];
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
return res.status(400).json({ error: 'Missing or malformed X-Webhook-Signature header' });
}
const receivedHex = signatureHeader.slice('sha256='.length);
// req.body is a raw Buffer here because of express.raw()
const expectedHex = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
// Constant-time comparison prevents timing attacks
const receivedBuf = Buffer.from(receivedHex, 'hex');
const expectedBuf = Buffer.from(expectedHex, 'hex');
if (
receivedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(expectedBuf, receivedBuf)
) {
return res.status(403).json({ error: 'Signature verification failed' });
}
// Attach the parsed event to the request for downstream handlers
req.webhookEvent = JSON.parse(req.body.toString('utf8'));
next();
}
// ──────────────────────────────────────────────────────────────────
// Webhook route
// express.raw() buffers the body as a Buffer — required for HMAC
// ──────────────────────────────────────────────────────────────────
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
verifySignature,
(req, res) => {
// Respond 200 immediately — never block here
res.status(200).end();
// Process asynchronously
handleEvent(req.webhookEvent).catch(err => {
console.error('Event processing failed:', err);
});
}
);
// ──────────────────────────────────────────────────────────────────
// Event router
// ──────────────────────────────────────────────────────────────────
async function handleEvent(event) {
const { id, event: eventType, data } = event;
console.log(`Received event ${eventType} [${id}]`);
switch (eventType) {
case 'message.received':
await handleMessageReceived(data);
break;
case 'message.sent':
await handleMessageSent(data);
break;
case 'conversation.created':
await handleConversationCreated(data);
break;
case 'conversation.closed':
await handleConversationClosed(data);
break;
case 'contact.created':
await handleContactCreated(data);
break;
case 'contact.updated':
await handleContactUpdated(data);
break;
default:
console.warn(`Unhandled event type: ${eventType}`);
}
}
// ──────────────────────────────────────────────────────────────────
// Event handlers — replace with your actual logic
// ──────────────────────────────────────────────────────────────────
async function handleMessageReceived(data) {
const { message_id, conversation_id, contact_id, channel, content, message_type, direction } = data;
console.log(`[message.received] channel=${channel} type=${message_type} direction=${direction}`);
console.log(` conversation=${conversation_id} contact=${contact_id}`);
console.log(` content=${content}`);
// TODO: trigger automations, notify agents, update CRM
}
async function handleMessageSent(data) {
const { message_id, conversation_id } = data;
console.log(`[message.sent] message=${message_id} conversation=${conversation_id}`);
// TODO: update delivery tracking, audit log
}
async function handleConversationCreated(data) {
const { conversation_id, contact_id, channel } = data;
console.log(`[conversation.created] id=${conversation_id} channel=${channel}`);
// TODO: assign to team, track response time
}
async function handleConversationClosed(data) {
const { conversation_id, contact_id, closed_by } = data;
console.log(`[conversation.closed] id=${conversation_id} closed_by=${closed_by}`);
// TODO: trigger CSAT survey, close ticket in helpdesk
}
async function handleContactCreated(data) {
const { contact_id } = data;
console.log(`[contact.created] id=${contact_id}`);
// TODO: sync to CRM, start onboarding sequence
}
async function handleContactUpdated(data) {
const { contact_id } = data;
console.log(`[contact.updated] id=${contact_id}`);
// TODO: sync updated fields to CRM
}
// ──────────────────────────────────────────────────────────────────
// Start server
// ──────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
console.log(`Endpoint: POST http://localhost:${PORT}/webhook`);
});
Start the server:
node src/webhook-server.js
During local development, expose the server publicly using ngrok:
ngrok http 3000
# Forwarding https://abc123.ngrok.io -> http://localhost:3000
Register https://abc123.ngrok.io/webhook as your webhook URL in SendSeven. SendSeven will deliver live events to your local machine. See the webhook glossary entry for a broader explanation of the mechanism.
Idempotency: handling duplicate deliveries
SendSeven retries webhooks when your server returns a non-2xx status or times out. The retry schedule is: 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours. After 20 consecutive failures, the webhook is paused. This means your handler may receive the same event more than once. Use the top-level id field as an idempotency key.
// src/idempotency.js
import { createClient } from 'redis';
const redis = await createClient().connect();
const TTL_SECONDS = 86_400; // 24 hours
export async function isAlreadyProcessed(eventId) {
const key = `webhook:processed:${eventId}`;
// SET NX: only sets if key does not already exist
const wasSet = await redis.set(key, '1', { EX: TTL_SECONDS, NX: true });
return wasSet === null; // null = key already existed = duplicate
}
// In handleEvent:
async function handleEvent(event) {
if (await isAlreadyProcessed(event.id)) {
console.log(`Duplicate event ignored: ${event.id}`);
return; // Still sent 200 already — correct behavior
}
// ... route to handler
}
No Redis? A database table with a unique index on event_id and a DATETIME expiry column works equally well.
TypeScript Type Definitions
If you are using TypeScript, these interfaces cover the full SendSeven API surface used in this tutorial. Put them in src/types/sendseven.ts.
// src/types/sendseven.ts
// ── Outbound messages ─────────────────────────────────────────────
export interface SendMessagePayload {
contact_id: string;
channel_id: string;
text?: string;
attachments?: Attachment[];
}
export interface Attachment {
type: 'image' | 'document' | 'audio' | 'video';
url: string; // Must be a publicly accessible HTTPS URL
}
export interface SendMessageResponse {
id: string; // Message ID (msg_...)
conversation_id: string;
contact_id: string;
channel: string;
text?: string;
direction: 'outbound';
status: 'sent' | 'delivered' | 'read' | 'failed';
created_at: string; // ISO 8601
}
// ── Template messages ─────────────────────────────────────────────
export interface TemplatePayload {
contact_id: string;
channel_id: string;
language: string; // BCP 47 language code, e.g. 'en', 'de'
variables?: TemplateVariables;
}
export interface TemplateVariables {
body?: string[]; // Positional: body[0] maps to {{1}}, body[1] to {{2}}, etc.
header?: string[];
buttons?: TemplateButtonVariable[];
}
export interface TemplateButtonVariable {
index: number; // 0-indexed button position
parameters: TemplateButtonParameter[];
}
export interface TemplateButtonParameter {
type: 'payload' | 'text' | 'url';
payload?: string; // For quick reply buttons
text?: string; // For URL buttons with dynamic suffix
}
// ── Webhook events ────────────────────────────────────────────────
export interface WebhookEvent {
id: string; // Idempotency key (evt_...)
event: WebhookEventType;
created_at: string; // ISO 8601
tenant_id: string;
data: T;
}
export type WebhookEventType =
| 'message.received'
| 'message.sent'
| 'conversation.created'
| 'conversation.closed'
| 'contact.created'
| 'contact.updated';
export interface WebhookEventData {
message_id?: string;
conversation_id?: string;
contact_id?: string;
channel?: string; // 'whatsapp' | 'telegram' | 'sms' | 'email' | 'messenger' | 'instagram' | 'livechat' | 'push'
content?: string;
message_type?: 'text' | 'image' | 'document' | 'audio' | 'video' | 'interactive';
direction?: 'inbound' | 'outbound';
status?: string;
closed_by?: string;
assigned_user_id?: string;
}
// ── API error response ────────────────────────────────────────────
export interface ApiError {
message: string;
code?: string;
errors?: Record<string, string[]>; // Validation errors per field
}
The typed webhook handler:
// src/handle-event.ts
import type { WebhookEvent, WebhookEventData } from './types/sendseven.js';
export async function handleEvent(event: WebhookEvent<WebhookEventData>): Promise<void> {
switch (event.event) {
case 'message.received':
await handleMessageReceived(event.data);
break;
case 'conversation.closed':
await handleConversationClosed(event.data);
break;
default:
console.warn(`Unhandled event: ${event.event}`);
}
}
async function handleMessageReceived(data: WebhookEventData): Promise<void> {
console.log(`Message from contact ${data.contact_id} on ${data.channel}: ${data.content}`);
}
async function handleConversationClosed(data: WebhookEventData): Promise<void> {
console.log(`Conversation ${data.conversation_id} closed by ${data.closed_by}`);
}
Error Handling and Retry Logic
The basic fetch calls in previous steps throw on non-OK responses, but production code needs more structure. This section gives you an error code reference and a WhatsAppClient class with exponential backoff for transient failures.
API error codes
| Status | Meaning | Action |
|---|---|---|
| 401 | Invalid or missing API key | Check S7_API_KEY in .env. Verify it starts with s7_api_. See the API auth guide. |
| 403 | Forbidden — key lacks permission | Check the key's scopes in your dashboard. Keys are scoped per operation. |
| 404 | Resource not found | Check contact_id, channel_id, or template_id. These IDs are case-sensitive. |
| 422 | Validation error | Check the errors field in the response body for per-field details. Common cause: missing required body fields. |
| 429 | Rate limited | Back off and retry. Respect the Retry-After header if present. Implement exponential backoff. |
| 500 | Server error | Transient. Retry with backoff. If it persists, check the SendSeven status page. |
WhatsAppClient with exponential backoff
// src/whatsapp-client.js
import 'dotenv/config';
const BASE_URL = process.env.S7_BASE_URL;
const API_KEY = process.env.S7_API_KEY;
// Errors that warrant a retry (transient server/rate-limit issues)
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 500;
export class WhatsAppClient {
#baseUrl;
#apiKey;
constructor(baseUrl = BASE_URL, apiKey = API_KEY) {
if (!baseUrl) throw new Error('S7_BASE_URL is not set');
if (!apiKey) throw new Error('S7_API_KEY is not set');
this.#baseUrl = baseUrl;
this.#apiKey = apiKey;
}
async #request(path, options = {}) {
const url = `${this.#baseUrl}${path}`;
const headers = {
'Authorization': `Bearer ${this.#apiKey}`,
'Content-Type': 'application/json',
...options.headers
};
let lastError;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) {
// Exponential backoff: 500ms, 1000ms, 2000ms
const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
const jitter = Math.random() * 100; // Prevent thundering herd
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
try {
const response = await fetch(url, { ...options, headers });
if (response.ok) {
return response.json();
}
if (!RETRYABLE_STATUS_CODES.has(response.status)) {
// Non-retryable error — throw immediately
const body = await response.json().catch(() => ({ message: response.statusText }));
const err = new Error(`API error ${response.status}: ${body.message ?? JSON.stringify(body)}`);
err.status = response.status;
err.body = body;
throw err;
}
// Retryable — save error and loop
const body = await response.json().catch(() => ({ message: response.statusText }));
lastError = new Error(`API error ${response.status}: ${body.message ?? JSON.stringify(body)}`);
lastError.status = response.status;
lastError.body = body;
console.warn(`Attempt ${attempt + 1}/${MAX_RETRIES + 1} failed (${response.status}), retrying...`);
} catch (err) {
if (err.status && !RETRYABLE_STATUS_CODES.has(err.status)) throw err;
lastError = err;
console.warn(`Attempt ${attempt + 1}/${MAX_RETRIES + 1} failed:`, err.message);
}
}
throw lastError;
}
async sendTextMessage({ contactId, channelId, text }) {
return this.#request('/messages', {
method: 'POST',
body: JSON.stringify({ contact_id: contactId, channel_id: channelId, text })
});
}
async sendTemplateMessage({ templateId, contactId, channelId, language = 'en', variables = {} }) {
return this.#request(`/whatsapp/templates/${templateId}/send`, {
method: 'POST',
body: JSON.stringify({ contact_id: contactId, channel_id: channelId, language, variables })
});
}
async sendMediaMessage({ contactId, channelId, mediaType, mediaUrl, caption }) {
const body = {
contact_id: contactId,
channel_id: channelId,
attachments: [{ type: mediaType, url: mediaUrl }]
};
if (caption && (mediaType === 'image' || mediaType === 'video')) {
body.text = caption;
}
return this.#request('/messages', {
method: 'POST',
body: JSON.stringify(body)
});
}
}
Usage:
import { WhatsAppClient } from './src/whatsapp-client.js';
const client = new WhatsAppClient();
try {
const result = await client.sendTextMessage({
contactId: 'contact_your_contact_id',
channelId: process.env.S7_CHANNEL_ID,
text: 'Hello from the WhatsApp client!'
});
console.log('Sent:', result.id);
} catch (err) {
console.error(`Failed after retries: ${err.message}`);
if (err.status === 422) {
console.error('Validation errors:', err.body?.errors);
}
}
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
Every request returns 401 Unauthorized | API key not loaded or malformed | Run console.log(process.env.S7_API_KEY) at startup. Confirm it starts with s7_api_. Confirm you called import 'dotenv/config' before any fetch calls. |
| Webhook signature always fails (403) | Body already parsed as JSON before HMAC | Check your middleware order. The express.raw({ type: 'application/json' }) call MUST come before express.json() or any global body-parsing middleware. Move it to a route-level middleware, not app-level. |
Top-level await throws a syntax error | Missing "type": "module" in package.json | Add "type": "module" to package.json, or rename files to .mjs. Alternatively, wrap everything in an async function main() { ... } main() pattern. |
Template send returns 404 | Template ID is wrong or template not approved | Confirm template status is APPROVED in your SendSeven dashboard. Template IDs are case-sensitive strings, not sequential integers. |
Media message fails: URL not accessible | File is behind auth or private network | The URL in attachments[].url must be reachable without authentication from WhatsApp's servers. Use a CDN or object storage with public access. Local localhost URLs will always fail. |
crypto.timingSafeEqual throws on buffer length mismatch | Receiving an unexpected or truncated signature header | Always check buffer lengths before calling timingSafeEqual. The guard if (receivedBuf.length !== expectedBuf.length) handles this — ensure it is in place before the comparison call. |
| Events stop arriving after several failures | Webhook circuit breaker triggered | SendSeven pauses the webhook after 20 consecutive delivery failures. Go to webhook settings in the dashboard and reactivate it after fixing your endpoint. |
| Same event processed twice | Retry delivered a duplicate | Implement idempotency using event.id as the key. See the idempotency section in Step 4. Return 200 on duplicates — do not return 400 or the retry cycle will continue. |
CJS require and ESM import mixed errors | Inconsistent module system | Pick one. For CJS: remove "type": "module" and use require() throughout. For ESM: keep "type": "module" and use import. Do not mix unless you know exactly what you are doing with interop. |
Next Steps
You now have a working Node.js WhatsApp integration. Here is where to go from here:
- Expand to other channels. The same API endpoints work for Telegram, SMS, Email, Messenger, Instagram, Live Chat, and Browser Push. See the full feature overview for all supported channels. The
channel_idis the only thing that changes. SendSeven routes to the correct channel automatically. - Build interactive messages. Buttons, lists, and carousels make WhatsApp feel like a mini app. See the WhatsApp Interactive Messages guide.
- Add AI support automation. Route incoming messages through the AI features to deflect repetitive questions automatically.
- Manage team conversations. For teams handling support at scale, the WhatsApp Team Inbox guide covers assignment rules, routing strategies, and agent workloads.
- Set up the full webhook pipeline. The webhook setup guide covers registering endpoints, managing secrets, and monitoring delivery logs in the dashboard.
- Go deeper on the API. The API authentication guide covers key rotation, scopes, and environment management for production deployments.
Frequently Asked Questions
Does this work with CommonJS (require) instead of ESM (import)?
Yes. Replace import 'dotenv/config' with require('dotenv').config(), replace all import X from 'Y' with const X = require('Y'), and remove the "type": "module" field from package.json. Top-level await is not available in CommonJS — wrap your code in async function main() { ... } main().catch(console.error). All logic, endpoints, and types remain identical.
Can I use this with Deno or Bun?
Yes, with minor adjustments. Both Deno and Bun support native fetch and the Web Crypto API. For Deno, replace dotenv/config with Deno.env.get() and replace the Node.js crypto module with the built-in crypto Web API. For Bun, the Node.js crypto module is available as a compatibility shim, so most code runs unchanged. The HTTP endpoint section needs replacing with Bun's native Bun.serve() instead of Express, but the signature verification logic itself is identical.
Why use express.raw() instead of express.json() for webhooks?
HMAC signature verification operates on the exact bytes transmitted over the network. If Express parses the JSON body first, you receive a JavaScript object. When you re-serialize it to JSON.stringify() to compute the HMAC, the output may differ from the original bytes — even if the data is semantically identical. Key ordering, whitespace, and number formatting can all change. express.raw() hands you the original Buffer unchanged, which is what you hash. express.json() destroys this information. The webhook handler in this tutorial uses express.raw() as a route-level middleware, leaving the rest of your application free to use express.json() for other routes.
What is the difference between a contact_id and a channel_id?
A contact_id identifies a person in your SendSeven contacts database — the same person can be reached on multiple channels. A channel_id identifies a specific connected messaging channel in your account, for example your WhatsApp Business number. When sending a message, you specify both: who to send it to (contact_id) and which channel to send it through (channel_id). If a contact has multiple channels available, specifying the channel_id controls which one is used. See the WhatsApp Business API glossary entry for more context on how the API layer connects to numbers.
What are the SendSeven API rate limits?
Rate limits apply per API key and depend on your plan tier. When you hit a limit, the API returns 429 Too Many Requests. The Retry-After header indicates how many seconds to wait. The WhatsAppClient class in this tutorial implements exponential backoff with jitter, which handles 429 responses automatically. For high-volume sends, batch your requests and distribute them over time rather than firing a burst.
Do I need to use a WhatsApp template for every message?
No. Templates are only required when you initiate a conversation or send a message outside the 24-hour service window after the customer's last message. If a customer messages you and you reply within 24 hours, that is a service message — free, unlimited, and no template required since November 2024. Templates are necessary for proactive outreach (order confirmations, shipping updates, appointment reminders) where you are starting the conversation. The WhatsApp Business API complete guide covers the message category rules in detail.
How do I test webhooks locally without deploying?
Use ngrok, tunnelmole, or localtunnel to create a public HTTPS tunnel to your local port. Start your server on port 3000, run ngrok http 3000, and copy the https:// URL it generates. Register that URL as your webhook endpoint in SendSeven. All events will tunnel to your local machine in real time. Remember that the tunnel URL changes each time you restart ngrok unless you have a paid account with a reserved domain.
Is SendSeven GDPR-compliant?
SendSeven is hosted in Europe and operates under GDPR. For WhatsApp-specific compliance, WhatsApp processes message metadata under its own terms as a Meta Business Partner platform. Your obligations as a data controller apply to how you store and process message content and contact data on your own infrastructure. For a detailed compliance walkthrough, the WhatsApp API guide covers the compliance considerations in the context of API usage.