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. Ghala Developer API Reference

Ghala Developer API Reference

v2

Every endpoint, parameter, and error code in the Ghala Developer API, with cURL, Python, and JavaScript examples.

Overview

The Ghala Developer API automates a connected WhatsApp number. With it you can:

  • Send WhatsApp messages: text, media, interactive menus, and approved templates
  • Read the templates the number is allowed to send, and why one is refused
  • Receive what happens next on your own endpoint, as signed event deliveries
  • Retry safely with idempotency keys

Base URL:

https://v2.ghala.io

Every path below is prefixed with /api/v2. That prefix is the API version, and it is the only version currently served: /api/v1 is Ghala's internal dashboard API, not a public developer surface, and it is not supported for integrations.

Request examples come in cURL, Python, and JavaScript; pick your language once and every example on the page follows. Every block has a copy button, and Copy for AI (top of the page) copies this whole reference as markdown. No SDK required.

The machine-readable spec is served live at https://v2.ghala.io/api/v2/openapi.json, with a browsable version at /api/v2/docs. See Developer Tooling for the Postman collection and runnable examples.

What this API does and does not cover

Ghala sits on top of the Meta WhatsApp Cloud API and deliberately exposes a narrow surface: the things an integration needs in order to send and to react. Account management, template authoring, catalogues, and campaigns live in the dashboard.

Two endpoints are served today:

Endpoint Purpose
POST /api/v2/messages Send a message
GET /api/v2/templates List the templates this number can send

Everything else, from connecting numbers and creating templates to registering event endpoints, contacts, campaigns, and orders, is dashboard work. Ghala API vs Meta Cloud API explains the split and why, and the capability matrix is the per-feature answer.

Authentication

Requests are authenticated with the connected number's WhatsApp access token, sent as a bearer token:

Authorization: Bearer YOUR_ACCESS_TOKEN

Copy it from Dashboard → Developer → Credentials, where the token for each connected number can be revealed and copied.

Your token authenticates exactly one connected number. Every endpoint is scoped to it; there is no account-level key that reaches across numbers. If you manage several numbers, you hold several tokens. See Multiple Numbers and Multi-Tenant Platforms.

This is the same secret Ghala uses to send on your behalf. It is issued by Meta. Keep it server-side, never in browser or mobile code and never in git. Reconnecting the number issues a new token and invalidates the old one, so an integration will start returning 401 after a reconnect until you update it.

A missing token and an unknown token both return the same flat 401 not_authenticated, so the API cannot be used to probe which tokens exist.

Token lifecycle, rotation, and what to do about a leaked token are covered in Authentication and Access Tokens.

Plan requirement

The team's plan must grant the API access entitlement. Without it every call returns:

{ "code": "plan_feature_locked", "message": "Your plan does not include API access" }

with status 402. Upgrade under Settings → Billing.

Response shape

Successful responses are the object itself, not an envelope. POST /api/v2/messages returns a message; GET /api/v2/templates returns a cursor page:

{
  "items": [],
  "next_cursor": "01JZ8Q4R2K7N3M5P9V1X6T0B2C",
  "has_more": true
}

Pass next_cursor back as ?cursor= to read the following page. limit defaults to 25 and caps at 100.

Errors

Errors are a flat object with a machine-readable code and a human-readable message:

{
  "code": "outside_messaging_window",
  "message": "The customer last messaged more than 24 hours ago; send a template instead"
}

Branch on code, not on message: messages are written for humans and may be reworded.

Code Status Meaning
not_authenticated 401 Missing, unknown, or rotated-away access token
plan_feature_locked 402 The team's plan does not include API access
outside_messaging_window 409 Free-form send outside the 24-hour window; send a template
idempotency_in_progress 409 A request with this key is still in flight
idempotency_key_reused 422 This key was used with a different body

And by status:

Status On POST /messages On GET /templates
400 The template's parameters do not match its approved shape Malformed query parameter
401 Missing, unknown, or rotated-away token Same
402 Plan does not include API access Same
409 Outside the 24-hour window, or an idempotency key still in flight Not returned
422 Malformed body, an interactive message WhatsApp would reject, or a reused idempotency key Invalid cursor or limit
429 WhatsApp is rate-limiting this number Not returned
502 WhatsApp rejected the message; the reason is in the body Not returned

