WEBHOOKS
HOW WEBHOOKS WORK
Every inbound WhatsApp event fires a POST request to the webhook_url you configure on each bridge. Your server receives the event in real time and responds with 200.
Set the webhook URL when provisioning a bridge:
curl -X POST https://wabridges.com/api/instances \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"customer_ref":"user-123","webhook_url":"https://yourbackend.com/hook"}'
You can update the webhook URL at any time from the bridge detail page in your dashboard.
All events share a common shape: a JSON object with an event string field identifying the type. Switch on that field to route events to the right handler.
POST https://yourbackend.com/hook
Content-Type: application/json
{"event":"message", ...event-specific fields}
DELIVERY BEHAVIOR
Respond fast. Return 200 immediately and do any processing asynchronously. If your handler takes longer than 10 seconds the attempt counts as failed and will be retried.
Retries mean at-least-once delivery: the same event can arrive more than once. Make your handler idempotent by using the X-Webhook-Event-Id header as a dedupe key. Recent delivery attempts (with event_id and attempt) are visible at GET /webhook-logs on your bridge.
VERIFYING DELIVERIES
Every delivery is authenticated with your bridge's webhook_secret (returned when you provision, and shown on the bridge detail page) in two ways:
Prefer the signature: it proves the payload wasn't tampered with and lets you reject replays. Recompute v1 from the raw request body (before any JSON parsing/re-encoding), compare in constant time, and reject stale timestamps (e.g. older than 5 minutes).
const crypto = require('crypto')
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf } }))
function verifyWebhook(req, secret) {
const m = (req.get('X-Webhook-Signature') || '').match(/^t=(\d+),v1=([0-9a-f]+)$/)
if (!m) return false
const [, t, v1] = m
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false // stale
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.`).update(req.rawBody).digest('hex')
return v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}
app.post('/hook', (req, res) => {
if (!verifyWebhook(req, process.env.WEBHOOK_SECRET)) return res.sendStatus(401)
res.sendStatus(200)
// ...handle req.body
})
import hmac, hashlib, re, time
from flask import Flask, request, abort
def verify_webhook(secret: str) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]+)",
request.headers.get("X-Webhook-Signature", ""))
if not m:
return False
t, v1 = m.groups()
if abs(time.time() - int(t)) > 300: # stale
return False
expected = hmac.new(secret.encode(),
f"{t}.".encode() + request.get_data(),
hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
@app.post("/hook")
def hook():
if not verify_webhook(WEBHOOK_SECRET):
abort(401)
return "", 200
EVENT TYPES
message
Fields: event, message_id, contact_id, phone, chat_id, name, body, type, media_type, is_group, from_me, timestamp
{"event":"message","message_id":"ACE41E...","contact_id":"15550001234@s.whatsapp.net","phone":"15550001234","chat_id":"15550001234@s.whatsapp.net","name":"Alice","body":"Hello!","type":"text","media_type":"","is_group":false,"from_me":false,"timestamp":1777180605}
connected
Fields: event, phone
{"event":"connected","phone":"15550001234"}
disconnected
Fields: event, reason? (logged_out|stream_replaced), detail?
{"event":"disconnected"}
typing
Fields: event, contact_id, chat_id, state (composing|paused), mode (text|voice)
{"event":"typing","contact_id":"15550001234@s.whatsapp.net","chat_id":"15550001234@s.whatsapp.net","state":"composing","mode":"text"}
poll_vote
Fields: event, contact_id, phone, chat_id, poll_id, message_id, name, from_me, timestamp, selected_options
{"event":"poll_vote","contact_id":"15550001234@s.whatsapp.net","phone":"15550001234","chat_id":"15550001234@s.whatsapp.net","poll_id":"ACPOLL0001","message_id":"ACVOTE0001","name":"Alice","from_me":false,"timestamp":1777330500,"selected_options":["Red"]}
event_response
Fields: event, contact_id, phone, chat_id, event_id, message_id, name, from_me, timestamp, response (going|not_going|maybe), extra_guest_count
{"event":"event_response","contact_id":"15550001234@s.whatsapp.net","phone":"15550001234","chat_id":"15550009999@g.us","event_id":"ACEVENT0001","message_id":"ACEVRESP0001","name":"Alice","from_me":false,"timestamp":1777330700,"response":"going","extra_guest_count":0}
call_incoming
Fields: event, call_id, contact_id, platform, timestamp
{"event":"call_incoming","call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","platform":"android","timestamp":1777180605}
call_terminated
Fields: event, call_id, contact_id, reason (timeout|hangup|decline|busy), timestamp
{"event":"call_terminated","call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","reason":"hangup","timestamp":1777180720}
offline_sync_preview
Fields: event, total, messages, notifications, receipts, app_data_changes
{"event":"offline_sync_preview","total":142,"messages":120,"notifications":8,"receipts":14,"app_data_changes":0}
offline_sync_completed
Fields: event, count
{"event":"offline_sync_completed","count":142}
profile_picture_updated
Fields: event, remove (bool), picture_id?, timestamp
{"event":"profile_picture_updated","remove":false,"picture_id":"12345678901","timestamp":1777180605}
HANDLING WEBHOOKS
The pattern is the same in every language: parse the body, switch on event, return 200 before doing any heavy work.
Node.js (Express)
const express = require('express')
const app = express()
app.use(express.json())
app.post('/hook', (req, res) => {
res.sendStatus(200) // respond immediately
const { event, ...data } = req.body
switch (event) {
case 'message':
if (!data.from_me) {
console.log(`${data.name}: ${data.body}`)
// reply, store, trigger workflow...
}
break
case 'connected':
console.log(`bridge connected, phone=${data.phone}`)
break
case 'disconnected':
console.warn('bridge disconnected')
break
case 'typing':
// data.state = 'composing' | 'paused'
break
}
})
app.listen(3000)
Python (Flask)
from flask import Flask, request, jsonify
import threading
app = Flask(__name__)
def process_event(payload):
event = payload.get('event')
if event == 'message' and not payload.get('from_me'):
print(f"{payload['name']}: {payload['body']}")
# reply, store, trigger workflow...
elif event == 'connected':
print(f"bridge connected, phone={payload['phone']}")
elif event == 'disconnected':
print('bridge disconnected')
@app.route('/hook', methods=['POST'])
def webhook():
payload = request.get_json()
# process async so we return 200 immediately
threading.Thread(target=process_event, args=(payload,)).start()
return '', 200
if __name__ == '__main__':
app.run(port=3000)
PHP
<?php
$payload = json_decode(file_get_contents('php://input'), true);
http_response_code(200); // respond immediately
$event = $payload['event'] ?? '';
switch ($event) {
case 'message':
if (empty($payload['from_me'])) {
error_log("{$payload['name']}: {$payload['body']}");
// reply, store, trigger workflow...
}
break;
case 'connected':
error_log("bridge connected, phone={$payload['phone']}");
break;
case 'disconnected':
error_log('bridge disconnected');
break;
}
Ruby (Sinatra)
require 'sinatra'
require 'json'
post '/hook' do
payload = JSON.parse(request.body.read)
status 200
Thread.new do
case payload['event']
when 'message'
next if payload['from_me']
puts "#{payload['name']}: #{payload['body']}"
# reply, store, trigger workflow...
when 'connected'
puts "bridge connected, phone=#{payload['phone']}"
when 'disconnected'
warn 'bridge disconnected'
end
end
''
end
Go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
type Event struct {
Event string `json:"event"`
Phone string `json:"phone"`
Name string `json:"name"`
Body string `json:"body"`
FromMe bool `json:"from_me"`
}
func hookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK) // respond immediately
go func() {
var ev Event
if err := json.Unmarshal(body, &ev); err != nil {
return
}
switch ev.Event {
case "message":
if !ev.FromMe {
fmt.Printf("%s: %s\n", ev.Name, ev.Body)
// reply, store, trigger workflow...
}
case "connected":
fmt.Printf("bridge connected, phone=%s\n", ev.Phone)
case "disconnected":
fmt.Println("bridge disconnected")
}
}()
}
func main() {
http.HandleFunc("/hook", hookHandler)
http.ListenAndServe(":3000", nil)
}
Rust (axum)
use axum::{extract::Json, http::StatusCode, routing::post, Router};
use serde::Deserialize;
use tokio::task;
#[derive(Deserialize)]
struct Event {
event: String,
phone: Option<String>,
name: Option<String>,
body: Option<String>,
from_me: Option<bool>,
}
async fn hook(Json(ev): Json<Event>) -> StatusCode {
task::spawn(async move {
match ev.event.as_str() {
"message" => {
if !ev.from_me.unwrap_or(false) {
println!("{}: {}", ev.name.unwrap_or_default(), ev.body.unwrap_or_default());
// reply, store, trigger workflow...
}
}
"connected" => println!("bridge connected, phone={}", ev.phone.unwrap_or_default()),
"disconnected" => eprintln!("bridge disconnected"),
_ => {}
}
});
StatusCode::OK
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/hook", post(hook));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}