# WhatsApp and Make Integration

> Send WhatsApp messages from any Make scenario with the Avox API and an HTTP module, and start scenarios from incoming customer messages with message forwarding - setup, checks, errors and FAQ.

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

Connect **Make** (formerly Integromat) to a WhatsApp number on Avox in both directions:

- **Send** - any scenario can send a WhatsApp message through the [Avox API](https://docs.theavoxai.com/integrations/avox-api) with Make's HTTP module. A new order, a form, a calendar event 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 scenario 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 Make trigger → your modules → HTTP request module → Avox API → customer's WhatsApp
Receive:  customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → scenario's webhook trigger
```

## Requirements

- A Make account where you can turn a scenario on.
- 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 scenario 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 scenario (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 Make its own key, so you can rotate or revoke it without touching anything else. See [API keys](https://docs.theavoxai.com/connections/api-keys).

## Send a WhatsApp message from a scenario

**1. Add your trigger**

Any Make trigger module.

**2. Build the body**

Optionally use one of Make's JSON tools to build the message, so mapped values are escaped correctly.

**3. Add an HTTP request module**

Use Make's HTTP app to make a request, configured as in the table below.

**4. Run once**

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

**5. Switch to the live key and turn it on**

Put your live key in the `Authorization` header, then switch the scenario on.

| Setting | Value |
| --- | --- |
| URL | `https://api.avox.com/api/messages/send` |
| Method | `POST` |
| Header `Authorization` | `Bearer ` followed by your full key, for example `Bearer avx_live_...` |
| Header `Idempotency-Key` | A value unique to the triggering record, such as `order-` plus the order id. 1 to 255 visible characters, no spaces. |
| Body | Raw, with content type `application/json` (or the JSON you built in the previous module) |
| Response | Parse it as JSON, so later modules can read `ok`, `message_id` and `error.code` |

```json
{
  "to": "{{1.phone}}",
  "type": "template",
  "template": {
    "name": "order_shipped",
    "language": { "code": "en_US" },
    "components": [
      { "type": "body", "parameters": [{ "type": "text", "text": "{{1.order_number}}" }] }
    ]
  }
}
```

`{{1.phone}}` and `{{1.order_number}}` stand for the fields you map from your trigger module. 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. Every message type is on [Message types](https://docs.theavoxai.com/avox-api/messages).

> **Note**
>
> Make's module names and labels can change over time. Treat these as the settings to look for, not an importable blueprint.

### 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 scenario's error routes too. See [Testing](https://docs.theavoxai.com/avox-api/testing).

## Check it's working

- The HTTP module'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 Make

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 trigger in Make, and each message runs your scenario.

**1. Start a scenario with a webhook trigger**

Use Make's custom webhook trigger and copy the URL it gives you.

**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`, which Make can use to learn the data structure.

**6. Build the rest of the scenario**

Add a filter that skips events where `test` is `true`, then the modules you want.

The fields you'll map 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 empty. |
| `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 scenario 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).

> **Warning**
>
> Every forwarded request carries an `Avox-Signature` header that proves it came from Avox. A webhook trigger accepts any request to its URL without checking that header, so keep the URL private. If the scenario changes data or replies automatically, put a small receiver in front that checks the signature and passes the event on - see [Verify forwarded messages first](#verify-forwarded-messages-first).

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 header name. |
| [`invalid_api_key`](https://docs.theavoxai.com/api/errors#invalid_api_key) | The value must be `Bearer ` followed by the whole key, with nothing cut off. |
| [`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 header. |
| [`invalid_payload`](https://docs.theavoxai.com/api/errors#invalid_payload) | A mapped value broke the JSON (for example, an unescaped quote), or a field is wrong. `error.details` names the field. Build the body with a JSON module. |
| [`invalid_recipient`](https://docs.theavoxai.com/api/errors#invalid_recipient) | The phone value has no country code, or isn't a phone number. |
| [`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 key has a space or is empty. 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. Make it unique per message. |
| [`rate_limited`](https://docs.theavoxai.com/api/errors#rate_limited) | Add a delay between iterations or lower parallel runs, and wait for the `Retry-After` seconds before retrying. |
| [`connection_paused`](https://docs.theavoxai.com/api/errors#connection_paused) | The number is paused. Resume it on [Connections](https://docs.theavoxai.com/connections). |
| "Your endpoint answered 301" after **Send test event** | Redirects aren't followed. Paste the final `https://` URL. |

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).
- Make's own plan limits (operations, data transfer) apply on Make's side.

## Security

- Keep the key in a Make connection or keychain rather than a shared blueprint.
- Give Make its own API key, so you can revoke it on its own. 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.
- Make's execution history can keep message content. Review its data retention to match your policy.

## Build your own server for Make

Make can call the Avox API directly, so a server is optional. Run a small relay when you want the Avox key out of Make, consistent retries and idempotency across scenarios, or shared rules. Scenarios then call the relay's `POST /notify` with your internal token and simple fields: `to`, `template`, `params`, `reference`.

```bash
# Environment of the relay
AVOX_API_KEY=avx_live_your_key
INTERNAL_TOKEN=a-long-random-string   # put this in the HTTP module's x-internal-token header
```

**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 scenario's webhook trigger. Set **Endpoint URL** in Avox to the receiver's `/avox/messages` address, and `FORWARD_TO_URL` to the scenario's webhook URL.

```bash
# Environment of the receiver
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret
FORWARD_TO_URL=https://your-make-webhook-url
```

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

Only Make's built-in HTTP and webhook tools. They cover sending through the Avox API and receiving forwarded messages.

**How do I handle 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 scenario on each reply, turn on message forwarding as described above.

**Can I send text instead of a template?**

Yes, to someone who messaged your number in the last 24 hours. See
[Messaging window](https://docs.theavoxai.com/whatsapp/messaging-window).

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

Each key belongs to one number. Create a key per number and use the key for the number you want to send from.
