# WhatsApp and Chatwoot Integration

> Send WhatsApp messages from Chatwoot events through the Avox API with a small relay, and bring customer messages back with message forwarding - setup, code, checks, errors and FAQ.

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

Send WhatsApp messages from **Chatwoot** through a number connected to Avox. Chatwoot's own
webhooks tell a small relay you run when something happens - a conversation is resolved, an agent
replies - and the relay sends the WhatsApp message with the [Avox API](https://docs.theavoxai.com/integrations/avox-api).
Customer replies on WhatsApp land in your Avox [Inbox](https://docs.theavoxai.com/inbox), and
[message forwarding](https://docs.theavoxai.com/connections/webhook-override) can send them to your relay as well.

## What you'll build

```
Send:     Chatwoot event (webhook) → your relay → Avox API → customer's WhatsApp
Receive:  customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → your relay (optional)
```

Typical uses: a "your ticket is resolved" message on WhatsApp, a follow-up survey after a
conversation closes, or sending an agent's Chatwoot reply to the customer's WhatsApp.

## Requirements

- A Chatwoot instance (Cloud or self-hosted) where you're an administrator and can add account
  webhooks.
- 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).
- Approved templates for the messages you start. See [Templates](https://docs.theavoxai.com/templates).
- A relay server reachable over `https://` (Node.js 18 or later for the code below, or Python 3.10 or later).
- Contacts in Chatwoot with phone numbers in international format.

```bash
# .env
AVOX_API_KEY=avx_live_your_key
RELAY_SECRET=a-long-random-string
```

> **Note**
>
> Chatwoot's webhook events and payload fields can differ between versions. The code follows
> Chatwoot's public webhook format - check it against your own instance. The Avox side stays the
> same.

## 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** while you build the relay (it never sends anything) or **Live key** to send real messages.

**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.

Keep the key on the relay only. See [API keys](https://docs.theavoxai.com/connections/api-keys).

## The relay

```js
// relay.js - Node.js 18+ (npm install express)
import express from "express";
import { timingSafeEqual } from "node:crypto";

const app = express();

async function sendTemplate(to, name, params, idempotencyKey) {
  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": idempotencyKey,
    },
    body: JSON.stringify({
      to,
      type: "template",
      template: {
        name,
        language: { code: "en" },
        components: [{ type: "body", parameters: params.map((text) => ({ type: "text", text: String(text) })) }],
      },
    }),
  });
  const data = await res.json().catch(() => null);
  if (!data?.ok) console.error("Avox send failed:", data?.error?.code, data?.error?.request_id);
  return data;
}

function secretMatches(given) {
  const a = Buffer.from(given ?? "");
  const b = Buffer.from(process.env.RELAY_SECRET ?? "");
  return b.length > 0 && a.length === b.length && timingSafeEqual(a, b);
}

// Chatwoot account webhook. The secret in the URL keeps strangers out.
app.post("/chatwoot/:secret", express.json({ limit: "1mb" }), async (req, res) => {
  if (!secretMatches(req.params.secret)) return res.sendStatus(401);
  res.sendStatus(200);
  const event = req.body;

  if (event.event === "conversation_status_changed" && event.status === "resolved") {
    const contact = event.meta?.sender;
    const phone = (contact?.phone_number ?? "").replace(/\D/g, "");
    if (!phone) return;
    await sendTemplate(
      phone,
      "ticket_resolved",
      [contact.name ?? "there", event.id],
      `chatwoot-resolved-${event.id}` // one message per resolved conversation
    );
  }
});

app.listen(3000);
```

The `ticket_resolved` template here is an example - create your own on
[Create](https://docs.theavoxai.com/templates/create), for instance: "Hi `{{1}}`, your request #`{{2}}` is resolved. Reply
here if you need anything else."

## Setup

**1. Create and approve the template**

On [Create](https://docs.theavoxai.com/templates/create), then wait for approval.

**2. Create the Avox API key**

See [Create the API key](#create-the-api-key), and store it as `AVOX_API_KEY`.

**3. Deploy the relay**

Make `https://relay.example.com/chatwoot/<RELAY_SECRET>` reachable.

**4. Add the Chatwoot webhook**

In Chatwoot's integrations settings, add a webhook with that URL and subscribe to conversation status changes (and any other events you use).

**5. Try it**

Resolve a test conversation whose contact has your own phone number.

> **Tip**
>
> Build with a test key first. 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` - so nothing reaches a real customer while you test. See [Testing](https://docs.theavoxai.com/avox-api/testing).

## Check it's working

- The relay logs no `Avox send failed` line, and 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 every request with its outcome and request id.
- Its delivery statuses are on [Logs](https://docs.theavoxai.com/logs).

## Bring WhatsApp messages back to Chatwoot

Customer messages always arrive in the Avox Inbox. To get them into Chatwoot too, turn on message
forwarding for the number and point it at your relay:

**1. Open Message forwarding**

Open [Connections](https://docs.theavoxai.com/connections), click **Settings** on the connection, and scroll to **Message forwarding**. You need the **Admin** role to change it.

**2. Save your URL**

Enter the receiver's address, such as `https://relay.example.com/avox/messages`, in **Endpoint URL** and click **Save URL**.

**3. Turn it on and copy the secret**

Switch **Message forwarding** on. Copy the signing secret from **Your signing secret** into `AVOX_WEBHOOK_SECRET` - it's shown only once.

**4. Send a test event**

Click **Send test event**. "Delivered - your endpoint answered 200" means the receiver accepted it.

Each new customer message arrives as a signed `message.received` event with the customer's number
(`data.from`, with a leading `+`), profile name (`data.contact_name`), type and content. The
[Receiver](#receiver) below checks the signature and hands you the event; from there, create the
message in Chatwoot through Chatwoot's own API, matching the contact by phone number. The full
payload for every message type is on [Receive messages](https://docs.theavoxai.com/avox-api/webhooks#receive-messages).

## Common errors

| Symptom | Fix |
| --- | --- |
| Nothing reaches the relay | Check the Chatwoot webhook URL (including the secret) and the subscribed events. |
| [`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. |
| [`invalid_recipient`](https://docs.theavoxai.com/api/errors#invalid_recipient) | The contact's phone number lacks a country code. Store numbers in international format in Chatwoot. |
| [`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). |
| [`template_params_invalid`](https://docs.theavoxai.com/api/errors#template_params_invalid) | Send exactly as many parameters as the template has placeholders. |
| [`outside_messaging_window`](https://docs.theavoxai.com/api/errors#outside_messaging_window) | The customer hasn't written in 24 hours, so an agent's plain reply can't go out. Send a template. |
| Two messages for one conversation | Keep the `Idempotency-Key` based on the Chatwoot conversation or message id. |
| Forwarding test shows "Your endpoint answered 401" | The signature check failed. Verify over the raw body with the whole `whsec_...` secret. |

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

## Limits

- Messages the relay starts must be templates. See [Messaging window](https://docs.theavoxai.com/whatsapp/messaging-window).
- Avox rate limits apply per key and per account. See [Rate limits](https://docs.theavoxai.com/avox-api/rate-limits).

## Security

- Keep the Avox key on the relay only, never in Chatwoot's frontend.
- Use a long random secret in the Chatwoot webhook path, and reject anything else.
- Verify the `Avox-Signature` on forwarded messages before creating anything in Chatwoot.
- Message only contacts who opted in to WhatsApp messages from you.

## Build your own server for Chatwoot

This version of the relay sends agents' replies: when an agent posts a public reply in a Chatwoot conversation, Chatwoot's outgoing webhook calls the route, and the route sends the text to the contact's WhatsApp number through the Avox API. The Chatwoot message id becomes the `Idempotency-Key`, so a repeated webhook never sends twice. A plain reply works within 24 hours of the customer's last WhatsApp message; outside it, send a template (as in the relay above).

```bash
# Environment
AVOX_API_KEY=avx_live_your_key
CHATWOOT_WEBHOOK_SECRET=a-long-random-string   # part of the webhook URL you give Chatwoot
```

**Express**

```js
// server.js - Node.js 18+ (npm install express). Run: AVOX_API_KEY=... CHATWOOT_WEBHOOK_SECRET=... 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);
}

// Chatwoot webhook (subscribe it to "Message Created"). The secret in the path keeps strangers out.
// When an agent sends a public reply in Chatwoot, this sends it to the contact on WhatsApp.
app.post("/chatwoot/:secret", express.json({ limit: "1mb" }), async (req, res) => {
  if (!safeEqual(req.params.secret, process.env.CHATWOOT_WEBHOOK_SECRET)) return res.sendStatus(401);
  res.sendStatus(200); // acknowledge first, then send

  const event = req.body;
  if (event?.event !== "message_created" || event.message_type !== "outgoing" || event.private) return;
  const to = String(event.conversation?.meta?.sender?.phone_number ?? "").replace(/\D/g, "");
  if (!to || !event.content) return;

  try {
    await sendWhatsApp({ to, type: "text", text: { body: event.content } }, `chatwoot-message-${event.id}`);
  } catch (err) {
    if (err.code === "outside_messaging_window") {
      console.warn(`[chatwoot] contact hasn't written in 24h - send a template instead (request_id=${err.requestId})`);
    }
  }
});

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

**Next.js**

```ts
// app/api/chatwoot/route.ts - Next.js 15+ (App Router)
import { randomUUID, timingSafeEqual } from "node:crypto";
import { after, 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);
}

// Chatwoot webhook URL: https://your-app.example.com/api/chatwoot?secret=... (subscribe to "Message Created").
// When an agent sends a public reply in Chatwoot, this sends it to the contact on WhatsApp.
export async function POST(request: NextRequest) {
  if (!safeEqual(request.nextUrl.searchParams.get("secret"), process.env.CHATWOOT_WEBHOOK_SECRET)) {
    return new Response(null, { status: 401 });
  }
  const event = await request.json().catch(() => null);
  if (event?.event !== "message_created" || event.message_type !== "outgoing" || event.private) {
    return new Response(null, { status: 200 });
  }
  const to = String(event.conversation?.meta?.sender?.phone_number ?? "").replace(/\D/g, "");
  if (!to || !event.content) return new Response(null, { status: 200 });

  after(async () => {
    try {
      await sendWhatsApp({ to, type: "text", text: { body: event.content } }, `chatwoot-message-${event.id}`);
    } catch (err) {
      const e = err as AvoxFailure;
      if (e.code === "outside_messaging_window") {
        console.warn(`[chatwoot] contact hasn't written in 24h - send a template instead (request_id=${e.requestId})`);
      }
    }
  });
  return new Response(null, { status: 200 });
}
```

**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 BackgroundTasks, FastAPI, Request
from fastapi.responses import 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))


async def send_agent_reply(to: str, body: str, message_id) -> None:
    try:
        await send_whatsapp({"to": to, "type": "text", "text": {"body": body}},
                            idempotency_key=f"chatwoot-message-{message_id}")
    except AvoxError as err:
        if err.code == "outside_messaging_window":
            log.warning("contact hasn't written in 24h - send a template instead (request_id=%s)", err.request_id)


# Chatwoot webhook (subscribe it to "Message Created"). The secret in the path keeps strangers out.
# When an agent sends a public reply in Chatwoot, this sends it to the contact on WhatsApp.
@app.post("/chatwoot/{secret}")
async def chatwoot(secret: str, request: Request, background: BackgroundTasks):
    expected = os.environ.get("CHATWOOT_WEBHOOK_SECRET", "")
    if not expected or not hmac.compare_digest(secret.encode(), expected.encode()):
        return Response(status_code=401)
    event = await request.json()
    if event.get("event") != "message_created" or event.get("message_type") != "outgoing" or event.get("private"):
        return Response(status_code=200)
    sender = ((event.get("conversation") or {}).get("meta") or {}).get("sender") or {}
    to = "".join(ch for ch in str(sender.get("phone_number") or "") if ch.isdigit())
    if to and event.get("content"):
        background.add_task(send_agent_reply, to, event["content"], event["id"])  # acknowledge first, then send
    return Response(status_code=200)
```

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 Chatwoot route, which answers Chatwoot first and sends afterwards. Run Express with Node.js 18 or later, the Next.js route in an App Router project on Next.js 15 or later (it uses `after`), 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).

### Receiver

Run this next to the relay (or add its route to it) to receive WhatsApp messages from Avox. Put your Chatwoot hand-off in `handleEvent`.

```bash
# Environment of the receiver
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret
```

**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).

## FAQ

**Where do WhatsApp replies go?**

To the Avox [Inbox](https://docs.theavoxai.com/inbox), where your team can answer them, and to your relay too if you turn on
message forwarding. For automatic answers, use [Automation](https://docs.theavoxai.com/automation).

**Can I send something other than a template?**

Yes, to a customer who messaged your WhatsApp number in the last 24 hours. See
[Message types](https://docs.theavoxai.com/avox-api/messages).

**How do Chatwoot and Avox connect?**

Through Chatwoot's webhooks, the Avox API and message forwarding, with the relay on this page in
between. You stay in control of which events send which message.
