Send a template message
This guide sends one WhatsApp template message from your server and follows it until it is read.
A template is a message that Meta approved in advance. You need a template to start a conversation, or to write to someone who has not written to you in the last 24 hours.
You need an API key with the send scope. See Authentication. The examples
read the key from the environment variable SANADWA_API_KEY.
Step 1: Find your phone number id
Section titled “Step 1: Find your phone number id”The phone_number_id is the id of the WhatsApp number you send from. Get it once and store it
in your configuration. In the console it is called the Sender ID
(Settings → Developers → Senders).
curl https://api.sanadwa.com/v1/phone-numbers \ -H "Authorization: Bearer $SANADWA_API_KEY"const res = await fetch("https://api.sanadwa.com/v1/phone-numbers", { headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}` },});const { data } = await res.json();for (const n of data) console.log(n.phone_number_id, n.display_phone_number, n.verified_name);import osimport requests
res = requests.get( "https://api.sanadwa.com/v1/phone-numbers", headers={"Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}"}, timeout=30,)for n in res.json()["data"]: print(n["phone_number_id"], n["display_phone_number"], n["verified_name"])<?php$ch = curl_init("https://api.sanadwa.com/v1/phone-numbers");curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SANADWA_API_KEY")], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,]);foreach (json_decode(curl_exec($ch), true)["data"] as $n) { echo $n["phone_number_id"], " ", $n["display_phone_number"], PHP_EOL;}{ "success": true, "data": [ { "phone_number_id": "100000000000001", "display_phone_number": "+1 202-555-0100", "verified_name": "Example Store", "name_status": "APPROVED", "new_name_status": null, "waba_id": "200000000000002", "quality_rating": "GREEN", "status": "CONNECTED" } ]}The list has every active number in your account. The values of name_status, quality_rating
and status come from WhatsApp (Meta). waba_id is the id of the WhatsApp Business Account
that the number belongs to. Templates belong to a WhatsApp Business Account, so a number can only
send the templates of its own account.
If the number is not ready to send yet (the console’s Senders panel shows Not ready to send),
the API accepts your request but WhatsApp refuses the message with send_failed.
Step 2: Find an approved template
Section titled “Step 2: Find an approved template”List the templates that this number can send. Pass the phone_number_id from step 1:
curl "https://api.sanadwa.com/v1/templates?phone_number_id=100000000000001&limit=200" \ -H "Authorization: Bearer $SANADWA_API_KEY"const url = new URL("https://api.sanadwa.com/v1/templates");url.searchParams.set("phone_number_id", "100000000000001");url.searchParams.set("limit", "200");const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}` },});const { data } = await res.json();for (const t of data) console.log(t.name, t.language, t.category);res = requests.get( "https://api.sanadwa.com/v1/templates", params={"phone_number_id": "100000000000001", "limit": 200}, headers={"Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}"}, timeout=30,)for t in res.json()["data"]: print(t["name"], t["language"], t["category"])<?php$query = http_build_query(["phone_number_id" => "100000000000001", "limit" => 200]);$ch = curl_init("https://api.sanadwa.com/v1/templates?$query");curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SANADWA_API_KEY")], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,]);foreach (json_decode(curl_exec($ch), true)["data"] as $t) { echo $t["name"], " ", $t["language"], " ", $t["category"], PHP_EOL;}{ "success": true, "page": 1, "limit": 200, "data": [ { "name": "order_update", "language": "en", "category": "UTILITY", "status": "APPROVED", "waba_id": "200000000000002", "components": [ { "type": "BODY", "text": "Hello {{1}}, your order {{2}} has shipped." } ] } ]}The list has only templates that Meta has approved. You can also search by name or language
with q, for example ?q=order. If you have many templates, read all pages; see
Pagination.
From the template, note:
nameandlanguage: you send both.- The variables in the text:
{{1}},{{2}}, and so on. You send one value for each. category:MARKETING,UTILITYorAUTHENTICATION. It affects the price and the rules. For example, people who opted out of marketing cannot receiveMARKETINGtemplates.
Step 3: Send the message
Section titled “Step 3: Send the message”Send POST /messages/template with:
| Field | Required | Meaning |
|---|---|---|
phone_number_id |
Yes | The number from step 1. |
to_phone |
Yes | The recipient, in E.164 format, for example +12025550123. |
template_name |
Yes | The template’s name. |
template_language |
No, but send it | The template’s language. If you leave it out, the API uses ar. |
template_params |
If the body has variables | A list of strings, one for each body variable, in order: the first value fills {{1}}. |
template_components |
No | Advanced: header or button variables. See Header and button variables. |
Always send an Idempotency-Key header, so that a retry cannot send the message twice.
See Idempotency.
curl https://api.sanadwa.com/v1/messages/template \ -H "Authorization: Bearer $SANADWA_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c2a8e-3b7d-4c55-9a1e-2d4f6b8c0e12" \ -d '{ "phone_number_id": "100000000000001", "to_phone": "+12025550123", "template_name": "order_update", "template_language": "en", "template_params": ["Sara", "A-1042"] }'import { randomUUID } from "node:crypto";
const idempotencyKey = randomUUID(); // store it with your order, reuse it on retriesconst res = await fetch("https://api.sanadwa.com/v1/messages/template", { method: "POST", headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ phone_number_id: "100000000000001", to_phone: "+12025550123", template_name: "order_update", template_language: "en", template_params: ["Sara", "A-1042"], }),});const body = await res.json();if (!body.success) throw new Error(`${res.status} ${body.code}: ${body.message}`);console.log("WhatsApp message id:", body.message_id);import osimport uuidimport requests
idempotency_key = str(uuid.uuid4()) # store it with your order, reuse it on retriesres = requests.post( "https://api.sanadwa.com/v1/messages/template", headers={ "Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}", "Idempotency-Key": idempotency_key, }, json={ "phone_number_id": "100000000000001", "to_phone": "+12025550123", "template_name": "order_update", "template_language": "en", "template_params": ["Sara", "A-1042"], }, timeout=30,)body = res.json()if not body["success"]: raise RuntimeError(f"{res.status_code} {body['code']}: {body['message']}")print("WhatsApp message id:", body["message_id"])<?php$idempotencyKey = bin2hex(random_bytes(16)); // store it with your order, reuse it on retries$ch = curl_init("https://api.sanadwa.com/v1/messages/template");curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("SANADWA_API_KEY"), "Content-Type: application/json", "Idempotency-Key: " . $idempotencyKey, ], CURLOPT_POSTFIELDS => json_encode([ "phone_number_id" => "100000000000001", "to_phone" => "+12025550123", "template_name" => "order_update", "template_language" => "en", "template_params" => ["Sara", "A-1042"], ]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,]);$body = json_decode(curl_exec($ch), true);if (!$body["success"]) { throw new RuntimeException($body["code"] . ": " . $body["message"]);}echo "WhatsApp message id: ", $body["message_id"], PHP_EOL;A 200 response means WhatsApp accepted the message. It does not yet mean the message was
delivered.
{ "success": true, "message_id": "wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI", "accepted_at": "2026-11-11T08:00:01.000Z", "upstream": { "messaging_product": "whatsapp", "contacts": [{ "input": "+12025550123", "wa_id": "12025550123" }], "messages": [{ "id": "wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI" }] }}Store message_id with your own record. It is the WhatsApp message id; status updates refer
to it. upstream is the response that SanadWA received from WhatsApp.
Common errors at this step:
| Code | Why | What to do |
|---|---|---|
validation_error (400) |
A required field is missing or has the wrong type. | Read message; it names the field. |
phone_not_found (404) |
The phone_number_id is not an active number in your account. |
Repeat step 1. |
PREPAID_CREDIT_CAP_REACHED (402) |
Not enough credits. | Buy credits in the console. |
MARKETING_SUPPRESSED (409) |
The recipient does not receive marketing from you. | Do not send marketing to this person. |
send_failed (varies) |
WhatsApp refused the message, for example because the template name, language or number of variables is wrong. | Read message and meta_error. |
The full list is on the Errors page.
Header and button variables
Section titled “Header and button variables”template_params fills only the variables in the template’s body. If your template has
variables in the header or in buttons, send template_components instead. It is passed to
WhatsApp unchanged, in the format of the
WhatsApp Cloud API.
When you send template_components, template_params is ignored, so include the body variables
in template_components too.
{ "phone_number_id": "100000000000001", "to_phone": "+12025550123", "template_name": "order_shipped_with_header", "template_language": "en", "template_components": [ { "type": "header", "parameters": [{ "type": "text", "text": "A-1042" }] }, { "type": "body", "parameters": [{ "type": "text", "text": "Sara" }] } ]}For an authentication template (a one-time code with a copy-code button), send the code as
the first value of template_params. SanadWA also puts it into the button for you.
Step 4: Check the status
Section titled “Step 4: Check the status”After WhatsApp accepts the message, it reports what happens to it: delivered, read, or failed. SanadWA records each report. You can ask for the current status, or be told when it changes.
Look it up
Section titled “Look it up”Call GET /messages/{id} with the message_id from step 3:
curl https://api.sanadwa.com/v1/messages/wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI \ -H "Authorization: Bearer $SANADWA_API_KEY"const id = "wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI"; // message_id from step 3const res = await fetch(`https://api.sanadwa.com/v1/messages/${encodeURIComponent(id)}`, { headers: { Authorization: `Bearer ${process.env.SANADWA_API_KEY}` },});const { data } = await res.json();console.log(data.status, data.delivered_at, data.read_at, data.error);from urllib.parse import quote
message_id = "wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI" # message_id from step 3res = requests.get( f"https://api.sanadwa.com/v1/messages/{quote(message_id, safe='')}", headers={"Authorization": f"Bearer {os.environ['SANADWA_API_KEY']}"}, timeout=30,)data = res.json()["data"]print(data["status"], data["delivered_at"], data["read_at"], data["error"])<?php$messageId = "wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI"; // message_id from step 3$ch = curl_init("https://api.sanadwa.com/v1/messages/" . rawurlencode($messageId));curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SANADWA_API_KEY")], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,]);$data = json_decode(curl_exec($ch), true)["data"];echo $data["status"], PHP_EOL;{ "success": true, "data": { "id": 98765, "provider_msg_id": "wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI", "status": "delivered", "sent_at": "2026-11-11T08:00:01.000Z", "template_name": "order_update", "to_phone": "+12025550123", "channel": "whatsapp", "provider": "waba", "error": null, "delivered_at": "2026-11-11T08:00:04.000Z", "read_at": null, "failed_at": null, "last_status": "delivered" }}status of a message sent with this API:
status |
Meaning |
|---|---|
sent |
WhatsApp accepted the message. This is the status right after the send call. |
delivered |
WhatsApp reported that the message reached the recipient’s phone. delivered_at is set. |
read |
WhatsApp reported that the recipient opened it. read_at is set. |
failed |
WhatsApp reported that it could not deliver the message. error holds WhatsApp’s reason. |
Good to know:
- The status changes only when WhatsApp’s report reaches SanadWA. Reports can come late, for example when the recipient’s phone is switched off. Do not expect a fixed delay.
- The status only moves forward:
sent, thendelivered, thenread. A late or repeated report never moves it back, and a message that was delivered never becomesfailed. - For messages sent with this API,
failed_atis alwaysnull. A failure shows asstatusfailedwitherror.last_statusis the same asstatus. - You can also look up messages sent from the console inbox and from campaigns. Messages that customers sent to you are not returned.
- Any id that does not start with
wamid., or that is not in your account, answers404 message_not_found. - Every lookup counts toward your rate limit. Do not check the same message every second; check now and then, or use webhooks.
Be told when it changes
Section titled “Be told when it changes”Instead of asking again and again, set up a webhook. Subscribe to these events:
| Event | Meaning |
|---|---|
message.sent |
WhatsApp accepted the message (sent right after a successful API call). |
message.delivered |
The message reached the recipient’s phone. |
message.read |
The recipient opened it. |
message.failed |
It could not be delivered. data.error says why. |
In the status events, match data.provider_msg_id to the message_id you stored in step 3:
// inside your webhook handler, after checking the signatureif (["message.delivered", "message.read", "message.failed"].includes(event.event)) { const order = await findOrderByWhatsappId(event.data.provider_msg_id); await order.updateWhatsappStatus(event.event, event.data);}Reply to the customer
Section titled “Reply to the customer”When the customer answers, you receive an inbound.message webhook. For the next 24 hours you may
reply with free-form text using POST /messages/text, without a template:
curl https://api.sanadwa.com/v1/messages/text \ -H "Authorization: Bearer $SANADWA_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "phone_number_id": "100000000000001", "to_phone": "+12025550123", "text": "Thank you, Sara. Your order arrives tomorrow." }'After 24 hours without a message from the customer, this returns
409 customer_service_window_closed, and you must send a template again.