Receive incoming messages, delivery updates, and read receipts on your own endpoint in real time.
By default your number's events flow into Ghala; that's what powers the inbox, AI auto-reply, and analytics. You don't need to configure anything for that.
When you're 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're additive: you get the data and the product keeps working. Reach for the override only when you're 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.
Create an event subscription in the dashboard: Developer → Events. Enter your public HTTPS endpoint, pick the events you want (message.received, message.status), and save.
Store the secret now. It's 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's 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'd be an SSRF surface. For local development, expose your server with a tunnel (ngrok, cloudflared) and register the tunnel URL.
Available events are message.received and message.status. Order and payment events aren't published yet, and asking for them returns 400 with the valid list in the message.
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 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
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 |
Before writing any parsing code, register a webhook.site URL and message the number from a real handset. You'll see the exact payloads arrive, which beats coding against an example.
200 within a few seconds and process asynchronously; slow responses count as failures.X-Ghala-Delivery, which is stable across retries.GET /api/v2/webhooks reports consecutive_failures and last_success_at. An endpoint is auto-disabled after 20 consecutive failed deliveries, so an outage you don't notice becomes silence you don't notice either.curl -X DELETE https://v2.ghala.io/api/v2/webhooks/WEBHOOK_ID \
-H "Authorization: Bearer $ACCESS_TOKEN"
Anything still queued for it is dropped, so a decommissioned endpoint won't 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. Remove the override any time to hand the number back.
Choose this only when you're 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're live.
Everything arrives as POST with Meta's envelope. The two payloads you'll 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.
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's what stops strangers registering your endpoint.