Receive new customer messages on your own server with message forwarding - signed payloads, signature checks, retries, test events and where statuses show up.
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 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.
Turn on Message forwarding on the connection's page (an Admin does this - see
Message forwarding). From then on, Avox sends one POST to your
Endpoint URL for each new message a customer sends to that number.
One event type, message.received. The body is 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?" }
}
}
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
"type": "text",
"text": { "body": "Hi! Is my order on the way?" }
Image, video, audio, sticker and document
"type": "image",
"media": { "id": "1203949581720283", "mime_type": "image/jpeg", "caption": "My receipt" }
"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
"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
"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:
"type": "interactive",
"context": { "message_id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5" },
"interactive": { "type": "button_reply", "id": "slot_keep", "title": "Yes, Friday" }
"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.
Template quick reply buttons
"type": "button",
"button": { "text": "Stop promotions", "payload": "STOP" }
Reactions
"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.
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:
Avox-Signature on commas. Take t and every v1.t is more than 5 minutes from your clock. This stops replays.v1 using a constant-time comparison. Accept if any
matches.// 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);
// 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
}
# 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"])
# 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 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.
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.
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:
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.
Send test event on the connection's page sends a sample right away and shows the result:
{
"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 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:
After 24 hours only the new secret signs. If a secret leaks, rotate it straight away.
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 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.
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.
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:
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.
Delivery statuses aren't forwarded; they're on 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.
{
"id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5",
"status": "delivered",
"timestamp": "1760090400",
"recipient_id": "15551234567",
"biz_opaque_callback_data": "order-A-1042"
}
On Logs you see Meta's own format. Message statuses and customer messages both arrive in a
change whose field is messages:
{
"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).
A failed status looks like this:
{
"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.
Replies appear in the Inbox, and - with forwarding on - at your endpoint as
message.received events (see What's sent). In Logs, a button tap
looks like this in Meta's format:
{
"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.
To answer common replies automatically - a keyword, a button tap, a first message - use Automation. It runs on every inbound message for the number, whether or not forwarding is on.
Meta also reports template decisions, quality changes, Flow status changes and account alerts. Avox uses them to keep Templates, Flows and Health current, and emails Admins about the important ones. They aren't forwarded. See Meta webhooks.
Last updated