GhalaGhalaHelp Center
Back to ghala.ioghala.ioSign in
  • Getting Started
    • Ghala Documentation
    • Send Your First WhatsApp Message via API
    • Receiving Events with Webhooks
  • Commerce
    • Start Selling on WhatsApp
    • Set Up the AI Sales Agent
    • Get Paid with Snippe
  • Ai Automation
    • Configuring AI Auto-Reply for WhatsApp
    • Setting Up Human Handover Protocol
  • Campaigns Messaging
    • WhatsApp Message Templates: Complete Guide
    • Bulk WhatsApp Messaging: Complete Campaign Guide
  • Contacts Crm
    • WhatsApp Contact Management Guide
  • Best Practices
    • WhatsApp Customer Support Best Practices
    • Message Template Best Practices
  • Api Reference
    • Ghala Developer API Reference
    • Ghala API vs Meta Cloud API
    • Supported Capabilities
    • Multiple Numbers and Multi-Tenant Platforms
    • Errors and Retries
    • Limits and Quotas
    • Authentication and Access Tokens
    • Connecting and Onboarding a Number
    • Templates and Media
    • Send One-Time Codes
    • Developer Tooling
    • Versioning and Changelog

Products

  • Ghala
  • Sarufi
  • Snippe
  • Sema

Explore

  • Use Cases
  • Pricing
  • Ghala Academy
  • Blog

Developers

  • Docs
  • API Reference
  • API Quickstart
  • Webhooks Guide

Contact

  • SkyCity Mall, 9th Floor, Dar es Salaam, Tanzania
  • info@ghala.io
  • +255 699 920 009
© 2026 Neurotech Company LimitedTerms of ServicePrivacy PolicySitemap
  1. Help Center
  2. Api Reference
  3. Templates and Media

Templates and Media

v2

Find a sendable template, fill its variables, media and location headers and buttons, pick the language, and handle what Ghala and Meta refuse.

Why templates matter

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.

Create templates in the dashboard

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.

  • Category: Marketing, Utility, or Authentication. Meta reviews against the category, and a promotion submitted as Utility is rejected.
  • Variables: {{1}}, {{2}} in order, or named, like {{first_name}}. One style per template. Each variable needs a sample value for review.
  • Review: usually minutes to a few hours. A rejected template can be edited and resubmitted.
  • Authentication: Meta writes the text; you set the expiry and the button. See Send One-Time Codes.

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.

Find out what you can send

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.

Which statuses can be sent

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.

Read what a template needs

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.

Send a 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.

No variables

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.

Body variables

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.

Named body variables

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.

Text header variable

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" }]
      }
    ]
  }'

Image, video, and document headers

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.

Location header

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

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.

Multiple languages

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.
  • Asking for a language the template does not have is 400 invalid_template, and the message lists the ones it does: template 'greeting' is not available in the language 'en'. available: sw.
  • Approval is per language. English can be approved while Swahili is still in review.
  • Placeholders can differ between languages. Read 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.

What Ghala checks before Meta

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.

What Meta can still refuse

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.

Starting a conversation

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:

  • A new number becomes a contact. A template to a number the workspace does not know adds it as a contact. At the plan's contact limit that send is 402 plan_limit_reached, and nothing is sent. A text, media, or interactive message to an unknown number is 409 outside_messaging_window instead.
  • Use an Idempotency-Key. A repeat with the same key and body within 24 hours replays the first response rather than messaging the customer again.
  • The AI agent stands down for that customer, as it does for every API send.
  • Meta's messaging limit applies. How many new customers a number may message in a rolling 24 hours is Meta's tier for that number. See Limits and Quotas.

Status and rejection handling

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 rules

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 only
  • localhost, 127.0.0.1, or any private address
  • Anything behind a login, a signed cookie, or an IP allowlist
  • Signed URLs that have already expired by the time Meta fetches
  • Redirect chains that end somewhere Meta will not follow

A 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:

  • Serve from object storage with a long-lived public URL, not from your application server.
  • If you must use signed URLs, give them a generous expiry. Meta fetches asynchronously.
  • Send the same file twice by sending the same URL twice. There is no media-id reuse through Ghala.
  • 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.

Inbound media

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.

What's next

  • Send One-Time Codes: authentication templates and the copy code button
  • Ghala Developer API Reference: the send and templates endpoints
  • Errors and Retries: template and media failures
  • WhatsApp Message Templates: creating templates in the dashboard
  • Message Template Best Practices: what gets approved first time
PreviousConnecting and Onboarding a NumberNextSend One-Time Codes

On this page

  • Why templates matter
  • Create templates in the dashboard
  • Find out what you can send
  • Which statuses can be sent
  • Read what a template needs
  • Send a template
  • No variables
  • Body variables
  • Named body variables
  • Text header variable
  • Image, video, and document headers
  • Location header
  • Buttons
  • Multiple languages
  • What Ghala checks before Meta
  • What Meta can still refuse
  • Starting a conversation
  • Status and rejection handling
  • Media rules
  • Inbound media
  • What's next