# WhatsApp API with Node.js

> Send WhatsApp messages from Node.js with the Avox API and receive customer messages with message forwarding - a complete client with idempotency and retries, a signed receiver, checks, errors and FAQ.

Source: https://docs.theavoxai.com/integrations/nodejs

Send and receive WhatsApp messages in any Node.js app through a number connected to Avox, using the
built-in `fetch`, the [Avox API](https://docs.theavoxai.com/integrations/avox-api) and
[message forwarding](https://docs.theavoxai.com/connections/webhook-override). No SDK to install - the client below is
complete and runs as-is.

## What you'll build

```
Send:     your Node.js app → Avox API → customer's WhatsApp
Receive:  customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → your Node.js route
```

- A `sendWhatsApp()` helper with an `Idempotency-Key` and safe retries.
- Examples for an order template, a button message and handling a refusal.
- A receiver that checks the `Avox-Signature` on forwarded messages.

## Requirements

- Node.js 18 or later (built-in `fetch`).
- A WhatsApp number connected to Avox. See [Getting started](https://docs.theavoxai.com/getting-started).
- An Avox API key for that number. Only an **Admin** can create one. See
  [Create the API key](#create-the-api-key).
- To receive messages: a server reachable from the internet over `https://`.

```bash
# .env
AVOX_API_KEY=avx_live_your_key
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret   # only if you receive forwarded messages
```

## Create the API key

**1. Open API Keys**

In the dashboard, go to **Settings > API Keys**.

**2. Pick the key type**

Under **Create a new key**, choose **Test key** for development and CI (it never sends anything) or **Live key** for production.

**3. Choose the number**

Pick the WhatsApp number in the **Choose a connection** list.

**4. Create and copy it**

Click **Create test key** or **Create live key**, then copy the key from **Your new API key** into `AVOX_API_KEY`. It's shown only once.

**5. Check it**

Click **Test** next to the key. "Working - routes to" followed by your connection's name means it's ready.

Live keys start with `avx_live_`, test keys with `avx_test_`. See [API keys](https://docs.theavoxai.com/connections/api-keys).

## The client

```js
// avox.js - Node.js 18+, no dependencies
import { randomUUID } from "node:crypto";

// 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}${data.test ? " (test key)" : ""}`);
      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));
  }
}

export { sendWhatsApp };
```

On success it returns `{ ok, request_id, message_id, result }` (plus `test: true` with a test key).
On failure it throws an error with `code`, `status`, `requestId` and `details`. It retries only
what's safe to retry, with the same `Idempotency-Key`, honouring `Retry-After`; after
`meta_rate_limited` it uses a new key, because WhatsApp's refusal is stored against the old one.
`meta_unavailable` isn't retried - WhatsApp didn't answer in time, so the message may already be
out. The rules are on [Rate limits](https://docs.theavoxai.com/avox-api/rate-limits) and [Idempotency](https://docs.theavoxai.com/avox-api/idempotency).

## Examples

```js
import { sendWhatsApp } from "./avox.js";

// 1. A template to start a conversation (an order confirmation).
const confirmation = await sendWhatsApp(
  {
    to: "15551234567",
    type: "template",
    template: {
      name: "order_confirmation",
      language: { code: "en" },
      components: [{ type: "body", parameters: [{ type: "text", text: "A-1042" }, { type: "text", text: "$42.00" }] }],
    },
    biz_opaque_callback_data: "order-A-1042",
  },
  "order-A-1042-confirmation" // one key per order: a repeat run never sends twice
);
console.log("Sent", confirmation.message_id);

// 2. Buttons, inside the 24-hour window. A tap comes back as a forwarded
//    "interactive" message with the button's id, such as "slot_keep".
await sendWhatsApp({
  to: "15551234567",
  type: "interactive",
  interactive: {
    type: "button",
    body: { text: "Is Friday at 3pm still good for delivery?" },
    action: {
      buttons: [
        { type: "reply", reply: { id: "slot_keep", title: "Yes, Friday" } },
        { type: "reply", reply: { id: "slot_change", title: "Pick another day" } },
      ],
    },
  },
});

