How Avox uses Meta's official WhatsApp Cloud API for every connected number - setup, checking it works, common errors and FAQ.
The WhatsApp Cloud API is Meta's official, Meta-hosted API for business messaging. Every number you connect to Avox runs on it. Avox handles the Meta side for you - the access token, the webhook subscription, the API calls - so you work in the dashboard or with a simple Avox API key instead.
You don't need a Meta developer account, a Meta app, or to copy any token - Avox's own Meta app and Embedded Signup take care of that.
Open Connections
Number type
Webhook setup
https:// Webhook URL for your own endpoint and, optionally, a Verify Token (Generate makes one). See Message forwarding.Test endpoint
Connect with Meta
When Meta finishes, Avox confirms the number belongs to the account you approved, subscribes to its events, and marks the connection Connected. The full walkthrough is on Setup.
Meta's own error numbers are explained on Meta error codes.
avx_test_) never sends
anything, so you can build safely before switching to a live key (avx_live_).Avox holds the Cloud API connection for you, so your server never talks to Meta directly. It sends through the Avox API with an Avox key from an environment variable. The server below gives your own apps one route, POST /notify, that sends an approved template through your number.
# 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.
No. Avox connects through its own Meta app, and Embedded Signup links your account to it.
Avox holds the Meta access token for you. To send from your own code, use the Avox API - it accepts WhatsApp's own message format.
Avox keeps its Meta calls on a current Graph API version and updates it as Meta releases new ones. You don't need to track versions yourself.
The number stays yours on Meta's platform. Deleting a connection in Avox is confirmed with a one-time code, and the connection is kept for a few days before it's removed for good. See Connections.
Last updated