Skip to content
Oxtro
Documentation menu

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
// 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"
202 Accepted
{
  "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.

Node.js
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;
200 OK
{
  "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.

Node.js
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}`);
}
200 OK
{
  "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.

Node.js
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);