// 3. Handling a refusal.
try {
  await sendWhatsApp({ to: "15551234567", type: "text", text: { body: "Thanks for your order!" } });
} catch (err) {
  if (err.code === "outside_messaging_window") {
    // send a template instead
  } else {
    console.error(err.message, err.requestId, err.details);
  }
}
```

Every message type is on [Message types](https://docs.theavoxai.com/avox-api/messages).

## Receive customer messages

Turn on message forwarding, and Avox posts each new customer message to your server as a signed
`message.received` event. Each `v1` value in the `Avox-Signature` header is a hex HMAC-SHA256 of
the `t` value, a full stop, then the raw body, keyed with your whole signing secret. Check it on
the raw bytes before parsing, refuse a `t` more than 5 minutes from your clock, answer `200` within
10 seconds, and skip events whose `id` you've already handled.

**Express**

```js
// receiver.js - Node.js 18+ (npm install express). Run: AVOX_WEBHOOK_SECRET=whsec_... node receiver.js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
// The key is the whole signing secret, whsec_ included, as plain text.
function verifyAvoxSignature(rawBody, header, secret) {
  if (!header || !secret) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value);
  }
  if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatures.some((sig) => timingSafeEqual(Buffer.from(sig, "hex"), expected));
}

// Retries and Resend keep the same event id. Keep handled ids in your database in production.
const seen = new Set();

async function handleEvent(event) {
  if (seen.has(event.id)) return;
  seen.add(event.id);
  if (seen.size > 10_000) seen.delete(seen.values().next().value);

  // Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
  if (process.env.FORWARD_TO_URL) {
    const res = await fetch(process.env.FORWARD_TO_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(event),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) console.warn(`[avox] passing on ${event.id} failed: HTTP ${res.status}`);
    return;
  }

  if (event.test) return console.info(`[avox] test event ${event.id} received`);
  const { from, contact_name, type, text } = event.data;
  console.info(`[avox] ${type} from ${contact_name ?? from}${text ? `: ${text.body}` : ""}`);
}

const app = express();

// express.raw keeps the exact bytes the signature covers - don't parse JSON on this route before checking it.
app.post("/avox/messages", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !verifyAvoxSignature(req.body, req.get("Avox-Signature"), process.env.AVOX_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // answer within 10 seconds, then do the work
  handleEvent(event).catch((err) => console.error("[avox] handling failed:", err.message));
});

app.listen(process.env.PORT ?? 3000, () => console.log("Listening"));
```

**Next.js**

```ts
// app/avox/messages/route.ts - Next.js 15+ (App Router)
import { createHmac, timingSafeEqual } from "node:crypto";
import { after } from "next/server";

export const runtime = "nodejs";
const TOLERANCE_SECONDS = 5 * 60;

type AvoxEvent = {
  id: string;
  type: "message.received";
  test?: true;
  connection_id: string;
  data: { message_id: string; from: string; contact_name: string | null; type: string; text?: { body: string } };
};

// Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
function verifyAvoxSignature(rawBody: string, header: string | null, secret: string | undefined): boolean {
  if (!header || !secret) return false;
  let timestamp: number | null = null;
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value);
  }
  if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest();
  return signatures.some((sig) => timingSafeEqual(Buffer.from(sig, "hex"), expected));
}

// Retries and Resend keep the same event id. Per instance only - keep handled ids in your database in production.
const seen = new Set<string>();

async function handleEvent(event: AvoxEvent) {
  if (seen.has(event.id)) return;
  seen.add(event.id);
  if (seen.size > 10_000) seen.delete(seen.values().next().value!);

  // Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
  if (process.env.FORWARD_TO_URL) {
    const res = await fetch(process.env.FORWARD_TO_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(event),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) console.warn(`[avox] passing on ${event.id} failed: HTTP ${res.status}`);
    return;
  }

  if (event.test) return console.info(`[avox] test event ${event.id} received`);
  const { from, contact_name, type, text } = event.data;
  console.info(`[avox] ${type} from ${contact_name ?? from}${text ? `: ${text.body}` : ""}`);
}

export async function POST(request: Request) {
  const rawBody = await request.text(); // the exact body, before any parsing
  if (!verifyAvoxSignature(rawBody, request.headers.get("avox-signature"), process.env.AVOX_WEBHOOK_SECRET)) {
    return new Response(null, { status: 401 });
  }
  const event = JSON.parse(rawBody) as AvoxEvent;
  after(() => handleEvent(event).catch((err) => console.error("[avox] handling failed:", err))); // runs after the answer
  return new Response(null, { status: 200 });
}
```

**FastAPI**

```python
# receiver.py - pip install fastapi uvicorn httpx ; run: uvicorn receiver:app --port 3000
import hashlib
import hmac
import json
import logging
import os
import re
import time
from collections import OrderedDict

