Send WhatsApp messages from Node.js with the Avox API and receive customer messages with message forwarding - a complete client with idempotency and retries, a signed receiver, checks, errors and FAQ.
Send and receive WhatsApp messages in any Node.js app through a number connected to Avox, using the
built-in fetch, the Avox API and
message forwarding. No SDK to install - the client below is
complete and runs as-is.
Send: your Node.js app → Avox API → customer's WhatsApp
Receive: customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → your Node.js route
sendWhatsApp() helper with an Idempotency-Key and safe retries.Avox-Signature on forwarded messages.fetch).https://.# .env
AVOX_API_KEY=avx_live_your_key
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret # only if you receive forwarded messages
Open API Keys
Pick the key type
Choose the number
Create and copy it
AVOX_API_KEY. It's shown only once.Check it
Live keys start with avx_live_, test keys with avx_test_. See API keys.
// avox.js - Node.js 18+, no dependencies
import { randomUUID } from "node:crypto";
// Avox client: one Idempotency-Key per message, retries only what's safe to retry.
const RETRY_SAME_KEY = new Set(["rate_limited", "idempotency_in_progress", "internal_error", "network_error"]);
const RETRY_NEW_KEY = new Set(["meta_rate_limited"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoffMs = (attempt) => Math.min(30_000, 1_000 * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);
async function sendWhatsApp(message, idempotencyKey = randomUUID(), maxAttempts = 5) {
let key = idempotencyKey;
for (let attempt = 1; ; attempt++) {
const res = await fetch("https://api.avox.com/api/messages/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AVOX_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(message),
signal: AbortSignal.timeout(30_000),
}).catch(() => null);
const data = res ? await res.json().catch(() => null) : null;
const requestId = res?.headers.get("X-Request-Id") ?? null;
if (data?.ok) {
console.info(`[avox] sent message_id=${data.message_id} request_id=${requestId}${data.test ? " (test key)" : ""}`);
return data;
}
const code = data?.error?.code ?? (res ? `http_${res.status}` : "network_error");
console.warn(`[avox] ${code} request_id=${requestId} attempt=${attempt}`);
const retryable = RETRY_SAME_KEY.has(code) || RETRY_NEW_KEY.has(code);
if (!retryable || attempt >= maxAttempts) {
throw Object.assign(new Error(`Avox send failed: ${code}`), {
code,
status: res?.status ?? 502,
requestId: data?.error?.request_id ?? requestId,
details: data?.error?.details,
});
}
if (RETRY_NEW_KEY.has(code)) key = randomUUID(); // WhatsApp's refusal is stored against the old key
const retryAfter = Number(res?.headers.get("Retry-After"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
}
}
export { sendWhatsApp };
On success it returns { ok, request_id, message_id, result } (plus test: true with a test key).
On failure it throws an error with code, status, requestId and details. It retries only
what's safe to retry, with the same Idempotency-Key, honouring Retry-After; after
meta_rate_limited it uses a new key, because WhatsApp's refusal is stored against the old one.
meta_unavailable isn't retried - WhatsApp didn't answer in time, so the message may already be
out. The rules are on Rate limits and Idempotency.
import { sendWhatsApp } from "./avox.js";
// 1. A template to start a conversation (an order confirmation).
const confirmation = await sendWhatsApp(
{
to: "15551234567",
type: "template",
template: {
name: "order_confirmation",
language: { code: "en" },
components: [{ type: "body", parameters: [{ type: "text", text: "A-1042" }, { type: "text", text: "$42.00" }] }],
},
biz_opaque_callback_data: "order-A-1042",
},
"order-A-1042-confirmation" // one key per order: a repeat run never sends twice
);
console.log("Sent", confirmation.message_id);
// 2. Buttons, inside the 24-hour window. A tap comes back as a forwarded
// "interactive" message with the button's id, such as "slot_keep".
await sendWhatsApp({
to: "15551234567",
type: "interactive",
interactive: {
type: "button",
body: { text: "Is Friday at 3pm still good for delivery?" },
action: {
buttons: [
{ type: "reply", reply: { id: "slot_keep", title: "Yes, Friday" } },
{ type: "reply", reply: { id: "slot_change", title: "Pick another day" } },
],
},
},
});
// 3. Handling a refusal.
try {
await sendWhatsApp({ to: "15551234567", type: "text", text: { body: "Thanks for your order!" } });
} catch (err) {
if (err.code === "outside_messaging_window") {
// send a template instead
} else {
console.error(err.message, err.requestId, err.details);
}
}
Every message type is on Message types.
Turn on message forwarding, and Avox posts each new customer message to your server as a signed
message.received event. Each v1 value in the Avox-Signature header is a hex HMAC-SHA256 of
the t value, a full stop, then the raw body, keyed with your whole signing secret. Check it on
the raw bytes before parsing, refuse a t more than 5 minutes from your clock, answer 200 within
10 seconds, and skip events whose id you've already handled.
// receiver.js - Node.js 18+ (npm install express). Run: AVOX_WEBHOOK_SECRET=whsec_... node receiver.js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
// The key is the whole signing secret, whsec_ included, as plain text.
function verifyAvoxSignature(rawBody, header, secret) {
if (!header || !secret) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i <= 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value);
}
if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((sig) => timingSafeEqual(Buffer.from(sig, "hex"), expected));
}
// Retries and Resend keep the same event id. Keep handled ids in your database in production.
const seen = new Set();
async function handleEvent(event) {
if (seen.has(event.id)) return;
seen.add(event.id);
if (seen.size > 10_000) seen.delete(seen.values().next().value);
// Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
if (process.env.FORWARD_TO_URL) {
const res = await fetch(process.env.FORWARD_TO_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(event),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) console.warn(`[avox] passing on ${event.id} failed: HTTP ${res.status}`);
return;
}
if (event.test) return console.info(`[avox] test event ${event.id} received`);
const { from, contact_name, type, text } = event.data;
console.info(`[avox] ${type} from ${contact_name ?? from}${text ? `: ${text.body}` : ""}`);
}
const app = express();
// express.raw keeps the exact bytes the signature covers - don't parse JSON on this route before checking it.
app.post("/avox/messages", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
if (!Buffer.isBuffer(req.body) || !verifyAvoxSignature(req.body, req.get("Avox-Signature"), process.env.AVOX_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // answer within 10 seconds, then do the work
handleEvent(event).catch((err) => console.error("[avox] handling failed:", err.message));
});
app.listen(process.env.PORT ?? 3000, () => console.log("Listening"));
// app/avox/messages/route.ts - Next.js 15+ (App Router)
import { createHmac, timingSafeEqual } from "node:crypto";
import { after } from "next/server";
export const runtime = "nodejs";
const TOLERANCE_SECONDS = 5 * 60;
type AvoxEvent = {
id: string;
type: "message.received";
test?: true;
connection_id: string;
data: { message_id: string; from: string; contact_name: string | null; type: string; text?: { body: string } };
};
// Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
function verifyAvoxSignature(rawBody: string, header: string | null, secret: string | undefined): boolean {
if (!header || !secret) return false;
let timestamp: number | null = null;
const signatures: string[] = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i <= 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value);
}
if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest();
return signatures.some((sig) => timingSafeEqual(Buffer.from(sig, "hex"), expected));
}
// Retries and Resend keep the same event id. Per instance only - keep handled ids in your database in production.
const seen = new Set<string>();
async function handleEvent(event: AvoxEvent) {
if (seen.has(event.id)) return;
seen.add(event.id);
if (seen.size > 10_000) seen.delete(seen.values().next().value!);
// Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
if (process.env.FORWARD_TO_URL) {
const res = await fetch(process.env.FORWARD_TO_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(event),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) console.warn(`[avox] passing on ${event.id} failed: HTTP ${res.status}`);
return;
}
if (event.test) return console.info(`[avox] test event ${event.id} received`);
const { from, contact_name, type, text } = event.data;
console.info(`[avox] ${type} from ${contact_name ?? from}${text ? `: ${text.body}` : ""}`);
}
export async function POST(request: Request) {
const rawBody = await request.text(); // the exact body, before any parsing
if (!verifyAvoxSignature(rawBody, request.headers.get("avox-signature"), process.env.AVOX_WEBHOOK_SECRET)) {
return new Response(null, { status: 401 });
}
const event = JSON.parse(rawBody) as AvoxEvent;
after(() => handleEvent(event).catch((err) => console.error("[avox] handling failed:", err))); // runs after the answer
return new Response(null, { status: 200 });
}
# receiver.py - pip install fastapi uvicorn httpx ; run: uvicorn receiver:app --port 3000
import hashlib
import hmac
import json
import logging
import os
import re
import time
from collections import OrderedDict
import httpx
from fastapi import BackgroundTasks, FastAPI, Request, Response
log = logging.getLogger("avox")
app = FastAPI()
TOLERANCE_SECONDS = 5 * 60
HEX64 = re.compile(r"[0-9a-fA-F]{64}")
# Retries and Resend keep the same event id. Per process only - keep handled ids in your database in production.
seen: "OrderedDict[str, None]" = OrderedDict()
def verify_avox_signature(raw: bytes, header: str | None, secret: str) -> bool:
# Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
if not header or not secret:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and HEX64.fullmatch(value):
signatures.append(value.lower())
if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
async def handle_event(event: dict) -> None:
if event["id"] in seen:
return
seen[event["id"]] = None
if len(seen) > 10_000:
seen.popitem(last=False)
# Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
forward_to = os.environ.get("FORWARD_TO_URL")
if forward_to:
try:
async with httpx.AsyncClient(timeout=10) as client:
res = await client.post(forward_to, json=event)
if res.status_code >= 300:
log.warning("passing on %s failed: HTTP %s", event["id"], res.status_code)
except httpx.HTTPError as err:
log.warning("passing on %s failed: %s", event["id"], err)
return
if event.get("test"):
log.info("test event %s received", event["id"])
return
data = event["data"]
log.info("%s from %s %s", data["type"], data.get("contact_name") or data["from"], (data.get("text") or {}).get("body", ""))
@app.post("/avox/messages")
async def receive(request: Request, background: BackgroundTasks):
raw = await request.body() # the exact bytes, before any parsing
if not verify_avox_signature(raw, request.headers.get("avox-signature"), os.environ.get("AVOX_WEBHOOK_SECRET", "")):
return Response(status_code=401)
background.add_task(handle_event, json.loads(raw)) # runs after the answer
return Response(status_code=200)
Each receiver checks the Avox-Signature header over the raw body before trusting anything, refuses a timestamp more than 5 minutes from your clock, accepts either signature during the 24 hours after you rotate the secret, answers 200 straight away and does the work afterwards, and skips repeats by event id. With FORWARD_TO_URL set it passes every verified event on unchanged - including test events, which carry "test": true so you can filter them. The full payload for every message type, the retry schedule and auto-pause are on Receive messages.
To answer a forwarded message, send a plain text reply to event.data.from - the customer has just
written, so the 24-hour window is open. Add context: { message_id: event.data.message_id } to
quote their message.
Create the API key
Add the client
avox.js into your app.Send to your own phone
to.Turn on forwarding (optional)
AVOX_WEBHOOK_SECRET, then click Send test event. You need the Admin role."test": true, the header Avox-Test-Mode: true, and a message_id starting wamid.TEST - and special test numbers return each error on purpose, so you can test your error handling. See Testing.ok: true and a message_id.200. The API logs page (Sent tab) has the full history - search it by request_id or message_id.Every code, with what to do about it, is on API errors.
Avox-Signature before trusting a forwarded message, and rotate the signing secret if it leaks.The avox.js client above drops into any Node.js app. If you'd rather run a small dedicated service that your other apps call, here's a complete one - with the same client built in - as an Express app or a Next.js route handler, plus a FastAPI version for Python teams. It exposes POST /notify, which sends an approved template.
# Environment
AVOX_API_KEY=avx_live_your_key
INTERNAL_TOKEN=a-long-random-string # your apps send this in x-internal-token
// server.js - Node.js 18+ (npm install express). Run: AVOX_API_KEY=... INTERNAL_TOKEN=... node server.js
import express from "express";
import { randomUUID, timingSafeEqual } from "node:crypto";
const app = express();
// Avox client: one Idempotency-Key per message, retries only what's safe to retry.
const RETRY_SAME_KEY = new Set(["rate_limited", "idempotency_in_progress", "internal_error", "network_error"]);
const RETRY_NEW_KEY = new Set(["meta_rate_limited"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoffMs = (attempt) => Math.min(30_000, 1_000 * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);
async function sendWhatsApp(message, idempotencyKey = randomUUID(), maxAttempts = 5) {
let key = idempotencyKey;
for (let attempt = 1; ; attempt++) {
const res = await fetch("https://api.avox.com/api/messages/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AVOX_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(message),
signal: AbortSignal.timeout(30_000),
}).catch(() => null);
const data = res ? await res.json().catch(() => null) : null;
const requestId = res?.headers.get("X-Request-Id") ?? null;
if (data?.ok) {
console.info(`[avox] sent message_id=${data.message_id} request_id=${requestId}${data.test ? " (test key)" : ""}`);
return data;
}
const code = data?.error?.code ?? (res ? `http_${res.status}` : "network_error");
console.warn(`[avox] ${code} request_id=${requestId} attempt=${attempt}`);
const retryable = RETRY_SAME_KEY.has(code) || RETRY_NEW_KEY.has(code);
if (!retryable || attempt >= maxAttempts) {
throw Object.assign(new Error(`Avox send failed: ${code}`), {
code,
status: res?.status ?? 502,
requestId: data?.error?.request_id ?? requestId,
details: data?.error?.details,
});
}
if (RETRY_NEW_KEY.has(code)) key = randomUUID(); // WhatsApp's refusal is stored against the old key
const retryAfter = Number(res?.headers.get("Retry-After"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
}
}
function safeEqual(a, b) {
const x = Buffer.from(a ?? "");
const y = Buffer.from(b ?? "");
return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
}
// POST /notify { "to": "15551234567", "template": "order_shipped", "params": ["A-1042"], "reference": "order-A-1042-shipped" }
// Your own tools call this with the x-internal-token header; the Avox key never leaves this server.
app.post("/notify", express.json({ limit: "100kb" }), async (req, res) => {
if (!safeEqual(req.get("x-internal-token"), process.env.INTERNAL_TOKEN)) return res.sendStatus(401);
const { to, template, params = [], language = "en_US", reference } = req.body ?? {};
if (!to || !template || !reference) return res.status(400).json({ error: "to, template and reference are required" });
try {
const result = await sendWhatsApp(
{
to,
type: "template",
template: {
name: template,
language: { code: language },
...(params.length ? { components: [{ type: "body", parameters: params.map((t) => ({ type: "text", text: String(t) })) }] } : {}),
},
biz_opaque_callback_data: reference,
},
reference // the same reference always means the same message: retries never send twice
);
res.json({ ok: true, message_id: result.message_id });
} catch (err) {
res.status(err.status >= 500 ? 502 : 422).json({ ok: false, code: err.code, request_id: err.requestId, details: err.details });
}
});
app.listen(process.env.PORT ?? 3000, () => console.log("Listening"));
// app/api/notify/route.ts - Next.js (App Router)
import { randomUUID, timingSafeEqual } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
export const runtime = "nodejs";
// Avox client: one Idempotency-Key per message, retries only what's safe to retry.
const RETRY_SAME_KEY = new Set(["rate_limited", "idempotency_in_progress", "internal_error", "network_error"]);
const RETRY_NEW_KEY = new Set(["meta_rate_limited"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const backoffMs = (attempt: number) => Math.min(30_000, 1_000 * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);
type AvoxFailure = Error & { code: string; status: number; requestId: string | null; details?: unknown };
async function sendWhatsApp(message: Record<string, unknown>, idempotencyKey: string = randomUUID(), maxAttempts = 5) {
let key = idempotencyKey;
for (let attempt = 1; ; attempt++) {
const res = await fetch("https://api.avox.com/api/messages/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AVOX_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(message),
cache: "no-store",
signal: AbortSignal.timeout(30_000),
}).catch(() => null);
const data = res ? await res.json().catch(() => null) : null;
const requestId = res?.headers.get("X-Request-Id") ?? null;
if (data?.ok) {
console.info(`[avox] sent message_id=${data.message_id} request_id=${requestId}${data.test ? " (test key)" : ""}`);
return data as { ok: true; request_id: string; message_id: string | null; test?: true; result: unknown };
}
const code: string = data?.error?.code ?? (res ? `http_${res.status}` : "network_error");
console.warn(`[avox] ${code} request_id=${requestId} attempt=${attempt}`);
const retryable = RETRY_SAME_KEY.has(code) || RETRY_NEW_KEY.has(code);
if (!retryable || attempt >= maxAttempts) {
throw Object.assign(new Error(`Avox send failed: ${code}`), {
code,
status: res?.status ?? 502,
requestId: data?.error?.request_id ?? requestId,
details: data?.error?.details,
}) as AvoxFailure;
}
if (RETRY_NEW_KEY.has(code)) key = randomUUID(); // WhatsApp's refusal is stored against the old key
const retryAfter = Number(res?.headers.get("Retry-After"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
}
}
function safeEqual(a: string | null, b: string | undefined) {
const x = Buffer.from(a ?? "");
const y = Buffer.from(b ?? "");
return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
}
// POST /api/notify { "to": "15551234567", "template": "order_shipped", "params": ["A-1042"], "reference": "order-A-1042-shipped" }
// Your own tools call this with the x-internal-token header; the Avox key never leaves the server.
export async function POST(request: NextRequest) {
if (!safeEqual(request.headers.get("x-internal-token"), process.env.INTERNAL_TOKEN)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => null);
const { to, template, params = [], language = "en_US", reference } = body ?? {};
if (!to || !template || !reference) {
return NextResponse.json({ error: "to, template and reference are required" }, { status: 400 });
}
try {
const result = await sendWhatsApp(
{
to,
type: "template",
template: {
name: template,
language: { code: language },
...(params.length ? { components: [{ type: "body", parameters: params.map((t: unknown) => ({ type: "text", text: String(t) })) }] } : {}),
},
biz_opaque_callback_data: reference,
},
reference // the same reference always means the same message: retries never send twice
);
return NextResponse.json({ ok: true, message_id: result.message_id });
} catch (err) {
const e = err as AvoxFailure;
return NextResponse.json({ ok: false, code: e.code, request_id: e.requestId, details: e.details }, { status: e.status >= 500 ? 502 : 422 });
}
}
# main.py - pip install fastapi uvicorn httpx ; run: uvicorn main:app --port 3000
import asyncio
import hmac
import logging
import os
import random
import uuid
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
log = logging.getLogger("avox")
app = FastAPI()
http = httpx.AsyncClient(timeout=30)
# Avox client: one Idempotency-Key per message, retries only what's safe to retry.
RETRY_SAME_KEY = {"rate_limited", "idempotency_in_progress", "internal_error", "network_error"}
RETRY_NEW_KEY = {"meta_rate_limited"}
class AvoxError(Exception):
def __init__(self, code, status, request_id, details=None):
super().__init__(f"Avox send failed: {code}")
self.code, self.status, self.request_id, self.details = code, status, request_id, details
def backoff(attempt):
return min(30.0, 2 ** (attempt - 1)) * (0.5 + random.random() / 2)
async def send_whatsapp(message: dict, idempotency_key: str | None = None, max_attempts: int = 5) -> dict:
key = idempotency_key or str(uuid.uuid4())
attempt = 0
while True:
attempt += 1
res, data = None, None
try:
res = await http.post(
"https://api.avox.com/api/messages/send",
headers={"Authorization": f"Bearer {os.environ['AVOX_API_KEY']}", "Idempotency-Key": key},
json=message,
)
data = res.json()
except (httpx.TransportError, ValueError):
pass
request_id = res.headers.get("X-Request-Id") if res is not None else None
if data and data.get("ok"):
log.info("sent message_id=%s request_id=%s", data.get("message_id"), request_id)
return data
error = (data or {}).get("error") or {}
code = error.get("code") or (f"http_{res.status_code}" if res is not None else "network_error")
log.warning("%s request_id=%s attempt=%s", code, request_id, attempt)
if code not in RETRY_SAME_KEY | RETRY_NEW_KEY or attempt >= max_attempts:
raise AvoxError(code, res.status_code if res is not None else 502,
error.get("request_id", request_id), error.get("details"))
if code in RETRY_NEW_KEY:
key = str(uuid.uuid4()) # WhatsApp's refusal is stored against the old key
retry_after = int(res.headers.get("Retry-After") or 0) if res is not None else 0
await asyncio.sleep(retry_after if retry_after > 0 else backoff(attempt))
# POST /notify {"to": "15551234567", "template": "order_shipped", "params": ["A-1042"], "reference": "order-A-1042-shipped"}
# Your own tools call this with the x-internal-token header; the Avox key never leaves this server.
@app.post("/notify")
async def notify(request: Request):
token = request.headers.get("x-internal-token", "")
expected = os.environ.get("INTERNAL_TOKEN", "")
if not token or not expected or not hmac.compare_digest(token.encode(), expected.encode()):
return Response(status_code=401)
body = await request.json()
to, name, reference = body.get("to"), body.get("template"), body.get("reference")
if not (to and name and reference):
return JSONResponse({"error": "to, template and reference are required"}, status_code=400)
template = {"name": name, "language": {"code": body.get("language", "en_US")}}
params = body.get("params") or []
if params:
template["components"] = [{"type": "body", "parameters": [{"type": "text", "text": str(p)} for p in params]}]
try:
# The same reference always means the same message: retries never send twice.
result = await send_whatsapp(
{"to": to, "type": "template", "template": template, "biz_opaque_callback_data": reference},
idempotency_key=reference,
)
return {"ok": True, "message_id": result.get("message_id")}
except AvoxError as err:
return JSONResponse({"ok": False, "code": err.code, "request_id": err.request_id, "details": err.details},
status_code=502 if err.status >= 500 else 422)
Each version is a complete file: the Avox client (an Idempotency-Key per message, retries with backoff that honour Retry-After, and error.code and request_id in every log line) plus the route. Run Express with Node.js 18 or later, the Next.js route in any App Router project, and FastAPI with Python 3.10 or later. meta_unavailable is deliberately not retried: WhatsApp didn't answer in time, so the message may already be out. For the full walkthrough - key setup, local testing with a tunnel, a deployment checklist - see Connect your server.
No. The API is a single HTTPS endpoint, so plain fetch is all you need - the client above is the
whole thing.
Yes. The code is standard ES modules; add types for the message body as you like. The Next.js versions above are already TypeScript.
Set up Automation in Avox - it runs on every inbound message for the number - or
receive forwarded messages with the receiver above and reply with sendWhatsApp(). Your team can
also answer from the Inbox.
On Logs, as sent, delivered, read or failed statuses carrying the message_id.
See Matching a status to a send.
Last updated