Turn on WhatsApp Business Calling for a connected number, ask customers for permission to call, and see call history in Avox.
WhatsApp Business Calling lets customers call your business number over WhatsApp. Avox turns calling on for a connected number with one switch, records the call events Meta reports in a call history, and sends customers WhatsApp's official request for permission to call them.
More detail: Calling (the switch), Calls (the page) and Calling on WhatsApp (Meta's side).
Open the connection
Turn on Calling
Open Calls
WhatsApp requires a customer's permission before a business calls them.
Find Request calling permission
Choose a contact
Click Send request
The request is a normal message, so it works within 24 hours of the customer's last message. For a customer who hasn't written recently, send a template from the Calling permission request template type instead. See Templates.
Turning calling on and viewing call history happen in Avox, so they need no server. What your server can do is ask customers for permission to call from your own systems - for example, when a support ticket needs a call. The Avox API sends WhatsApp's call permission request as an interactive message, the same request the Send request button sends. It follows the 24-hour window, so the route below falls back to your approved Calling permission request template when WhatsApp reports the window is closed. Calling must be on for the connection.
# Environment
AVOX_API_KEY=avx_live_your_key
INTERNAL_TOKEN=a-long-random-string # your apps send this in x-internal-token
CALL_PERMISSION_TEMPLATE=call_permission_request # your approved template's name
A closed window can also be reported shortly after sending, as a failed status on Logs with Meta code 131047 - check there if a request doesn't reach the customer.
// 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 /call-permission { "to": "15551234567", "reference": "ticket-881" }
// Asks a customer for permission to call. Inside the 24-hour window it sends WhatsApp's
// call permission request; outside it, your approved "Calling permission request" template.
app.post("/call-permission", express.json(), async (req, res) => {
if (!safeEqual(req.get("x-internal-token"), process.env.INTERNAL_TOKEN)) return res.sendStatus(401);
const { to, reference } = req.body ?? {};
if (!to || !reference) return res.status(400).json({ error: "to and reference are required" });
try {
const result = await sendWhatsApp(
{
to,
type: "interactive",
interactive: {
type: "call_permission_request",
body: { text: "We'd like to call you about your recent conversation - is that okay?" },
},
},
`call-permission-${reference}`
);
return res.json({ ok: true, message_id: result.message_id });
} catch (err) {
if (err.code === "outside_messaging_window" && process.env.CALL_PERMISSION_TEMPLATE) {
const result = await sendWhatsApp(
{ to, type: "template", template: { name: process.env.CALL_PERMISSION_TEMPLATE, language: { code: "en_US" } } },
`call-permission-template-${reference}`
);
return res.json({ ok: true, message_id: result.message_id, via: "template" });
}
return res.status(err.status >= 500 ? 502 : 422).json({ ok: false, code: err.code, request_id: err.requestId });
}
});
app.listen(process.env.PORT ?? 3000, () => console.log("Listening"));
// app/api/call-permission/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/call-permission { "to": "15551234567", "reference": "ticket-881" }
// Inside the 24-hour window: WhatsApp's call permission request. Outside it: your approved template.
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 { to, reference } = (await request.json().catch(() => null)) ?? {};
if (!to || !reference) return NextResponse.json({ error: "to and reference are required" }, { status: 400 });
try {
const result = await sendWhatsApp(
{
to,
type: "interactive",
interactive: {
type: "call_permission_request",
body: { text: "We'd like to call you about your recent conversation - is that okay?" },
},
},
`call-permission-${reference}`
);
return NextResponse.json({ ok: true, message_id: result.message_id });
} catch (err) {
const e = err as AvoxFailure;
if (e.code === "outside_messaging_window" && process.env.CALL_PERMISSION_TEMPLATE) {
const result = await sendWhatsApp(
{ to, type: "template", template: { name: process.env.CALL_PERMISSION_TEMPLATE, language: { code: "en_US" } } },
`call-permission-template-${reference}`
);
return NextResponse.json({ ok: true, message_id: result.message_id, via: "template" });
}
return NextResponse.json({ ok: false, code: e.code, request_id: e.requestId }, { 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 /call-permission {"to": "15551234567", "reference": "ticket-881"}
# Inside the 24-hour window: WhatsApp's call permission request. Outside it: your approved template.
@app.post("/call-permission")
async def call_permission(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, reference = body.get("to"), body.get("reference")
if not (to and reference):
return JSONResponse({"error": "to and reference are required"}, status_code=400)
try:
result = await send_whatsapp(
{
"to": to,
"type": "interactive",
"interactive": {
"type": "call_permission_request",
"body": {"text": "We'd like to call you about your recent conversation - is that okay?"},
},
},
idempotency_key=f"call-permission-{reference}",
)
return {"ok": True, "message_id": result.get("message_id")}
except AvoxError as err:
template_name = os.environ.get("CALL_PERMISSION_TEMPLATE")
if err.code == "outside_messaging_window" and template_name:
result = await send_whatsapp(
{"to": to, "type": "template", "template": {"name": template_name, "language": {"code": "en_US"}}},
idempotency_key=f"call-permission-template-{reference}",
)
return {"ok": True, "message_id": result.get("message_id"), "via": "template"}
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.
Once Calling is on, customers call your number from WhatsApp the way they'd call anyone.
The in-chat request always uses the wording above. For your own wording, use the Calling permission request template.
Last updated