# WhatsApp API with Laravel

> 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.

Source: https://docs.theavoxai.com/integrations/laravel

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](https://docs.theavoxai.com/integrations/avox-api) and
[message forwarding](https://docs.theavoxai.com/connections/webhook-override).

## What you'll build

```
Send:     your Laravel app (event / job) → Avox API → customer's WhatsApp
Receive:  customer's WhatsApp → Avox (Inbox, Automation) → message forwarding → your Laravel route
```

- An `AvoxClient` service.
- A queued `SendWhatsAppTemplate` job that's safe to retry.
- A route that checks the `Avox-Signature` on forwarded messages and handles each one once.

## Requirements

- A Laravel app with a queue worker running.
- A WhatsApp number connected to Avox. See [Getting started](https://docs.theavoxai.com/getting-started).
- An Avox API key for that number. Only an **Admin** can create one. See
  [Create the API key](#create-the-api-key).
- To receive messages: the app reachable from the internet over `https://`.

```bash
# .env
AVOX_API_KEY=avx_live_your_key
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret   # only if you receive forwarded messages
```

```php
// config/services.php
'avox' => [
    'api_key' => env('AVOX_API_KEY'),
    'webhook_secret' => env('AVOX_WEBHOOK_SECRET'),
],
```

## Create the API key

**1. Open API Keys**

In the dashboard, go to **Settings > API Keys**.

**2. Pick the key type**

Under **Create a new key**, choose **Test key** for local and staging environments (it never sends anything) or **Live key** for production.

**3. Choose the number**

Pick the WhatsApp number in the **Choose a connection** list.

**4. Create and copy it**

Click **Create test key** or **Create live key**, then copy the key from **Your new API key** into `AVOX_API_KEY`. It's shown only once.

**5. Check it**

Click **Test** next to the key. "Working - routes to" followed by your connection's name means it's ready.

Give this app its own key, so you can rotate or revoke it on its own. See [API keys](https://docs.theavoxai.com/connections/api-keys).

## The client

```php
// 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.

## A queued job that's safe to retry

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.

```php
// 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:

```php
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](https://docs.theavoxai.com/avox-api/messages).

## Receive customer messages

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.

```php
// routes/api.php
use App\Http\Controllers\AvoxWebhookController;

Route::post('/avox/messages', AvoxWebhookController::class);
```

```php
// 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](https://docs.theavoxai.com/avox-api/webhooks#receive-messages).

## Setup

**1. Create the API key**

See [Create the API key](#create-the-api-key).

**2. Approve your templates**

Create the templates your app sends on [Create](https://docs.theavoxai.com/templates/create) and wait for approval.

**3. Add the client and job**

Copy the two classes above, run `php artisan config:clear`, and start a queue worker.

**4. Dispatch a test**

Dispatch the job with your own phone number.

**5. Turn on forwarding (optional)**

Open [Connections](https://docs.theavoxai.com/connections), click **Settings** on the connection, and in **Message forwarding** enter your route's address in **Endpoint URL** and click **Save URL**. Switch forwarding on, copy the secret from **Your signing secret** into `AVOX_WEBHOOK_SECRET`, run `php artisan config:clear`, then click **Send test event**. You need the **Admin** role.

> **Tip**
>
> Use a test key in local and staging environments. It runs every check a live key does and answers with a pretend result - `"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](https://docs.theavoxai.com/avox-api/testing).

## Check it's working

- `storage/logs/laravel.log` shows `WhatsApp sent` with a `message_id`.
- With a live key, the message arrives on your phone.
- In **Settings > API Keys**, open the key's **Recent requests**: the send is listed as **Send** with status `200`. The **API logs** page (**Sent** tab) has the full history - search it by `request_id` or `message_id`.
- Delivery statuses for the message are on [Logs](https://docs.theavoxai.com/logs).
- After **Send test event**, the log shows `Avox test event received`, and Avox shows "Delivered - your endpoint answered 200". Forwarded messages are listed on the **API logs** page (**Received** tab).

## Common errors

| Symptom | Fix |
| --- | --- |
| [`invalid_api_key`](https://docs.theavoxai.com/api/errors#invalid_api_key) | Run `php artisan config:clear` after changing `.env`, and check the key is complete. |
| [`key_revoked`](https://docs.theavoxai.com/api/errors#key_revoked) or [`key_expired`](https://docs.theavoxai.com/api/errors#key_expired) | Create a new key in **Settings > API Keys** and update `.env`. |
| [`template_not_found`](https://docs.theavoxai.com/api/errors#template_not_found) | Use the exact template name and language from [Templates](https://docs.theavoxai.com/templates). |
| [`template_params_invalid`](https://docs.theavoxai.com/api/errors#template_params_invalid) | Pass exactly as many params as the template has placeholders. |
| [`invalid_recipient`](https://docs.theavoxai.com/api/errors#invalid_recipient) | Store phone numbers with their country code. |
| [`outside_messaging_window`](https://docs.theavoxai.com/api/errors#outside_messaging_window) | The customer hasn't written in 24 hours. Send a template. |
| [`idempotency_conflict`](https://docs.theavoxai.com/api/errors#idempotency_conflict) | The same key was used with a different body. Keep the job's data unchanged between retries. |
| [`meta_rate_limited`](https://docs.theavoxai.com/api/errors#meta_rate_limited) | WhatsApp is limiting how fast the number sends. The refusal is stored against the key, so dispatch a new job with a new `idempotencyKey` later. |
| Forwarding test shows "Your endpoint answered 419" | The route is behind the CSRF check. Put it in `routes/api.php`. |
| Forwarding test shows "Your endpoint answered 401" | The signature check failed. Check `AVOX_WEBHOOK_SECRET` holds the whole `whsec_...` secret, and run `php artisan config:clear`. |

Every code, with what to do about it, is on [API errors](https://docs.theavoxai.com/api/errors).

## Limits

- Request bodies up to 64 KB, and per-key and per-account rate limits with `Retry-After`. See [Rate limits](https://docs.theavoxai.com/avox-api/rate-limits).
- WhatsApp's 24-hour window and messaging limits. See
  [Messaging window](https://docs.theavoxai.com/whatsapp/messaging-window).

## Security

- Keep the key and the signing secret in `.env`, never in committed code, and never send them to the browser.
- Give this app its own API key so it can be revoked on its own.
- Check the `Avox-Signature` before trusting a forwarded message, and rotate the signing secret if it leaks.

## Build your own server for Laravel

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.

```bash
# 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
```

**Express**

```js
// 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"));
```

**Next.js**

```ts
// 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 });
  }
}
```

**FastAPI**

```python
# 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](https://docs.theavoxai.com/avox-api/connect-your-server).

To receive this number's WhatsApp messages on your own server, see [Message forwarding](https://docs.theavoxai.com/integrations/webhooks).

## FAQ

**Do I need a package?**

No. The API is one HTTPS endpoint, so Laravel's `Http` facade is the whole client - the
`AvoxClient` service above is all you need.

**How do I reply to customers automatically?**

Use [Automation](https://docs.theavoxai.com/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](https://docs.theavoxai.com/inbox).

**How do I send from several numbers?**

Create one key per connected number and choose the key by sending number. See
[Authentication](https://docs.theavoxai.com/avox-api/authentication).
