# WA AGENT developer documentation Source: https://waagent.in/docs and https://waagent.in/docs/api --- # WA AGENT API — Quickstart Send WhatsApp template messages from your own software over HTTP. ## Get an API key Open **API & Webhooks** in your portal and create a key on the **Keys** tab. Name it after the system that will use it — that name is how you will know which key to revoke later. The key is shown **once**. We store a one-way hash of it, so we genuinely cannot show it to you again; if you lose it, revoke it and create another. Put it in your environment, never in your source control. Every request carries it as a bearer token: > **Warning:** Call this API from your server, never from a browser or a mobile app. A key in client-side code is a key you have published — anyone who can open your page can read it and send messages as you. **Header** ```http Authorization: Bearer wa_live_YOUR_KEY ``` ## Send a template message One endpoint, one recipient per call: `POST /v1/messages`. You need two things — the `phone_number_id` of the number you are sending from (shown on your **WhatsApp Numbers** screen) and an **approved** template. WhatsApp does not let you send arbitrary text to someone who has not messaged you first. That is why every send here is a template, and why the template has to be approved by Meta before it will go anywhere. **cURL** ```bash curl -X POST https://api.waagent.in/v1/messages \ -H "Authorization: Bearer wa_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4471-shipped" \ -d '{ "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "order_shipped", "language": { "code": "en" }, "variables": { "body.1": "Arjit", "body.2": "SR-4471", "button.0.1": "SR-4471" } } }' ``` **Node.js** ```javascript const response = await fetch("https://api.waagent.in/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.WA_AGENT_API_KEY}`, "Content-Type": "application/json", // Safe to retry: the same key never sends twice. "Idempotency-Key": `order-${orderId}-shipped`, }, body: JSON.stringify({ messaging_product: "whatsapp", phone_number_id: "106540352242922", to: "+919876543210", type: "template", template: { name: "order_shipped", language: { code: "en" }, variables: { "body.1": "Arjit", "body.2": "SR-4471", "button.0.1": "SR-4471", }, }, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error.code); console.log(data.messages[0].id); // wamid.HBgM… ``` **Python** ```python import os import requests response = requests.post( "https://api.waagent.in/v1/messages", headers={ "Authorization": f"Bearer {os.environ['WA_AGENT_API_KEY']}", "Idempotency-Key": "order-4471-shipped", }, json={ "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "order_shipped", "language": {"code": "en"}, "variables": { "body.1": "Arjit", "body.2": "SR-4471", "button.0.1": "SR-4471", }, }, }, timeout=20, ) response.raise_for_status() print(response.json()["messages"][0]["id"]) ``` **PHP** ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("WA_AGENT_API_KEY"), "Content-Type: application/json", "Idempotency-Key: order-4471-shipped", ], CURLOPT_POSTFIELDS => json_encode([ "messaging_product" => "whatsapp", "phone_number_id" => "106540352242922", "to" => "+919876543210", "type" => "template", "template" => [ "name" => "order_shipped", "language" => ["code" => "en"], "variables" => [ "body.1" => "Arjit", "body.2" => "SR-4471", "button.0.1" => "SR-4471", ], ], ]), ]); $data = json_decode(curl_exec($ch), true); echo $data["messages"][0]["id"]; ``` ## Send a template with an image, video or document A template whose header is an image, video or document sends the file you uploaded when you created it — send nothing extra and that is what goes out. To send a **different file for this one message** — this customer's invoice, their ticket, the photo of their order — add `header_media` beside `variables`, with a `link` to the file. The link must be a public `https` URL, because Meta fetches the file itself when it sends. Its type has to match the header: JPEG or PNG for an image, MP4 or 3GPP for a video, PDF for a document. A link we cannot reach, or the wrong type, fails that message with Meta's own `meta_code`. For a template created **outside WA AGENT** — directly in WhatsApp Manager — there is no stored file to fall back on, so `header_media` is required. `GET /v1/templates` tells you in advance: `header_media.has_stored_file` is `false`. Sending `header_media` for a template with no media header is refused with `HEADER_MEDIA_NOT_APPLICABLE`. The example fills everything such a template can take at once: the document header, two body variables (`Hi {{1}}, your invoice for order {{2}} is attached.`) and the URL button's variable (`https://shop.example.com/orders/{{1}}`). **cURL** ```bash curl -X POST https://api.waagent.in/v1/messages \ -H "Authorization: Bearer wa_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4471-invoice" \ -d '{ "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "order_invoice", "language": { "code": "en" }, "variables": { "body.1": "Arjit", "body.2": "SR-4471", "button.0.1": "SR-4471" }, "header_media": { "link": "https://files.example.com/invoices/SR-4471.pdf" } } }' ``` **Node.js** ```javascript const response = await fetch("https://api.waagent.in/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.WA_AGENT_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `order-${orderId}-invoice`, }, body: JSON.stringify({ messaging_product: "whatsapp", phone_number_id: "106540352242922", to: "+919876543210", type: "template", template: { name: "order_invoice", language: { code: "en" }, variables: { "body.1": "Arjit", // Hi {{1}}, "body.2": "SR-4471", // your invoice for order {{2}} is attached. "button.0.1": "SR-4471", // https://shop.example.com/orders/{{1}} }, // Your own file for the document header — public https, fetched by Meta. header_media: { link: `https://files.example.com/invoices/${orderId}.pdf` }, }, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error.code); console.log(data.messages[0].id); // wamid.HBgM… ``` **Python** ```python import os import requests response = requests.post( "https://api.waagent.in/v1/messages", headers={ "Authorization": f"Bearer {os.environ['WA_AGENT_API_KEY']}", "Idempotency-Key": "order-4471-invoice", }, json={ "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "order_invoice", "language": {"code": "en"}, "variables": { "body.1": "Arjit", "body.2": "SR-4471", "button.0.1": "SR-4471", }, "header_media": { "link": "https://files.example.com/invoices/SR-4471.pdf", }, }, }, timeout=20, ) response.raise_for_status() print(response.json()["messages"][0]["id"]) ``` **PHP** ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("WA_AGENT_API_KEY"), "Content-Type: application/json", "Idempotency-Key: order-4471-invoice", ], CURLOPT_POSTFIELDS => json_encode([ "messaging_product" => "whatsapp", "phone_number_id" => "106540352242922", "to" => "+919876543210", "type" => "template", "template" => [ "name" => "order_invoice", "language" => ["code" => "en"], "variables" => [ "body.1" => "Arjit", "body.2" => "SR-4471", "button.0.1" => "SR-4471", ], "header_media" => [ "link" => "https://files.example.com/invoices/SR-4471.pdf", ], ], ]), ]); $data = json_decode(curl_exec($ch), true); echo $data["messages"][0]["id"]; ``` ## Send a free-form text message Inside the **24 hours after a contact last wrote to you**, you can reply with plain text — no template. Send `type: "text"` with a `text.body` instead of a `template`. It goes out from the same endpoint, with the same `Idempotency-Key`. Outside that window WhatsApp only allows templates. We check first: a contact who never wrote to you is refused with `SERVICE_WINDOW_NEVER_OPENED`, and one whose window has expired with `SERVICE_WINDOW_CLOSED` (both HTTP 409) — send a template to reopen the conversation. If WhatsApp itself refuses the message anyway (`131047` means the window closed in between), you get `MESSAGE_SEND_FAILED` with WhatsApp's own `meta_code`. A text message counts against your plan's message limit exactly like a template does, is refunded if delivery fails, and appears in Delivery Reports as **Free-form**. The reply also shows in the conversation in your Inbox. **cURL** ```bash curl -X POST https://api.waagent.in/v1/messages \ -H "Authorization: Bearer wa_live_YOUR_KEY" \ -H "Idempotency-Key: order-4471-reply" \ -H "Content-Type: application/json" \ -d '{ "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "text", "text": { "body": "Your order has shipped." } }' ``` ## Add a lead form to your website A different job from everything above: instead of your server sending a message, your **website's own form** posts a visitor's name and phone straight to us, and they land in your Contacts with source `website`. This uses a separate credential, a **lead key** — create one under **Developer → Website Leads** in your portal. A lead key can only ever create a lead; it cannot send a message or read anything. That is what makes it safe to put in your website's own code, unlike your API key. Optionally restrict a lead key to your own domain when you create it. If it ever leaks — checked into a public repo, visible in your page source, whatever — the worst it can do is let someone submit junk leads, which is rate-limited on our side either way. > **Warning:** Never put your wa_live_ API key here. That key can send billed messages; a lead key can only create a lead. Using the wrong one in your website's public code hands a stranger the ability to send messages on your account. **Browser fetch** ```javascript // In your website's own page, or a script it loads. const response = await fetch("https://api.waagent.in/v1/leads", { method: "POST", headers: { Authorization: "Bearer wa_lead_YOUR_LEAD_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ name: document.querySelector("#name").value, phone: document.querySelector("#phone").value, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error.code); console.log(data.status); // "received" ``` **cURL** ```bash curl -X POST https://api.waagent.in/v1/leads \ -H "Authorization: Bearer wa_lead_YOUR_LEAD_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Arjit", "phone": "9876543210" }' ``` ## Phone numbers The `to` field must be in E.164 **including the leading `+`** — `+919876543210`, not `919876543210`. We refuse the bare form on purpose, and it is the one place we are deliberately stricter than Meta. Without a `+` there is no country code to read, and ten digits is a complete national number in India *and* a complete number including its country code in Singapore, Norway and Denmark. Guessing wrong does not fail — it delivers your message to a stranger, and that cannot be undone. ## Filling in template variables Where Meta wants a nested `components` array, we take a flat `variables` map keyed by **slot id**. The id says where in the template the value goes, so you never have to get Meta's parameter *ordering* right — the mistake that silently swaps two customers' values. Every variable your template declares must be filled. A missing one is refused rather than sent blank — Meta would happily deliver "Hi , your order has shipped" to a real person. `header.1` covers a plain text header. A LOCATION header is `header.location`, taking one JSON **string** value — `{"latitude","longitude","name","address"}`. A media header (image, video or document) is **not** a `variables` entry — anything sent under `header.media` is ignored. Leave it out to send the template's stored file, or pass your own with `header_media` — see **Send a template with an image, video or document** above. The number in `button..` is the button's position in the template's **full** button list — starting at 0, counting every button on it, whatever type each one is — not a count of URL buttons alone. Only a URL button ever carries a variable. So a template whose buttons are, in order, a quick reply and then a URL button has its variable at `button.1.1`, not `button.0.1`. You never have to count: `GET /v1/templates` — or the template's **For the API** card in your portal — lists every button with the exact slot id to use. > **Note:** A URL button value must be percent-encoded by you. Meta appends it to the approved URL rather than substituting it in place, so “Gonçalves” has to arrive as “Gon%C3%A7alves”. | Slot id | Where the value goes | | --- | --- | | `body.1` | the first `{{1}}` in the body | | `body.2` | the second, and so on | | `body.first_name` | a named `{{first_name}}` in the body | | `header.1` | the variable in a text header | | `header.location` | a LOCATION header's value | | `button.0.1` | a URL button's variable — see below for how the number is chosen | | `code` | the one-time code, for an authentication template | | `header_media.link` | not a slot — a field beside `variables`: your own file for an image, video or document header | ## Sending a one-time code An authentication template takes exactly one variable, `code`. Meta owns the wording of the message itself, so there is nothing else to fill in. The code is masked before it is written to our database. It appears in no report, no log and no export — including to us. That is a platform rule, not a setting. **Body** ```json { "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "login_code", "language": { "code": "en" }, "variables": { "code": "483920" } } } ``` ## Retrying safely Send an `Idempotency-Key` header and a retry with the same value returns the original message instead of sending a second one. Use something stable from your own system — an order id, an invoice number — rather than a random string, so a retry after a timeout actually matches. Without the header, every request sends. That is deliberate: an OTP resend is a genuine second message, and silently swallowing it would be the wrong answer. A replayed request answers `200` with the original `id` and `"deduplicated": true`. ## Checking delivery A `200` means Meta **accepted** the message, not that it arrived. To find out what happened next, poll `GET /v1/messages/{id}` with the `id` the send returned. Status moves `queued` → `sent` → `delivered` → `read`, or to `failed`. It only ever moves forward, so a late webhook from Meta can never walk it backwards. You can also pass the `message_id` we return alongside it — that one exists even for a message Meta refused, which has no `wamid` at all. **cURL** ```bash curl https://api.waagent.in/v1/messages/wamid.HBgM… \ -H "Authorization: Bearer wa_live_YOUR_KEY" ``` **Response** ```json { "id": "wamid.HBgM…", "message_id": "66f3a1c2e4b0a1d2c3e4f5a6", "status": "delivered", "to": "+919876543210", "template_name": "order_shipped", "error": null, "sent_at": "2026-09-09T10:12:01.000Z", "delivered_at": "2026-09-09T10:12:04.000Z", "read_at": null, "failed_at": null } ``` ## Receive events on your server (webhooks) Polling `GET /v1/messages/{id}` works, but it is the wrong shape for two things: knowing the moment a contact **replies**, and knowing a message was **delivered or read** without asking over and over. Give us an HTTPS URL and we send you a signed `POST` instead. Set it up under **API & Webhooks → Webhooks** in your portal: paste the URL, choose the events, and press **Verify & save**. We immediately send a test request to the URL, and **we only save it if your server answers that request correctly** — see the next section. A URL that is wrong, unreachable, or not yours to answer for never gets any of your messages. You are shown a **signing secret** (`wh_sec_…`) once, when the webhook is created. It is how you prove a request came from us — see **Verify the signature**. We store it so we can sign with it, so unlike an API key you can also rotate it: the old secret keeps working for 24 hours so you can deploy the new one without losing events. There are two events you can subscribe to, plus the test request: > **Note:** Delivery status is sent only for messages you sent through the API — not for campaigns or Inbox replies. A campaign to a large list would otherwise fire hundreds of thousands of requests at your server. Campaign results stay in the portal's delivery reports. | Event | When it is sent | | --- | --- | | `message.received` | A contact sent a message to one of your numbers. | | `message.status` | A message you sent **through this API** was sent, delivered, read or failed. One event per change. | | `webhook.test` | When you save the webhook, and when you press **Send test event**. You do not subscribe to it. | ## What your endpoint must do Three rules. **Answer `2xx`** to say you have it — any other status, or no answer within **10 seconds**, counts as a failure and is retried. **Answer first, work afterwards**: acknowledge the request and process it in the background, rather than making us wait while you update a CRM. **Echo the challenge on the verification request.** The request sent when you save the webhook is a `webhook.test` event whose `data.challenge` is a random string. Reply `2xx` with the JSON body `{"challenge": ""}`. A server that simply answers `200` to everything does not pass, which is the point: it proves whoever owns the URL read our request and answered it on purpose. The test request you trigger later with **Send test event** carries no challenge and needs only a `2xx`. **Keep the raw body.** The signature is computed over the exact bytes we sent. If your framework parses the JSON and you re-serialise it, key order and whitespace change and every signature fails. Read the raw bytes, verify, then parse. > **Note:** Both examples call `verifyWebhook` / `verify_webhook`, defined in the next section. **Node.js** ```javascript const express = require("express"); const app = express(); // Keep the body RAW. The signature covers the exact bytes; parsing and re-serialising changes them. app.post("/hooks/whatsapp", express.raw({ type: "application/json" }), (req, res) => { const ok = verifyWebhook({ secret: process.env.WA_AGENT_WEBHOOK_SECRET, rawBody: req.body, timestamp: req.header("X-WAAgent-Timestamp"), signatureHeader: req.header("X-WAAgent-Signature"), }); if (!ok) return res.sendStatus(400); const event = JSON.parse(req.body.toString("utf8")); // The request sent when you SAVE the webhook: echo the challenge back. if (event.type === "webhook.test" && event.data.challenge) { return res.json({ challenge: event.data.challenge }); } // Acknowledge first, work afterwards: a reply slower than 10 seconds is retried. res.sendStatus(200); handleEvent(event); // de-duplicate on event.id — you may receive the same event twice }); app.listen(3000); ``` **Python** ```python import os from flask import Flask, jsonify, request app = Flask(__name__) @app.post("/hooks/whatsapp") def whatsapp_webhook(): raw = request.get_data() # bytes, exactly as received ok = verify_webhook( os.environ["WA_AGENT_WEBHOOK_SECRET"], raw, request.headers.get("X-WAAgent-Timestamp", ""), request.headers.get("X-WAAgent-Signature", ""), ) if not ok: return "", 400 event = request.get_json(force=True) # The request sent when you SAVE the webhook: echo the challenge back. if event["type"] == "webhook.test" and event["data"].get("challenge"): return jsonify(challenge=event["data"]["challenge"]) handle_event_later(event) # de-duplicate on event["id"] — you may receive it twice return "", 200 ``` ## Verify the signature Anyone who learns your URL can send it a request that looks like ours. The signature is how you tell the difference. Every request carries four headers: > **Warning:** Reject any request whose timestamp is more than 5 minutes from your clock. The timestamp is part of what is signed, which is what stops someone who captured one genuine request from replaying it later. Compare signatures in constant time, as the examples do. | Header | What it is | | --- | --- | | `X-WAAgent-Event` | The event type — the same as `type` in the body. | | `X-WAAgent-Delivery` | The event `id`. **The same on every retry of the same event**, so it is what you de-duplicate on. | | `X-WAAgent-Timestamp` | Unix seconds when this attempt was signed. | | `X-WAAgent-Signature` | `v1=` followed by the hex HMAC-SHA256 of `{timestamp}.{raw body}`, keyed with your signing secret. | **Node.js** ```javascript const crypto = require("node:crypto"); // rawBody: a Buffer or string holding the request body EXACTLY as it arrived. function verifyWebhook({ secret, rawBody, timestamp, signatureHeader, toleranceSeconds = 300, nowSeconds = Date.now() / 1000, }) { const ts = Number(timestamp); if (!Number.isFinite(ts) || Math.abs(nowSeconds - ts) > toleranceSeconds) return false; const expected = crypto .createHmac("sha256", secret) .update(timestamp + ".") .update(rawBody) .digest("hex"); // During a secret rotation the header carries two values ("v1=…,v1=…"): accept either. return String(signatureHeader || "") .split(",") .map((part) => part.trim()) .filter((part) => part.startsWith("v1=")) .map((part) => part.slice(3)) .some((candidate) => { const a = Buffer.from(candidate, "hex"); const b = Buffer.from(expected, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); }); } ``` **Python** ```python import hashlib import hmac import time def verify_webhook(secret, raw_body, timestamp, signature_header, tolerance=300, now=None): """raw_body must be the request body as BYTES, exactly as it arrived.""" now = time.time() if now is None else now try: if abs(now - int(timestamp)) > tolerance: return False except (TypeError, ValueError): return False message = timestamp.encode() + b"." + raw_body expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() # During a secret rotation the header carries two values ("v1=…,v1=…"): accept either. candidates = [ part.strip()[3:] for part in (signature_header or "").split(",") if part.strip().startswith("v1=") ] return any(hmac.compare_digest(candidate, expected) for candidate in candidates) ``` **PHP** ```php $tolerance) { return false; } $expected = hash_hmac("sha256", $timestamp . "." . $rawBody, $secret); // During a secret rotation the header carries two values ("v1=…,v1=…"): accept either. foreach (explode(",", $signatureHeader) as $part) { $part = trim($part); if (strncmp($part, "v1=", 3) === 0 && hash_equals($expected, substr($part, 3))) { return true; } } return false; } ``` ## Check your implementation Before you trust your verifier, feed it this known request. It should accept it when your clock reads `1700000000`, and refuse it if you change a single character of the body. | Input | Value | | --- | --- | | Secret | `wh_sec_test` | | `X-WAAgent-Timestamp` | `1700000000` | | Raw body | `{"event":"webhook.test"}` | | `X-WAAgent-Signature` | `v1=7d116fcb08589b014f00a1bb88227f063ed8c2b2090317443da55ebc9d6ba9d3` | ## Events and payloads Every body has the same envelope — `id`, `type`, `created_at` and `data` — and `data` depends on the type. `created_at` is when the thing **happened**, not when this attempt was made. **`message.received`.** Exactly one of `from.phone` and `from.user_id` is set: a contact who uses a WhatsApp username may not expose their number, and then only `user_id` identifies them. `to.phone_number_id` is which of your numbers received it — the same id `POST /v1/messages` takes. `media` is `null` unless the message carries a file. **message.received** ```json { "id": "msg_in_65f1c0a2b7d4e83a9c1f0b22", "type": "message.received", "created_at": "2026-10-26T10:00:00.000Z", "data": { "message": { "id": "65f1c0a2b7d4e83a9c1f0b22", "wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgg", "type": "image", "text": "Is this the right size?", "timestamp": "2026-10-26T10:00:00.000Z", "reply_to_wamid": null, "location": null, "contacts": null }, "from": { "phone": "+919876543210", "user_id": null, "name": "Jane" }, "to": { "phone_number_id": "106540352242922" }, "media": { "id": "65f1c0a2b7d4e83a9c1f0b22", "type": "image", "mime_type": "image/jpeg", "filename": null, "url": "https://api.waagent.in/v1/media/65f1c0a2b7d4e83a9c1f0b22" } } } ``` ## Delivery status events **`message.status`.** `message_id` is the id `POST /v1/messages` gave you, so you can match the event to your own send. `status` is `sent`, `delivered`, `read` or `failed`; on `failed`, `error.code` is WhatsApp's own code (`131026` means the number is not on WhatsApp). One event is sent per change, and a status never goes backwards. **`webhook.test`.** What we send when you save the webhook, so you know what to answer: **message.status** ```json { "id": "status_wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS_delivered", "type": "message.status", "created_at": "2026-10-26T10:00:05.000Z", "data": { "message_id": "66f3a1c2e4b0a1d2c3e4f5a6", "wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS", "status": "delivered", "timestamp": "2026-10-26T10:00:05.000Z", "recipient": { "phone": "+919876543210", "user_id": null }, "error": null } } ``` **webhook.test** ```json { "id": "evt_test_0b7f2c1e-5a1d-4c88-9a43-2f4d8e6b7a10", "type": "webhook.test", "created_at": "2026-10-26T10:00:00.000Z", "data": { "message": "Verification request from WA AGENT. Respond with HTTP 200 and the JSON body {\"challenge\": \"\"}.", "challenge": "9f2c6a41d3b84e07a5c1e2f3b4d5a697" } } ``` ## Downloading attachments When a contact sends a photo, document, voice note or video, `media.url` is a link to the file. `GET` it with your API key; it answers with a redirect to a short-lived download link, or with the file itself, so **follow redirects** and do not forward your key to the address you are redirected to (most HTTP clients already strip the `Authorization` header across origins). Unlike WhatsApp's own media links, which expire within minutes and need a token, this one stays valid for **30 days** — long enough that a retry hours later can still fetch the file. Always use `media.url`; the `media.id` is ours and means nothing to WhatsApp. It answers `404` if the id is not on your account, `410` if the file is no longer available from WhatsApp, and `413` for a document over 16 MB that we have not stored yet — retry that one shortly. A `409` with `META_ACCESS_REVOKED` means access to your WhatsApp Business Account was removed: reconnect your number in the portal, because retrying cannot help. **cURL** ```bash curl -L https://api.waagent.in/v1/media/65f1c0a2b7d4e83a9c1f0b22 \ -H "Authorization: Bearer wa_live_YOUR_KEY" \ -o photo.jpg ``` ## Retries, duplicates and pausing Delivery is **at least once**: the same event can arrive more than once (a retry after your server answered too slowly, say), so de-duplicate on the event `id` — it is also the `X-WAAgent-Delivery` header, identical on every attempt. Events are **not guaranteed to arrive in order**; use `created_at` if order matters to you. A `2xx` ends it. Anything else is retried on this schedule, for 8 attempts in all over about 21 hours, and then the event is marked **Failed** in the delivery log: | After attempt | We wait | | --- | --- | | 1 | 10 seconds | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 6 hours | | 7 | 12 hours | ## When your endpoint keeps failing Two things stop deliveries to a URL. Answering **`410 Gone`** pauses it at once — that is how a server says this address is gone for good. Failing **continuously for 3 days** (and at least 10 times) pauses it too, so we are not hammering a dead address. A `429` with a `Retry-After` is honoured: we wait at least that long, up to 6 hours. A paused webhook shows a banner in your portal. Fix your endpoint, then press **Re-enable** — it runs the same verification as saving a new URL. **Events that happen while it is paused are not queued.** To catch up, look up the messages you sent with `GET /v1/messages/{id}`. Every attempt, with its HTTP status and how long it took, is in the **Delivery log** for 30 days. A failed event can be **replayed** once your server is fixed; it is sent again under the same event `id`. ## Which URLs we will call Because the URL is yours to choose, we only call addresses that are safe for us to call. It must be **`https`** on port **443 or 8443**, with no username or password in it, and its hostname must resolve to a **public** address. We refuse localhost, private networks and cloud-metadata addresses — when you save, and again on every delivery, since a hostname can be re-pointed later. We do not follow redirects (a `3xx` counts as a failure), we wait at most 10 seconds, and we keep only the first 1 KB of your reply for the delivery log. We do not publish a fixed list of sending IP addresses, so **authenticate with the signature, not the source address**. While you build, point the webhook at a tunnel (such as an `ngrok` or Cloudflare Tunnel address) rather than `localhost`. ## If saving your webhook is refused Saving fails with one of these, and the message says what we saw. Nothing is saved when it fails, and a webhook that was already working is left exactly as it was. | Code | What it means and what to do | | --- | --- | | `WEBHOOK_URL_UNSAFE` | The URL is not `https`, uses another port, has credentials in it, or points at a private or local address. Use a public `https` URL. | | `WEBHOOK_UNREACHABLE` | We could not connect: the hostname does not resolve, the certificate is invalid, the port is closed, or you did not answer within 10 seconds. | | `WEBHOOK_HTTP_STATUS` | Your server answered, but not with `2xx`. The message names the status. Check the path and that the route accepts `POST`. | | `WEBHOOK_CHALLENGE_FAILED` | Your server answered `2xx` but not with the `challenge` we sent. Reply with JSON `{"challenge": ""}`. | ## When something goes wrong Every failure has the same shape. Branch on `error.code`, which is a stable string — never on the message, which we may reword. When Meta itself refused the message, `meta_code` carries their own numeric code, which you can look up in Meta's Cloud API error reference. `131026`, for instance, means the number is not on WhatsApp — and it is the only way to find that out, since no API can tell you in advance. The full code list is in the API reference. **Error** ```json { "error": { "code": "MESSAGE_VARIABLES_INCOMPLETE", "message": "Missing values for template variable(s): body.2", "request_id": "0f0b1c9a-…" } } ``` ## Rate limits and quotas Two separate ceilings. Requests are limited per key per minute — every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`, and a `429` adds `Retry-After`. Watch `X-RateLimit-Remaining` and slow down before you are refused. Separately, your plan caps messages per month, day and hour. A send counts when WhatsApp accepts it, and is given back if WhatsApp later reports it failed or not charged for — a utility template inside an open 24-hour service window, for example. Hitting a cap answers `402` with `QUOTA_EXCEEDED`, and no amount of backing off will clear it — you need a larger plan. --- # WA AGENT API — Reference Version 1.0.0 Send WhatsApp template and free-form text messages from your own software. ## Authenticating Every request carries your API key as a bearer token. Keys start with `wa_live_`, are shown in full exactly once when you create them, and are managed under **API Keys** in the customer portal. ``` Authorization: Bearer wa_live_xxxxxxxxxxxxxxxxxxxx ``` **This API is server-to-server.** It sends no CORS headers to arbitrary origins, and you should never call it from a browser or a mobile app — an API key in client-side code is a key you have published. Call it from your backend. ## Template variables Where Meta wants a nested `components` array, this API takes a flat `variables` map keyed by slot id. The ids mirror where the placeholder sits in the template: | Slot id | Where it goes | | --- | --- | | `body.1` | the first `{{1}}` in the body | | `body.first_name` | a named `{{first_name}}` in the body | | `header.1` | the text header — Meta allows at most one variable in a header | | `header.location` | a LOCATION header — a JSON **string** of `{"latitude","longitude","name","address"}` | | `button.0.1` | a URL button's variable — see below for how the number is chosen | | `code` | the one-time code, for an `AUTHENTICATION` template | **A media header (image, video or document) is not a `variables` entry.** Do not send a `header.media` key — any value there is ignored. Instead, either: - send nothing, and the platform attaches the file uploaded when the template was created (resolving a fresh Meta media id itself, the same way the Campaigns and Inbox screens do); or - send `template.header_media: { "link": "https://…" }` to use **your own file for this message** — an invoice PDF, a ticket, a product photo. Meta fetches it from that URL, so it must be publicly reachable over `https`, and its type must match the header (JPEG/PNG for an image, MP4/3GPP for a video, PDF for a document). A template synced from WhatsApp Manager has no stored file, so for one of those `header_media` is required. A template mixes positional and named variables never — Meta forbids it — so a given template uses one style throughout. **The number in `button..` is the button's position in the template's FULL button list — starting at 0, counting every button, whatever type each one is.** It is not a count of URL buttons alone. A template can mix quick-reply, phone-number and URL buttons in one list (Meta only requires quick-replies to sit together, before or after the others), and only a URL button ever carries a variable. So a template whose buttons are `[Quick reply, Call now, Track order]` — where "Track order" is the one URL button — has its variable at `button.2.1`, not `button.0.1`. Call `GET /templates` or `GET /templates/{id}` rather than counting buttons by hand — the response already carries `variables` (every slot id this endpoint will accept, precomputed) and `buttons` (each button's position and type), so `buttons[2].type === "URL"` is what makes `button.2.1` the right id for that example. **A URL button value must be percent-encoded by you.** Meta appends it to the approved URL rather than substituting it, so `Gonçalves` has to arrive as `Gon%C3%A7alves`. ## Numbers and templates `GET /numbers` lists every WhatsApp number on your account, connected or not — the `phone_number_id` field names the one every other endpoint takes. A number that is not `"sendable"` still shows up rather than disappearing: `POST /messages` against one refuses with `WHATSAPP_NUMBER_NOT_SENDABLE`, and this is how you tell "wrong id" from "this number needs attention in the portal". `GET /templates` and `GET /templates/{id}` list your templates with their `variables` and `buttons` already computed — this is the intended way to learn a template's slot ids and button layout instead of reading them off the Templates screen. A template also shows up here regardless of status, the same "readable is not sendable" split as `/numbers`: a `PENDING` or `REJECTED` template is visible (with `rejected_reason` when there is one) so you can tell it apart from a typo'd id, but `POST /messages` still refuses anything that is not `APPROVED` with `TEMPLATE_NOT_SENDABLE`. An `AUTHENTICATION` template's `one_time_use_only` is `true` and its `variables` is always the single `code` slot — never the scanned `body.*` slots a template of that category would otherwise appear to have. ## Idempotency Send an `Idempotency-Key` header and a retry with the same key returns the original message's id instead of sending a second one. Without the header every request sends. Keys are scoped to your account, so they need only be unique to you. ## Rate limits Requests are limited per key per minute. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; a `429` adds `Retry-After`. Your plan also caps messages per month, day and hour — those refuse with `QUOTA_EXCEEDED`. ## Webhooks Instead of polling `GET /messages/{id}`, give us an HTTPS URL and we will POST a signed event to it when a contact messages you (`message.received`) or a message you sent here changes state (`message.status`). Add it under **API & Webhooks → Webhooks** in the portal; we send a test request first and only save the URL if your server answers it correctly. The events are described in the **Webhooks** section below, and the quickstart at `/docs` shows how to verify the signature. ## Website leads `POST /leads` is the one exception to everything above: it uses a separate **lead key** (`wa_lead_…`), not your messaging API key, and it is the one endpoint that accepts requests from a browser. Create a lead key under **Developer → Website Leads**, optionally restricted to your site's own origin, and use it in your website's own form — never your `wa_live_…` key, which can send billed messages and must stay on your server. ## Servers | URL | Description | | --- | --- | | `https://api.waagent.in/v1` | WA AGENT API | ## Endpoints ### POST /messages **Send a template message** Sends one WhatsApp template message to one recipient. Returns as soon as Meta accepts it — acceptance is not delivery, so poll `GET /messages/{id}` or read the DLR report for that. | Parameter | In | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Retrying with the same value returns the original message instead of sending again. | **Request** ```bash curl -X POST https://api.waagent.in/v1/messages \ -H "Authorization: Bearer wa_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "text", "text": { "body": "Your order has shipped." } }' ``` **Request body** ```json { "type": "object", "required": [ "messaging_product", "phone_number_id", "to", "type" ], "properties": { "messaging_product": { "type": "string", "enum": [ "whatsapp" ] }, "phone_number_id": { "type": "string", "description": "Meta's phone number id for the number you are sending FROM. Shown on the WhatsApp Numbers screen. It must be connected — a number in review or paused is refused." }, "to": { "type": "string", "pattern": "^\\+[1-9]\\d{6,14}$", "description": "The recipient, in E.164 **including the leading `+`**. The `+` is required: without a country code we would have to guess one, and ten digits is a complete number in several countries. Meta accepts the bare form; we do not, because guessing wrong delivers your message to a stranger.", "examples": [ "+919876543210" ] }, "type": { "type": "string", "enum": [ "template", "text" ], "description": "`template` sends an approved template and needs `template`. `text` sends a free-form message and needs `text`; it is only deliverable within 24 hours of the contact last writing to you (otherwise `SERVICE_WINDOW_CLOSED` or `SERVICE_WINDOW_NEVER_OPENED`, HTTP 409). A `text` message counts against your plan quota like a template, and is refunded if delivery fails." }, "text": { "type": "object", "description": "Required when `type` is `text`.", "required": [ "body" ], "properties": { "body": { "type": "string", "minLength": 1, "maxLength": 4096 } } }, "template": { "type": "object", "required": [ "variables" ], "properties": { "name": { "type": "string", "description": "The template's name at Meta. Requires `language`. Provide this or `id`, not both." }, "id": { "type": "string", "description": "The template id from the Templates screen. Unambiguous on its own, so `language` is not needed with it." }, "language": { "type": "object", "required": [ "code" ], "properties": { "code": { "type": "string", "examples": [ "en", "en_US" ] } }, "description": "Required with `name`. Must match the template exactly — `en` and `en_US` are two different templates at Meta." }, "variables": { "type": "object", "additionalProperties": { "type": "string", "maxLength": 1024 }, "description": "Values keyed by slot id — see the variables table above. Every slot the template declares must be filled; a missing one is refused rather than sent blank, because Meta would deliver \"Hi , your order shipped\" to a real person.", "examples": [ { "body.1": "Arjit", "body.2": "SR-4471", "button.0.1": "SR-4471" } ] }, "header_media": { "type": "object", "additionalProperties": false, "required": [ "link" ], "description": "Your own file for an IMAGE/VIDEO/DOCUMENT header, replacing the one stored with the template, for this message only. Refused on a template with no media header (`HEADER_MEDIA_NOT_APPLICABLE`).", "properties": { "link": { "type": "string", "format": "uri", "maxLength": 2048, "pattern": "^https://", "description": "A public `https` URL. Meta fetches it at send time; a dead or slow link fails the message with Meta's own `meta_code`." } } } } } } } ``` **Example — A free-form reply inside the 24-hour window** ```json { "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "text", "text": { "body": "Your order has shipped." } } ``` **Example — By template name** ```json { "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "order_shipped", "language": { "code": "en" }, "variables": { "body.1": "Arjit", "button.0.1": "SR-4471" } } } ``` **Example — A one-time code** An AUTHENTICATION template takes exactly one variable, `code`. Meta owns the wording of the message. The code is masked before it is written to our database, so it appears in no report and no log. ```json { "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "login_code", "language": { "code": "en" }, "variables": { "code": "483920" } } } ``` **Example — A media-header template** An IMAGE/VIDEO/DOCUMENT header needs no value from you — omit it from `variables` entirely. The platform attaches the file you uploaded when creating the template, resolving a fresh Meta media id if the cached one has expired. To send your own file instead, add `header_media` — see the next example. ```json { "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "shipping_update_with_photo", "language": { "code": "en" }, "variables": { "body.1": "Arjit" } } } ``` **Example — Media header + body + URL button — your own file** A DOCUMENT-header template with body variables and a URL-button variable, sending the caller's own PDF for this one message. The template is `Hi {{1}}, your invoice for order {{2}} is attached.` with a URL button `https://shop.example.com/orders/{{1}}`. `header_media` sits beside `variables`, not inside it. The link must be a public `https` URL whose file type matches the header (JPEG/PNG, MP4/3GPP, or PDF); Meta fetches it at send time. Required when `GET /templates` shows `header_media.has_stored_file: false`. ```json { "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "order_invoice", "language": { "code": "en" }, "variables": { "body.1": "Arjit", "body.2": "SR-4471", "button.0.1": "SR-4471" }, "header_media": { "link": "https://files.example.com/invoices/SR-4471.pdf" } } } ``` **Example — A location-header template** A LOCATION header takes one variable, at `header.location`, as a JSON **string** (not an object). A malformed value — not valid JSON, or missing `latitude`/`longitude` — is refused with `MESSAGE_VARIABLES_INCOMPLETE`, naming the slot. ```json { "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "store_nearby", "language": { "code": "en" }, "variables": { "header.location": "{\"latitude\":\"12.9716\",\"longitude\":\"77.5946\",\"name\":\"Store\"}" } } } ``` **Example — A URL button that isn't the template's first button** This template's buttons are, in order, a quick reply and then a URL button — so the URL button is at position **1**, not 0, and its variable is `button.1.1`. The number always counts every button on the template, never just the URL ones. ```json { "messaging_product": "whatsapp", "phone_number_id": "106540352242922", "to": "+919876543210", "type": "template", "template": { "name": "order_shipped_with_options", "language": { "code": "en" }, "variables": { "body.1": "Arjit", "button.1.1": "SR-4471" } } } ``` | Status | Meaning | | --- | --- | | `200` | Meta accepted the message. | | `401` | The API key is missing, malformed, revoked, or suspended. | | `402` | The plan quota is used up, or the subscription has lapsed. | | `403` | This plan does not include API access. | | `404` | No such template, or no stored file for its media header. | | `409` | The number or the template cannot send right now. | | `422` | The body failed validation, a template variable is missing, or `header_media` was sent for a template with no media header. | | `429` | Too many requests on this key. See `Retry-After`. | | `502` | Meta refused the message, or the media upload it needed. See `meta_code`. | ### GET /messages/{id} **Look up a message** The delivery state of one message. Accepts either the `wamid` a send returned or our own `message_id`. Status moves `queued` → `sent` → `delivered` → `read`, or to `failed`. It only ever moves forward: a late webhook cannot walk it back. | Parameter | In | Required | Description | | --- | --- | --- | --- | | `id` | path | yes | A `wamid.…` or a `message_id`. | **Request** ```bash curl -X GET https://api.waagent.in/v1/messages/wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSN0QxRjhCMzk2NkE1RDcyRTVFAA== \ -H "Authorization: Bearer wa_live_YOUR_KEY" ``` | Status | Meaning | | --- | --- | | `200` | The message. | | `401` | The API key is missing, malformed, revoked, or suspended. | | `403` | This plan does not include API access. | | `404` | No message with that id on this account. | | `429` | Too many requests on this key. | ### GET /media/{id} **Download an inbound attachment** The photo, document, audio or video a contact sent. This is the `media.url` of a `message.received` webhook event, and `{id}` is that event's `media.id`. The link is stable for as long as the message is retained (30 days), so a late retry of the webhook can still use it — unlike WhatsApp's own media links, which expire within minutes and need a token. Authenticate with your API key, as for every other endpoint. Usually answers `302` to a short-lived signed download link: **follow the redirect, and do not forward your API key to it** (most HTTP clients already strip `Authorization` across origins). Before we have stored our own copy it answers `200` with the file itself. Both are correct; handle both. | Parameter | In | Required | Description | | --- | --- | --- | --- | | `id` | path | yes | The `media.id` from a `message.received` event. | **Request** ```bash curl -X GET https://api.waagent.in/v1/media/65f1c0a2b7d4e83a9c1f0b22 \ -H "Authorization: Bearer wa_live_YOUR_KEY" ``` | Status | Meaning | | --- | --- | | `200` | The file, when it is served directly. | | `302` | A short-lived signed link to the file. Follow it. | | `401` | The API key is missing, malformed, revoked, or suspended. | | `403` | This plan does not include API access. | | `404` | No attachment with that id on this account. | | `409` | Your WhatsApp access was removed. Reconnect your number; do not retry. | | `410` | The attachment is no longer available. | | `413` | The attachment is over 16 MB and not stored yet. | | `429` | Too many requests on this key. | ### GET /numbers **List your WhatsApp numbers** Every number on your account, connected or not. `phone_number_id` is the field `POST /messages` and `GET /numbers` itself both key on. **Request** ```bash curl -X GET https://api.waagent.in/v1/numbers \ -H "Authorization: Bearer wa_live_YOUR_KEY" ``` | Status | Meaning | | --- | --- | | `200` | Your numbers. | | `401` | The API key is missing, malformed, revoked, or suspended. | | `403` | This plan does not include API access. | | `429` | Too many requests on this key. | ### GET /templates **List your templates** Your templates with `variables` and `buttons` already computed — see "Numbers and templates" above. Unfiltered and unpaged is the common case for a typical WABA's template catalogue. | Parameter | In | Required | Description | | --- | --- | --- | --- | | `status` | query | no | Only templates in this status — `APPROVED` for "what can I send". | | `waba_id` | query | no | Only templates on this WABA. From `GET /numbers`' `waba_id`. | | `page` | query | no | | | `page_size` | query | no | | **Request** ```bash curl -X GET https://api.waagent.in/v1/templates \ -H "Authorization: Bearer wa_live_YOUR_KEY" ``` | Status | Meaning | | --- | --- | | `200` | Your templates. | | `401` | The API key is missing, malformed, revoked, or suspended. | | `403` | This plan does not include API access. | | `422` | A query parameter failed validation. | | `429` | Too many requests on this key. | ### GET /templates/{id} **Look up a template** One template, by our own id (the `id` field `GET /templates` returns). | Parameter | In | Required | Description | | --- | --- | --- | --- | | `id` | path | yes | | **Request** ```bash curl -X GET https://api.waagent.in/v1/templates/{id} \ -H "Authorization: Bearer wa_live_YOUR_KEY" ``` | Status | Meaning | | --- | --- | | `200` | The template. | | `401` | The API key is missing, malformed, revoked, or suspended. | | `403` | This plan does not include API access. | | `404` | No template with that id on this account. | | `429` | Too many requests on this key. | ### POST /leads **Create a lead from your website's form** For website integrations only. Authenticate with a **lead key**, never an API key — a lead key (`wa_lead_…`) can only ever create a lead, so it is safe to use from a browser or a form-builder tool, unlike your messaging API key. Create one under **Developer → Website Leads** in the customer portal. The contact lands in your Contacts with source `website`. The response never says whether the contact was new or already existed, by design. **Request** ```bash curl -X POST https://api.waagent.in/v1/leads \ -H "Authorization: Bearer wa_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Arjit", "phone": "9876543210" }' ``` **Request body** ```json { "type": "object", "required": [ "name", "phone" ], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 120 }, "phone": { "type": "string", "description": "Loosely accepted here and normalised on our side; an unusable number comes back as a 422 your form can show the visitor.", "examples": [ "9876543210" ] } } } ``` **Example — lead** ```json { "name": "Arjit", "phone": "9876543210" } ``` | Status | Meaning | | --- | --- | | `202` | Accepted. Deliberately generic — the same body whether this created a new contact or matched one you already had. | | `401` | The lead key is missing, malformed, revoked, or suspended. | | `403` | This key's origin allow-list does not include the caller. | | `422` | The body failed validation, or the phone is unusable. | | `429` | Too many requests on this key, or from this IP. | ### GET /openapi.json **This document** The machine-readable spec. Import the URL into Postman or Insomnia. **Request** ```bash curl -X GET https://api.waagent.in/v1/openapi.json \ -H "Authorization: Bearer wa_live_YOUR_KEY" ``` | Status | Meaning | | --- | --- | | `200` | The OpenAPI document. | ## Webhooks ### Event message.received **A contact messaged you** Sent when a contact sends a message to one of your numbers. Subscribe to it when you set up your webhook. | Header | Description | | --- | --- | | `X-WAAgent-Event` | The event type — the same as `type` in the body. | | `X-WAAgent-Delivery` | The event `id`. **Identical on every retry of the same event**, so it is the key to de-duplicate on: you can receive the same event more than once. | | `X-WAAgent-Timestamp` | Unix seconds when this attempt was signed. Reject requests more than 5 minutes from your clock. | | `X-WAAgent-Signature` | `v1=` plus the hex HMAC-SHA256 of `{timestamp}.{raw body}` keyed with your signing secret. During the 24 hours after a secret rotation there are two comma-separated `v1=` values — accept the request if either matches. | **Example — message.received** ```json { "id": "msg_in_65f1c0a2b7d4e83a9c1f0b22", "type": "message.received", "created_at": "2026-10-26T10:00:00.000Z", "data": { "message": { "id": "65f1c0a2b7d4e83a9c1f0b22", "wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgg", "type": "image", "text": "Is this the right size?", "timestamp": "2026-10-26T10:00:00.000Z", "reply_to_wamid": null, "location": null, "contacts": null }, "from": { "phone": "+919876543210", "user_id": null, "name": "Jane" }, "to": { "phone_number_id": "106540352242922" }, "media": { "id": "65f1c0a2b7d4e83a9c1f0b22", "type": "image", "mime_type": "image/jpeg", "filename": null, "url": "https://api.waagent.in/v1/media/65f1c0a2b7d4e83a9c1f0b22" } } } ``` ### Event message.status **A message you sent changed state** Sent as a message you sent **through this API** is sent, delivered, read or fails — once per change. Messages sent from campaigns or the Inbox are not announced here. | Header | Description | | --- | --- | | `X-WAAgent-Event` | The event type — the same as `type` in the body. | | `X-WAAgent-Delivery` | The event `id`. **Identical on every retry of the same event**, so it is the key to de-duplicate on: you can receive the same event more than once. | | `X-WAAgent-Timestamp` | Unix seconds when this attempt was signed. Reject requests more than 5 minutes from your clock. | | `X-WAAgent-Signature` | `v1=` plus the hex HMAC-SHA256 of `{timestamp}.{raw body}` keyed with your signing secret. During the 24 hours after a secret rotation there are two comma-separated `v1=` values — accept the request if either matches. | **Example — message.status** ```json { "id": "status_wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS_delivered", "type": "message.status", "created_at": "2026-10-26T10:00:05.000Z", "data": { "message_id": "66f3a1c2e4b0a1d2c3e4f5a6", "wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS", "status": "delivered", "timestamp": "2026-10-26T10:00:05.000Z", "recipient": { "phone": "+919876543210", "user_id": null }, "error": null } } ``` ### Event webhook.test **A test request** Sent when you save a webhook (with a `challenge` to echo) and when you press **Send test event**. Not something you subscribe to. | Header | Description | | --- | --- | | `X-WAAgent-Event` | The event type — the same as `type` in the body. | | `X-WAAgent-Delivery` | The event `id`. **Identical on every retry of the same event**, so it is the key to de-duplicate on: you can receive the same event more than once. | | `X-WAAgent-Timestamp` | Unix seconds when this attempt was signed. Reject requests more than 5 minutes from your clock. | | `X-WAAgent-Signature` | `v1=` plus the hex HMAC-SHA256 of `{timestamp}.{raw body}` keyed with your signing secret. During the 24 hours after a secret rotation there are two comma-separated `v1=` values — accept the request if either matches. | **Example — webhook.test** ```json { "id": "evt_test_0b7f2c1e-5a1d-4c88-9a43-2f4d8e6b7a10", "type": "webhook.test", "created_at": "2026-10-26T10:00:00.000Z", "data": { "message": "Verification request from WA AGENT. Respond with HTTP 200 and the JSON body {\"challenge\": \"\"}.", "challenge": "9f2c6a41d3b84e07a5c1e2f3b4d5a697" } } ```