# WhatsApp and n8n Integration

> Send WhatsApp messages from any n8n workflow with the Avox API and an HTTP Request node, and start workflows from incoming customer messages with message forwarding - setup, checks, errors and FAQ.

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

Connect **n8n** to a WhatsApp number on Avox in both directions:

- **Send** - any workflow can send a WhatsApp message through the [Avox API](https://docs.theavoxai.com/integrations/avox-api) with an HTTP request node. A schedule, a form, a CRM update or a new row in a sheet becomes a WhatsApp template on your customer's phone.
- **Receive** - [message forwarding](https://docs.theavoxai.com/connections/webhook-override) posts each new customer message to a URL you choose, so a workflow can run whenever a customer writes to you.

Everything still lands in your Avox [Inbox](https://docs.theavoxai.com/inbox) as usual, and your [Automation](https://docs.theavoxai.com/automation) keeps running.

## What you'll build

```
Send:     any n8n trigger → your workflow logic → HTTP request node → Avox API → customer's WhatsApp
Receive:  customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → n8n webhook node
```

## Requirements

- An n8n Cloud or self-hosted instance where you can activate workflows. To receive messages, it must be reachable from the internet over `https://`.
- 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; a **Manager** can see keys and test them.
- Approved templates for any message the workflow starts. See [Templates](https://docs.theavoxai.com/templates).

## 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 workflow (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. A key always belongs to one connection.

**4. Create and copy it**

Click **Create test key** or **Create live key**, then copy the key from **Your new API key**. It's shown only once - Avox keeps just a hash of it.

**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_`. Give n8n its own key, so you can rotate or revoke it without touching anything else. See [API keys](https://docs.theavoxai.com/connections/api-keys).

Keep the key in n8n's credential store, not in a node field: a header-based credential whose header name is `Authorization` and whose value is `Bearer ` followed by your key.

## Send a WhatsApp message from a workflow

**1. Create the credential**

In n8n, add a header-based credential: name `Authorization`, value `Bearer ` followed by your key.

**2. Add your trigger**

Any trigger node - a schedule, a form, a CRM or spreadsheet trigger.

**3. Add an HTTP request node**

Configure it as in the table below.

**4. Test the workflow**

Run it once with your own phone number, or with a test key.

**5. Switch to the live key and activate**

Point the credential at your live key, then activate the workflow.

| Setting | Value |
| --- | --- |
| Method | `POST` |
| URL | `https://api.avox.com/api/messages/send` |
| Authentication | Your header-based Avox credential |
| Header `Idempotency-Key` | A value unique to the item, such as `appt-{{ $json.id }}`. 1 to 255 visible characters, no spaces. |
| Body | JSON, as below |
| Errors | Set the node so an error answer doesn't stop the workflow (or use its error output), so you can branch on the result |

```json
{
  "to": "{{ $json.phone }}",
  "type": "template",
  "template": {
    "name": "appointment_reminder",
    "language": { "code": "en_US" },
    "components": [
      {
        "type": "body",
        "parameters": [
          { "type": "text", "text": "{{ $json.name }}" },
          { "type": "text", "text": "{{ $json.time }}" }
        ]
      }
    ]
  }
}
```

The phone number needs its country code; a leading `+`, spaces, dashes and brackets are fine. The template name and language must match an approved template exactly, with one parameter per placeholder. After the node, branch on `{{ $json.ok }}`: when it's true, store `{{ $json.message_id }}`; when it's false, branch on `{{ $json.error.code }}`. Every message type is on [Message types](https://docs.theavoxai.com/avox-api/messages).

> **Note**
>
> n8n's node names and field labels can change between versions. Treat these as the settings to look for, not an importable export.

### Build with a test key first

A test key runs every check a live key does - the key, the connection, your plan, rate limits, the body and the `Idempotency-Key` - and then answers with a pretend result instead of sending. The response carries `"test": true`, the header `Avox-Test-Mode: true`, and a `message_id` starting `wamid.TEST`. Special test numbers return each error on purpose, so you can build the workflow's error branches too. See [Testing](https://docs.theavoxai.com/avox-api/testing).

## Check it's working

- The HTTP request node's output shows `"ok": true`, a `request_id` 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 arrow next to it): the call is listed as **Send** with status `200`.
- The **API logs** page (**Sent** tab) has every request with its outcome, recipient (masked), request id and latency. Search it by `request_id` or `message_id`.
- Delivery statuses (sent, delivered, read) for the message are on [Logs](https://docs.theavoxai.com/logs).

## Receive customer messages in n8n

Turn on message forwarding for the number, and Avox posts every new customer message to your **Endpoint URL** as a signed `message.received` event. Point it at a webhook node in n8n, and each message runs your workflow.

**1. Start a workflow with a webhook node**

Set it to accept `POST` and answer straight away, then copy its production URL.

**2. Open Message forwarding**

In Avox, 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.

**3. Save the URL**

Paste the URL into **Endpoint URL** and click **Save URL**.

**4. Turn it on**

Switch **Message forwarding** on. The first time, Avox creates a signing secret and shows it once in **Your signing secret** - copy it and keep it somewhere safe.

**5. Send a sample**

Click **Send test event**. Avox posts a sample message marked `"test": true`.

**6. Build the rest of the workflow**

Skip events where `test` is `true`, then add the nodes you want.

The fields you'll use most often:

| Field | What it holds |
| --- | --- |
| `id` | The event id (`evt_...`). The same on every retry and resend - use it to skip repeats. |
| `test` | Only on test events, and then `true`. |
| `data.from` | The customer's number with a leading `+`. You can use it as `to` when you reply. |
| `data.contact_name` | The customer's WhatsApp profile name, or `null`. |
| `data.type` | `text`, `image`, `audio`, `video`, `document`, `sticker`, `location`, `contacts`, `interactive`, `button` or `reaction`. |
| `data.text.body` | The message text, on text messages. |
| `data.message_id` | WhatsApp's message id. Send it as `context.message_id` to quote the customer's message in your reply. |

A workflow that answers a forwarded message can send a plain text message - the customer has just written, so the 24-hour window is open. See [Messaging window](https://docs.theavoxai.com/whatsapp/messaging-window).

### Check the signature in n8n

Every forwarded request carries an `Avox-Signature` header that proves it came from Avox: `t=<unix seconds>,v1=<signature>`. Each `v1` is a hex HMAC-SHA256 of the `t` value, a full stop, then the raw request body, keyed with your whole signing secret (`whsec_` included). If your n8n setup can compute an HMAC over the raw body, check it there and refuse requests whose `t` is more than 5 minutes from your clock. Otherwise put the small receiver below in front of n8n - it checks the signature and passes the event on. See [Check the signature](https://docs.theavoxai.com/avox-api/webhooks#check-the-signature).

> **Warning**
>
> A webhook node that doesn't check the signature accepts any request to its URL. Keep the URL private, and check the signature before a workflow changes data or replies automatically.

The endpoint must use `https://` on the standard port, be reachable from the internet, answer with a `2xx` status within 10 seconds, and not redirect. If it doesn't, Avox retries for about 21 hours. If 5 deliveries in a row still fail after all their retries, forwarding pauses and your Admins get an email. Every delivery is on the connection's **Recent deliveries** list and on the **API logs** page (**Received** tab), where an Admin can resend it. See [Message forwarding](https://docs.theavoxai.com/connections/webhook-override).

## Common errors

| Symptom | Fix |
| --- | --- |
| [`missing_api_key`](https://docs.theavoxai.com/api/errors#missing_api_key) | The request had no `Authorization` header. Check the node uses your credential. |
| [`invalid_api_key`](https://docs.theavoxai.com/api/errors#invalid_api_key) | The credential's value must be `Bearer ` followed by the whole 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 the credential. |
| [`invalid_payload`](https://docs.theavoxai.com/api/errors#invalid_payload) | An expression produced invalid JSON or an empty value. `error.details` names the field. |
| [`invalid_recipient`](https://docs.theavoxai.com/api/errors#invalid_recipient) | The phone field has no country code. Format it before the HTTP node. |
| [`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 one parameter per placeholder. |
| [`outside_messaging_window`](https://docs.theavoxai.com/api/errors#outside_messaging_window) | The customer hasn't written in 24 hours. Send a template. |
| [`invalid_idempotency_key`](https://docs.theavoxai.com/api/errors#invalid_idempotency_key) | The expression produced a space or an empty value. Build it from ids only. |
| [`idempotency_conflict`](https://docs.theavoxai.com/api/errors#idempotency_conflict) | The same `Idempotency-Key` was used for a different message. Base it on the item's own id. |
| [`rate_limited`](https://docs.theavoxai.com/api/errors#rate_limited) | Add a wait or use batching, and honour `Retry-After`. |
| [`connection_paused`](https://docs.theavoxai.com/api/errors#connection_paused) | The number is paused. Resume it on [Connections](https://docs.theavoxai.com/connections). |
| Duplicate messages on re-runs | Base the `Idempotency-Key` on the item's own id. |
| Test event fails with "Your endpoint answered 404" | Use the webhook node's production URL, and activate the workflow. |

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

## Limits

- Avox rate limits apply per key and per account, and a key can have a daily allowance ([`quota_exceeded`](https://docs.theavoxai.com/api/errors#quota_exceeded), which resets at midnight UTC). Test keys have their own, separate counters. See [Rate limits](https://docs.theavoxai.com/avox-api/rate-limits).
- WhatsApp's messaging limit caps how many people your number can start conversations with each day. See [Quality and limits](https://docs.theavoxai.com/whatsapp/quality-and-limits).

## Security

- Store the API key as an n8n credential. If you self-host, set n8n's own encryption key.
- Give n8n its own API key, so you can revoke it without affecting other systems. Avox warns you on the key if it's used from a new place or far more than usual.
- Verify the `Avox-Signature` on forwarded messages before acting on them, and rotate the signing secret if it leaks.
- n8n's execution history can keep message content. Review its retention settings to match your own policy.

## Build your own server for n8n

n8n can call the Avox API directly with the HTTP request node above, so a server is optional. Run a small relay when you want the Avox key out of n8n entirely, one place that adds idempotency and retries for every workflow, or shared rules (allowed templates, logging). Workflows then call the relay's `POST /notify` with your internal token instead of the Avox API.

```bash
# Environment of the relay
AVOX_API_KEY=avx_live_your_key
INTERNAL_TOKEN=a-long-random-string   # store this as the n8n credential instead of the Avox key
```

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

### Verify forwarded messages first

This receiver checks every forwarded message's signature and then passes the event, unchanged, to your n8n webhook node. Set **Endpoint URL** in Avox to the receiver's `/avox/messages` address, and `FORWARD_TO_URL` to the webhook node's production URL.

```bash
# Environment of the receiver
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret
FORWARD_TO_URL=https://your-n8n-host/webhook/your-path
```

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

**What do I need in n8n?**

n8n's built-in HTTP request and webhook nodes cover everything: sending through the Avox API and receiving forwarded messages.

**How do I handle customer replies?**

Replies arrive in the Avox [Inbox](https://docs.theavoxai.com/inbox), where your team can answer them, and [Automation](https://docs.theavoxai.com/automation) can answer common questions for you. To run a workflow on each reply, turn on message forwarding as described above.

**Can one workflow send from several numbers?**

Yes. Each key belongs to one number, so create a key per number, store each as its own credential, and pick the credential per branch.
