How Avox API rate limits work, what a 429 response means, how to read Retry-After, and a safe retry strategy with backoff.
The Avox API protects every account - and your WhatsApp number's standing with Meta - by limiting
how fast messages can be sent. When you go over a limit you get a 429 response with a
Retry-After header that tells you exactly how many seconds to wait.
The limits are generous for normal transactional traffic. They matter most for bulk sends - a loop that fires thousands of requests at once will hit them. Spread large sends out over time instead.
Every request counts towards these limits, including ones that fail validation, so fix failing requests rather than retrying them in a tight loop.
Your plan doesn't cap API sends - API messages don't use automation credits. WhatsApp's own messaging limits still apply to every message your number starts. See Quality and limits.
Test keys (avx_test_...) are counted separately from live keys,
so a test suite never uses up your live allowance, and live traffic never slows your tests:
The test limits are set for a normal test suite. A loop that runs away will hit them, which keeps a bug from flooding your API logs.
+15550000005, which
returns meta_rate_limited with a Retry-After header.HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-Request-Id: req_7a1e0c94d2b35f8e61c0a9d4
Content-Type: application/json
{
"ok": false,
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait the number of seconds in the Retry-After header, then try again.",
"docs_url": "https://docs.avox.com/api/errors#rate_limited",
"request_id": "req_7a1e0c94d2b35f8e61c0a9d4"
}
}
Retry-After is always a whole number of seconds, at least 1. For
quota_exceeded it counts down to midnight UTC.
Use exponential backoff with jitter: wait longer after each failure, add a little randomness so many
workers don't retry in lockstep, honour Retry-After whenever it's present, and give up after a
few attempts.
# curl retries 429 and 5xx responses itself, honouring Retry-After.
# That includes 502 meta_unavailable, so use this only where a rare duplicate is acceptable.
curl -X POST https://api.avox.com/api/messages/send \
--retry 5 --retry-max-time 120 \
-H "Authorization: Bearer $AVOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9b4c3c4e-2f0a-4d4e-8f6b-0f1d6a1c2e33" \
-d '{"to":"15551234567","type":"text","text":{"body":"Your code is ready."}}'
import { randomUUID } from "node:crypto";
const RETRYABLE = new Set(["rate_limited", "meta_rate_limited", "idempotency_in_progress", "internal_error"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function sendWithRetry(message, { attempts = 5 } = {}) {
let idempotencyKey = randomUUID(); // one key for every attempt of this message
for (let attempt = 1; attempt <= attempts; attempt++) {
let res;
try {
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(message),
signal: AbortSignal.timeout(30_000),
});
} catch (networkError) {
if (attempt === attempts) throw networkError;
await sleep(backoff(attempt));
continue;
}
const data = await res.json();
if (data.ok) return data;
if (!RETRYABLE.has(data.error.code) || attempt === attempts) {
throw new Error(`${data.error.code} (${data.error.request_id}): ${data.error.message}`);
}
// WhatsApp's refusal is stored against the key, so this one needs a fresh key.
if (data.error.code === "meta_rate_limited") idempotencyKey = randomUUID();
const retryAfter = Number(res.headers.get("Retry-After"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
}
}
function backoff(attempt) {
const base = Math.min(30_000, 1_000 * 2 ** (attempt - 1)); // 1s, 2s, 4s ... up to 30s
return base / 2 + Math.random() * (base / 2);
}
import os
import random
import time
import uuid
import requests
RETRYABLE = {"rate_limited", "meta_rate_limited", "idempotency_in_progress", "internal_error"}
def backoff(attempt):
base = min(30.0, 2 ** (attempt - 1)) # 1s, 2s, 4s ... up to 30s
return base / 2 + random.random() * base / 2
def send_with_retry(message, attempts=5):
idempotency_key = str(uuid.uuid4()) # one key for every attempt of this message
for attempt in range(1, attempts + 1):
try:
res = requests.post(
"https://api.avox.com/api/messages/send",
headers={
"Authorization": f"Bearer {os.environ['AVOX_API_KEY']}",
"Idempotency-Key": idempotency_key,
},
json=message,
timeout=30,
)
except requests.RequestException:
if attempt == attempts:
raise
time.sleep(backoff(attempt))
continue
data = res.json()
if data["ok"]:
return data
error = data["error"]
if error["code"] not in RETRYABLE or attempt == attempts:
raise RuntimeError(f"{error['code']} ({error['request_id']}): {error['message']}")
# WhatsApp's refusal is stored against the key, so this one needs a fresh key.
if error["code"] == "meta_rate_limited":
idempotency_key = str(uuid.uuid4())
retry_after = int(res.headers.get("Retry-After", "0") or 0)
time.sleep(retry_after if retry_after > 0 else backoff(attempt))
meta_unavailable is left out of the retryable list on purpose - decide case by case, because the
first attempt may already have reached the customer.
Every limited request is on the API logs page in the dashboard. Pick rate_limited,
quota_exceeded or meta_rate_limited in the error code filter (Any error code) and, if you
like, one key or one date range, to see when and how often it happened. Export CSV downloads
the same list. See API logs.
The per-address limits are checked before Avox looks up the key, so a request refused by one of
them isn't tied to your account and doesn't appear there. Your own logs of the 429 and its
X-Request-Id are the record for those.
Last updated