Developer docs
Send WhatsApp messages from your own software
One HTTP call sends an approved template to a customer. No SDK to install, no queue to run — if your code can make a POST request, it can send WhatsApp.
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:
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.
Authorization: Bearer wa_live_YOUR_KEYSend 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 -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"
}
}
}'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 -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"
}
}
}'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 -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.
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.
// 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"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.<N>.<token> 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.
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.
{
"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 https://api.waagent.in/v1/messages/wamid.HBgM… \
-H "Authorization: Bearer wa_live_YOUR_KEY"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:
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": "<that value>"}. 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.
Both examples call `verifyWebhook` / `verify_webhook`, defined in the next section.
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);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:
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. |
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);
});
}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.
{
"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:
{
"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
}
}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 -L https://api.waagent.in/v1/media/65f1c0a2b7d4e83a9c1f0b22 \
-H "Authorization: Bearer wa_live_YOUR_KEY" \
-o photo.jpgRetries, 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": "<value from data.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": {
"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.
Everything else
Every field, every error code and a runnable example per endpoint are in the API reference. It is generated from the API itself, so it cannot drift out of date — and the same specification imports straight into Postman.
Open the API reference