The full catalogue, including which failures are safe to retry, is in Errors and Retries.

Retry guidance: 429 and 5xx are safe to retry with exponential backoff, and should carry an idempotency key. 4xx errors are yours to fix; retrying the same request will not change the answer.

The 24-hour messaging window

WhatsApp only allows a free-form message within 24 hours of the customer's last inbound message. Outside that window, only an approved template may be sent.

Ghala enforces this before calling Meta, so you get a clean 409 outside_messaging_window instead of Meta's opaque 131047. Handle that code by falling back to a template, and use GET /api/v2/templates?sendable=true to pick one you know will be accepted.

Sending stands the AI agent down

A send through this API pauses the AI agent for that customer, exactly as a reply from the dashboard inbox does. Without it the assistant would answer alongside you and the customer would get two voices.

The agent resumes after the number's takeover window elapses, or immediately if the customer texts BOT. A number with human takeover disabled is unaffected.

Idempotency

Send an Idempotency-Key header from any integration that retries, and every integration retries eventually.

Idempotency-Key: order-1042-confirmation
  • A repeat with the same key and same body replays the original response, with Idempotency-Replayed: true in the headers. Nothing reaches WhatsApp, so the customer gets one message rather than two.
  • A repeat sent while the first is still in flight returns 409 idempotency_in_progress.
  • The same key with a different body returns 422 idempotency_key_reused: replaying the first response would answer a question you did not ask.
  • A failed send releases its key, so a transient outage cannot permanently burn one.

Use a key derived from the thing you are messaging about (order-1042-confirmation), not a random value, so a retry after a crash reuses it.

Messages

POST /api/v2/messages

One endpoint handles every message type. The recipient is addressed by phone number in full international format without + (e.g. 255712345678), so there is no conversation id to resolve first: you already know who you are replying to from the event you just received.

type selects which group of fields is read:

type Reads
text text
template template_name, template_language, template_components
image, document, video, audio media_url, media_caption, media_filename
interactive interactive

Send a text message

curl -X POST https://v2.ghala.io/api/v2/messages \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "255712345678",
    "type": "text",
    "text": "Habari! Hii ni ujumbe kutoka kwenye API."
  }'
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": "text",
        "text": "Habari! Hii ni ujumbe kutoka kwenye API.",
    },
)
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: "text",
    text: "Habari! Hii ni ujumbe kutoka kwenye API.",
  }),
});
console.log(await resp.json());

Response:

{
  "id": "01JZ8Q4R2K7N3M5P9V1X6T0B2C",
  "direction": "OUTBOUND",
  "message_type": "text",
  "content": "Habari! Hii ni ujumbe kutoka kwenye API.",
  "status": "sent",
  "wa_message_id": "wamid.HBgM...",
  "created_at": "2026-08-05T09:14:22Z"
}

Delivery is asynchronous: 200 means accepted, and the message.status event reports what happened next.

text is capped at 4096 characters. This only works inside the 24-hour window; outside it you get 409 outside_messaging_window.

Send a template message

This is the only message type allowed outside the 24-hour window, so it is how you re-open a conversation.

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": "hello_world",
    "template_language": "en_US"
  }'
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": "hello_world",
        "template_language": "en_US",
    },
)
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: "hello_world",
    template_language: "en_US",
  }),
});
console.log(await resp.json());

The template must already be approved on the number's WABA, and template_language must match one of its submitted languages. Meta models each language as its own template, so the same name can appear more than once.

For a template with variables, pass template_components in Meta's own component format. GET /api/v2/templates hands you the approved component list so you know which variables to supply. Templates and Media has worked examples for body variables, header media, and buttons.

Parameters that do not match the approved shape return 400. A template Meta refuses outright comes back as 502, usually because it is not approved, or the name and language do not match.

Send media

curl -X POST https://v2.ghala.io/api/v2/messages \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "255712345678",
    "type": "image",
    "media_url": "https://example.com/product.png",
    "media_caption": "Bidhaa yetu mpya"
  }'
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": "image",
        "media_url": "https://example.com/product.png",
        "media_caption": "Bidhaa yetu mpya",
    },
)
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: "image",
    media_url: "https://example.com/product.png",
    media_caption: "Bidhaa yetu mpya",
  }),
});
console.log(await resp.json());

