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
    • 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

Filling template variables, media headers, buttons, multiple languages, rejection handling, and the rules Meta applies to media URLs.

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

Find out what you can send

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.

Filling variables

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.

Body variables

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.

Header variables

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}}.

Image and document headers

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.

Buttons

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.

Multiple languages

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.
  • Approval is per language. English can be approved while Swahili is still pending or rejected.
  • Placeholder counts can differ between languages if the translations were written that way. Read 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 and rejection handling

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 rules

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

  • Serve from object storage with a long-lived public URL, not from your application server.
  • If you must use signed URLs, give them 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 media_filename. It is what the customer sees, and without it they get something unhelpful.

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

What's next

  • Ghala Developer API Reference — the send and templates endpoints
  • Errors and Retries — template and media failures
  • Message Template Best Practices — what gets approved first time
PreviousConnecting and Onboarding a NumberNextDeveloper Tooling

On this page

  • Why templates matter
  • Find out what you can send
  • Filling variables
  • Body variables
  • Header variables
  • Image and document headers
  • Buttons
  • Multiple languages
  • Status and rejection handling
  • Media rules
  • Inbound media
  • What's next