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

Products

  • Ghala
  • Sarufi
  • Snippe
  • Sema

Explore

  • Use Cases
  • Pricing
  • 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

The /api/v2 developer API: send WhatsApp messages, subscribe to events, and verify signed deliveries.

Overview

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

  • Send WhatsApp messages: text, templates, images, video, and documents
  • Subscribe an HTTPS endpoint to the number's events and receive signed deliveries
  • Retry safely with idempotency keys

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.

Authentication

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 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 can't be used to probe which tokens exist.

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.

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

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.

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.

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 didn't ask.
  • A failed send releases its key, so a transient outage can't permanently burn one.

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.

Messages

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.

Send a text message

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.

Send a template message

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.

Send media

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.

Message parameters

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

Message statuses

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

Event subscriptions

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.

Register an endpoint

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.

List endpoints

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.

Remove an endpoint

DELETE /api/v2/webhooks/{id}

Anything still queued for the endpoint is dropped, so a removed endpoint won't receive a backlog days later.

Verifying deliveries

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:

  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.

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

Events

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.

What's next

  • Send your first message end to end: Send Your First WhatsApp Message
  • Receive replies and status updates: Receiving Events with Webhooks
PreviousMessage Template Best Practices

On this page

  • Overview
  • Authentication
  • Plan requirement
  • Errors
  • The 24-hour messaging window
  • Sending stands the AI agent down
  • Idempotency
  • Messages
  • Send a text message
  • Send a template message
  • Send media
  • Message parameters
  • Message statuses
  • Event subscriptions
  • Register an endpoint
  • List endpoints
  • Remove an endpoint
  • Verifying deliveries
  • Events
  • What's next