type can be image, document, video, or audio. Documents also take media_filename, the name the customer sees. WhatsApp does not allow a caption on audio, so media_caption is ignored there; audio is how you send a voice note.

media_url must be public HTTPS. Meta fetches it directly, so a URL that only resolves on your machine or inside your VPC will fail.

Send an interactive message

Reply buttons, a list menu, or a URL button. The id you give each button or row is what comes back on the message.received event when the customer taps it, so make it something you can act on.

curl -X POST https://v2.ghala.io/api/v2/messages \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "255712345678",
    "type": "interactive",
    "interactive": {
      "type": "buttons",
      "body": "Oda yako #1042 iko tayari. Ungependa vipi kuipata?",
      "footer": "Duka la Amina",
      "buttons": [
        { "id": "delivery", "title": "Niletewe" },
        { "id": "pickup", "title": "Nitakuja kuchukua" }
      ]
    }
  }'
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": "interactive",
        "interactive": {
            "type": "buttons",
            "body": "Oda yako #1042 iko tayari. Ungependa vipi kuipata?",
            "footer": "Duka la Amina",
            "buttons": [
                {"id": "delivery", "title": "Niletewe"},
                {"id": "pickup", "title": "Nitakuja kuchukua"},
            ],
        },
    },
)
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: "interactive",
    interactive: {
      type: "buttons",
      body: "Oda yako #1042 iko tayari. Ungependa vipi kuipata?",
      footer: "Duka la Amina",
      buttons: [
        { id: "delivery", title: "Niletewe" },
        { id: "pickup", title: "Nitakuja kuchukua" },
      ],
    },
  }),
});
console.log(await resp.json());

Three interactive types, each reading a different field:

interactive.type Reads Shape
buttons buttons Up to 3 reply buttons, each { id, title }
list sections, button_text Titled sections of rows, each row { id, title, description? }; up to 10 rows in total
cta_url cta One button that opens a URL: { display_text, url }

A list menu:

{
  "to": "255712345678",
  "type": "interactive",
  "interactive": {
    "type": "list",
    "body": "Chagua bidhaa",
    "button_text": "Ona bidhaa",
    "sections": [
      {
        "title": "Vinywaji",
        "rows": [
          { "id": "sku-101", "title": "Chai", "description": "TZS 1,500" },
          { "id": "sku-102", "title": "Kahawa", "description": "TZS 2,000" }
        ]
      }
    ]
  }
}

Interactive messages are free-form, so they need an open 24-hour window. Exceeding WhatsApp's limits, such as more than 3 buttons, more than 10 list rows, or repeated option ids, is rejected locally with 422 rather than handed to Meta to fail.

Message parameters

Field Applies to Notes
to all Required. Full international format, no +
type all text, template, image, document, video, audio, interactive. Defaults to text
text text The message body. Max 4096 characters
template_name template Must be approved on the number's WABA
template_language template e.g. en_US or sw
template_components template Meta's component format, for variables
media_url media Public HTTPS URL that Meta can fetch
media_caption image, document, video Max 1024 characters. Not allowed on audio
media_filename document Filename shown to the customer
interactive interactive See the table above

Interactive field limits: footer 60 characters, button_text 20, section title 24, row description 72.

Message statuses

Status Meaning
sent Accepted by WhatsApp
delivered Reached the recipient's device
read Opened by the recipient
played A voice note the recipient played
failed Not delivered; the event carries the reason

The response also carries sent_at, delivered_at, read_at, played_at, failed_at, and failure_reason as they become known.

Templates

GET /api/v2/templates

Every message template on the connected number, newest first.

Use this before sending outside the 24-hour window. After 24 hours a template is the only thing that delivers, and sendable=true is the list to offer at that moment.

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());

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!" }
      ],
      "quality_score": "GREEN",
      "approved_at": "2026-07-30T11:02:00Z",
      "status_changed_at": "2026-07-30T11:02:00Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}
Query parameter Notes
sendable true for templates a send would accept right now, false for the ones it would refuse. Omit for all
cursor next_cursor from a previous response
limit Page size, default 25, max 100

