Every endpoint, parameter, and error code in the Ghala Developer API, with cURL, Python, and JavaScript examples.
The Ghala Developer API automates a connected WhatsApp number. With it you can:
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.
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.
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
401after 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.
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.
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 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.
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.
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.
Send an Idempotency-Key header from any integration that retries, and every integration retries eventually.
Idempotency-Key: order-1042-confirmation
Idempotency-Replayed: true in the headers. Nothing reaches WhatsApp, so the customer gets one message rather than two.409 idempotency_in_progress.422 idempotency_key_reused: replaying the first response would answer a question you did not ask.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.
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 |
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.
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.
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.
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.
| 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.
| 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.
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.
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/v2endpoint for registering, listing, or deleting event subscriptions; a request to/api/v2/webhooksreturns404. 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.
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:
== leaks the expected signature a byte at a time.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.
| 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.