# WhatsApp and Zapier Integration

> Send WhatsApp messages from any Zap with the Avox API, and start Zaps from incoming customer messages with message forwarding - setup, request settings, checks, errors and FAQ.

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

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

- **Send** - any Zap can send a WhatsApp message through the [Avox API](https://docs.theavoxai.com/integrations/avox-api) with Zapier's built-in webhook action. A new order, a form entry or a calendar event 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 Zap can start 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 app's trigger → Zap → webhook action (custom request) → Avox API → customer's WhatsApp
Receive:  customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → Zap's webhook trigger
```

## Requirements

- A Zapier plan that includes Zapier's webhook app (its custom request action and its catch-hook trigger).
- 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 the messages your Zaps start. 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 Zap (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 Zapier 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 Zap

**1. Pick your trigger**

Any app - a new form entry, a new order, a calendar event.

**2. Add the webhook action**

Add Zapier's webhook action and choose its custom request option, so you can send a nested JSON body.

**3. Fill in the request**

Use the settings in the table below, and map your fields into the body.

**4. Test the step**

Testing the step sends one real request. Use your own phone number, or a test key.

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

Put your live key in the `Authorization` header, then turn the Zap on.

| Setting | Value |
| --- | --- |
| Method | `POST` |
| URL | `https://api.avox.com/api/messages/send` |
| Header `Authorization` | `Bearer ` followed by your full key, for example `Bearer avx_live_...` |
| Header `Content-Type` | `application/json` |
| 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 | The JSON below, with your mapped fields |

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

`{{phone}}`, `{{customer_name}}` and `{{appointment_time}}` stand for the fields you map from earlier steps. The phone number needs its country code; a leading `+`, spaces, dashes and brackets are fine (`+1 (555) 123-4567` works). 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).

> **Tip**
>
> Mapped values that contain quotes or line breaks can break hand-written JSON. If a step fails with `invalid_payload`, check the mapped values first.

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

## Check it's working

- The step's result 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 Zapier

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 Zapier, and each message starts your Zap.

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

Use Zapier's catch-hook 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`, and Zapier can use it to map fields.

**6. Build the rest of the Zap**

Add a filter that skips events where `test` is `true`, then the steps 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 Zap 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 catch-hook trigger accepts any request to its URL without checking that header, so keep the URL private. If the Zap 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) | The body isn't valid JSON or a field is wrong - often a mapped value with quotes in it. `error.details` names the field. |
| [`invalid_recipient`](https://docs.theavoxai.com/api/errors#invalid_recipient) | The mapped phone has no country code, or isn't a phone number. Add a step that builds the full 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, not per Zap. |
| [`rate_limited`](https://docs.theavoxai.com/api/errors#rate_limited) | Too many requests. Wait for the `Retry-After` seconds, then retry. |
| [`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 | Base the `Idempotency-Key` on the triggering record's id, so a replayed Zap run doesn't send again. |
| "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).
- Zapier's own task limits apply on Zapier's side.

## Security

- Keep the API key in the action's header field, and don't share the Zap as a template with the key inside.
- Give Zapier 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.
- Zapier's task history can keep message content. Review its retention to match your policy.

## Build your own server for Zapier

Zaps can call the Avox API directly, so a server is optional. A small relay helps when you want the Avox key out of Zapier, flat fields instead of nested JSON (the relay builds the WhatsApp body), or the same retries and idempotency for every Zap. Zaps then `POST` to the relay's `/notify` with simple fields: `to`, `template`, `params`, `reference`.

```bash
# Environment of the relay
AVOX_API_KEY=avx_live_your_key
INTERNAL_TOKEN=a-long-random-string   # add it as the x-internal-token header in the Zap step
```

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

```bash
# Environment of the receiver
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret
FORWARD_TO_URL=https://your-zap-webhook-trigger-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

**Do I need to install anything in Zapier?**

No. Zapier's built-in webhook app sends to the Avox API and catches 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 Zap on each reply, turn on message forwarding as described above.

**Which template should a Zap use?**

Any approved template on your account. Check the exact name and language on [Templates](https://docs.theavoxai.com/templates).

**Can one Zap 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.
