Two ways to receive a number's events, how to verify a signed delivery, and what to do when an endpoint is disabled.
By default your number's events flow into Ghala; that is what powers the inbox, AI auto-reply, and analytics. You do not need to configure anything for that.
When you are building your own integration, there are two ways to get those 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: 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. Registering a subscription is refused while a callback override is in place.
Both are configured in the dashboard. There is no
/api/v2endpoint for registering, listing, or deleting event subscriptions./api/v2/webhooksreturns404; if you have seen that path in older documentation or from an assistant, it was never served.
Create an event subscription in the dashboard: Developer → Events. Enter your public HTTPS endpoint, pick the events you want, and save.
Store the secret now. It is returned exactly once, at creation, and is encrypted at rest afterwards. There is no way to read it back; if you lose it, delete the subscription and register a new one.
There is no verification handshake to implement. Ghala starts delivering as soon as the subscription is ACTIVE.
Endpoint requirements. Public HTTPS only. Plain http://, loopback, link-local (169.254.169.254), and RFC1918 private addresses are rejected with 400. Ghala makes these requests, so they would be an SSRF surface. For local development, expose your server with a tunnel (ngrok, cloudflared) and register the tunnel URL.
If you run several numbers, register a separate path per number — /ghala/acme/webhook, /ghala/bakari-ltd/webhook. Each subscription has its own signing secret, so the URL is what tells you which secret to verify with. See Multiple Numbers and Multi-Tenant Platforms.
Each 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>
Recompute the signature and compare. Three details decide whether this is real security or decoration:
== leaks the expected signature one byte at a time.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: a parsed body cannot 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
X-Ghala-Event names what happened; the payload is JSON in the body.
| Event | Fires when |
|---|---|
message.received |
A customer sends a message to your 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 but not confirmed as delivering.
Write your handler so it dispatches on the header and tolerates fields it does not recognise:
function handle(event, payload) {
switch (event) {
case "message.received":
return onInbound(payload);
case "message.status":
return onStatusChange(payload);
default:
// Unknown event types are not an error. Log and move on, so a new
// event type cannot take your endpoint down.
return log.info({ event }, "unhandled ghala event");
}
}
Payload field reference. The per-event JSON schemas are not published in this help centre yet. Until they are, read the exact envelope from Developer → Events in the dashboard: the event log holds 30 days of events, and opening one shows the precise body a subscriber was sent. That is the authoritative shape — not a third-party request bin, and not an example on a page.
200 within a few seconds and process asynchronously; slow responses count as failures.X-Ghala-Delivery, which is stable across retries.An endpoint is auto-disabled after 20 consecutive failed deliveries. An outage you do not notice otherwise becomes silence you do not notice either.
To inspect what happened, open the subscription under Developer → Events. Each delivery attempt records:
PENDING, DELIVERED, or FAILEDThat is what tells a bad certificate apart from a 500 in your own handler. Settled attempts are pruned after 7 days, so it is a recent window rather than a full history.
To reactivate a disabled endpoint, fix the underlying problem and register the endpoint again under Developer → Events. Registering produces a new signing secret, shown once, so update your handler's secret at the same time.
To recover events you missed, use the event log: it is written whether or not anyone is subscribed, is retained 30 days, and each entry carries the exact payload a subscriber is sent. Re-delivery is not exposed, so catching up means reading the log rather than asking for a replay.
Removing an endpoint drops anything still queued for it, so a decommissioned endpoint will not receive a backlog days later.
The other path points your number's WhatsApp callback at your server. Events then arrive directly from Meta in the standard WhatsApp Cloud API webhook format.
Trade-off to know: while the override is active, events for that number go to you instead of Ghala, so the Ghala inbox and AI auto-reply pause for it, orders stop being captured, and any event subscriptions go silent. Remove the override any time to hand the number back.
Choose this only when you are replacing Ghala's message handling. If you just want a copy of the data, use event subscriptions above.
Meta requires a one-time verification handshake: a GET with hub.mode, hub.verify_token, and hub.challenge query parameters. Check the token, echo the challenge.
import express from "express"
const app = express()
app.use(express.json())
const VERIFY_TOKEN = process.env.VERIFY_TOKEN // you choose this value
// Meta calls this once when the webhook is configured
app.get("/webhook", (req, res) => {
const mode = req.query["hub.mode"]
const token = req.query["hub.verify_token"]
const challenge = req.query["hub.challenge"]
if (mode === "subscribe" && token === VERIFY_TOKEN) {
return res.status(200).send(challenge)
}
res.sendStatus(403)
})
// Events arrive here
app.post("/webhook", (req, res) => {
res.sendStatus(200) // acknowledge first, process after
const value = req.body.entry?.[0]?.changes?.[0]?.value
for (const message of value?.messages ?? []) {
console.log("incoming:", message.from, message.text?.body)
}
for (const status of value?.statuses ?? []) {
console.log("status:", status.id, status.status)
}
})
app.listen(3000)
For local development, expose your server with a tunnel and use the tunnel URL.
Open Developer → Webhooks, enter your callback URL and the verify token you chose, and save. Ghala configures the override with Meta and runs the verification handshake immediately; if your endpoint echoes the challenge, you are live.
Everything arrives as POST with Meta's envelope. The two payloads you will care about:
An incoming customer message
{
"entry": [{
"changes": [{
"field": "messages",
"value": {
"contacts": [{ "profile": { "name": "Amina" }, "wa_id": "255712345678" }],
"messages": [{
"id": "wamid.HBgM...",
"from": "255712345678",
"timestamp": "1721300000",
"type": "text",
"text": { "body": "Do you deliver to Dodoma?" }
}]
}
}]
}]
}
Other type values (image, audio, document, location, interactive) carry a matching object instead of text. Meta's payload examples are the reference for every one of them.
A status update for a message you sent
{
"entry": [{
"changes": [{
"field": "messages",
"value": {
"statuses": [{
"id": "wamid.HBgM...",
"status": "delivered",
"timestamp": "1721300005",
"recipient_id": "255712345678"
}]
}
}]
}]
}
status progresses sent → delivered → read, or lands on failed with an errors array explaining why.
Deliveries carry Meta's X-Hub-Signature-256 header, and failed deliveries are retried with backoff, so deduplicate by message id. Keep your verify token secret and unguessable; it is what stops strangers registering your endpoint.