Fields worth knowing:

  • sendable — branch on this, not on status. A FLAGGED or LOCKED template still sends, and one Ghala has not synced yet is reported sendable and left to Meta to judge, because a missing sync is not your problem.
  • unsendable_reason — why sendable is false, in words you can show a merchant.
  • components — Meta's component list, as approved. Read the BODY component's text for the {{1}} placeholders you must supply as template_components when sending.
  • quality_score — Meta's rating: GREEN, YELLOW, RED, or UNKNOWN. It turns RED before Meta pauses a template, so it is the earliest warning you get.
  • id — Ghala's id, which also arrives on the template.status event, so a cached list can be updated in place.

Templates are created in the dashboard under Templates, not through this API.

Receiving events

There are two ways to get a number's events onto your own server, and they are not interchangeable:

Event subscriptions WhatsApp callback override
What it does Ghala sends you a signed copy of each event Meta delivers to you instead of Ghala
Ghala inbox, AI agent, orders Keep working Pause for that number
Payload format Ghala events (message.received, message.status) Raw WhatsApp Cloud API
Set up in Dashboard → Developer → Events Dashboard → Developer → Webhooks

Start with event subscriptions: they are additive, so you get the data and the product keeps working. Reach for the override only when you are deliberately replacing Ghala's message handling for that number. The two are mutually exclusive, and registering a subscription is refused while an override is in place.

Both are configured in the dashboard. There is no /api/v2 endpoint for registering, listing, or deleting event subscriptions; a request to /api/v2/webhooks returns 404. If you have seen that path in older documentation or from an assistant, it was never served.

The webhooks guide walks through both, including signature verification, deduplication, and what to do when an endpoint is auto-disabled.

Verifying deliveries

Every event-subscription delivery carries four headers:

X-Ghala-Signature: sha256=<hex(HMAC-SHA256(secret, "{timestamp}.{raw_body}"))>
X-Ghala-Timestamp: <unix seconds>
X-Ghala-Event:     message.received
X-Ghala-Delivery:  <ULID, stable across retries>

Three rules, all of which matter:

  1. Sign the exact bytes you received, not a re-serialized body. Parsing and re-encoding the JSON changes whitespace and key order, and the signature will never match.
  2. Compare in constant time. A plain == leaks the expected signature a byte at a time.
  3. Reject a stale timestamp. More than 300 seconds from now means a replayed delivery.

Delivery is at-least-once and unordered, so deduplicate on X-Ghala-Delivery, which is stable across retries. Two events for one customer can also arrive out of order; use the payload's own timestamps to sequence them, not arrival order.

Full verification code in JavaScript and Python is in the webhooks guide.

Events

Event Fires when
message.received A customer sends a message to the number
message.status A message you sent changes state: sent, delivered, read, or failed
message.sent The number sends a message, from any source
template.status A template's Meta review state changes
account.error Something is wrong with the number's WhatsApp account
conversation.escalated A conversation is handed to a human
order.paid An order on the number's shop is paid

message.received and message.status are the two confirmed to publish today. The rest are declared by the API but not yet confirmed as delivering; subscribe to what you need and check Dashboard → Developer → Events for what your number is actually receiving.

The event name is in the X-Ghala-Event header and the payload is JSON in the body.

What's next

  • Ghala API vs Meta Cloud API — what Ghala handles for you and where it stops
  • Supported Capabilities — the per-feature matrix
  • Multiple Numbers and Multi-Tenant Platforms — one token per number, and how to route them
  • Errors and Retries — the full catalogue and what is safe to retry
  • Limits and Quotas — payload caps, rate limiting, and Meta's own limits
  • Send Your First WhatsApp Message — end to end in five minutes
PreviousMessage Template Best PracticesNextGhala API vs Meta Cloud API

On this page

  • Overview
  • What this API does and does not cover
  • Authentication
  • Plan requirement
  • Response shape
  • Errors
  • The 24-hour messaging window
  • Sending stands the AI agent down
  • Idempotency
  • Messages
  • Send a text message
  • Send a template message
  • Send media
  • Send an interactive message
  • Message parameters
  • Message statuses
  • Templates
  • Receiving events
  • Verifying deliveries
  • Events
  • What's next