Errors & rate limits
The API uses conventional HTTP status codes and returns a JSON error body:
{ "error": "contact_opt_in_required", "message": "An active WhatsApp opt-in record is required before sending to this contact."}Status codes
Section titled “Status codes”| Code | Meaning |
|---|---|
200 |
Success. |
400 |
Invalid request (missing/!malformed fields). |
401 |
Missing or invalid API key. |
403 |
Key lacks the required scope. |
404 |
Resource not found. |
409 |
Conflict or current-state block (e.g. duplicate idempotency key, outside the messaging window, contact opt-in required, template not approved). |
412 |
Channel/project readiness failed (e.g. WhatsApp publish_readiness blockers such as BUSINESS_NOT_VERIFIED or NO_PAYMENT_METHOD). |
422 |
Valid shape but semantically invalid for the operation. |
429 |
Rate limited — back off and retry. |
5xx |
Transient server error — safe to retry. |
Version skew & unavailable endpoints
Section titled “Version skew & unavailable endpoints”A 404 or 405 can mean one of two different things:
- The resource doesn’t exist — a genuine
404from a real handler (e.g. an unknown flow id). These carry a JSON error body and aWabery-Versionresponse header. - The endpoint isn’t served by the connected instance — the method/path
isn’t deployed (your SDK is newer than the API build). These come from the
framework/proxy, so they have no
Wabery-Versionheader.
The SDK distinguishes the two for you: a 404/405 with no Wabery-Version
header is raised as a WaberyEndpointNotAvailableError (a subclass of
WaberyApiError) with a message that names the method and path and points at the
likely capability gap — rather than a bare “request failed with HTTP 404”.
import { WaberyEndpointNotAvailableError } from "@wabery/sdk";
try { await wabery.flows.sendByConfigKey("lead_intake", { channelId, to });} catch (err) { if (err instanceof WaberyEndpointNotAvailableError) { // This build doesn't serve POST /flows/send. The list endpoints carry the // same data, so fall back to them (e.g. flows.list() / projects.list()). }}If you call the REST API directly, treat a 404/405 without a
Wabery-Version response header as “endpoint not available on this instance”
and fall back to the list endpoint (GET /flows, GET /projects) that exposes
the same data.
Retries & idempotency
Section titled “Retries & idempotency”Retry 429 and 5xx responses with exponential backoff. To make retries safe,
send an Idempotency-Key header on writes — Wabery returns the original result
for a repeated key instead of sending twice:
await wabery.messages.send({ channelId: "channel_...", conversationId: "conversation_...", text: "Hi", idempotencyKey: "7c3f-order-2291",});curl https://api.wabery.com/v1/messages \ -H "Authorization: Bearer $WABERY_API_KEY" \ -H "Idempotency-Key: 7c3f-order-2291" \ -H "Content-Type: application/json" \ -d '{ "channel_id": "channel_...", "conversation_id": "conversation_...", "text": "Hi" }'Rate limits
Section titled “Rate limits”Two limits apply per API key: a rate limit (default 600 requests / 60
seconds) and a monthly quota (default 100,000 requests / calendar month).
Both scale with your plan. Read the live values for your key from GET /limits:
const limits = await wabery.limits.retrieve();// { object: "public_api_limits",// rate_limit: { requests: 600, window_seconds: 60, scope: "api_key" },// monthly_quota: { requests: 100000, scope: "api_key", period: "2026-06" } }Exceeding either returns 429 with a JSON body and headers. X-Wabery-Limit-Type
tells you which limit tripped (rate_limit vs monthly_quota):
{ "error": "rate_limit", "message": "Public API rate limit exceeded", "limit": 600, "remaining": 0, "reset_at": "2026-06-20T14:22:00Z"}| Header | Meaning |
|---|---|
RateLimit-Limit |
The ceiling for the window that tripped. |
RateLimit-Remaining |
Requests left in the current window. |
RateLimit-Reset |
Unix seconds until the window resets. |
Retry-After |
Seconds to wait before retrying — respect this. |
X-Wabery-Limit-Type |
rate_limit or monthly_quota. |
Pagination
Section titled “Pagination”List endpoints (contacts.list, conversations.list, conversations.listMessages,
templates.list, …) return a WaberyList:
{ "object": "list", "data": [ /* … */ ], "has_more": true }Page with limit and a starting_after cursor set to the last id you saw:
let startingAfter: string | undefined;do { const page = await wabery.contacts.list({ limit: 100, startingAfter }); for (const contact of page.data) process(contact); startingAfter = page.has_more ? page.data.at(-1)?.id : undefined;} while (startingAfter);Or let the SDK manage the cursor with the listAll() async iterator:
for await (const contact of wabery.contacts.listAll()) process(contact);