Send WhatsApp messages from Laravel with the Avox API and receive customer messages with message forwarding - an Http facade client, a queued job with idempotency, a signed webhook route, checks and FAQ.
Send and receive WhatsApp messages in a Laravel app through a number connected to Avox, using only
Laravel's own Http facade, queues and routes with the Avox API and
message forwarding.
Send: your Laravel app (event / job) → Avox API → customer's WhatsApp
Receive: customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → your Laravel route
AvoxClient service.SendWhatsAppTemplate job that's safe to retry.Avox-Signature on forwarded messages and handles each one once.https://.# .env
AVOX_API_KEY=avx_live_your_key
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret # only if you receive forwarded messages
// config/services.php
'avox' => [
'api_key' => env('AVOX_API_KEY'),
'webhook_secret' => env('AVOX_WEBHOOK_SECRET'),
],
Open API Keys
Pick the key type
Choose the number
Create and copy it
AVOX_API_KEY. It's shown only once.Check it
Give this app its own key, so you can rotate or revoke it on its own. See API keys.
// app/Services/AvoxClient.php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class AvoxClient
{
public function send(array $message, ?string $idempotencyKey = null): Response
{
return Http::withToken(config('services.avox.api_key'))
->withHeaders(['Idempotency-Key' => $idempotencyKey ?? (string) Str::uuid()])
->acceptJson()
->timeout(30)
->post('https://api.avox.com/api/messages/send', $message);
}
}
Http::withToken() sets Authorization: Bearer ... for you, and post() sends the array as JSON.
Every response carries an X-Request-Id header, and the body's request_id matches it - log it.
The idempotency key is built from your own record, so every retry of the job reuses it and a message is never sent twice. Avox keeps the result of each key for 24 hours.
// app/Jobs/SendWhatsAppTemplate.php
namespace App\Jobs;
use App\Services\AvoxClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendWhatsAppTemplate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public function __construct(
public string $to,
public string $template,
public array $params,
public string $idempotencyKey,
public string $language = 'en',
) {}
public function handle(AvoxClient $avox): void
{
$template = ['name' => $this->template, 'language' => ['code' => $this->language]];
if ($this->params !== []) {
$template['components'] = [[
'type' => 'body',
'parameters' => array_map(fn ($text) => ['type' => 'text', 'text' => (string) $text], $this->params),
]];
}
$response = $avox->send(['to' => $this->to, 'type' => 'template', 'template' => $template], $this->idempotencyKey);
if ($response->json('ok')) {
logger()->info('WhatsApp sent', [
'message_id' => $response->json('message_id'),
'request_id' => $response->json('request_id'),
'test' => (bool) $response->json('test'),
]);
return;
}
$code = $response->json('error.code');
// Wait and retry with the same key only what's safe to retry.
if (in_array($code, ['rate_limited', 'idempotency_in_progress', 'internal_error'], true)) {
$this->release((int) ($response->header('Retry-After') ?: 30));
return;
}
logger()->error('WhatsApp send failed', [
'code' => $code,
'request_id' => $response->json('error.request_id'),
'details' => $response->json('error.details'),
]);
$this->fail(new \RuntimeException("Avox send failed: {$code}"));
}
}
Dispatch it from anywhere - a controller, an event listener, a scheduled command:
SendWhatsAppTemplate::dispatch(
to: $order->customer_phone, // full international number, e.g. 15551234567 or +1 555 123 4567
template: 'order_confirmation',
params: [$order->number, $order->total],
idempotencyKey: "order-{$order->id}-confirmation",
);
A connection error or timeout throws, so the queue retries the job with the same key - if the first attempt did reach WhatsApp, the retry gets the stored result instead of sending again. Every message type is on Message types.
Turn on message forwarding, and Avox posts each new customer message to your app as a signed
message.received event. This route checks the signature over the raw body, answers straight
away, skips repeats, and does the work on the queue.
// routes/api.php
use App\Http\Controllers\AvoxWebhookController;
Route::post('/avox/messages', AvoxWebhookController::class);
// app/Http/Controllers/AvoxWebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class AvoxWebhookController extends Controller
{
private const TOLERANCE_SECONDS = 300;
public function __invoke(Request $request)
{
$raw = $request->getContent(); // the exact body, before any parsing
$secret = (string) config('services.avox.webhook_secret');
if (! $this->signatureIsValid($raw, (string) $request->header('Avox-Signature'), $secret)) {
return response()->noContent(401);
}
$event = json_decode($raw, true);
// Retries and Resend keep the same event id, so each event is handled once.
if (Cache::add('avox-event:'.$event['id'], true, now()->addDay())) {
dispatch(function () use ($event) {
if ($event['test'] ?? false) {
logger()->info('Avox test event received', ['id' => $event['id']]);
return;
}
logger()->info('WhatsApp message received', [
'from' => $event['data']['from'],
'name' => $event['data']['contact_name'],
'type' => $event['data']['type'],
'text' => $event['data']['text']['body'] ?? null,
]);
});
}
return response()->noContent(200);
}
// 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.
private function signatureIsValid(string $raw, string $header, string $secret): bool
{
if ($header === '' || $secret === '') {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && preg_match('/^[0-9a-f]{64}$/i', $value)) {
$signatures[] = strtolower($value);
}
}
if ($timestamp === null || abs(time() - $timestamp) > self::TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$raw, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}
Routes in routes/api.php have no CSRF check, and get the /api prefix, so the address is
https://your-app.example.com/api/avox/messages. On Laravel 11 or later, php artisan install:api
creates that file if your app doesn't have it yet. The full payload for every message type is on
Receive messages.
Create the API key
Approve your templates
Add the client and job
php artisan config:clear, and start a queue worker.Dispatch a test
Turn on forwarding (optional)
AVOX_WEBHOOK_SECRET, run php artisan config:clear, then click Send test event. You need the Admin role."test": true, the header Avox-Test-Mode: true, and a message_id starting wamid.TEST - so your test suite never messages a real customer. See Testing.storage/logs/laravel.log shows WhatsApp sent with a message_id.200. The API logs page (Sent tab) has the full history - search it by request_id or message_id.Avox test event received, and Avox shows "Delivered - your endpoint answered 200". Forwarded messages are listed on the API logs page (Received tab).Every code, with what to do about it, is on API errors.
Retry-After. See Rate limits..env, never in committed code, and never send them to the browser.Avox-Signature before trusting a forwarded message, and rotate the signing secret if it leaks.The PHP client, queued job and webhook route above are all a Laravel app needs. If your team prefers a separate sending service that Laravel (and your other apps) call over HTTP, here's a complete one in Express, Next.js or FastAPI. Laravel then calls POST /notify with Http::withHeaders(['x-internal-token' => ...])->post(...), and the Avox key lives only in the service.
# Environment of the sending service
AVOX_API_KEY=avx_live_your_key
INTERNAL_TOKEN=a-long-random-string # Laravel sends this in x-internal-token
// 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.
To receive this number's WhatsApp messages on your own server, see Message forwarding.
No. The API is one HTTPS endpoint, so Laravel's Http facade is the whole client - the
AvoxClient service above is all you need.
Use Automation in Avox, which runs on every inbound message, or handle forwarded messages in your own route and answer with the client - the customer has just written, so a plain text reply works. Your team can also answer from the Inbox.
Create one key per connected number and choose the key by sending number. See Authentication.
Last updated