# Anekant AI WhatsApp Business API > Meta Cloud API-compatible REST API for sending and receiving WhatsApp messages. Version 1.0.0. Base URL: `https://wa.anekantai.com`. This document is the complete API reference, generated from the OpenAPI specification at `https://wa.anekantai.com/openapi.json`. It is safe to paste in full into an AI assistant. Send WhatsApp messages to your customers over a simple REST API, and receive inbound messages and delivery statuses on your own webhook endpoint. ## Compatible with Meta's Cloud API Request and response bodies mirror Meta's WhatsApp Cloud API (`POST /{phone_number_id}/messages`) exactly. Any Meta sample code, SDK payload, or template JSON works here unchanged — only the base URL and the token differ. ## Which identifier goes where We issue you exactly **two** things. You do **not** need a Meta App ID, an App Secret, a WhatsApp Business Account (WABA) ID, or a Meta access token — we hold and manage those for you. If you are porting code from Meta's Cloud API, delete that configuration. | What you have | Where it goes | Example | |---|---|---| | **API key** | the `Authorization` header, on every request | `Authorization: Bearer bt-3f1c8e2a…` | | **Phone number ID** | the **URL path** — the number you send **from** | `POST /v1/123456789012345/messages` | | **Recipient's number** | the `to` field in the JSON body — who you send **to** | `"to": "91XXXXXXXXXX"` | **The phone number ID is not a phone number.** It is an opaque identifier we issue you — either the numeric ID assigned to your number, or a name we give you such as `sales_line`. Putting your own display number (`+91…`) in the path is the single most common integration error; it returns `404 Unknown phone_number_id`. **Where to find them — step by step.** 1. Sign in to your dashboard at . 2. Open **API access** — the `` icon in the left-hand rail. 3. **Your numbers** lists every number we have issued you. Under each one: - **Phone number ID** — the value that goes in the URL path. - **WhatsApp Business Account ID** — your WABA ID. Click either value to copy it. 4. **API keys** lists your keys. Only the prefix is shown: we store just a hash, so the full key is displayed once when it is created and never again. If it is lost or leaked, ask us to revoke and reissue — there is no way to recover it. You do **not** need the WABA ID to send anything through this API; sending needs the phone number ID and the API key, nothing else. It is listed because Meta support, WhatsApp Manager and your own records refer to your account by it. (On a number still served by our older upstream it may show as not set — ask us if you need it.) `to` is **digits only, country code first** — no `+`, no spaces, no dashes. Your API key is scoped to your **account**, not to one number. If we issue you a second endpoint later, the same key works for it — you just change the ID in the path. A phone number ID may also be a **load-balanced pool**: one endpoint that we spread across several of your WhatsApp numbers. Nothing changes in how you call the API; recipients may simply see messages arrive from one of several sender numbers. ## The 24-hour rule If a customer messaged you within the last 24 hours, you may reply with **any** free-form message (text, media, interactive). To message a customer **outside** that window — or to message them first — you must send a **pre-approved template**. Free-form sends outside the window are rejected by WhatsApp. ## Sending a template — utility, marketing, or authentication Every template has a **category** fixed when it is approved. **The API call is identical for all three** — there is no category field in the request. You send the template's **name** and the **language code it was approved in**; WhatsApp applies the category itself (it governs pricing and policy, not the request shape). So sending a utility message is just: get a utility template approved (we do that for you), then reference it by name. ```bash curl -X POST "https://wa.anekantai.com/v1/$PHONE_NUMBER_ID/messages" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "template", "template": { "name": "order_update", "language": { "code": "en" }, "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "Asha" }, { "type": "text", "text": "#1234" } ]} ] } }' ``` That fills a template whose body reads `Hi {{1}}, your order {{2}} has shipped.` **Check the template before you send it.** `GET /v1/{phone_number_id}/message_templates` lists what exists on your number and the status of each, so you can confirm a name and language are `APPROVED` rather than discovering it from a failed send: ```bash curl -s -G "https://wa.anekantai.com/v1/$PHONE_NUMBER_ID/message_templates" \ -H "Authorization: Bearer $API_KEY" \ --data-urlencode "name=order_update" --data-urlencode "language=en" ``` An empty `data` array means no such template on this number; a `PENDING` status means it exists but cannot be sent yet. Its `components` also tell you how many variables to pass. What trips people up: - `name` and `language.code` must match the approved template **exactly**. `en` and `en_US` are different templates; a mismatch fails with a `132001`-class error. - Supply **exactly as many parameters as the template has variables**, in order — `{{1}}` is the first parameter, `{{2}}` the second. Too few or too many fails with `132000`. - A template with **no** variables needs no `components` key at all. - A template with an image/document/video header needs a `header` component carrying that media — see the "Template with a document header" example on the send endpoint. - Templates are the **only** way to reach someone outside the 24-hour window. A utility template works there; plain text does not. ## Delivery is asynchronous A `200` means WhatsApp accepted the message, not that it arrived. Track the real outcome either by consuming the `status` webhook or by polling `GET /v1/{phone_number_id}/messages`. If you do neither, failures are invisible to you. ## Getting started ```bash curl -X POST "https://wa.anekantai.com/v1/$PHONE_NUMBER_ID/messages" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "text", "text": { "body": "Hello from our team!" } }' ``` ## Verifying a webhook signature Every request we push to your endpoint carries three headers: | Header | Meaning | |---|---| | `X-Wa-Signature-256` | `sha256=` — HMAC-SHA256 of the **raw request body**, keyed with your signing secret | | `X-Wa-Event` | `message` or `status` — what kind of event this is | | `X-Wa-Delivery-Id` | our id for this delivery attempt; the same id repeats across retries | **Verify before you parse.** Compute the HMAC over the exact bytes you received: a body that has been JSON-parsed and re-serialised will not hash to the same value (key order and whitespace change), and every mismatch report we have investigated has been this. Compare in constant time — `===` on the hex string leaks how much of the signature was right. The secret is issued once, when we register your endpoint, and is not readable afterwards. Ask us to re-issue if it is lost. **Node.js** ```js const crypto = require("crypto"); // Keep the raw bytes. In express: // app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })) // In a Cloudflare Worker / Deno: // const raw = await request.text(); // parse only AFTER verifying function verifyWaSignature(rawBody, header, secret) { const mine = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(mine); const b = Buffer.from(header || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); } app.post("/wa-webhook", (req, res) => { if (!verifyWaSignature(req.rawBody, req.get("X-Wa-Signature-256"), process.env.WA_WEBHOOK_SECRET)) { return res.status(401).end(); } res.status(200).end(); // acknowledge fast, then process out of band handleEvent(JSON.parse(req.rawBody.toString("utf8"))); }); ``` **Python** ```python import hmac, hashlib def verify_wa_signature(raw_body: bytes, header: str, secret: str) -> bool: mine = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(mine, header or "") # Flask — request.get_data() is the raw body; request.json is not. @app.post("/wa-webhook") def wa_webhook(): if not verify_wa_signature(request.get_data(), request.headers.get("X-Wa-Signature-256", ""), os.environ["WA_WEBHOOK_SECRET"]): return "", 401 handle_event(request.get_json()) return "", 200 ``` **Retries.** Any response other than a `2xx`, and any timeout, is retried with exponential backoff. Reply `200` as soon as the signature checks out and do your work afterwards — a slow handler looks identical to a broken one. Deliveries are at-least-once, so make your handler idempotent on the `wamid` (statuses) or the message `id` (inbound). ## Getting a webhook endpoint **Endpoints are registered by us, not self-serve.** Send us the HTTPS URL you want events pushed to — write to ketul.shah@anekantai.com — and we register it and hand you the signing secret once. Your dashboard's **API access** page then shows the endpoint, its recent delivery attempts and any errors, so you can debug your side without asking us. There is no API to create one. This is deliberate: a mistyped URL silently ships your customers' messages to a stranger. **No public server? Poll instead.** `GET /v1/{phone_number_id}/events` is a pull outbox carrying the same events in the same shape. Pass the cursor from the previous page and call it as often as you like — reads are not counted against your rate limit. ```bash curl -s -G "https://wa.anekantai.com/v1/$PHONE_NUMBER_ID/events" \ -H "Authorization: Bearer $API_KEY" \ --data-urlencode "since=$CURSOR" ``` This is the whole integration for a back-office system, a cron job, or anything behind a firewall — no inbound HTTP, no signature verification, no public hostname. ## Error catalogue Errors use Meta's envelope: `{ "error": { "message", "type", "code", "error_data", "fbtrace_id" } }`. Quote `fbtrace_id` when you ask us about one — it identifies the exact request in our logs. | HTTP | `code` | Means | What to do | |---|---|---|---| | 400 | `100` | The request body failed validation — a missing field, a bad `type`, a malformed `to` | Read `error_data.details`; it names the field. `to` is digits only, country code first | | 401 | `401` | Missing `Authorization` header, or a key that is unknown or revoked | Send `Authorization: Bearer `. A present-but-rejected key is deliberately not distinguished from an unknown one | | 403 | `403` | The number or pool you addressed is disabled | Contact us — a disabled number is an account state, not something the API can undo | | 404 | `404` | Unknown `phone_number_id` **for your account** | You almost certainly put a display number (`+91…`) in the path. Use the phone number ID from **API access** | | 409 | `139001` | Duplicate: an identical message went to this recipient within 24 hours | See *Idempotency* below. `error_data.details` names the original message and its time | | 429 | `130429` | Rate limit, or a daily sending cap for this number | Back off and retry. Cap errors state used/limit and when they reset | | 501 | `501` | The operation is not supported on this number's upstream | Rare; the message says which. Ask us | | 502 | `131000` | We called WhatsApp and the call failed | Transient — retry with backoff. If it persists the message names what Meta said | **Meta's own codes pass through.** When WhatsApp rejects a message, its code and title reach you unchanged — in `error_data` on a synchronous rejection, and in `statuses[0].errors` on a `failed` status webhook. The ones you will actually see: | Code | Meaning | What to do | |---|---|---| | `131026` | Message undeliverable — the recipient is not on WhatsApp, or cannot receive this | Stop sending to that number; we add it to your blocked list automatically | | `131047` | Re-engagement required — the 24-hour window is closed | Send an approved template instead | | `131049` | WhatsApp chose not to deliver, to protect the user experience (per-recipient marketing frequency cap) | Not a fault in your request. Reduce marketing frequency to that recipient; retry later | | `132000` | Template parameter count mismatch | Supply exactly as many parameters as the template has `{{n}}` variables, in order | | `132001` | Template does not exist in that name/language pair | `en` and `en_US` are different templates. Check with `GET /message_templates` | The full list is Meta's: . ## Rate limits and sending caps Two separate things limit you. They fail with the same HTTP status but different messages. **1. API rate limit — 600 mutating requests per minute, per account.** A fixed 60-second window across your whole account (all numbers, all keys). Exceeding it returns `429` with code `130429` and a `Retry-After: 60` header. **Reads do not count.** `GET` and `HEAD` requests — the pull outbox, message history, the template list — are checked against the window but never add to it, so polling `GET /v1/{phone_number_id}/events` every few seconds is free and is what it is designed for. **2. Daily sending caps — per number, set by us.** A cap on **business-initiated** messages from one number in a day: - Only messages sent **outside** the 24-hour customer-care window count. Replies to a customer who just messaged you are never capped — a bot must not go quiet because a marketing blast used up the quota. - Failed sends do not count. - The day boundary is **midnight IST** (UTC+05:30), not UTC. - A cap may **ramp weekly** by an agreed percentage, so a new number can warm up. - Caps are **per number, never pooled**: two numbers capped at 500 each send 1,000 between them. The error states exactly where you are: *"Daily message limit reached for this number (500/500 today). Resets at midnight IST."* A second cap may also apply per recipient per day. Separately, WhatsApp enforces its own messaging-tier limit on unique customers per day. That one is Meta's, is raised by them on quality and volume, and surfaces as a Meta error code. ## Idempotency and duplicate suppression These are two different protections. One you ask for; the other is always on. **Idempotency keys — for safe retries.** Send `Idempotency-Key: ` on a send. If we have already seen that key for your account, we return the **original result** (the same `wamid`) without sending anything again. The key is scoped to your account, so it need only be unique within it — a UUID or your own order/message id is ideal. If you cannot set headers, `biz_opaque_callback_data` in the body is used as the key instead. Use it whenever a retry is possible: a network timeout where you never saw the response, a queue worker that may run twice, a webhook handler you re-drive. **Duplicate suppression — for accidents.** Independently, an identical message to the same recipient within **24 hours** is refused with `409` and code `139001`, so a double-click or a runaway retry loop is not billed twice. The fingerprint covers the recipient and the exact content: any wording change makes it a new message. The error names the original message id and its timestamp, and there are three ways past it — change the content, wait out the 24 hours, or send with an `Idempotency-Key` to declare the retry intentional. If your use case legitimately sends the same text repeatedly (an OTP, a daily digest), ask us to switch duplicate suppression off for your account. ## Media — sizes, types and expiry Upload with `POST /v1/{phone_number_id}/media`, then send the returned id. We validate before storing, so a bad file fails immediately rather than at send time. | Kind | Maximum size | Accepted | |---|---|---| | Image | 5 MB | JPEG, PNG, WebP, GIF, BMP, TIFF | | Document | 100 MB | PDF, Word, Excel, PowerPoint, plain text | | Video | 16 MB | MP4, 3GPP, MPEG, QuickTime | | Audio | 16 MB | MP3/MPEG, AAC, AMR, OGG/Opus, MP4 audio, WAV | The type is sniffed from the file's own bytes, not trusted from the `Content-Type` you send, so a mislabelled upload is corrected rather than rejected. Two files are refused outright: **SVG** (a script-capable format WhatsApp does not deliver anyway) and anything claiming to be a PDF without a `%PDF` header. Over-size files give the actual size and the limit. **Media expires after 30 days.** Every stored file — uploaded by you or received from a customer — is deleted 30 days after it is created, and its download link stops working then. If you need a customer's attachment for longer, copy it to your own storage when the inbound webhook arrives. Media ids from **inbound** WhatsApp webhooks expire much sooner (7 days, Meta's rule), so fetch those promptly. ## Testing without spending **The 24-hour rule decides what a first send costs you.** A cold send — to someone who has not messaged that number in the last 24 hours — must be a template, and a template send is billed. A reply inside the window is free and can be any message type. So: 1. **Message your own number first.** Send a WhatsApp message *from* your phone *to* the business number we issued you. That opens the 24-hour window on your own contact. 2. **Now send anything free-form** to that number for the next 24 hours — text, media, interactive — at no cost, while you build and debug your integration. 3. **Check what happened** with `GET /v1/{phone_number_id}/messages` or the pull outbox, rather than by staring at the phone. Both show the real status, including failures a `200` hid from you. **Try the platform before you write code.** deep-links into live demo bots on our own number, so you can see inbound messages, statuses and automation working end to end without touching your own account. **Things worth testing deliberately**, because they are the ones that surprise people in production: a template send with the wrong parameter count (`132000`), a send to a number that is not on WhatsApp (`131026` on a `failed` status webhook, not on the send call), and your webhook handler's behaviour when it receives the same delivery twice. --- ## Endpoints ### GET /v1/{phone_number_id}/messages **List messages and their delivery status** _Auth: `Authorization: Bearer `._ Returns messages for this number, newest first — both directions. Use it to poll delivery status if you have not set up a webhook. Statuses move `accepted` → `sent` → `delivered` → `read`, or terminate at `failed`. Paginate by passing the returned `next_cursor` back as `before`. #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `phone_number_id` | path | yes | string | The ID of the WhatsApp number you send **from** — issued to you by us. It is an opaque identifier, **not** a phone number: either the numeric ID assigned to your number, or a name we give you (which may be a pool we load-balance across several of your numbers). Sending your own display number here returns 404. | | `limit` | query | no | integer | How many messages to return (1–100). | | `before` | query | no | string | Cursor — pass the `next_cursor` from the previous page (an ISO 8601 `created_at`). | #### Responses **`200`** — A page of messages. ```json { "data": [ { "id": "msg_4b1e9c7a2f", "wamid": "wamid.HBgMOTFYWFhYWFhYWFgVAgARGBI3OEFENzNCMkM0RDVFNkY3ODkA", "direction": "out", "type": "text", "to": "91XXXXXXXXXX", "from": "91NNNNNNNNNN", "status": "delivered", "body": { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "text", "text": { "body": "Hello!" } }, "created_at": "2026-06-29T08:22:20.599Z" } ], "next_cursor": "2026-06-29T08:10:00.000Z" } ``` **`400`** — The request body failed validation. The offending field is named in `error_data.details`. ```json { "error": { "message": "Invalid request body", "type": "WaApiError", "code": 100, "error_data": { "details": "text: Missing \"text\" object for type \"text\"" }, "fbtrace_id": "req_9f2c…" } } ``` **`401`** — Missing, invalid, or revoked API key. ```json { "error": { "message": "Invalid or missing API key", "type": "WaApiError", "code": 401, "fbtrace_id": "req_9f2c…" } } ``` **`404`** — Unknown `phone_number_id` for your account — use the ID we issued you. ```json { "error": { "message": "Unknown phone_number_id for this account — if you passed a phone number like +91…, use the phone number ID instead (find it under API access in your dashboard).", "type": "WaApiError", "code": 404, "fbtrace_id": "req_9f2c…" } } ``` **`429`** — Too many requests, or every number behind a load-balanced endpoint has hit its daily send limit. Back off using the `Retry-After` header (seconds) and retry with exponential backoff. ```json { "error": { "message": "Rate limit hit", "type": "WaApiError", "code": 130429, "fbtrace_id": "req_9f2c…" } } ``` ### POST /v1/{phone_number_id}/messages **Send a message** _Auth: `Authorization: Bearer `._ Sends a WhatsApp message from one of your numbers. The body is identical to Meta's Cloud API send body: `messaging_product`, `to`, `type`, and an object named after `type`. A `200` means WhatsApp **accepted** the message — not that it was delivered. Delivery is reported asynchronously via the `status` webhook, or by polling `GET /v1/{phone_number_id}/messages`. **Duplicate protection.** An identical message body sent to the same recipient within 24 hours is rejected with `409` (code `139001`) rather than sent twice. Vary the content, or talk to us if your use case needs repeats. #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `phone_number_id` | path | yes | string | The ID of the WhatsApp number you send **from** — issued to you by us. It is an opaque identifier, **not** a phone number: either the numeric ID assigned to your number, or a name we give you (which may be a pool we load-balance across several of your numbers). Sending your own display number here returns 404. | | `Idempotency-Key` | header | no | string | A unique key (e.g. a UUID) per distinct message. If a request with this key was already processed, we return the original result instead of sending again — so a retry after a network timeout is safe. Use a fresh key for each new message. | #### Request body `application/json` (required) — schema: `SendMessageRequest` **Example — Text (inside the 24-hour window)** ```json { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "text", "text": { "body": "Hello! Your order has shipped.", "preview_url": false } } ``` **Example — Template with body variables** ```json { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "template", "template": { "name": "order_update", "language": { "code": "en" }, "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "Asha" }, { "type": "text", "text": "#1234" } ] } ] } } ``` **Example — Template with a document header** ```json { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "template", "template": { "name": "offer_letter", "language": { "code": "en" }, "components": [ { "type": "header", "parameters": [ { "type": "document", "document": { "link": "https://wa.anekantai.com/dl/med_ab12cd34?t=8f21c4a9b7e0d3f5", "filename": "offer-letter.pdf" } } ] }, { "type": "body", "parameters": [ { "type": "text", "text": "Asha" } ] } ] } } ``` **Example — Image by link** ```json { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "image", "image": { "link": "https://example.com/banner.jpg", "caption": "New arrivals" } } ``` **Example — Document by link** ```json { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "document", "document": { "link": "https://example.com/invoice.pdf", "filename": "invoice-1234.pdf", "caption": "Your invoice" } } ``` **Example — Interactive — up to 3 reply buttons** ```json { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "interactive", "interactive": { "type": "button", "body": { "text": "Confirm your appointment for 5 PM?" }, "action": { "buttons": [ { "type": "reply", "reply": { "id": "confirm_yes", "title": "Yes" } }, { "type": "reply", "reply": { "id": "confirm_no", "title": "Reschedule" } } ] } } } ``` **Example — Interactive — list menu** ```json { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "interactive", "interactive": { "type": "list", "body": { "text": "Pick a service" }, "action": { "button": "View services", "sections": [ { "title": "Salon", "rows": [ { "id": "svc_haircut", "title": "Haircut", "description": "30 min" }, { "id": "svc_spa", "title": "Spa", "description": "60 min" } ] } ] } } } ``` **Example — Reply in-thread to a customer message** ```json { "messaging_product": "whatsapp", "to": "91XXXXXXXXXX", "type": "text", "context": { "message_id": "wamid.HBgMOTFYWFhYWFhYWFgVAgARGBI3OEFENzNCMkM0RDVFNkY3ODkA" }, "text": { "body": "Yes — we can do 5 PM." } } ``` #### Responses **`200`** — Accepted by WhatsApp. `messages[0].id` is the **wamid** — store it to join delivery statuses. ```json { "messaging_product": "whatsapp", "contacts": [ { "input": "91XXXXXXXXXX", "wa_id": "91XXXXXXXXXX" } ], "messages": [ { "id": "wamid.HBgMOTFYWFhYWFhYWFgVAgARGBI3OEFENzNCMkM0RDVFNkY3ODkA" } ] } ``` **`400`** — The request body failed validation. The offending field is named in `error_data.details`. ```json { "error": { "message": "Invalid request body", "type": "WaApiError", "code": 100, "error_data": { "details": "text: Missing \"text\" object for type \"text\"" }, "fbtrace_id": "req_9f2c…" } } ``` **`401`** — Missing, invalid, or revoked API key. ```json { "error": { "message": "Invalid or missing API key", "type": "WaApiError", "code": 401, "fbtrace_id": "req_9f2c…" } } ``` **`403`** — This phone number ID exists but is disabled. **`404`** — Unknown `phone_number_id` for your account — use the ID we issued you. ```json { "error": { "message": "Unknown phone_number_id for this account — if you passed a phone number like +91…, use the phone number ID instead (find it under API access in your dashboard).", "type": "WaApiError", "code": 404, "fbtrace_id": "req_9f2c…" } } ``` **`409`** — Duplicate — an identical message to this recipient was already sent within 24 hours. ```json { "error": { "message": "Duplicate message suppressed", "type": "WaApiError", "code": 139001, "error_data": { "details": "Identical content sent to 91XXXXXXXXXX within 24h" }, "fbtrace_id": "req_9f2c…" } } ``` **`429`** — Too many requests, or every number behind a load-balanced endpoint has hit its daily send limit. Back off using the `Retry-After` header (seconds) and retry with exponential backoff. ```json { "error": { "message": "Rate limit hit", "type": "WaApiError", "code": 130429, "fbtrace_id": "req_9f2c…" } } ``` **`502`** — Temporary delivery error from WhatsApp. Safe to retry shortly (use an `Idempotency-Key`). ```json { "error": { "message": "Upstream provider error", "type": "WaApiError", "code": 131000, "fbtrace_id": "req_9f2c…" } } ``` ### GET /v1/{phone_number_id}/events **Pull inbound events (webhook alternative)** _Auth: `Authorization: Bearer `._ Pull the same inbound events we would otherwise **push** to your webhook. For systems that have nowhere for a push to land — an on-premise application behind a corporate VPN that deliberately accepts no inbound connection. Rather than opening a tunnel into that network, poll this. Each element of `events` is **the identical Meta-shaped body** a webhook delivery would have carried, so your parser is the same code either way. **Cursor discipline.** Events come oldest first. Store the `cursor` you get back and pass it as `since` next time — but advance it only once everything in the page is safely yours. A cursor moved past a failure loses those events for good. Re-reading from a cursor you did not advance is expected and harmless: every event carries its `wamid`, which is what you de-duplicate on. At the end of the stream `events` is empty and `cursor` comes back unchanged. **Inbound only.** Delivery and read statuses are the webhook contract's business; a puller is reading its own incoming mail, and its own sends echoed back into that stream is a trap rather than a feature. If you need statuses, take the webhook. Media works exactly as it does for a pushed event — `GET /v1/{phone_number_id}/media/{media_id}` — and must be fetched promptly: the provider holds inbound media for 7 days and we do not copy it. ```bash curl -s -G "$BASE/v1/$PHONE_NUMBER_ID/events" \ -H "Authorization: Bearer $API_KEY" \ --data-urlencode "since=$CURSOR" --data-urlencode "limit=100" ``` #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `phone_number_id` | path | yes | string | The ID of the WhatsApp number you send **from** — issued to you by us. It is an opaque identifier, **not** a phone number: either the numeric ID assigned to your number, or a name we give you (which may be a pool we load-balance across several of your numbers). Sending your own display number here returns 404. | | `since` | query | no | string | Opaque cursor from the previous page. Omit to start at the beginning of your history. | | `limit` | query | no | integer | Events per page, 1–100. | #### Responses **`200`** — A page of inbound events, oldest first. An empty `events` array means you are caught up — not an error. **`404`** — Unknown phone_number_id for this account. ### POST /v1/{phone_number_id}/media **Upload a file and get a signed link** _Auth: `Authorization: Bearer `._ Use this for **private** files (invoices, offer letters, reports) that must not be hosted at a public URL. We store the file and return a short-lived **signed link**. Drop the returned `link` straight into a message's `image` / `document` / `video` `link` field, or into a template's header parameter. The link is valid for **2 hours** — long enough for WhatsApp to fetch the file at send time — and is not browsable without its token. Send as `multipart/form-data` with the file in a field named `file`. ```bash curl -X POST "$BASE/v1/$PHONE_NUMBER_ID/media" \ -H "Authorization: Bearer $API_KEY" \ -F "file=@/path/to/offer-letter.pdf" \ -F "filename=offer-letter.pdf" ``` #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `phone_number_id` | path | yes | string | The ID of the WhatsApp number you send **from** — issued to you by us. It is an opaque identifier, **not** a phone number: either the numeric ID assigned to your number, or a name we give you (which may be a pool we load-balance across several of your numbers). Sending your own display number here returns 404. | #### Request body `multipart/form-data` (required) ```json { "type": "object", "required": [ "file" ], "properties": { "file": { "type": "string", "format": "binary", "description": "The file to upload. Its type is sniffed from the bytes, not trusted from the header." }, "filename": { "type": "string", "description": "Optional display filename. Defaults to the uploaded file's own name.", "example": "offer-letter.pdf" } } } ``` #### Responses **`201`** — Stored. Use `link` as the media `link` in a send. ```json { "id": "med_7c3a91be04d24f", "link": "https://wa.anekantai.com/dl/med_7c3a91be04d24f?t=8f21c4a9b7e0d3f5", "mime": "application/pdf", "category": "document", "filename": "offer-letter.pdf", "size": 148213 } ``` **`400`** — Missing `file` field, or the file failed validation (unsupported type, too large, or bytes that do not match the declared type). **`401`** — Missing, invalid, or revoked API key. ```json { "error": { "message": "Invalid or missing API key", "type": "WaApiError", "code": 401, "fbtrace_id": "req_9f2c…" } } ``` **`404`** — Unknown `phone_number_id` for your account — use the ID we issued you. ```json { "error": { "message": "Unknown phone_number_id for this account — if you passed a phone number like +91…, use the phone number ID instead (find it under API access in your dashboard).", "type": "WaApiError", "code": 404, "fbtrace_id": "req_9f2c…" } } ``` ### GET /v1/{phone_number_id}/media/{media_id} **Download media a customer sent you** _Auth: `Authorization: Bearer `._ An inbound `message` webhook for a photo or document gives you only WhatsApp's media id. Call this to get the actual bytes. This collapses Meta's two-step flow (resolve URL, then download with a bearer token) into one call, because the upstream token is ours and never leaves the platform. The response is the raw file with its own `Content-Type`. #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `phone_number_id` | path | yes | string | The ID of the WhatsApp number you send **from** — issued to you by us. It is an opaque identifier, **not** a phone number: either the numeric ID assigned to your number, or a name we give you (which may be a pool we load-balance across several of your numbers). Sending your own display number here returns 404. | | `media_id` | path | yes | string | The media id from the inbound webhook (e.g. `messages[0].image.id`). | #### Responses **`200`** — The file bytes. **`401`** — Missing, invalid, or revoked API key. ```json { "error": { "message": "Invalid or missing API key", "type": "WaApiError", "code": 401, "fbtrace_id": "req_9f2c…" } } ``` **`404`** — Unknown `phone_number_id` for your account — use the ID we issued you. ```json { "error": { "message": "Unknown phone_number_id for this account — if you passed a phone number like +91…, use the phone number ID instead (find it under API access in your dashboard).", "type": "WaApiError", "code": 404, "fbtrace_id": "req_9f2c…" } } ``` **`502`** — Temporary delivery error from WhatsApp. Safe to retry shortly (use an `Idempotency-Key`). ```json { "error": { "message": "Upstream provider error", "type": "WaApiError", "code": 131000, "fbtrace_id": "req_9f2c…" } } ``` ### GET /v1/media/{id} **Fetch a file you uploaded** _Auth: `Authorization: Bearer `._ Returns a file you previously uploaded via `POST /v1/{phone_number_id}/media`, using your API key rather than the signed link. Only files belonging to your account are visible. #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `id` | path | yes | string | The `id` returned by the upload call. | #### Responses **`200`** — The file bytes. **`401`** — Missing, invalid, or revoked API key. ```json { "error": { "message": "Invalid or missing API key", "type": "WaApiError", "code": 401, "fbtrace_id": "req_9f2c…" } } ``` **`404`** — Unknown id, or the file has expired. ### GET /dl/{id} **Signed media link (no API key)** _Auth: none (public)._ The capability URL returned as `link` by the upload endpoint. WhatsApp fetches it server-side at send time, so it carries a signed token instead of an API key. You normally never call this yourself — just pass the whole `link` into a message. #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `id` | path | yes | string | | | `t` | query | yes | string | Signed token. Already embedded in the `link` we return. | #### Responses **`200`** — The file bytes. **`403`** — Invalid or expired link. **`404`** — Not found or expired. ### GET /v1/health **Health check** _Auth: none (public)._ Public, unauthenticated liveness probe. #### Responses **`200`** — Service is up. ### GET /v1/{phone_number_id}/message_templates **List templates / check one exists** _Auth: `Authorization: Bearer `._ Lists the templates registered for this number, with the **status** of each. Use it to confirm a template exists and is `APPROVED` **before** you try to send it. Two failures look identical from a send error alone — a wrong name, and a real template that is not approved in the language you asked for. This tells them apart: - template in the response with `"status": "APPROVED"` → safe to send - present but `PENDING` / `REJECTED` / `PAUSED` / `DISABLED` → it exists, but sending it will fail; it needs approval (or fixing) first - **empty `data`** → no template by that name and language on this number. Check spelling and remember that `en` and `en_US` are *different* templates. Filter with `name` and `language` to check one specific template, or omit both to list everything. The `components` of each template show you exactly which variables it takes and in what order, so you can build the `parameters` array correctly. ```bash # Is order_update approved in English on this number? curl -s -G "$BASE/v1/$PHONE_NUMBER_ID/message_templates" \ -H "Authorization: Bearer $API_KEY" \ --data-urlencode "name=order_update" --data-urlencode "language=en" ``` Results are cached for up to an hour, so a template approved moments ago can take a little while to appear here. #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `phone_number_id` | path | yes | string | The ID of the WhatsApp number you send **from** — issued to you by us. It is an opaque identifier, **not** a phone number: either the numeric ID assigned to your number, or a name we give you (which may be a pool we load-balance across several of your numbers). Sending your own display number here returns 404. | | `name` | query | no | string | Exact template name to look for (case-insensitive). Omit to list all. | | `language` | query | no | string | Language code to look for, e.g. `en` or `en_US`. Omit to list every language. | #### Responses **`200`** — Matching templates. An empty `data` array means no template matched — not an error. ```json { "data": [ { "name": "order_update", "language": "en", "status": "APPROVED", "category": "UTILITY", "components": [ { "type": "BODY", "text": "Hi {{1}}, your order {{2}} has shipped.", "example": { "body_text": [ [ "Asha", "#1234" ] ] } } ] } ] } ``` **`401`** — Missing, invalid, or revoked API key. ```json { "error": { "message": "Invalid or missing API key", "type": "WaApiError", "code": 401, "fbtrace_id": "req_9f2c…" } } ``` **`404`** — Unknown `phone_number_id` for your account — use the ID we issued you. ```json { "error": { "message": "Unknown phone_number_id for this account — if you passed a phone number like +91…, use the phone number ID instead (find it under API access in your dashboard).", "type": "WaApiError", "code": 404, "fbtrace_id": "req_9f2c…" } } ``` **`501`** — This number is on an upstream that exposes no template list. Ask us for the approved names. --- ## Webhooks These are events **we send to you**. You implement an HTTPS endpoint and register it with us; the request shapes below are what will arrive. ### POST (your endpoint) — inboundMessage **Inbound message (X-Wa-Event: message)** _Auth: none (public)._ A customer sent you a message. Delivered to the HTTPS endpoint you register with us. **Respond `2xx` immediately** and queue the payload for processing — slow handlers are treated as failures. Non-`2xx`, timeouts, and connection errors are retried up to **8 times** with exponential backoff (≈5s, 10s, 20s … capped at ~15 min), then dropped. Retries mean the **same event can arrive twice**, and events are **not ordered**. Make your handler idempotent — de-duplicate on `X-Wa-Delivery-Id`, or on the message id. The body is Meta Cloud API-shaped, so Meta sample code parses it unchanged. #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `X-Wa-Signature-256` | header | yes | string | `sha256=` — HMAC-SHA256 of the **raw request body**, keyed with your signing secret. Compute it over the exact raw bytes: do not deserialize and re-serialize the JSON first, or key order and whitespace will change and the signature will not match. Compare in **constant time**. Your signing secret is shared once, out of band; if lost, we rotate it. Verification is optional to receive events, but you should implement it — your URL is public, so without it anyone could post fake delivery statuses into your system. | | `X-Wa-Event` | header | yes | "message" \| "status" | Which event this is. | | `X-Wa-Delivery-Id` | header | yes | string | Unique id for this delivery attempt chain — de-duplicate on it, since retries can deliver the same event twice. | #### Request body `application/json` — schema: `InboundMessageEvent` **Example — Text reply** ```json { "object": "whatsapp_business_account", "entry": [ { "changes": [ { "field": "messages", "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "91NNNNNNNNNN", "phone_number_id": "123456789012345" }, "contacts": [ { "wa_id": "91XXXXXXXXXX", "profile": { "name": "Ramesh" } } ], "messages": [ { "id": "wamid.HBgMOTFYWFhYWFhYWFgVAgASGBQzQTFCMkMzRDRFNUY2QTdCOEMA", "from": "91XXXXXXXXXX", "timestamp": "1783950000", "type": "text", "text": { "body": "Yes, please call me" } } ] } } ] } ] } ``` **Example — Customer tapped a reply button** ```json { "object": "whatsapp_business_account", "entry": [ { "changes": [ { "field": "messages", "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "91NNNNNNNNNN", "phone_number_id": "123456789012345" }, "contacts": [ { "wa_id": "91XXXXXXXXXX", "profile": { "name": "Ramesh" } } ], "messages": [ { "id": "wamid.HBgMOTFYWFhYWFhYWFgVAgASGBQ0QjJDM0Q0RTVGNkE3QjhDOUQA", "from": "91XXXXXXXXXX", "timestamp": "1783950120", "type": "interactive", "interactive": { "type": "button_reply", "button_reply": { "id": "confirm_yes", "title": "Yes" } } } ] } } ] } ] } ``` **Example — Customer sent a photo** ```json { "object": "whatsapp_business_account", "entry": [ { "changes": [ { "field": "messages", "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "91NNNNNNNNNN", "phone_number_id": "123456789012345" }, "contacts": [ { "wa_id": "91XXXXXXXXXX", "profile": { "name": "Ramesh" } } ], "messages": [ { "id": "wamid.HBgMOTFYWFhYWFhYWFgVAgASGBQ1QzNENEU1RjZBN0I4QzlEMEUA", "from": "91XXXXXXXXXX", "timestamp": "1783950300", "type": "image", "image": { "id": "987654321098765", "mime_type": "image/jpeg", "sha256": "9f86d081…", "caption": "Here is the receipt" } } ] } } ] } ] } ``` #### Responses **`200`** — Return any `2xx` as fast as possible. Anything else is retried. ### POST (your endpoint) — messageStatus **Delivery status (X-Wa-Event: status)** _Auth: none (public)._ A message you sent changed state: `sent`, `delivered`, `read`, or `failed`. Join on `statuses[0].id` — it is the same **wamid** the send API returned to you. Treat status as a ladder: only ever move a message **forward** (`sent` → `delivered` → `read`), never backwards — a `read` can arrive before its `delivered`. `failed` is terminal, and is the only status that carries `errors`. Common failure codes: `131031` business account locked · `131026` undeliverable (not a WhatsApp user) · `131047` re-engagement required (outside the 24-hour window) · `131049` blocked or locked recipient · `132000`–`132015` template problems. If you never consume this webhook, **failures are invisible to you** — a `200` from the send API only means the message was queued. #### Parameters | Name | In | Required | Type | Description | | --- | --- | --- | --- | --- | | `X-Wa-Signature-256` | header | yes | string | `sha256=` — HMAC-SHA256 of the **raw request body**, keyed with your signing secret. Compute it over the exact raw bytes: do not deserialize and re-serialize the JSON first, or key order and whitespace will change and the signature will not match. Compare in **constant time**. Your signing secret is shared once, out of band; if lost, we rotate it. Verification is optional to receive events, but you should implement it — your URL is public, so without it anyone could post fake delivery statuses into your system. | | `X-Wa-Event` | header | yes | "message" \| "status" | Which event this is. | | `X-Wa-Delivery-Id` | header | yes | string | Unique id for this delivery attempt chain — de-duplicate on it, since retries can deliver the same event twice. | #### Request body `application/json` — schema: `MessageStatusEvent` **Example — Delivered** ```json { "object": "whatsapp_business_account", "entry": [ { "changes": [ { "field": "messages", "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "91NNNNNNNNNN", "phone_number_id": "123456789012345" }, "statuses": [ { "id": "wamid.HBgMOTFYWFhYWFhYWFgVAgARGBI5QUQyN0E4RDc2QTM5RDBFQzcA", "status": "delivered", "timestamp": "1783950000", "recipient_id": "91XXXXXXXXXX" } ] } } ] } ] } ``` **Example — Failed — with error detail** ```json { "object": "whatsapp_business_account", "entry": [ { "changes": [ { "field": "messages", "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "91NNNNNNNNNN", "phone_number_id": "123456789012345" }, "statuses": [ { "id": "wamid.HBgMOTFYWFhYWFhYWFgVAgARGBI5QUQyN0E4RDc2QTM5RDBFQzcA", "status": "failed", "timestamp": "1783950000", "recipient_id": "91XXXXXXXXXX", "errors": [ { "code": 131031, "title": "Business Account locked", "message": "Message failed to send because the business account is locked" } ] } ] } } ] } ] } ``` #### Responses **`200`** — Return any `2xx` as fast as possible. Anything else is retried. --- ## Schemas JSON Schema for every object referenced above. `$ref` pointers resolve within this section. ### SendMessageRequest Meta Cloud API send body. Alongside the fields below you must include **one object named after `type`** — e.g. `type: "text"` requires a `text` object. The contents of that object are passed through to WhatsApp unchanged, so any Meta-documented payload works. ```json { "type": "object", "required": [ "messaging_product", "to", "type" ], "additionalProperties": true, "description": "Meta Cloud API send body. Alongside the fields below you must include **one object named\nafter `type`** — e.g. `type: \"text\"` requires a `text` object. The contents of that object\nare passed through to WhatsApp unchanged, so any Meta-documented payload works.\n", "properties": { "messaging_product": { "type": "string", "const": "whatsapp", "description": "Always `whatsapp`." }, "recipient_type": { "type": "string", "const": "individual", "description": "Optional; only `individual` is supported." }, "to": { "type": "string", "minLength": 5, "description": "Recipient in international format, **digits only** — country code first, no `+`, no spaces, no dashes. An Indian mobile is the 2-digit country code followed by the 10-digit number, e.g. `91` followed by `XXXXXXXXXX`. Examples here use `91XXXXXXXXXX` as a placeholder: substitute a real recipient before sending.", "example": "91XXXXXXXXXX" }, "type": { "type": "string", "enum": [ "text", "template", "image", "document", "audio", "video", "sticker", "location", "contacts", "interactive", "reaction" ], "description": "Which kind of message this is. The object named here must be present." }, "context": { "type": "object", "description": "Reply in-thread to a specific message.", "required": [ "message_id" ], "properties": { "message_id": { "type": "string", "description": "The wamid of the message you are replying to." } } }, "biz_opaque_callback_data": { "type": "string", "description": "Opaque string echoed back on status webhooks. Also used as a fallback idempotency key when\nno `Idempotency-Key` header is sent.\n" }, "text": { "$ref": "#/components/schemas/TextObject" }, "template": { "$ref": "#/components/schemas/TemplateObject" }, "image": { "$ref": "#/components/schemas/MediaObject" }, "document": { "$ref": "#/components/schemas/DocumentObject" }, "audio": { "$ref": "#/components/schemas/MediaObject" }, "video": { "$ref": "#/components/schemas/MediaObject" }, "sticker": { "$ref": "#/components/schemas/MediaObject" }, "interactive": { "$ref": "#/components/schemas/InteractiveObject" }, "location": { "$ref": "#/components/schemas/LocationObject" }, "reaction": { "$ref": "#/components/schemas/ReactionObject" } } } ``` ### TextObject ```json { "type": "object", "required": [ "body" ], "properties": { "body": { "type": "string", "description": "The message text.", "example": "Hello! Your order has shipped." }, "preview_url": { "type": "boolean", "default": false, "description": "Render a link preview for the first URL in `body`." } } } ``` ### MediaObject Media by public HTTPS `link`. For private files, upload first and use the signed link we return. ```json { "type": "object", "description": "Media by public HTTPS `link`. For private files, upload first and use the signed link we return.", "properties": { "link": { "type": "string", "format": "uri", "description": "HTTPS URL WhatsApp can fetch.", "example": "https://example.com/banner.jpg" }, "id": { "type": "string", "description": "A WhatsApp media id, if you already have one." }, "caption": { "type": "string", "description": "Caption shown under the media." } } } ``` ### DocumentObject ```json { "allOf": [ { "$ref": "#/components/schemas/MediaObject" }, { "type": "object", "properties": { "filename": { "type": "string", "description": "Filename the recipient sees. Include the extension.", "example": "invoice-1234.pdf" } } } ] } ``` ### TemplateObject Required to open a conversation or to message outside the 24-hour window. Templates are created and approved through us; once approved you reference one by name. ```json { "type": "object", "required": [ "name", "language" ], "description": "Required to open a conversation or to message outside the 24-hour window. Templates are\ncreated and approved through us; once approved you reference one by name.\n", "properties": { "name": { "type": "string", "description": "The approved template name.", "example": "order_update" }, "language": { "type": "object", "required": [ "code" ], "properties": { "code": { "type": "string", "description": "Language code the template was approved in.", "example": "en" } } }, "components": { "type": "array", "description": "Fills the template's variables. Omit entirely for templates with no variables.\nParameters fill `{{1}}`, `{{2}}`, … **in order**.\n", "items": { "type": "object", "properties": { "type": { "type": "string", "enum": [ "header", "body", "button" ], "description": "Which part of the template this fills." }, "sub_type": { "type": "string", "description": "For `button` components: `quick_reply` or `url`." }, "index": { "type": "string", "description": "For `button` components: the button position, `\"0\"`-based." }, "parameters": { "type": "array", "items": { "type": "object", "additionalProperties": true, "properties": { "type": { "type": "string", "enum": [ "text", "image", "document", "video", "currency", "date_time", "payload" ] }, "text": { "type": "string" }, "image": { "$ref": "#/components/schemas/MediaObject" }, "document": { "$ref": "#/components/schemas/DocumentObject" }, "video": { "$ref": "#/components/schemas/MediaObject" } } } } } } } } } ``` ### InteractiveObject Reply buttons (max 3) or a list menu. Whatever `id` you set is echoed back to you in the inbound webhook when the customer taps it. ```json { "type": "object", "required": [ "type", "action" ], "additionalProperties": true, "description": "Reply buttons (max 3) or a list menu. Whatever `id` you set is echoed back to you in the\ninbound webhook when the customer taps it.\n", "properties": { "type": { "type": "string", "enum": [ "button", "list", "cta_url", "flow" ], "description": "`button` = up to 3 quick replies; `list` = a tappable menu." }, "header": { "type": "object", "additionalProperties": true }, "body": { "type": "object", "properties": { "text": { "type": "string" } } }, "footer": { "type": "object", "properties": { "text": { "type": "string" } } }, "action": { "type": "object", "additionalProperties": true, "properties": { "buttons": { "type": "array", "maxItems": 3, "description": "For `type: button`.", "items": { "type": "object", "properties": { "type": { "type": "string", "const": "reply" }, "reply": { "type": "object", "properties": { "id": { "type": "string", "description": "Echoed back when tapped." }, "title": { "type": "string", "description": "Button label (max 20 chars)." } } } } } }, "button": { "type": "string", "description": "For `type: list` — the label that opens the menu.", "example": "View services" }, "sections": { "type": "array", "description": "For `type: list`.", "items": { "type": "object", "properties": { "title": { "type": "string" }, "rows": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "description": "Echoed back when tapped." }, "title": { "type": "string" }, "description": { "type": "string" } } } } } } } } } } } ``` ### LocationObject ```json { "type": "object", "required": [ "latitude", "longitude" ], "properties": { "latitude": { "type": "number", "example": 21.1702 }, "longitude": { "type": "number", "example": 72.8311 }, "name": { "type": "string" }, "address": { "type": "string" } } } ``` ### ReactionObject ```json { "type": "object", "required": [ "message_id", "emoji" ], "properties": { "message_id": { "type": "string", "description": "wamid of the message to react to." }, "emoji": { "type": "string", "description": "The emoji, or `\"\"` to remove a reaction." } } } ``` ### SendMessageResponse ```json { "type": "object", "properties": { "messaging_product": { "type": "string", "const": "whatsapp" }, "contacts": { "type": "array", "items": { "type": "object", "properties": { "input": { "type": "string", "description": "The number as you sent it." }, "wa_id": { "type": "string", "description": "The number as WhatsApp resolved it." } } } }, "messages": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "description": "The **wamid**. Store it — delivery statuses reference this id." } } } } } } ``` ### MessageListResponse ```json { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/StoredMessage" } }, "next_cursor": { "type": [ "string", "null" ], "description": "Pass back as `before` for the next page. `null` when there are no more." } } } ``` ### StoredMessage ```json { "type": "object", "properties": { "id": { "type": "string", "description": "Our internal message id." }, "wamid": { "type": [ "string", "null" ], "description": "WhatsApp's message id — the one the send API returned." }, "direction": { "type": "string", "enum": [ "in", "out" ], "description": "`out` = you sent it; `in` = the customer sent it." }, "type": { "type": "string", "example": "text" }, "to": { "type": [ "string", "null" ] }, "from": { "type": [ "string", "null" ] }, "status": { "type": [ "string", "null" ], "enum": [ "accepted", "sent", "delivered", "read", "failed", null ], "description": "Latest known status. Moves forward only; `failed` is terminal." }, "body": { "type": "object", "additionalProperties": true, "description": "The message payload, as sent or received." }, "error": { "type": "object", "additionalProperties": true, "description": "Present only when `status` is `failed`." }, "created_at": { "type": "string", "format": "date-time" } } } ``` ### MediaUploadResponse ```json { "type": "object", "properties": { "id": { "type": "string", "example": "med_7c3a91be04d24f" }, "link": { "type": "string", "format": "uri", "description": "Signed URL, valid 2 hours. Use it directly as a message's media `link`." }, "mime": { "type": "string", "description": "Canonical MIME type, sniffed from the file's bytes.", "example": "application/pdf" }, "category": { "type": "string", "enum": [ "image", "document", "audio", "video", "sticker" ], "description": "Which message `type` this file can be sent as." }, "filename": { "type": "string" }, "size": { "type": "integer", "description": "Bytes." } } } ``` ### ErrorResponse Meta Graph-shaped error envelope — the same shape for every failure. ```json { "type": "object", "description": "Meta Graph-shaped error envelope — the same shape for every failure.", "properties": { "error": { "type": "object", "properties": { "message": { "type": "string", "description": "Human-readable description." }, "type": { "type": "string", "const": "WaApiError" }, "code": { "type": "integer", "description": "Machine-readable error code." }, "error_data": { "type": "object", "properties": { "details": { "type": "string", "description": "Which field or condition failed." } } }, "fbtrace_id": { "type": "string", "description": "Request id — quote it when contacting support." } } } } } ``` ### InboundMessageEvent Meta-shaped inbound message notification. ```json { "type": "object", "description": "Meta-shaped inbound message notification.", "properties": { "object": { "type": "string", "const": "whatsapp_business_account" }, "entry": { "type": "array", "items": { "type": "object", "properties": { "changes": { "type": "array", "items": { "type": "object", "properties": { "field": { "type": "string", "const": "messages" }, "value": { "type": "object", "properties": { "messaging_product": { "type": "string", "const": "whatsapp" }, "metadata": { "$ref": "#/components/schemas/WebhookMetadata" }, "contacts": { "type": "array", "items": { "type": "object", "properties": { "wa_id": { "type": "string", "description": "The customer's number." }, "profile": { "type": "object", "properties": { "name": { "type": "string" } } } } } }, "messages": { "type": "array", "items": { "type": "object", "additionalProperties": true, "properties": { "id": { "type": "string", "description": "wamid of the inbound message." }, "from": { "type": "string" }, "timestamp": { "type": "string", "description": "Unix seconds, as a string." }, "type": { "type": "string", "enum": [ "text", "image", "document", "audio", "video", "sticker", "location", "contacts", "button", "interactive" ] }, "text": { "type": "object", "properties": { "body": { "type": "string" } } }, "image": { "$ref": "#/components/schemas/InboundMediaRef" }, "document": { "$ref": "#/components/schemas/InboundMediaRef" }, "audio": { "$ref": "#/components/schemas/InboundMediaRef" }, "video": { "$ref": "#/components/schemas/InboundMediaRef" }, "button": { "type": "object", "description": "A template quick-reply button tap.", "properties": { "text": { "type": "string" }, "payload": { "type": "string" } } }, "interactive": { "type": "object", "description": "A reply-button or list-item tap. `id` is the one you set when sending.", "properties": { "type": { "type": "string", "enum": [ "button_reply", "list_reply" ] }, "button_reply": { "type": "object", "properties": { "id": { "type": "string" }, "title": { "type": "string" } } }, "list_reply": { "type": "object", "properties": { "id": { "type": "string" }, "title": { "type": "string" }, "description": { "type": "string" } } } } } } } } } } } } } } } } } } ``` ### InboundMediaRef A file the customer sent. Fetch the bytes with `GET /v1/{phone_number_id}/media/{id}`. ```json { "type": "object", "description": "A file the customer sent. Fetch the bytes with `GET /v1/{phone_number_id}/media/{id}`.", "properties": { "id": { "type": "string", "description": "Media id — pass to the media download endpoint." }, "mime_type": { "type": "string" }, "sha256": { "type": "string" }, "caption": { "type": "string" }, "filename": { "type": "string" } } } ``` ### MessageStatusEvent Meta-shaped delivery-status notification. ```json { "type": "object", "description": "Meta-shaped delivery-status notification.", "properties": { "object": { "type": "string", "const": "whatsapp_business_account" }, "entry": { "type": "array", "items": { "type": "object", "properties": { "changes": { "type": "array", "items": { "type": "object", "properties": { "field": { "type": "string", "const": "messages" }, "value": { "type": "object", "properties": { "messaging_product": { "type": "string", "const": "whatsapp" }, "metadata": { "$ref": "#/components/schemas/WebhookMetadata" }, "statuses": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "description": "The wamid returned by the send API. Join on this." }, "status": { "type": "string", "enum": [ "sent", "delivered", "read", "failed" ] }, "timestamp": { "type": "string", "description": "Unix seconds, as a string." }, "recipient_id": { "type": "string", "description": "The customer's number." }, "errors": { "type": "array", "description": "Present only when `status` is `failed`.", "items": { "type": "object", "properties": { "code": { "type": "integer", "example": 131031 }, "title": { "type": "string" }, "message": { "type": "string" } } } } } } } } } } } } } } } } } ``` ### WebhookMetadata ```json { "type": "object", "properties": { "display_phone_number": { "type": "string", "description": "The business number, as displayed.", "example": "91NNNNNNNNNN" }, "phone_number_id": { "type": "string", "description": "Which of your numbers this event belongs to.", "example": "123456789012345" } } } ``` ### TemplateListResponse ```json { "type": "object", "properties": { "data": { "type": "array", "description": "Matching templates. Empty when nothing matched.", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Use this as `template.name` when sending.", "example": "order_update" }, "language": { "type": "string", "description": "Use this as `template.language.code` when sending — exactly.", "example": "en" }, "status": { "type": "string", "enum": [ "APPROVED", "PENDING", "REJECTED", "PAUSED", "DISABLED" ], "description": "Only `APPROVED` templates can be sent." }, "category": { "type": "string", "enum": [ "UTILITY", "MARKETING", "AUTHENTICATION" ], "description": "Fixed at approval. It affects pricing and policy, never the request shape." }, "components": { "type": "array", "items": { "type": "object", "additionalProperties": true }, "description": "The template's registered structure — header, body, footer, buttons.\nThe `{{n}}` placeholders in the body text are the variables you must fill, in order." } } } } } } ``` ### EventPage One page of pulled inbound events. ```json { "type": "object", "description": "One page of pulled inbound events.", "properties": { "events": { "type": "array", "description": "Meta-shaped webhook bodies, oldest first — identical to what a push would deliver.", "items": { "$ref": "#/components/schemas/InboundMessageEvent" } }, "cursor": { "type": [ "string", "null" ], "description": "Pass back as `since` to continue. Unchanged when there was nothing new.", "example": "2026-08-22T06:02:47.149Z|msg_5d1b531331e14ae698d8f7fbbfe97862" } } } ```