import httpx
from fastapi import BackgroundTasks, FastAPI, Request, Response

log = logging.getLogger("avox")
app = FastAPI()
TOLERANCE_SECONDS = 5 * 60
HEX64 = re.compile(r"[0-9a-fA-F]{64}")

# Retries and Resend keep the same event id. Per process only - keep handled ids in your database in production.
seen: "OrderedDict[str, None]" = OrderedDict()


def verify_avox_signature(raw: bytes, header: str | None, secret: str) -> bool:
    # Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
    if not header or not secret:
        return False
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and HEX64.fullmatch(value):
            signatures.append(value.lower())
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)


async def handle_event(event: dict) -> None:
    if event["id"] in seen:
        return
    seen[event["id"]] = None
    if len(seen) > 10_000:
        seen.popitem(last=False)

    # Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
    forward_to = os.environ.get("FORWARD_TO_URL")
    if forward_to:
        try:
            async with httpx.AsyncClient(timeout=10) as client:
                res = await client.post(forward_to, json=event)
            if res.status_code >= 300:
                log.warning("passing on %s failed: HTTP %s", event["id"], res.status_code)
        except httpx.HTTPError as err:
            log.warning("passing on %s failed: %s", event["id"], err)
        return

    if event.get("test"):
        log.info("test event %s received", event["id"])
        return
    data = event["data"]
    log.info("%s from %s %s", data["type"], data.get("contact_name") or data["from"], (data.get("text") or {}).get("body", ""))


@app.post("/avox/messages")
async def receive(request: Request, background: BackgroundTasks):
    raw = await request.body()  # the exact bytes, before any parsing
    if not verify_avox_signature(raw, request.headers.get("avox-signature"), os.environ.get("AVOX_WEBHOOK_SECRET", "")):
        return Response(status_code=401)
    background.add_task(handle_event, json.loads(raw))  # runs after the answer
    return Response(status_code=200)
