# WhatsApp Cloud API Integration

> How Avox uses Meta's official WhatsApp Cloud API for every connected number - setup, checking it works, common errors and FAQ.

Source: https://docs.theavoxai.com/integrations/whatsapp-cloud-api

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](https://docs.theavoxai.com/integrations/avox-api) key instead.

## What it does in Avox

| Cloud API capability | Where you use it in Avox |
| --- | --- |
| Send and receive messages | [Inbox](https://docs.theavoxai.com/inbox), [Automation](https://docs.theavoxai.com/automation), [Avox API](https://docs.theavoxai.com/avox-api) |
| Message templates | [Templates](https://docs.theavoxai.com/templates) - create, submit for review, track status, use Meta's template library |
| WhatsApp Flows | [Flows](https://docs.theavoxai.com/flows) - see [WhatsApp Flows](https://docs.theavoxai.com/integrations/whatsapp-flows) |
| Calling | [Calls](https://docs.theavoxai.com/calls) - see [WhatsApp Calling](https://docs.theavoxai.com/integrations/whatsapp-calling) |
| Webhook events | [Logs](https://docs.theavoxai.com/logs) (every event with its payload), [Inbox](https://docs.theavoxai.com/inbox), [Health](https://docs.theavoxai.com/health) |
| Number health | **Check health now** on [Health](https://docs.theavoxai.com/health) |
| Number settings | The connection's page: **Calling**, **Identity-Key Check**, `wa.me` username |

## Requirements

- An Avox account with an active plan that has a free number slot. See [Plans](https://docs.theavoxai.com/plans).
- A Facebook account that's an admin of your Meta Business Portfolio (or permission to create one
  during signup).
- A phone number: either one already in the WhatsApp Business app (Coexistence) or a number not
  used on WhatsApp that can receive a verification code.

You don't need a Meta developer account, a Meta app, or to copy any token - Avox's own Meta app
and [Embedded Signup](https://docs.theavoxai.com/integrations/embedded-signup) take care of that.

## Setup

**1. Open Connections**

In the dashboard, go to [Connections](https://docs.theavoxai.com/connections) and click **Connect another number** (with no number yet, the wizard opens on its own).

**2. Number type**

Choose **Existing number (Coexistence)** or **New number (Bot only)**.

**3. Webhook setup**

Enter a **Connection Name**, an `https://` **Webhook URL** for your own endpoint and, optionally, a **Verify Token** (**Generate** makes one). See [Message forwarding](https://docs.theavoxai.com/integrations/webhooks).

**4. Test endpoint**

Click **Test endpoint** to check your URL answers. The wizard shows the result and moves on to the last step either way.

**5. Connect with Meta**

Click **Continue to Meta Embedded Signup** and follow Meta's window to the end. See [Embedded Signup](https://docs.theavoxai.com/integrations/embedded-signup).

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

## Check it's working

- The connection shows **Connected** on [Connections](https://docs.theavoxai.com/connections), and its page lists **WABA ID**,
  **Phone Number ID** and **Business ID** under **Meta identifiers**.
- On [Health](https://docs.theavoxai.com/health), the heartbeat shows **Operational** once Meta starts sending events, and
  **Check health now** shows "Can send messages: This connection can send messages right now."
- Send a WhatsApp message to the number from your phone. It appears in the [Inbox](https://docs.theavoxai.com/inbox), and the event
  is listed on [Logs](https://docs.theavoxai.com/logs).
- In **Settings > API Keys**, click **Test** on a key. It checks the key is active and its number is
  connected and ready to send, without sending a real message. See
  [API keys](https://docs.theavoxai.com/connections/api-keys).

## Common errors

| You see | Fix |
| --- | --- |
| [AVX-3002](https://docs.theavoxai.com/error-codes/avx-3002) - the connection attempt didn't go through | Read the reason in the wizard and click **Retry connection**. See [Embedded Signup](https://docs.theavoxai.com/integrations/embedded-signup#common-errors). |
| [AVX-3009](https://docs.theavoxai.com/error-codes/avx-3009) - every connection your plan allows is used | Upgrade, or pause or remove a number. See [Plans](https://docs.theavoxai.com/plans). |
| [AVX-3080](https://docs.theavoxai.com/error-codes/avx-3080) - couldn't check health with Meta | Try again in a minute. If it repeats, reconnect the number. |
| Connection shows **Error** | Setup didn't finish on Meta's side. Connect the number again. |
| Heartbeat shows **Down** | Meta hasn't sent an event for over 30 minutes. Normal for a quiet number - send a test message. If messages aren't arriving, run **Check health now**. |
| API sends fail with [`connection_not_ready`](https://docs.theavoxai.com/api/errors#connection_not_ready) | Avox's access for the number expired or was removed on Meta's side. Reconnect it. |

Meta's own error numbers are explained on [Meta error codes](https://docs.theavoxai.com/meta-error-codes).

## Limits

- **Numbers per account** - set by your plan. See [Plans](https://docs.theavoxai.com/plans).
- **Messaging limits and quality** - set by Meta per number. See
  [Quality and limits](https://docs.theavoxai.com/whatsapp/quality-and-limits).
- **The 24-hour window** - outside it, only templates go out. See
  [Messaging window](https://docs.theavoxai.com/whatsapp/messaging-window).
- **Meta's message pricing** - billed by Meta to your WhatsApp Business Account. See
  [Pricing](https://docs.theavoxai.com/whatsapp-platform).

## Security

- Meta's access token for your number stays inside Avox, encrypted. It's never shown in the
  dashboard and never sent to your browser or your servers.
- Your own servers use an [Avox API key](https://docs.theavoxai.com/avox-api/authentication) that can only send from one
  number. Admins can rotate a key (the old one keeps working for the time you choose, so you can
  swap it without downtime) or revoke it at any time. A test key (`avx_test_`) never sends
  anything, so you can build safely before switching to a live key (`avx_live_`).
- Every request made with your keys is listed in **API logs**, with its outcome and request id.
- You can remove Avox's access to your WhatsApp Business Account from your Business Portfolio
  settings on Meta at any time. The connection then needs reconnecting.

## Build your own server for WhatsApp Cloud API

Avox holds the Cloud API connection for you, so your server never talks to Meta directly. It sends through the [Avox API](https://docs.theavoxai.com/integrations/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.

```bash
# Environment
AVOX_API_KEY=avx_live_your_key
INTERNAL_TOKEN=a-long-random-string   # your apps send this in x-internal-token
```

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

**Next.js**

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

**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 /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](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 I need my own Meta developer app?**

No. Avox connects through its own Meta app, and Embedded Signup links your account to it.

**Can I still call Meta's API directly?**

Avox holds the Meta access token for you. To send from your own code, use the
[Avox API](https://docs.theavoxai.com/integrations/avox-api) - it accepts WhatsApp's own message format.

**Which Graph API version does Avox use?**

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.

**What happens to my number if I disconnect it from Avox?**

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

**Where do I see Meta's raw data?**

[Logs](https://docs.theavoxai.com/logs) shows every webhook event's payload, and **View raw response** on
[Health](https://docs.theavoxai.com/health) shows Meta's health answer unchanged.
