Send WhatsApp messages from any Make scenario with the Avox API and an HTTP module, and start scenarios from incoming customer messages with message forwarding - setup, checks, errors and FAQ.
Connect Make (formerly Integromat) to a WhatsApp number on Avox in both directions:
Everything still lands in your Avox Inbox as usual, and your Automation keeps running.
Send: any Make trigger → your modules → HTTP request module → Avox API → customer's WhatsApp
Receive: customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → scenario's webhook trigger
Open API Keys
Pick the key type
Choose the number
Create and copy it
Check it
Live keys start with avx_live_, test keys with avx_test_. Give Make its own key, so you can rotate or revoke it without touching anything else. See API keys.
Add your trigger
Build the body
Add an HTTP request module
Run once
Switch to the live key and turn it on
Authorization header, then switch the scenario on.{
"to": "{{1.phone}}",
"type": "template",
"template": {
"name": "order_shipped",
"language": { "code": "en_US" },
"components": [
{ "type": "body", "parameters": [{ "type": "text", "text": "{{1.order_number}}" }] }
]
}
}
{{1.phone}} and {{1.order_number}} stand for the fields you map from your trigger module. The phone number needs its country code; a leading +, spaces, dashes and brackets are fine. The template name and language must match an approved template exactly, with one parameter per placeholder. Every message type is on Message types.
A test key runs every check a live key does - the key, the connection, your plan, rate limits, the body and the Idempotency-Key - and then answers with a pretend result instead of sending. The response carries "test": true, the header Avox-Test-Mode: true, and a message_id starting wamid.TEST. Special test numbers return each error on purpose, so you can build the scenario's error routes too. See Testing.
"ok": true, a request_id and a message_id.200.request_id or message_id.Turn on message forwarding for the number, and Avox posts every new customer message to your Endpoint URL as a signed message.received event. Point it at a webhook trigger in Make, and each message runs your scenario.
Start a scenario with a webhook trigger
Open Message forwarding
Save the URL
Turn it on
Send a sample
"test": true, which Make can use to learn the data structure.Build the rest of the scenario
test is true, then the modules you want.The fields you'll map most often:
A scenario that answers a forwarded message can send a plain text message - the customer has just written, so the 24-hour window is open. See Messaging window.
Avox-Signature header that proves it came from Avox. A webhook trigger accepts any request to its URL without checking that header, so keep the URL private. If the scenario changes data or replies automatically, put a small receiver in front that checks the signature and passes the event on - see Verify forwarded messages first.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.
Every code, with what to do about it, is on API errors.
quota_exceeded, which resets at midnight UTC). Test keys have their own, separate counters. See Rate limits.Avox-Signature on forwarded messages before acting on them, and rotate the signing secret if it leaks.Make can call the Avox API directly, so a server is optional. Run a small relay when you want the Avox key out of Make, consistent retries and idempotency across scenarios, or shared rules. Scenarios then call the relay's POST /notify with your internal token and simple fields: to, template, params, reference.
# Environment of the relay
AVOX_API_KEY=avx_live_your_key
INTERNAL_TOKEN=a-long-random-string # put this in the HTTP module's x-internal-token header
// 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"));
// 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 });
}
}
# 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.
This receiver checks every forwarded message's signature and then passes the event, unchanged, to your scenario's webhook trigger. Set Endpoint URL in Avox to the receiver's /avox/messages address, and FORWARD_TO_URL to the scenario's webhook URL.
# Environment of the receiver
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret
FORWARD_TO_URL=https://your-make-webhook-url
// 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.
Only Make's built-in HTTP and webhook tools. They cover sending through the Avox API and receiving forwarded messages.
Replies arrive in the Avox Inbox, where your team can answer them, and Automation can answer common questions for you. To run a scenario on each reply, turn on message forwarding as described above.
Yes, to someone who messaged your number in the last 24 hours. See Messaging window.
Each key belongs to one number. Create a key per number and use the key for the number you want to send from.
Last updated