```

Each receiver checks the `Avox-Signature` header over the raw body before trusting anything, refuses a timestamp more than 5 minutes from your clock, accepts either signature during the 24 hours after you rotate the secret, answers `200` straight away and does the work afterwards, and skips repeats by event id. With `FORWARD_TO_URL` set it passes every verified event on unchanged - including test events, which carry `"test": true` so you can filter them. The full payload for every message type, the retry schedule and auto-pause are on [Receive messages](https://docs.theavoxai.com/avox-api/webhooks#receive-messages).

To answer a forwarded message, send a plain text reply to `event.data.from` - the customer has just
written, so the 24-hour window is open. Add `context: { message_id: event.data.message_id }` to
quote their message.

## Setup

**1. Create the API key**

See [Create the API key](#create-the-api-key).

**2. Add the client**

Copy `avox.js` into your app.

**3. Send to your own phone**

Run an example with your personal WhatsApp number as `to`.

**4. Turn on forwarding (optional)**

Open [Connections](https://docs.theavoxai.com/connections), click **Settings** on the connection, and in **Message forwarding** enter your receiver's address in **Endpoint URL** and click **Save URL**. Switch forwarding on, copy the secret from **Your signing secret** into `AVOX_WEBHOOK_SECRET`, then click **Send test event**. You need the **Admin** role.

> **Tip**
>
> Use a test key in development and CI. It runs every check a live key does and answers with a pretend result - `"test": true`, the header `Avox-Test-Mode: true`, and a `message_id` starting `wamid.TEST` - and special test numbers return each error on purpose, so you can test your error handling. See [Testing](https://docs.theavoxai.com/avox-api/testing).

## Check it's working

- The call returns `ok: true` and a `message_id`.
- With a live key, the message arrives on your phone.
- In **Settings > API Keys**, open the key's **Recent requests**: the send is listed as **Send** with status `200`. The **API logs** page (**Sent** tab) has the full history - search it by `request_id` or `message_id`.
- Delivery statuses for the message are on [Logs](https://docs.theavoxai.com/logs).
- Reply from your phone - the reply appears in the Avox [Inbox](https://docs.theavoxai.com/inbox), and with forwarding on, in your receiver's log and on the **API logs** page (**Received** tab).

## Common errors

| Symptom | Fix |
| --- | --- |
| [`invalid_api_key`](https://docs.theavoxai.com/api/errors#invalid_api_key) | `AVOX_API_KEY` is missing, cut off or quoted. Copy it again or create a new key. |
| [`key_revoked`](https://docs.theavoxai.com/api/errors#key_revoked) or [`key_expired`](https://docs.theavoxai.com/api/errors#key_expired) | Create a new key in **Settings > API Keys** and update your environment. |
| [`outside_messaging_window`](https://docs.theavoxai.com/api/errors#outside_messaging_window) | The person hasn't written in 24 hours - send a template. |
| [`template_not_found`](https://docs.theavoxai.com/api/errors#template_not_found) | Use the exact template name and language from [Templates](https://docs.theavoxai.com/templates). |
| [`invalid_payload`](https://docs.theavoxai.com/api/errors#invalid_payload) | Read `err.details` - each entry names the field to fix. |
| [`payload_too_large`](https://docs.theavoxai.com/api/errors#payload_too_large) | Keep the request body under 64 KB. |
| [`rate_limited`](https://docs.theavoxai.com/api/errors#rate_limited) | The client waits `Retry-After` and retries. For bulk sends, queue messages. |
| [`idempotency_conflict`](https://docs.theavoxai.com/api/errors#idempotency_conflict) | The same `Idempotency-Key` was used for a different message. Make it unique per message. |
| `fetch is not defined` | Upgrade to Node.js 18 or later. |
| Forwarding test shows "Your endpoint answered 401" | The signature check failed. Verify over the raw body (`express.raw`, `request.text()`), with the whole `whsec_...` secret. |

Every code, with what to do about it, is on [API errors](https://docs.theavoxai.com/api/errors).

## Limits

- Request bodies up to 64 KB, per-key and per-account rate limits. See
  [Rate limits](https://docs.theavoxai.com/avox-api/rate-limits).
- WhatsApp's 24-hour window and messaging limits. See
  [Messaging window](https://docs.theavoxai.com/whatsapp/messaging-window).

## Security

- Load the key and the signing secret from environment variables or a secrets manager, never from source code.
- Run the client on your server only - never in a browser bundle.
- Give this app its own API key, so you can revoke it without affecting other systems.
- Check the `Avox-Signature` before trusting a forwarded message, and rotate the signing secret if it leaks.

## Build your own server for Node.js

The `avox.js` client above drops into any Node.js app. If you'd rather run a small dedicated service that your other apps call, here's a complete one - with the same client built in - as an Express app or a Next.js route handler, plus a FastAPI version for Python teams. It exposes `POST /notify`, which sends an approved template.

```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=... INTERNAL_TOKEN=... 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}${data.test ? " (test key)" : ""}`);
      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 tools call this with the x-internal-token header; the Avox 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}${data.test ? " (test key)" : ""}`);
      return data as { ok: true; request_id: string; message_id: string | null; test?: true; 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 tools call this with the x-internal-token header; the Avox 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))


# POST /notify  {"to": "15551234567", "template": "order_shipped", "params": ["A-1042"], "reference": "order-A-1042-shipped"}
# Your own tools call this with the x-internal-token header; the Avox key never leaves this server.
@app.post("/notify")
async def notify(request: Request):
    token = request.headers.get("x-internal-token", "")
    expected = os.environ.get("INTERNAL_TOKEN", "")
    if not token or not expected or not hmac.compare_digest(token.encode(), expected.encode()):
        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 JSONResponse({"ok": False, "code": err.code, "request_id": err.request_id, "details": err.details},
                            status_code=502 if err.status >= 500 else 422)
```

Each version is a complete file: the Avox client (an `Idempotency-Key` per message, retries with backoff that honour `Retry-After`, and `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. `meta_unavailable` is deliberately not retried: WhatsApp didn't answer in time, so the message may already be out. 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).

## FAQ

**Do I need an SDK?**

No. The API is a single HTTPS endpoint, so plain `fetch` is all you need - the client above is the
whole thing.

**Can I use TypeScript?**

Yes. The code is standard ES modules; add types for the message body as you like. The Next.js
versions above are already TypeScript.

**How do I answer customer replies automatically?**

Set up [Automation](https://docs.theavoxai.com/automation) in Avox - it runs on every inbound message for the number - or
receive forwarded messages with the receiver above and reply with `sendWhatsApp()`. Your team can
also answer from the [Inbox](https://docs.theavoxai.com/inbox).

**Where do I see whether a message was delivered?**

On [Logs](https://docs.theavoxai.com/logs), as `sent`, `delivered`, `read` or `failed` statuses carrying the `message_id`.
See [Matching a status to a send](https://docs.theavoxai.com/avox-api/webhooks#matching-a-status-to-a-send).
