Connect your own systems to WhatsApp with an Avox API key - requirements, setup, a first request, checks, common errors and FAQ.
The Avox API lets any system that can make an HTTPS request - your backend, a store, a CRM, an automation tool - send WhatsApp messages through a number connected to Avox. One endpoint, one key per number, WhatsApp's own message format.
This page is the quick guide. The full reference starts at Avox API.
message_id, so you can match delivery statuses and replies later.avx_live_...) that send for real, and test keys (avx_test_...) that run
every check without sending anything. See Testing.Create a key
Copy it once
Store it
AVOX_API_KEY.Send a request
POST https://api.avox.com/api/messages/send with Authorization: Bearer <key>.Follow up
curl -X POST https://api.avox.com/api/messages/send \
-H "Authorization: Bearer $AVOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f0c6a52-1b7e-4b8e-9d0f-6a2c51e4d7b9" \
-d '{"to":"15551234567","type":"template","template":{"name":"hello_world","language":{"code":"en_US"}}}'
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": crypto.randomUUID(),
},
body: JSON.stringify({
to: "15551234567",
type: "template",
template: { name: "hello_world", language: { code: "en_US" } },
}),
});
console.log(res.status, await res.json());
import os
import uuid
import requests
res = requests.post(
"https://api.avox.com/api/messages/send",
headers={
"Authorization": f"Bearer {os.environ['AVOX_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"to": "15551234567",
"type": "template",
"template": {"name": "hello_world", "language": {"code": "en_US"}},
},
timeout=30,
)
print(res.status_code, res.json())
Use a template that's approved on your account - hello_world is only an example name.
200 with "ok": true and a message_id. A test key's answer also has
"test": true.200. Or
expand the key in Settings > API Keys to see it under Recent requests as Send.Retry-After on every 429. See
Rate limits.Your server is the only place that should hold the API key. The server below wraps the Avox API in one route, POST /notify, that your other apps call with an internal token and a reference from your own system. The reference becomes the Idempotency-Key, so the same notification is never sent twice.
# 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=... 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}`);
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 apps call this with the x-internal-token header; the 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}`);
return data as { ok: true; request_id: string; message_id: string | null; 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 apps call this with the x-internal-token header; the 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))
def avox_error_response(err: AvoxError):
status = 502 if err.status >= 500 else 422
return JSONResponse({"ok": False, "code": err.code, "request_id": err.request_id, "details": err.details},
status_code=status)
# POST /notify {"to": "15551234567", "template": "order_shipped", "params": ["A-1042"], "reference": "order-A-1042-shipped"}
# Your own apps call this with the x-internal-token header; the key never leaves this server.
@app.post("/notify")
async def notify(request: Request):
token = request.headers.get("x-internal-token", "")
if not token or not hmac.compare_digest(token, os.environ.get("INTERNAL_TOKEN", "")):
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 avox_error_response(err)
Each version is a complete file: the Avox client (an Idempotency-Key per message, retries with backoff that honour Retry-After, 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. For the full walkthrough - key setup, local testing with a tunnel, a deployment checklist - see Connect your server.
To receive this number's WhatsApp messages on your own server, see Message forwarding.
API access depends on the plan. The plan cards on Plans and on Billing > Change
plan list API access on the plans that include it. On a plan without it, your keys stay listed
but can't send (api_not_in_plan), and new keys can't be created or rotated until you move to a plan
with API access. See API access and your plan.
Each key belongs to one number, so create one key per number and pick the key in your code. See Authentication.
Send an Idempotency-Key header. See Idempotency.
Open Logs: each message's sent, delivered, read and failed events carry its
message_id. See Delivery statuses and replies.
Yes. Create a Test key (avx_test_...). It runs every check a live send does, then answers
with a pretend result ("test": true) and never sends anything. Special test numbers return each
WhatsApp error on purpose. The Test button on a key is a separate dry run that checks the key
and its number from the dashboard. See Testing.
On the API logs page in the dashboard. The Sent tab lists every request made with your keys: time, key, connection, outcome and error code, masked recipient, request id and latency. It has filters, search by request id or message id, and Export CSV. See API logs.
Rotate it in Settings > API Keys. You get a new key straight away and choose when the old one stops: immediately, in 1 hour, in 24 hours or in 7 days. See Rotating a key.
Last updated