Webhooks
Bahasha forwards real-time events to a URL you control — delivery reports (sent, delivered, read, failed) and replies from your customers — every time a message is sent.
Your webhook URL must be reachable over public HTTPS. Bahasha cannot reach localhost. Use a tunnel like ngrok during local development.
Setup
Implement your webhook endpoint
Your server needs two handlers at the same URL path — one GET for the initial verification when you register, and one POST to receive live events.
Node.js (Express)
const express = require('express');
const app = express();
app.use(express.json());
// GET — called once by Bahasha when you register the webhook
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 === process.env.WEBHOOK_VERIFICATION_TOKEN) {
// Echo back the challenge — this proves you own the URL
return res.status(200).send(challenge);
}
res.sendStatus(403);
});
// POST — receives delivery reports and customer replies
app.post('/webhook', (req, res) => {
// Acknowledge immediately — process asynchronously if needed
res.sendStatus(200);
const { entry } = req.body;
for (const e of entry ?? []) {
for (const change of e.changes ?? []) {
if (change.field !== 'messages') continue;
const { messages = [], statuses = [] } = change.value;
// Delivery status updates (sent, delivered, read, failed)
for (const s of statuses) {
console.log(`[${s.status.toUpperCase()}] message ${s.id}`);
if (s.status === 'failed') {
console.error('Delivery error:', s.errors?.[0]?.title);
}
}
// Incoming customer replies
for (const m of messages) {
console.log(`Incoming ${m.type} from ${m.from}`);
if (m.type === 'text') console.log('Body:', m.text.body);
if (m.type === 'interactive') console.log('Reply:', m.interactive.button_reply ?? m.interactive.list_reply);
if (m.type === 'button') console.log('Button:', m.button.text);
}
}
}
});
app.listen(3000, () => console.log('Webhook receiver running on port 3000'));Set WEBHOOK_VERIFICATION_TOKEN as an environment variable. The value must exactly match what you enter in the dashboard when registering.
Deploy and expose your endpoint
Your webhook handler must be publicly accessible. Some options:
- Production — deploy to any cloud provider and use your public HTTPS URL
- Local development — use ngrok :
ngrok http 3000then copy thehttps://URL it gives you
Register in the Bahasha dashboard
Go to Settings → Webhooks (or Developers → Webhooks) in your dashboard and enter:
- Webhook URL — your public endpoint, e.g.
https://yourapp.com/webhook - Verification token — the secret string you stored in
WEBHOOK_VERIFICATION_TOKEN
Bahasha will immediately send a GET request to verify the URL. If your GET handler echoes back hub.challenge with 200 OK, the webhook is confirmed and live events will start flowing.
How verification works
When you register, Bahasha sends a one-time GET request with three query parameters:
| Parameter | Value |
|---|---|
hub.mode | Always subscribe |
hub.verify_token | The secret token you entered in the dashboard |
hub.challenge | A random string — your handler must return this verbatim |
Your handler must:
- Check that
hub.mode === 'subscribe' - Check that
hub.verify_tokenmatches your stored secret - Respond with the raw
hub.challengestring and HTTP200
Any other response (wrong token, non-200 status) will fail verification and the webhook won’t be registered.
Event payloads
All events arrive as POST requests with a JSON body. Respond with 200 OK as fast as possible — if Bahasha doesn’t receive a timely acknowledgement it will retry. Do any heavy processing after you’ve sent the response.
The top-level shape is always the same. Check changes[].value to find what happened:
| What to look for | Event type |
|---|---|
changes[].value.statuses is present | Delivery status update |
changes[].value.messages is present | Incoming customer reply |
Delivery status update
Fired when a message transitions to sent, delivered, read, or failed.
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "+254700000000",
"phone_number_id": "123456789"
},
"statuses": [{
"id": "wamid.HBgNMjU0NzEyMzQ1Njc4FQIAERgSNDQ1...",
"status": "delivered",
"timestamp": "1700000001",
"recipient_id": "254712345678"
}]
}
}]
}]
}For failed statuses, an errors array is included:
"statuses": [{
"id": "wamid.HBgNMjU0NzEyMzQ1Njc4FQIAERgSNDQ1...",
"status": "failed",
"timestamp": "1700000010",
"recipient_id": "254712345678",
"errors": [{
"code": 131026,
"title": "Message undeliverable",
"message": "This message is undeliverable",
"error_data": {
"details": "The recipient phone number is not a WhatsApp phone number."
}
}]
}]Incoming customer reply
Fired when a customer sends a message back to your number.
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "+254700000000",
"phone_number_id": "123456789"
},
"contacts": [{
"profile": { "name": "Jane Smith" },
"wa_id": "254712345678"
}],
"messages": [{
"from": "254712345678",
"id": "wamid.HBgNMjU0NzEyMzQ1Njc4FQIAEhgSM...",
"timestamp": "1700000005",
"type": "text",
"text": { "body": "Thank you, I received my order!" }
}]
}
}]
}]
}Possible type values: text · image · audio · video · document · interactive · button · reaction