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, Ghala can point your number's webhooks at your endpoint instead. Events then arrive at your URL in the standard WhatsApp Cloud API webhook format, directly from Meta. No polling, no translation layer to learn.
Trade-off to know: while your 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 to Ghala.
Your endpoint must be public HTTPS and answer Meta's one-time verification handshake: a GET request with hub.mode, hub.verify_token, and hub.challenge query parameters. Check the token, echo the challenge.
A complete Express example:
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 (ngrok, cloudflared) and use the tunnel URL.
In the dashboard, 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 this 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.
200 within a few seconds and process asynchronously; slow responses are treated as failures.id.X-Hub-Signature-256 header.