Examples
Send WhatsApp messages and OTP codes from Node.js
Plain fetch, no SDK. Works on Node.js 18 and later.
You need an API key and the ID of a connected number. The quickstart covers both.
Send a message
The API answers 202 with status: "queued". The Idempotency-Key header makes a retry safe. Full reference: Messages API.
// Node.js 18 or later (built-in fetch)
const res = await fetch('https://api.getoxtro.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OXTRO_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-4821-confirmation',
},
body: JSON.stringify({
connection_id: 'conn_123',
to: '+213555123456',
text: 'Your order ORD-4821 has been confirmed.',
}),
});
const body = await res.json();
if (!res.ok) {
throw new Error(`${body.error.code}: ${body.error.message}`);
}
console.log(body.id, body.status); // "msg_123" "queued"{
"id": "msg_123",
"status": "queued",
"queued_at": "2026-10-10T09:41:07.000Z"
}Send a one-time code
Oxtro generates the code and sends it on WhatsApp. You only keep the request_id. Full reference: OTP API.
const res = await fetch('https://api.getoxtro.com/v1/otp/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OXTRO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ connection_id: 'conn_123', to: '+213555123456' }),
});
const body = await res.json();
if (!res.ok) {
throw new Error(`${body.error.code}: ${body.error.message}`);
}
// Keep request_id with the user's session. The code itself never reaches your server.
const requestId = body.request_id;{
"request_id": "otp_123",
"expires_in": 300,
"status": "sent"
}Verify the code
A correct code returns 200. A wrong, expired or locked code returns 400 with the code OTP_INVALID, OTP_EXPIRED or OTP_MAX_ATTEMPTS.
async function verifyCode(requestId, code) {
const res = await fetch('https://api.getoxtro.com/v1/otp/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OXTRO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ request_id: requestId, code }),
});
if (res.ok) return { verified: true };
// A wrong, expired or locked code is an HTTP 400 with one of these codes.
const { error } = await res.json();
if (['OTP_INVALID', 'OTP_EXPIRED', 'OTP_MAX_ATTEMPTS'].includes(error.code)) {
return { verified: false, reason: error.code };
}
throw new Error(`${error.code}: ${error.message}`);
}{
"verified": true,
"request_id": "otp_123",
"phone_number": "+213555123456",
"verified_at": "2026-10-10T09:42:31.000Z"
}Verify a webhook signature
Oxtro signs every webhook with HMAC-SHA256 over <timestamp>.<raw body>. Check it before you trust the event. Full reference: Webhooks.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// express.raw keeps the body as the exact bytes Oxtro signed.
app.post('/webhooks/oxtro', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Webhook-Signature');
if (!isValidSignature(req.body, header, process.env.OXTRO_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
if (event.event === 'message.received') {
console.log(event.data.from, event.data.content);
}
res.status(200).end();
});
function isValidSignature(rawBody, header = '', secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((pair) => pair.split('=')));
if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
app.listen(3000);