# Avox API Webhooks: Receive Messages

> Receive new customer messages on your own server with message forwarding - signed payloads, signature checks, retries, test events and where statuses show up.

Source: https://docs.theavoxai.com/avox-api/webhooks

The Avox API answers each send right away with a `message_id`. What happens after that - the
customer replying, the message being delivered or read - reaches you in two ways:

- **Message forwarding** POSTs each new incoming customer message to your own server, signed by
  Avox. See [Receive messages](#receive-messages) below.
- **Inside Avox**, every event Meta sends for your number is on [Logs](https://docs.theavoxai.com/logs), and conversations are
  in the [Inbox](https://docs.theavoxai.com/inbox).

## Where to see them

| What | Where |
| --- | --- |
| New messages from customers, as they arrive | Your own endpoint, with [message forwarding](https://docs.theavoxai.com/connections/webhook-override) on - and the [Inbox](https://docs.theavoxai.com/inbox) |
| Every event Meta sent, with its full payload - including the `sent`, `delivered`, `read` and `failed` statuses of messages you sent with the API | [Logs](https://docs.theavoxai.com/logs) - open an event to see its JSON |
| The people who wrote to you | [Contacts](https://docs.theavoxai.com/contacts) |
| Each API request's own result | The **Sent** tab of the **API logs** page, and the key's **Recent requests** in **Settings > API Keys** |
| Each message forwarded to your endpoint, across all your numbers | The **Received** tab of the **API logs** page - see [API logs](https://docs.theavoxai.com/connections/api-keys#api-logs) |

Message forwarding only sends **incoming customer messages**. Delivery statuses, calls, messages your
team sends, chat history imports and template or account events are not forwarded - read statuses on
[Logs](https://docs.theavoxai.com/logs).

## Receive messages

Turn on **Message forwarding** on the connection's page (an Admin does this - see
[Message forwarding](https://docs.theavoxai.com/connections/webhook-override)). From then on, Avox sends one `POST` to your
**Endpoint URL** for each new message a customer sends to that number.

### What's sent

One event type, `message.received`. The body is JSON:

```json
{
  "id": "evt_5f0c9a7e1b2d4c6e8f00a1b2c3d4e5f6",
  "type": "message.received",
  "api_version": "2026-10-01",
  "created_at": "2026-10-10T10:00:02.000Z",
  "connection_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d",
  "data": {
    "message_id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdGRDA2QzQ1MTk1NkU2NDhCRgA=",
    "from": "+15551234567",
    "contact_name": "Maria Lopez",
    "timestamp": "2026-10-10T10:00:00.000Z",
    "type": "text",
    "text": { "body": "Hi! Is my order on the way?" }
  }
}
```

| Field | Meaning |
| --- | --- |
| `id` | The event id, `evt_` + 32 characters. The same for every retry and resend of this message - use it to skip repeats. Also in the `Avox-Event-Id` header. |
| `type` | Always `message.received`. |
| `api_version` | `2026-10-01`. A breaking change to this payload would come with a new version. |
| `created_at` | When Avox created the event (ISO 8601, UTC). |
| `connection_id` | The Avox connection (WhatsApp number) the message was sent to. |
| `test` | Only on test events, and then always `true`. |
| `data.message_id` | WhatsApp's message id (`wamid...`). Pass it as `context.message_id` to reply to this message. |
| `data.from` | The customer's number in E.164 format, with a leading `+`. |
| `data.contact_name` | The customer's WhatsApp profile name, or `null`. |
| `data.timestamp` | When the customer sent it (ISO 8601, UTC). |
| `data.type` | One of `text`, `image`, `audio`, `video`, `document`, `sticker`, `location`, `contacts`, `interactive`, `button`, `reaction`. |
| `data.context` | Only when the customer replied to (quoted) a message: `{ "message_id": "wamid..." }`. |

The rest of `data` depends on the type. Nothing else is ever included - no access tokens, no Meta
account ids, no other customers' details, and not Meta's raw payload. Media comes as Meta's media id
and type only, not the file.

**Text**

```json
"type": "text",
"text": { "body": "Hi! Is my order on the way?" }
```

**Image, video, audio, sticker and document**

```json
"type": "image",
"media": { "id": "1203949581720283", "mime_type": "image/jpeg", "caption": "My receipt" }
```

```json
"type": "document",
"media": { "id": "820493119374620", "mime_type": "application/pdf", "caption": "Invoice", "filename": "invoice-1042.pdf" }
```

`caption` is only there when the customer added one; `filename` only on documents. `audio`, `video`
and `sticker` have the same `media` shape. The `id` is Meta's media id - to download the file, use
Meta's media endpoint with your own access, or ignore it.

**Location**

```json
"type": "location",
"location": { "latitude": 19.0760, "longitude": 72.8777, "name": "Head office", "address": "1 Marine Drive, Mumbai" }
```

`name` and `address` are only there when the customer shared a named place.

**Contacts**

```json
"type": "contacts",
"contacts": [{ "name": "Sam Lee", "phones": ["+1 555 0100"] }]
```

Contact cards the customer shared: the name and phone numbers only.

**Interactive replies**

A tap on a reply button or a list row from an [interactive message](https://docs.theavoxai.com/avox-api/messages#interactive):

```json
"type": "interactive",
"context": { "message_id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5" },
"interactive": { "type": "button_reply", "id": "slot_keep", "title": "Yes, Friday" }
```

```json
"type": "interactive",
"interactive": { "type": "list_reply", "id": "size_m", "title": "Medium", "description": "Fits 38-40" }
```

`id` is the id you chose when you sent the buttons or list. Flow submissions aren't forwarded - see
them on [Flows](https://docs.theavoxai.com/flows).

**Template quick reply buttons**

```json
"type": "button",
"button": { "text": "Stop promotions", "payload": "STOP" }
```

**Reactions**

```json
"type": "reaction",
"reaction": { "message_id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5", "emoji": "👍" }
```

`emoji` is `null` when the customer removed their reaction.

Other kinds of WhatsApp message (system notices, unsupported or unknown types) aren't forwarded.

### Headers

| Header | Value |
| --- | --- |
| `Avox-Signature` | `t=1760090402,v1=5257a869e7ec...` - the time Avox signed it, and one or two signatures |
| `Avox-Event-Id` | The event `id` |
| `Avox-Event-Type` | `message.received` |
| `User-Agent` | `Avox-Webhooks/1.0` |
| `Content-Type` | `application/json` |

### Check the signature

Each `v1` value is a hex HMAC-SHA256 of `<t>.<raw body>` - the `t` value, a full stop, then the
request body exactly as received - keyed with your signing secret. Use the **whole** secret as the
key, including `whsec_`, as plain text; don't decode it.

To check a request:

1. Read the **raw** body before any JSON parsing. Re-serialising parsed JSON changes the bytes and
   the signature won't match.
2. Split `Avox-Signature` on commas. Take `t` and every `v1`.
3. Refuse the request if `t` is more than **5 minutes** from your clock. This stops replays.
4. Compute the HMAC and compare it with each `v1` using a **constant-time** comparison. Accept if any
   matches.

**Node.js (Express)**

```js
// server.js - npm install express
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.AVOX_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;

export function verifyAvoxSignature(rawBody, header, secret = SECRET) {
  if (!header || !secret) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    if (key === "v1") 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) => {
    const given = Buffer.from(sig, "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

const app = express();

// express.raw keeps the exact bytes - don't use express.json() on this route.
app.post("/avox/messages", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
  if (!verifyAvoxSignature(req.body, req.get("Avox-Signature"))) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // answer first...
  setImmediate(() => handleEvent(event).catch(console.error)); // ...then do the work
});

async function handleEvent(event) {
  if (await alreadyHandled(event.id)) return; // dedupe on Avox-Event-Id
  if (event.test) return console.log("Avox test event received");
  const { from, contact_name, type } = event.data;
  console.log(`New ${type} from ${contact_name ?? from}`);
}

app.listen(3000);
```

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

function verifyAvoxSignature(rawBody: string, header: string | null, secret: string): boolean {
  if (!header || !secret) return false;
  let timestamp: number | null = null;
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    if (key === "v1") 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) => {
    const given = Buffer.from(sig, "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

export async function POST(request: Request) {
  const rawBody = await request.text(); // the exact body, before parsing
  if (!verifyAvoxSignature(rawBody, request.headers.get("avox-signature"), process.env.AVOX_WEBHOOK_SECRET ?? "")) {
    return new Response("invalid signature", { status: 401 });
  }
  const event = JSON.parse(rawBody);
  after(() => handleEvent(event)); // runs after the response is sent
  return new Response(null, { status: 200 });
}

async function handleEvent(event: { id: string; test?: boolean; data: { from: string; type: string } }) {
  // dedupe on event.id, then your own logic
}
```

**FastAPI**

```python
# main.py - pip install fastapi uvicorn
import hashlib, hmac, json, os, time

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

SECRET = os.environ["AVOX_WEBHOOK_SECRET"].encode()  # the whole whsec_... string
TOLERANCE_SECONDS = 5 * 60
app = FastAPI()


def verify_avox_signature(raw: bytes, header: str | None) -> bool:
    if not header:
        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":
            signatures.append(value)
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)


@app.post("/avox/messages")
async def receive(request: Request, background: BackgroundTasks, avox_signature: str | None = Header(default=None)):
    raw = await request.body()  # raw bytes, before any parsing
    if not verify_avox_signature(raw, avox_signature):
        return Response(status_code=401)
    background.add_task(handle_event, json.loads(raw))  # runs after the response
    return Response(status_code=200)


def handle_event(event: dict) -> None:
    # dedupe on event["id"], then your own logic
    print("New", event["data"]["type"], "from", event["data"]["from"])
```

**Flask**

```python
# app.py - pip install flask
import hashlib, hmac, json, os, time
from concurrent.futures import ThreadPoolExecutor

from flask import Flask, request

SECRET = os.environ["AVOX_WEBHOOK_SECRET"].encode()  # the whole whsec_... string
TOLERANCE_SECONDS = 5 * 60
app = Flask(__name__)
pool = ThreadPoolExecutor(max_workers=4)


def verify_avox_signature(raw: bytes, header: str | None) -> bool:
    if not header:
        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":
            signatures.append(value)
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)


@app.post("/avox/messages")
def receive():
    raw = request.get_data()  # raw bytes, before any parsing
    if not verify_avox_signature(raw, request.headers.get("Avox-Signature")):
        return "", 401
    pool.submit(handle_event, json.loads(raw))  # do the work after answering
    return "", 200


def handle_event(event: dict) -> None:
    # dedupe on event["id"], then your own logic
    print("New", event["data"]["type"], "from", event["data"]["from"])
```

### Answer fast, work later

Answer with any `2xx` status within **10 seconds**, before doing slow work like database writes or
calling other services. Avox doesn't read the response body, and a redirect (`3xx`) counts as a
failure - point the Endpoint URL at the final address. If your own processing fails after you've
answered, it's yours to retry; Avox already counts the delivery as done.

### Skip repeats

The same message can reach you more than once - a retry after a slow answer, or a **Resend** from
the delivery log. The event `id` (and the `Avox-Event-Id` header) stays the same, so store the ids
you've handled and ignore any you've seen before. `data.message_id` works too.

### Retries

A delivery fails when your endpoint doesn't answer `2xx` within 10 seconds - an error status, a
redirect, a timeout, a refused connection or an invalid TLS certificate. Avox then retries:

| Attempt | When |
| --- | --- |
| 1 | A few seconds after the message arrives |
| 2 | About 1 minute later |
| 3 | About 5 minutes later |
| 4 | About 30 minutes later |
| 5 | About 2 hours later |
| 6 | About 6 hours later |
| 7 | About 12 hours later - the last try |

Each wait varies by up to 10% so retries don't arrive in lockstep. After the seventh attempt (about
21 hours after the first) the delivery is marked **Failed**. Messages can arrive out of order when an
earlier one is being retried - sort by `data.timestamp` if order matters.

Avox sends at most 2 requests at a time to one connection's endpoint, so a slow endpoint gets
messages one or two at a time rather than all at once.

### Test events

**Send test event** on the connection's page sends a sample right away and shows the result:

```json
{
  "id": "evt_9c1f0e2d3b4a59687766554433221100",
  "type": "message.received",
  "api_version": "2026-10-01",
  "created_at": "2026-10-10T10:05:00.000Z",
  "connection_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d",
  "test": true,
  "data": {
    "message_id": "wamid.TEST_5B1E9C0A7D3F2E4A6C8B0D1F",
    "from": "+15550001234",
    "contact_name": "Avox test",
    "timestamp": "2026-10-10T10:05:00.000Z",
    "type": "text",
    "text": { "body": "This is a test event from Avox. Your endpoint is receiving forwarded messages." }
  }
}
```

It's signed exactly like a real delivery, so it tests your signature check too. Check for
`"test": true` and don't act on it as a real customer message. A test is sent once - it isn't
retried and never counts toward auto-pause. You can send one whether forwarding is on or off, once
there's an Endpoint URL and a signing secret.

### Rotate the secret

**Rotate secret** creates a new signing secret and shows it once. For the next **24 hours** every
request carries two `v1` signatures - one with the new secret, one with the old:

```
Avox-Signature: t=1760090402,v1=<signature with the new secret>,v1=<signature with the old secret>
```

Code that accepts any matching `v1` (like the examples above) keeps working with either secret, so:

1. Rotate the secret and copy the new one.
2. Deploy it to your server.
3. Send a test event to confirm.

After 24 hours only the new secret signs. If a secret leaks, rotate it straight away.

### The delivery log and Resend

**Recent deliveries** on the connection's page lists every forwarded message, newest first, with its
status (**Queued**, **Sending**, **Retrying**, **Delivered**, **Failed**), HTTP code, number of
attempts and time. **Payload** shows exactly what was sent. **Resend** (Admins, with forwarding on)
queues it again with a fresh set of retries and the same event id. Deliveries are kept for 30 days,
or your **Webhook Logs** retention on [Compliance](https://docs.theavoxai.com/compliance) if that's shorter.

The **Received** tab of the **API logs** page shows the same deliveries for every number in the
Switchboard at once. Filter by connection, date or status (**Delivered**, **Failed or retrying**,
**Queued or sending**), search by message id (`wamid...`) or event id (`evt_...`), and click a row
for its details - event id, attempts, response code and last error. An Admin can **Resend to your
endpoint** from there too. The message text itself isn't shown there; read it in your
[Inbox](https://docs.theavoxai.com/inbox).

Turning forwarding off stops anything still queued: those deliveries are marked **Failed** with
"Forwarding was turned off", and you can resend them after turning it back on.

### Auto-pause

If **5 deliveries in a row** end up **Failed** - each after all its retries - Avox pauses
forwarding for that connection so it stops sending to an endpoint that isn't working:

- anything still queued is marked **Failed**,
- your Admins get one email,
- the section shows a **Paused** notice ([AVX-3104](https://docs.theavoxai.com/error-codes/avx-3104)).

Messages keep arriving in your Inbox the whole time. To recover, fix the endpoint, click **Send test
event** until it shows **Delivered**, turn forwarding back on, and **Resend** anything you need from
the log. Any successful delivery resets the count.

### Troubleshooting

| What you see | Likely cause | Fix |
| --- | --- | --- |
| "Your endpoint answered 401" (or 403) | The signature check failed. | Verify over the raw body, use the whole `whsec_...` secret as the key, and check you have the current secret. After a rotation, deploy the new one. |
| Signature fails only in production | A proxy or framework parses and re-serialises the JSON before your code. | Read the raw body on this route (`express.raw`, `request.text()`, `request.body()`, `get_data()`). |
| "Your endpoint answered 301" (or 302, 307, 308) | Redirects aren't followed. | Use the final `https://` address, including any trailing slash your server expects. |
| "Your endpoint didn't answer within 10 seconds" | Slow work before answering, or a cold start. | Answer `200` first and process afterwards. |
| "Your endpoint's TLS certificate isn't valid" | Expired, self-signed or wrong-host certificate. | Use a certificate from a public authority that matches the host. |
| "That host isn't reachable from the internet" | The URL points at a private, local or reserved address. | Use a public host. For local testing, use a tunnel (see [Connect your server](https://docs.theavoxai.com/avox-api/connect-your-server#receive-messages)). |
| "The connection was refused" / "That host doesn't resolve" | The server is down, or the domain has no DNS record. | Check the server is running and the domain resolves. |
| Stale timestamp errors in your logs | Your server's clock is off. | Sync the clock (NTP). The tolerance is 5 minutes. |
| The same message handled twice | A retry or a resend. | Dedupe on `Avox-Event-Id`. |
| Nothing arrives | Forwarding is off or paused, or the message type isn't forwarded. | Check the switch and the **Paused** notice; statuses, echoes and Flow answers are never forwarded. |

## Matching a status to a send

Delivery statuses aren't forwarded; they're on [Logs](https://docs.theavoxai.com/logs). Every status event carries the
message's id - the same `message_id` the API returned. If you set `biz_opaque_callback_data` when
sending, WhatsApp includes it on the status too, which makes a message easy to find in Logs by your
own reference.

```json
{
  "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5",
  "status": "delivered",
  "timestamp": "1760090400",
  "recipient_id": "15551234567",
  "biz_opaque_callback_data": "order-A-1042"
}
```

## The event envelope in Logs

On [Logs](https://docs.theavoxai.com/logs) you see Meta's own format. Message statuses and customer messages both arrive in a
change whose `field` is `messages`:

```json
{
  "object": "whatsapp_business_account",
  "entry": [
    {
      "id": "102290129340398",
      "changes": [
        {
          "field": "messages",
          "value": {
            "messaging_product": "whatsapp",
            "metadata": {
              "display_phone_number": "15550001111",
              "phone_number_id": "106540352242922"
            },
            "statuses": [ ... ],
            "messages": [ ... ]
          }
        }
      ]
    }
  ]
}
```

`entry[].id` is your WhatsApp Business Account id and `metadata.phone_number_id` is your number's
id - both are on the connection's page under **Meta identifiers** (**WABA ID** and **Phone Number
ID**).

## Delivery statuses

| `status` | Meaning |
| --- | --- |
| `sent` | WhatsApp sent the message. |
| `delivered` | It reached the customer's phone. |
| `read` | The customer opened it (when their read receipts are on). |
| `failed` | It couldn't be delivered. `errors` says why, with Meta's code - see [Meta error codes](https://docs.theavoxai.com/meta-error-codes). |

A failed status looks like this:

```json
{
  "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5",
  "status": "failed",
  "timestamp": "1760090460",
  "recipient_id": "15551234567",
  "errors": [
    {
      "code": 131026,
      "title": "Message undeliverable",
      "message": "Message undeliverable",
      "error_data": { "details": "Message Undeliverable." }
    }
  ]
}
```

Some problems are found by WhatsApp only after it accepts a message - for example a text to someone
whose 24-hour window has closed - so they show up as a `failed` status rather than as an API error.
Statuses can arrive out of order (a `read` before its `delivered`). Pricing and other fields Meta
adds can change over time.

## Customer replies

Replies appear in the [Inbox](https://docs.theavoxai.com/inbox), and - with forwarding on - at your endpoint as
`message.received` events (see [What's sent](#whats-sent)). In Logs, a button tap
looks like this in Meta's format:

```json
{
  "from": "15551234567",
  "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQ5RDJF",
  "timestamp": "1760090580",
  "type": "interactive",
  "context": { "from": "15550001111", "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5" },
  "interactive": {
    "type": "button_reply",
    "button_reply": { "id": "slot_keep", "title": "Yes, Friday" }
  }
}
```

A reply opens a new 24-hour window, so you can answer it with any message type - from the Inbox or
with the API. To quote the customer's message, send its id as `context.message_id`. See
[Message types](https://docs.theavoxai.com/avox-api/messages#replying-to-a-message).

## Automations on replies

To answer common replies automatically - a keyword, a button tap, a first message - use
[Automation](https://docs.theavoxai.com/automation). It runs on every inbound message for the number, whether or not
forwarding is on.

## Other events

Meta also reports template decisions, quality changes, Flow status changes and account alerts.
Avox uses them to keep [Templates](https://docs.theavoxai.com/templates), [Flows](https://docs.theavoxai.com/flows) and [Health](https://docs.theavoxai.com/health) current, and
emails Admins about the important ones. They aren't forwarded. See [Meta webhooks](https://docs.theavoxai.com/whatsapp/webhooks).
