The /api/v2 developer API: send WhatsApp messages, subscribe to events, and verify signed deliveries.
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. 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.
Requests are authenticated with the connected number's 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.
This is the same secret Ghala uses to send on your behalf. Keep it server-side, never in browser code or 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 can't be used to probe which tokens exist.
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.
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 or unknown access token |
plan_feature_locked |
402 | The team's plan doesn't 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 |
subscription_exists |
409 | That endpoint URL is already registered |
Validation failures return 422, a malformed endpoint URL returns 400, and a send that WhatsApp itself rejects returns 502 with Meta's reason in message.
Retry guidance: 5xx is safe to retry with exponential backoff, ideally with an idempotency key. 4xx errors are yours to fix; retrying the same request won't 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.
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.
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 didn't ask.Use a key derived from the thing you're messaging about (order-1042-confirmation), not a random value, so a retry after a crash reuses it.
One endpoint handles every message type. The recipient is addressed by phone number in full international format without + (e.g. 255712345678), so there's no conversation id to resolve first: you already know who you're replying to from the webhook you just received.
POST /api/v2/messages
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.
Only works inside the 24-hour window; outside it you get 409 outside_messaging_window.
POST /api/v2/messages
This is the only message type allowed outside the 24-hour window, so it's 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. For a template with variables, pass template_components in Meta's own component format.
A template Meta refuses comes back as 502, usually because it isn't approved, or the name and language don't match.
POST /api/v2/messages
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, video, or document; documents also take media_filename.
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.
| Field | Applies to | Notes |
|---|---|---|
to |
all | Full international format, no + |
type |
all | text, template, image, video, document |
text |
text |
The message body; must not be blank |
template_name |
template |
Must be approved on the number's WABA |
template_language |
template |
e.g. en_US |
template_components |
template |
Meta's component format, for variables |
media_url |
media | Public HTTPS URL |
media_caption |
media | Optional caption |
media_filename |
document |
Filename shown to the customer |
| Status | Meaning |
|---|---|
sent |
Accepted by WhatsApp |
delivered |
Reached the recipient's device |
read |
Opened by the recipient |
failed |
Not delivered; the event carries the reason |
Register an HTTPS endpoint and Ghala POSTs a signed copy of every event to it, after processing the event itself, so the AI agent, inbox, and orders all keep working.
This is not the same as pointing the number's WhatsApp callback at your own server. That override replaces delivery and takes Ghala out of the loop entirely, and registering a subscription is refused while one is in place. The webhooks guide covers both and helps you pick.
POST /api/v2/webhooks
curl -X POST https://v2.ghala.io/api/v2/webhooks \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/ghala/webhook",
"events": ["message.received", "message.status"],
"description": "production"
}'
import os, requests
resp = requests.post(
"https://v2.ghala.io/api/v2/webhooks",
headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
json={
"url": "https://example.com/ghala/webhook",
"events": ["message.received", "message.status"],
"description": "production",
},
)
print(resp.json())
const resp = await fetch("https://v2.ghala.io/api/v2/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/ghala/webhook",
events: ["message.received", "message.status"],
description: "production",
}),
});
console.log(await resp.json());
Response (201):
{
"id": "01JZ8Q4R2K7N3M5P9V1X6T0B2C",
"url": "https://example.com/ghala/webhook",
"events": ["message.received", "message.status"],
"description": "production",
"status": "ACTIVE",
"secret": "whsec_9f2a..."
}
The signing secret is returned exactly once, at creation. It's encrypted at rest and can never be shown again; if you lose it, delete the subscription and register a new one.
events is optional, but pass it explicitly so you know what you'll receive. Valid names are message.received and message.status; anything else returns 400 with the valid list in the message.
Endpoints must be public HTTPS. Plain http://, loopback, link-local (169.254.169.254), and RFC1918 private addresses are all rejected with 400. Ghala makes these requests, so they'd be an SSRF surface.
Registering a URL that's already subscribed returns 409 subscription_exists, because two rows for one endpoint would double every delivery.
GET /api/v2/webhooks
[
{
"id": "01JZ8Q4R2K7N3M5P9V1X6T0B2C",
"url": "https://example.com/ghala/webhook",
"events": ["message.received", "message.status"],
"description": "production",
"status": "ACTIVE",
"consecutive_failures": 0,
"last_success_at": "2026-08-05T09:20:11Z"
}
]
Secrets are never returned on read. consecutive_failures and last_success_at are the health signals to watch: an endpoint is auto-disabled after 20 consecutive failed deliveries.
DELETE /api/v2/webhooks/{id}
Anything still queued for the endpoint is dropped, so a removed endpoint won't receive a backlog days later.
Every 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.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.GHALA_WEBHOOK_SECRET; // whsec_...
const MAX_SKEW_SECONDS = 300;
function isValid(rawBody, signature, timestamp) {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) return false;
const hmac = crypto.createHmac("sha256", SECRET);
hmac.update(`${timestamp}.`);
hmac.update(rawBody); // the exact bytes, still a Buffer
const expected = Buffer.from(hmac.digest("hex"), "hex");
const received = Buffer.from(
String(signature ?? "").replace(/^sha256=/, ""),
"hex",
);
return (
expected.length === received.length &&
crypto.timingSafeEqual(expected, received)
);
}
const app = express();
// express.raw, not express.json: the parsed body can't be verified.
app.post(
"/ghala/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const valid = isValid(
req.body,
req.get("X-Ghala-Signature"),
req.get("X-Ghala-Timestamp"),
);
if (!valid) return res.sendStatus(401);
res.sendStatus(200); // acknowledge first, process after
const deliveryId = req.get("X-Ghala-Delivery");
if (alreadyHandled(deliveryId)) return;
const event = req.get("X-Ghala-Event");
const payload = JSON.parse(req.body.toString("utf8"));
handle(event, payload);
},
);
app.listen(3000);
import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
SECRET = os.environ["GHALA_WEBHOOK_SECRET"].encode() # whsec_...
MAX_SKEW_SECONDS = 300
app = Flask(__name__)
@app.post("/ghala/webhook")
def ghala_webhook():
raw = request.get_data() # the exact bytes
timestamp = request.headers.get("X-Ghala-Timestamp", "")
signature = request.headers.get("X-Ghala-Signature", "")
try:
if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
abort(401)
except ValueError:
abort(401)
expected = hmac.new(
SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature.removeprefix("sha256=")):
abort(401)
delivery_id = request.headers.get("X-Ghala-Delivery")
if not already_handled(delivery_id):
handle(request.headers.get("X-Ghala-Event"), request.get_json())
return "", 200
Two events are published today. Order and payment events aren't available yet, and asking for them returns 400.
| 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 |
The event name is in the X-Ghala-Event header and the payload is JSON in the body. Register a webhook.site URL and message the number from a handset to watch real deliveries arrive before you write any parsing code.