# WhatsApp Calling Integration

> Turn on WhatsApp Business Calling for a connected number, ask customers for permission to call, and see call history in Avox.

Source: https://docs.theavoxai.com/integrations/whatsapp-calling

**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](https://docs.theavoxai.com/connections/calling) (the switch), [Calls](https://docs.theavoxai.com/calls) (the page) and
[Calling on WhatsApp](https://docs.theavoxai.com/whatsapp/calling) (Meta's side).

## Requirements

- A connected WhatsApp number with **Connected** status (not paused).
- The **Admin** role to turn calling on or off.
- Any member (Admin or Manager) can view call history and send permission requests.
- Calling is available on every plan.

## Setup

**1. Open the connection**

On [Connections](https://docs.theavoxai.com/connections), open the number.

**2. Turn on Calling**

Switch on **Calling** ("Lets customers call this number over WhatsApp."). Avox updates the number's calling setting on Meta.

**3. Open Calls**

The [Calls](https://docs.theavoxai.com/calls) page now shows "Real call events, populated by Meta's own Calling webhook."

## Asking a customer for permission to call

WhatsApp requires a customer's permission before a business calls them.

**1. Find Request calling permission**

On [Calls](https://docs.theavoxai.com/calls), use the **Request calling permission** card.

**2. Choose a contact**

Pick them under **Choose a contact**.

**3. Click Send request**

The customer receives WhatsApp's permission request with the text "We'd like to call you about your recent conversation - is that okay?" You'll see "Permission request sent."

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](https://docs.theavoxai.com/templates).

## Check it's working

- The **Calling** switch stays on after a page refresh.
- When a customer calls, the call appears on [Calls](https://docs.theavoxai.com/calls) with **Direction** (**Inbound** or
  **Outbound**), **Status** (**Connected**, **Ended** or **Missed**), **Duration**, **Contact** and
  **When**. Click the contact's name to open their conversation in the [Inbox](https://docs.theavoxai.com/inbox).
- The same events show in **Recent calls** on [Health](https://docs.theavoxai.com/health), with **View all** back to Calls.

## Common errors

| You see | Fix |
| --- | --- |
| "Couldn't update calling settings" when using the switch | Only an Admin can change it. Otherwise, try again - if it repeats, run **Check health now** on [Health](https://docs.theavoxai.com/health). |
| The switch is greyed out | The number is paused. Resume it on [Connections](https://docs.theavoxai.com/connections). |
| "Calling isn't enabled on this connection yet" on Calls | Click **Enable calling in Connections** and turn the switch on. |
| "Enable calling on this connection first." | Turn on **Calling** before sending permission requests. |
| "It's been over 24 hours since this contact last messaged you..." | Send the **Calling permission request** template instead. |
| "You're sending too quickly - wait a moment and try again." | Avox spaces out the requests each team member sends. Wait a moment and retry. |
| "Your WhatsApp number isn't connected right now." | Check the connection on [Connections](https://docs.theavoxai.com/connections) - resume it if it's paused, or connect it again. |
| [AVX-3065](https://docs.theavoxai.com/error-codes/avx-3065) | The permission request couldn't be sent. Try again. |
| [AVX-3064](https://docs.theavoxai.com/error-codes/avx-3064) | The call log couldn't load. Refresh the page. |

## Limits

- Permission requests follow the 24-hour window. See
  [Messaging window](https://docs.theavoxai.com/whatsapp/messaging-window).
- Requests are spaced out per team member - send them one customer at a time.
- Meta's own calling rules and pricing apply. See [Calling on WhatsApp](https://docs.theavoxai.com/whatsapp/calling).

## Security

- Only Admins can turn calling on or off for a number.
- Calls are tied to the contact, so the history links straight to their conversation in the
  [Inbox](https://docs.theavoxai.com/inbox).

## Build your own server for WhatsApp Calling

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](https://docs.theavoxai.com/integrations/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.

```bash
# 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](https://docs.theavoxai.com/logs) with Meta code `131047` - check there if a request doesn't reach the customer.

**Express**

```js
// 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"));
```

**Next.js**

```ts
// 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 });
  }
}
```

**FastAPI**

```python
# 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](https://docs.theavoxai.com/avox-api/connect-your-server).

To receive this number's WhatsApp messages on your own server, see [Message forwarding](https://docs.theavoxai.com/integrations/webhooks).

## FAQ

**Do customers need to do anything to call us?**

Once **Calling** is on, customers call your number from WhatsApp the way they'd call anyone.

**Can I change the permission request text?**

The in-chat request always uses the wording above. For your own wording, use the **Calling
permission request** template.

**Where do I see one customer's calls?**

Click **Calls** from the contact in [Contacts](https://docs.theavoxai.com/contacts) or the [Inbox](https://docs.theavoxai.com/inbox) header. The Calls
page then shows "Showing calls for" that customer, with **Clear filter**.
