Filling template variables, media headers, buttons, multiple languages, rejection handling, and the rules Meta applies to media URLs.
Outside the 24-hour messaging window, an approved template is the only thing that delivers. Everything else is refused with 409 outside_messaging_window before it reaches Meta. Templates are therefore how you start a conversation, send a reminder, confirm an order, or follow up.
Templates are created in the dashboard under Templates. The API reads them and sends them.
GET /api/v2/templates?sendable=true
Do this before sending, not after failing. The response tells you three things that matter:
{
"items": [
{
"id": "01JZ8Q4R2K7N3M5P9V1X6T0B2C",
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"status": "APPROVED",
"sendable": true,
"unsendable_reason": null,
"components": [
{
"type": "BODY",
"text": "Hi {{1}}, your order #{{2}} is confirmed and will arrive on {{3}}."
}
],
"quality_score": "GREEN",
"approved_at": "2026-07-30T11:02:00Z",
"status_changed_at": "2026-07-30T11:02:00Z"
}
],
"next_cursor": null,
"has_more": false
}
sendable is what to branch on, not status. A FLAGGED or LOCKED template still sends, and one Ghala has not synced yet is reported sendable and left to Meta to judge.unsendable_reason explains a false in words you can show a merchant.components is the approved shape. Read the BODY text to see how many {{n}} placeholders you must supply.Counting the placeholders in components rather than hard-coding them is what stops your integration breaking the day somebody edits the template.
Variables go in template_components, which is passed straight through to Meta in Meta's own component format. Parameters fill {{1}}, {{2}}, {{3}} in order.
For the template above:
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "255712345678",
"type": "template",
"template_name": "order_update",
"template_language": "en_US",
"template_components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Amina" },
{ "type": "text", "text": "1042" },
{ "type": "text", "text": "Ijumaa" }
]
}
]
}'
import os, requests
resp = requests.post(
"https://v2.ghala.io/api/v2/messages",
headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
json={
"to": "255712345678",
"type": "template",
"template_name": "order_update",
"template_language": "en_US",
"template_components": [
{
"type": "body",
"parameters": [
{"type": "text", "text": "Amina"},
{"type": "text", "text": "1042"},
{"type": "text", "text": "Ijumaa"},
],
}
],
},
)
print(resp.json())
const resp = await fetch("https://v2.ghala.io/api/v2/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: "255712345678",
type: "template",
template_name: "order_update",
template_language: "en_US",
template_components: [
{
type: "body",
parameters: [
{ type: "text", text: "Amina" },
{ type: "text", text: "1042" },
{ type: "text", text: "Ijumaa" },
],
},
],
}),
});
console.log(await resp.json());
Supplying the wrong number of parameters returns 400: the parameters do not match the approved shape.
A text header with its own {{1}}:
{
"template_components": [
{
"type": "header",
"parameters": [{ "type": "text", "text": "Oda #1042" }]
},
{
"type": "body",
"parameters": [{ "type": "text", "text": "Amina" }]
}
]
}
Header and body are counted separately: the header's {{1}} is not the body's {{1}}.
A template whose header is media takes a link at send time. The template must have been created with a media header of that type; you are supplying the file, not changing the shape.
{
"to": "255712345678",
"type": "template",
"template_name": "receipt_ready",
"template_language": "sw",
"template_components": [
{
"type": "header",
"parameters": [
{
"type": "document",
"document": {
"link": "https://example.com/receipts/1042.pdf",
"filename": "Risiti-1042.pdf"
}
}
]
},
{
"type": "body",
"parameters": [{ "type": "text", "text": "Amina" }]
}
]
}
An image header is the same with { "type": "image", "image": { "link": "..." } }, and video with video.
Meta fetches that link, so it must be public HTTPS. See the media rules below.
Quick-reply buttons carry a payload back to you when tapped; URL buttons take a dynamic suffix. Both are addressed by their zero-based index in the template.
A dynamic URL button — the template was approved with a URL like https://shop.example/orders/{{1}}:
{
"template_components": [
{
"type": "body",
"parameters": [{ "type": "text", "text": "Amina" }]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "1042" }]
}
]
}
A quick-reply button, where the payload is what comes back on message.received:
{
"type": "button",
"sub_type": "quick_reply",
"index": "0",
"parameters": [{ "type": "payload", "payload": "confirm-order-1042" }]
}
Static buttons — a fixed URL, or a phone number — need no parameters at all. Only dynamic ones appear in template_components.
Meta models each language as its own template. The same name appears once per language, so GET /api/v2/templates will return order_update in en_US and order_update in sw as two separate entries, each with its own status, sendable, and components.
That means:
template_language must match a language that template was actually submitted in. There is no fallback: asking for sw when only en_US is approved fails.components per language rather than assuming they match.A safe language picker:
/** Pick the customer's language if it is sendable, else fall back. */
async function pickTemplate(token, name, preferred, fallback = "en_US") {
const { items } = await getTemplates(token, { sendable: true });
const candidates = items.filter((t) => t.name === name);
return (
candidates.find((t) => t.language === preferred) ??
candidates.find((t) => t.language === fallback) ??
null
);
}
Returning null rather than guessing is the point: a template that is not sendable will not become sendable because you sent it anyway.
status is Meta's review state. sendable is whether a send would be accepted right now, and they are not the same question.
| What you see | What to do |
|---|---|
status: "APPROVED", sendable: true |
Send it |
status: "PENDING" |
Wait. Approval usually takes minutes to a few hours |
status: "REJECTED" |
Edit and resubmit in the dashboard. Read Message Template Best Practices first |
sendable: false with an unsendable_reason |
Show the reason; do not retry the send |
quality_score: "YELLOW" |
Customers are reacting badly. Review the content before Meta acts |
quality_score: "RED" |
Meta is about to pause this template. Stop using it and fix it now |
quality_score is the earliest warning the API gives you. A template that goes red and then paused takes a working integration down with it, so it is worth alerting on rather than merely logging.
The most common rejection cause is category mismatch: a marketing message submitted as UTILITY. Meta reviews against the category, and gets this right more often than people expect it to.
Media is sent by public HTTPS URL, and the single most important consequence is this:
Meta fetches the URL, not Ghala. A URL that works from your laptop, your server, or inside your VPC is irrelevant. It must be reachable from the open internet, anonymously.
That rules out:
http:// — HTTPS onlylocalhost, 127.0.0.1, or any private addressA failure here surfaces as 502, because Meta is the one reporting it.
Formats and size limits are Meta's, set per media type, and Meta changes them. Rather than print numbers that go stale, check Meta's WhatsApp Business Platform documentation for the current table. What is stable is the shape of the rule: images, video, audio, and documents each have their own allowed MIME types and their own maximum size, and exceeding either is a rejection rather than a truncation.
Practical advice that does not go stale:
audio takes no caption. WhatsApp does not allow one; it is how you send a voice note.document should always carry media_filename. It is what the customer sees, and without it they get something unhelpful.When a customer sends you a photo, a voice note, or a document, the message record carries the media details: a URL, its MIME type, the filename, and for voice notes a duration in milliseconds.
Inbound media is visible in the dashboard inbox. If you need to fetch media directly from Meta by media id, that requires the raw callback override — and note that Meta's media ids expire, so a download has to happen promptly rather than being deferred to a nightly job.
Uploading to Meta's media store and sending by media id is not supported through Ghala; sends take a URL.