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 Keys in your portal and create one. 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"
}
}
}'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"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