Send WhatsApp messages from Chatwoot events through the Avox API with a small relay, and bring customer messages back with message forwarding - setup, code, checks, errors and FAQ.
Send WhatsApp messages from Chatwoot through a number connected to Avox. Chatwoot's own webhooks tell a small relay you run when something happens - a conversation is resolved, an agent replies - and the relay sends the WhatsApp message with the Avox API. Customer replies on WhatsApp land in your Avox Inbox, and message forwarding can send them to your relay as well.
Send: Chatwoot event (webhook) → your relay → Avox API → customer's WhatsApp
Receive: customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → your relay (optional)
Typical uses: a "your ticket is resolved" message on WhatsApp, a follow-up survey after a conversation closes, or sending an agent's Chatwoot reply to the customer's WhatsApp.
https:// (Node.js 18 or later for the code below, or Python 3.10 or later).# .env
AVOX_API_KEY=avx_live_your_key
RELAY_SECRET=a-long-random-string
Chatwoot's webhook events and payload fields can differ between versions. The code follows Chatwoot's public webhook format - check it against your own instance. The Avox side stays the same.
Open API Keys
Pick the key type
Choose the number
Create and copy it
AVOX_API_KEY. It's shown only once.Check it
Keep the key on the relay only. See API keys.
// relay.js - Node.js 18+ (npm install express)
import express from "express";
import { timingSafeEqual } from "node:crypto";
const app = express();
async function sendTemplate(to, name, params, idempotencyKey) {
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": idempotencyKey,
},
body: JSON.stringify({
to,
type: "template",
template: {
name,
language: { code: "en" },
components: [{ type: "body", parameters: params.map((text) => ({ type: "text", text: String(text) })) }],
},
}),
});
const data = await res.json().catch(() => null);
if (!data?.ok) console.error("Avox send failed:", data?.error?.code, data?.error?.request_id);
return data;
}
function secretMatches(given) {
const a = Buffer.from(given ?? "");
const b = Buffer.from(process.env.RELAY_SECRET ?? "");
return b.length > 0 && a.length === b.length && timingSafeEqual(a, b);
}
// Chatwoot account webhook. The secret in the URL keeps strangers out.
app.post("/chatwoot/:secret", express.json({ limit: "1mb" }), async (req, res) => {
if (!secretMatches(req.params.secret)) return res.sendStatus(401);
res.sendStatus(200);
const event = req.body;
if (event.event === "conversation_status_changed" && event.status === "resolved") {
const contact = event.meta?.sender;
const phone = (contact?.phone_number ?? "").replace(/\D/g, "");
if (!phone) return;
await sendTemplate(
phone,
"ticket_resolved",
[contact.name ?? "there", event.id],
`chatwoot-resolved-${event.id}` // one message per resolved conversation
);
}
});
app.listen(3000);
The ticket_resolved template here is an example - create your own on
Create, for instance: "Hi {{1}}, your request #{{2}} is resolved. Reply
here if you need anything else."
Create and approve the template
Create the Avox API key
AVOX_API_KEY.Deploy the relay
https://relay.example.com/chatwoot/<RELAY_SECRET> reachable.Add the Chatwoot webhook
Try it
"test": true, the header Avox-Test-Mode: true, and a message_id starting wamid.TEST - so nothing reaches a real customer while you test. See Testing.Avox send failed line, and the message arrives on your phone.200.Customer messages always arrive in the Avox Inbox. To get them into Chatwoot too, turn on message forwarding for the number and point it at your relay:
Open Message forwarding
Save your URL
https://relay.example.com/avox/messages, in Endpoint URL and click Save URL.Turn it on and copy the secret
AVOX_WEBHOOK_SECRET - it's shown only once.Send a test event
Each new customer message arrives as a signed message.received event with the customer's number
(data.from, with a leading +), profile name (data.contact_name), type and content. The
Receiver below checks the signature and hands you the event; from there, create the
message in Chatwoot through Chatwoot's own API, matching the contact by phone number. The full
payload for every message type is on Receive messages.
Every code, with what to do about it, is on API errors.
Avox-Signature on forwarded messages before creating anything in Chatwoot.This version of the relay sends agents' replies: when an agent posts a public reply in a Chatwoot conversation, Chatwoot's outgoing webhook calls the route, and the route sends the text to the contact's WhatsApp number through the Avox API. The Chatwoot message id becomes the Idempotency-Key, so a repeated webhook never sends twice. A plain reply works within 24 hours of the customer's last WhatsApp message; outside it, send a template (as in the relay above).
# Environment
AVOX_API_KEY=avx_live_your_key
CHATWOOT_WEBHOOK_SECRET=a-long-random-string # part of the webhook URL you give Chatwoot
// server.js - Node.js 18+ (npm install express). Run: AVOX_API_KEY=... CHATWOOT_WEBHOOK_SECRET=... 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);
}
// Chatwoot webhook (subscribe it to "Message Created"). The secret in the path keeps strangers out.
// When an agent sends a public reply in Chatwoot, this sends it to the contact on WhatsApp.
app.post("/chatwoot/:secret", express.json({ limit: "1mb" }), async (req, res) => {
if (!safeEqual(req.params.secret, process.env.CHATWOOT_WEBHOOK_SECRET)) return res.sendStatus(401);
res.sendStatus(200); // acknowledge first, then send
const event = req.body;
if (event?.event !== "message_created" || event.message_type !== "outgoing" || event.private) return;
const to = String(event.conversation?.meta?.sender?.phone_number ?? "").replace(/\D/g, "");
if (!to || !event.content) return;
try {
await sendWhatsApp({ to, type: "text", text: { body: event.content } }, `chatwoot-message-${event.id}`);
} catch (err) {
if (err.code === "outside_messaging_window") {
console.warn(`[chatwoot] contact hasn't written in 24h - send a template instead (request_id=${err.requestId})`);
}
}
});
app.listen(process.env.PORT ?? 3000, () => console.log("Listening"));
// app/api/chatwoot/route.ts - Next.js 15+ (App Router)
import { randomUUID, timingSafeEqual } from "node:crypto";
import { after, 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);
}
// Chatwoot webhook URL: https://your-app.example.com/api/chatwoot?secret=... (subscribe to "Message Created").
// When an agent sends a public reply in Chatwoot, this sends it to the contact on WhatsApp.
export async function POST(request: NextRequest) {
if (!safeEqual(request.nextUrl.searchParams.get("secret"), process.env.CHATWOOT_WEBHOOK_SECRET)) {
return new Response(null, { status: 401 });
}
const event = await request.json().catch(() => null);
if (event?.event !== "message_created" || event.message_type !== "outgoing" || event.private) {
return new Response(null, { status: 200 });
}
const to = String(event.conversation?.meta?.sender?.phone_number ?? "").replace(/\D/g, "");
if (!to || !event.content) return new Response(null, { status: 200 });
after(async () => {
try {
await sendWhatsApp({ to, type: "text", text: { body: event.content } }, `chatwoot-message-${event.id}`);
} catch (err) {
const e = err as AvoxFailure;
if (e.code === "outside_messaging_window") {
console.warn(`[chatwoot] contact hasn't written in 24h - send a template instead (request_id=${e.requestId})`);
}
}
});
return new Response(null, { status: 200 });
}
# 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 BackgroundTasks, FastAPI, Request
from fastapi.responses import 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))
async def send_agent_reply(to: str, body: str, message_id) -> None:
try:
await send_whatsapp({"to": to, "type": "text", "text": {"body": body}},
idempotency_key=f"chatwoot-message-{message_id}")
except AvoxError as err:
if err.code == "outside_messaging_window":
log.warning("contact hasn't written in 24h - send a template instead (request_id=%s)", err.request_id)
# Chatwoot webhook (subscribe it to "Message Created"). The secret in the path keeps strangers out.
# When an agent sends a public reply in Chatwoot, this sends it to the contact on WhatsApp.
@app.post("/chatwoot/{secret}")
async def chatwoot(secret: str, request: Request, background: BackgroundTasks):
expected = os.environ.get("CHATWOOT_WEBHOOK_SECRET", "")
if not expected or not hmac.compare_digest(secret.encode(), expected.encode()):
return Response(status_code=401)
event = await request.json()
if event.get("event") != "message_created" or event.get("message_type") != "outgoing" or event.get("private"):
return Response(status_code=200)
sender = ((event.get("conversation") or {}).get("meta") or {}).get("sender") or {}
to = "".join(ch for ch in str(sender.get("phone_number") or "") if ch.isdigit())
if to and event.get("content"):
background.add_task(send_agent_reply, to, event["content"], event["id"]) # acknowledge first, then send
return Response(status_code=200)
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 Chatwoot route, which answers Chatwoot first and sends afterwards. Run Express with Node.js 18 or later, the Next.js route in an App Router project on Next.js 15 or later (it uses after), and FastAPI with Python 3.10 or later. For the full walkthrough - key setup, local testing with a tunnel, a deployment checklist - see Connect your server.
Run this next to the relay (or add its route to it) to receive WhatsApp messages from Avox. Put your Chatwoot hand-off in handleEvent.
# Environment of the receiver
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret
// 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"));
// 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 });
}
# 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.
To the Avox Inbox, where your team can answer them, and to your relay too if you turn on message forwarding. For automatic answers, use Automation.
Yes, to a customer who messaged your WhatsApp number in the last 24 hours. See Message types.
Through Chatwoot's webhooks, the Avox API and message forwarding, with the relay on this page in between. You stay in control of which events send which message.
Last updated