Find a sendable template, fill its variables, media and location headers and buttons, pick the language, and handle what Ghala and Meta refuse.
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 how you start a conversation, send a reminder, confirm an order, or follow up.
The API reads templates and sends them. It does not create them.
Templates are created in the dashboard under Templates, or in Meta's WhatsApp Manager. Either way Meta reviews each one before it can be sent.
{{1}}, {{2}} in order, or named, like {{first_name}}. One style per template. Each variable needs a sample value for review.The message templates guide walks through the form, and Message Template Best Practices covers what gets approved.
A template made in WhatsApp Manager does not need a sync before you send it. When a send names a template Ghala has not seen, Ghala looks it up on WhatsApp first.
GET /api/v2/templates
Every template on the connected number, newest first, one entry per language.
curl "https://v2.ghala.io/api/v2/templates?sendable=true&limit=25" \
-H "Authorization: Bearer $ACCESS_TOKEN"
import os, requests
resp = requests.get(
"https://v2.ghala.io/api/v2/templates",
headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
params={"sendable": "true", "limit": 25},
)
print(resp.json())
const url = new URL("https://v2.ghala.io/api/v2/templates");
url.searchParams.set("sendable", "true");
url.searchParams.set("limit", "25");
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
});
console.log(await resp.json());
| Query parameter | Notes |
|---|---|
sendable |
true for templates a send would not refuse for their state, false for the ones it would. Omit for all |
cursor |
next_cursor from the previous page. A malformed cursor is 400 invalid_cursor |
limit |
Page size, 1 to 100, default 25. Outside that range is 422 validation_error |
There is no filter by name, language, or category. Read the pages and filter on your side.
If one access token is held by several of your connected numbers, add X-Phone-Number-Id to say which number's templates you want. Without it the request is 400 ambiguous_number, with the ids in phone_number_ids.
The response:
{
"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
}
| Field | What it is |
|---|---|
id |
Ghala's id. The same id arrives on the template.status event |
name |
Send it as template_name |
language |
Send it as template_language, exactly |
category |
MARKETING, UTILITY, or AUTHENTICATION |
status |
Meta's review state, below |
sendable |
Whether Ghala would let a send through for this state |
unsendable_reason |
Why sendable is false, for example was rejected by Meta |
components |
The template as Meta approved it. This is how you know what to send |
quality_score |
GREEN, YELLOW, RED, or UNKNOWN, or null before Meta rates it |
approved_at, status_changed_at |
When it was approved, and when its status last moved |
When has_more is true, pass next_cursor back as ?cursor= for the next page.
status |
sendable |
Notes |
|---|---|---|
APPROVED |
true |
Send it |
FLAGGED |
true |
Still sends. Meta is warning about quality |
LOCKED |
true |
Still sends. It cannot be edited |
SUBMITTED, DRAFT |
true |
In review, or not yet heard from Meta. Ghala lets the send through and Meta decides; a template still in review comes back as 502 |
REJECTED, IN_APPEAL |
false |
Edit and resubmit in the dashboard |
PAUSED, DISABLED |
false |
Meta stopped it after negative customer feedback |
ARCHIVED |
false |
Meta archives a template after 12 months without a send |
PENDING_DELETION, DELETED |
false |
Gone, or about to be |
LIMIT_EXCEEDED |
false |
The WhatsApp Business Account is at its template limit |
sendable is the same rule the send path enforces, so a template listed as sendable is never refused by Ghala for its state. To offer only templates Meta will accept, keep sendable: true and a status of APPROVED, FLAGGED, or LOCKED.
components uses Meta's upper-case names. Each part tells you what to put in template_components when you send:
In components |
What to send |
|---|---|
HEADER with format IMAGE, VIDEO, or DOCUMENT |
A header parameter with that media, by link. Required |
HEADER with format LOCATION |
A header parameter with a latitude and longitude. Required |
HEADER with format TEXT and a {{1}} |
A header text parameter |
BODY text with {{1}}, {{2}} |
One text parameter per number, in order |
BODY text with {{first_name}} |
One text parameter per name, each with parameter_name |
BUTTONS with a URL button whose url has {{1}} |
A button parameter for that button's position |
BUTTONS with a QUICK_REPLY button |
Optionally, a payload for that button's position |
BUTTONS with a static URL or PHONE_NUMBER button |
Nothing |
A button with otp_type, or category AUTHENTICATION |
The one-time code. See Send One-Time Codes |
Count the placeholders from components rather than hard-coding them, so your integration keeps working when somebody edits the template.
POST /api/v2/messages with type: "template":
| Field | Notes |
|---|---|
template_name |
Required |
template_language |
Required. The exact language from the list |
template_components |
The parameters, in Meta's send format. Leave it out for a template with no variables |
Component and parameter type values are lower case in the send format: header, body, button, text, image.
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-255712345678" \
-d '{
"to": "255712345678",
"type": "template",
"template_name": "hello_world",
"template_language": "en_US"
}'
Every template send returns 200 with the recorded message:
{
"id": "01JZ8Q4R2K7N3M5P9V1X6T0B2C",
"direction": "OUTBOUND",
"message_type": "template",
"content": "[template:hello_world]",
"status": "SENT",
"source": "HUMAN",
"wa_message_id": "wamid.HBgM...",
"media_url": null,
"media_mime_type": null,
"media_filename": null,
"media_duration_ms": null,
"interactive": null,
"referral": null,
"sent_at": "2026-10-01T09:14:22Z",
"delivered_at": null,
"read_at": null,
"played_at": null,
"failed_at": null,
"failure_reason": null,
"template_name": "hello_world",
"redacted": false,
"created_at": "2026-10-01T09:14:22Z"
}
The examples below return the same shape, with their own template_name and content of [template:<name>]. 200 means WhatsApp accepted the message; the message.status event reports delivered, read, or failed. The message.sent event carries the wording as it went out, filled with your values.
For the order_update template above, with {{1}}, {{2}} and {{3}}:
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-confirmation" \
-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']}",
"Idempotency-Key": "order-1042-confirmation",
},
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",
"Idempotency-Key": "order-1042-confirmation",
},
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());
Parameters fill {{1}}, {{2}}, {{3}} in order. Fewer parameters than the highest placeholder is 400 invalid_template, for example this template body needs 3 parameters, got 1.
A template written with named placeholders, such as Hi {{first_name}}, your order {{order_id}} is ready., takes each value with its parameter_name:
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-ready" \
-d '{
"to": "255712345678",
"type": "template",
"template_name": "order_ready",
"template_language": "en",
"template_components": [
{
"type": "body",
"parameters": [
{ "type": "text", "parameter_name": "first_name", "text": "Amina" },
{ "type": "text", "parameter_name": "order_id", "text": "1042" }
]
}
]
}'
Every name in the template must be sent, and no other. A missing name, an unknown name, or a parameter without parameter_name is 400 invalid_template, and the message names the parameter that is wrong or missing. A numbered template refuses parameter_name.
A text header with its own {{1}}, counted separately from the body's:
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"to": "255712345678",
"type": "template",
"template_name": "order_shipped",
"template_language": "sw",
"template_components": [
{
"type": "header",
"parameters": [{ "type": "text", "text": "Oda #1042" }]
},
{
"type": "body",
"parameters": [{ "type": "text", "text": "Amina" }]
}
]
}'
A template created with a media header needs that media at send time. You supply the file; the type is fixed by the template.
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: receipt-1042" \
-d '{
"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": "https://example.com/product.jpg" } }, and a video header with { "type": "video", "video": { "link": "https://example.com/demo.mp4" } }.
Send media by link. Ghala's check also accepts Meta's media id in place of link, but Ghala has no way to upload a file to Meta, so a link is the supported path. Meta fetches the link, so it must be public HTTPS; see the media rules below.
Leaving the header out, or sending the wrong media type, is 400 invalid_template, for example this template's header needs an image. add a header parameter with the image link.
A template created with a location header needs a latitude and a longitude; a name and an address are optional:
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pickup-1042" \
-d '{
"to": "255712345678",
"type": "template",
"template_name": "pickup_point",
"template_language": "sw",
"template_components": [
{
"type": "header",
"parameters": [
{
"type": "location",
"location": {
"latitude": "-6.7924",
"longitude": "39.2083",
"name": "Duka la Amina",
"address": "Mtaa wa Samora, Dar es Salaam"
}
}
]
},
{
"type": "body",
"parameters": [{ "type": "text", "text": "1042" }]
}
]
}'
Without both coordinates it is 400 invalid_template: this template's header needs a location. add a header parameter with latitude and longitude.
Buttons are addressed by their zero-based index in the template's BUTTONS list. Only buttons that take a value go in template_components; a static link or a phone number needs nothing.
A dynamic URL button, approved with a URL like https://shop.example/orders/{{1}}:
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: track-1042" \
-d '{
"to": "255712345678",
"type": "template",
"template_name": "track_order",
"template_language": "en_US",
"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 with your own payload:
curl -X POST https://v2.ghala.io/api/v2/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: confirm-1042" \
-d '{
"to": "255712345678",
"type": "template",
"template_name": "confirm_order",
"template_language": "en_US",
"template_components": [
{
"type": "body",
"parameters": [{ "type": "text", "text": "1042" }]
},
{
"type": "button",
"sub_type": "quick_reply",
"index": "0",
"parameters": [{ "type": "payload", "payload": "confirm-order-1042" }]
}
]
}'
When the customer taps it, message.received carries the button text followed by your payload in brackets, for example Confirm [confirm-order-1042].
Ghala does not check button parameters before sending. A missing or wrong one comes back from Meta as 502 send_failed.
A copy code button belongs to an authentication template. Send the code as described in Send One-Time Codes.
Meta models each language as its own template. The same name appears once per language, each with its own status, sendable, and components.
template_language must be the exact code the template was approved in. en and en_US are different languages, and there is no fallback.400 invalid_template, and the message lists the ones it does: template 'greeting' is not available in the language 'en'. available: sw.components per language.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.
Ghala checks a template send against its synced copy of the template before anything reaches WhatsApp. Nothing is sent when one of these fails.
| Problem | Status | code |
|---|---|---|
template_name or template_language missing |
422 |
validation_error |
| The template is not on the number's WhatsApp account, or WhatsApp could not be asked | 422 |
template_not_found |
| The template exists, but not in that language | 400 |
invalid_template |
| The template is rejected, paused, disabled, archived, in appeal, deleted, being deleted, or over the account's limit | 400 |
invalid_template |
| Fewer body or header parameters than placeholders | 400 |
invalid_template |
A named template without the right parameter_name values, or a numbered one with them |
400 |
invalid_template |
| A media or location header left out | 400 |
invalid_template |
| An authentication template without a valid code | 422 |
invalid_one_time_code |
The message names what is wrong, for example template 'promo' cannot be sent: it is paused by Meta after negative customer feedback. These are not retryable; fix the request.
Ghala does not check extra parameters, button parameters, or the length of a value. Meta does.
When WhatsApp refuses a template send, the API answers 502 send_failed with the reason in message:
message |
What to do |
|---|---|
| The message didn't match the approved template structure. | Compare your parameters with components |
| Template not found or not approved yet. | Wait for approval, or check the name and language |
| Template parameter format is invalid. | Check the parameter types and names |
| This message is too long once this person’s details are filled in. | Shorten the values |
| The template was rejected by Meta's policy review. | Edit and resubmit in the dashboard |
| The template is paused by Meta. | Wait, or use another template |
| The template is disabled by Meta. | Use another template |
| Payment issue. Update billing in Meta Business settings. | Fix billing in Meta Business settings |
Some refusals arrive later instead, as a failed message.status event with the reason. Branch on the status and code, and log the message for a person to read; its wording can change. When WhatsApp is rate-limiting the number, the answer is 429 rate_limited instead: back off and retry with the same Idempotency-Key.
Errors and Retries has the full catalogue and which failures are safe to retry.
A template is the one message you can send to a customer whose 24-hour window is closed, or who has never written to you. Some rules apply only to that kind of send:
402 plan_limit_reached, and nothing is sent. A text, media, or interactive message to an unknown number is 409 outside_messaging_window instead.Idempotency-Key. A repeat with the same key and body within 24 hours replays the first response rather than messaging the customer again.status is Meta's review state. sendable is whether Ghala lets a send through, and they are not the same question.
| What you see | What to do |
|---|---|
status: "APPROVED", sendable: true |
Send it |
status: "SUBMITTED" |
Wait. Review 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 |
Subscribe to the template.status event to hear about a change as it happens, with Meta's reason and the quality score. A template that goes red and then paused takes a working integration down with it, so alert on it rather than only logging it.
The most common rejection cause is category mismatch: a marketing message submitted as UTILITY.
Media is sent by public HTTPS URL, and the 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 send_failed, because Meta is the one reporting it.
Formats and size limits are Meta's, set per media type, and Meta changes them. Check Meta's WhatsApp Business Platform documentation for the current table. Images, video, audio, and documents each have their own allowed types and 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 a file name (media_filename, or filename in a template header). It is what the customer sees.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 file name, and for voice notes a duration in milliseconds.
Inbound media is visible in the dashboard inbox. Fetching media directly from Meta by media id requires the raw callback override, and Meta's media ids expire, so a download has to happen promptly.
Uploading to Meta's media store and sending by media id is not supported through Ghala; sends take a URL.