Skip to Content
Webhooks

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.

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 3000 then copy the https:// 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:

ParameterValue
hub.modeAlways subscribe
hub.verify_tokenThe secret token you entered in the dashboard
hub.challengeA random string — your handler must return this verbatim

Your handler must:

  1. Check that hub.mode === 'subscribe'
  2. Check that hub.verify_token matches your stored secret
  3. Respond with the raw hub.challenge string and HTTP 200

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 forEvent type
changes[].value.statuses is presentDelivery status update
changes[].value.messages is presentIncoming 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