Webhooks
One URL. Every event.
Each bridge POSTs to the webhook_url you configure. Set it when you provision, change it any time from the dashboard or with a PATCH call.
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"}'
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}
Ordered, retried, deduplicated.
Return 200 immediately and process asynchronously. A handler slower than 10 seconds counts as a failed attempt and gets retried.
| Method | POST |
| Content-Type | application/json |
| Expected response | 200 OK (body ignored) |
| Timeout | 10 seconds per attempt |
| Retries | Up to 5 attempts with backoff (10s / 30s / 1m / 3m) on network errors, 429, and 5xx. Other 4xx are not retried. |
| Ordering | Guaranteed, events are delivered one at a time, in order |
| Dedupe | X-Webhook-Event-Id header, stable across retries. X-Webhook-Attempt is the 1-based attempt. |
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.
Check the signature. Then trust the payload.
Every delivery is authenticated with your bridge’s webhook_secret, returned when you provision and shown on the bridge detail page, in two ways.
| Bearer | Authorization: Bearer <webhook_secret> |
| Signature | X-Webhook-Signature: t=<unix>,v1=<hex> where v1 is HMAC-SHA256 over <t>.<raw body> keyed with the secret |
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 or 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
Every event type. One JSON shape.
Everything the number does reaches you here. Jump to a payload, or read them in order.
- EVTmessageinbound or outbound message
- EVTconnectedthe session is live
- EVTdisconnectedthe session dropped
- EVTtypingcomposing or paused
- EVTpresenceonline, or last seen
- EVTpoll_votesomeone voted
- EVTevent_responsegoing, maybe, not going
- EVTcall_incominga call started
- EVTcall_terminateda call ended
- EVToffline_sync_previewcatch-up starting
- EVToffline_sync_completedcatch-up finished
- EVTprofile_picture_updatedthe avatar changed
message
Inbound or outbound message.
eventmessage_idcontact_idphonechat_idnamebodytypemedia_typeis_groupfrom_metimestampcaption?quoted?mentions?forwarded?reaction?edit?target_id?location?options?max_answers?event_details?offline?is_broadcast?verified_business?{"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
The session is live.
eventphone{"event":"connected","phone":"15550001234"}
disconnected
The session dropped.
eventreason? (logged_out|stream_replaced)detail?{"event":"disconnected"}
typing
Composing or paused.
eventcontact_idchat_idstate (composing|paused)mode (text|voice){"event":"typing","contact_id":"15550001234@s.whatsapp.net","chat_id":"15550001234@s.whatsapp.net","state":"composing","mode":"text"}
presence
Online, or last seen.
eventcontact_idunavailable (bool)last_seen?{"event":"presence","contact_id":"15550001234@s.whatsapp.net","last_seen":1777180605,"unavailable":true}
poll_vote
Someone voted.
eventcontact_idphonechat_idpoll_idmessage_idnamefrom_metimestampselected_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
Going, maybe, not going.
eventcontact_idphonechat_idevent_idmessage_idnamefrom_metimestampresponse (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
A call started.
eventcall_idcontact_idplatformtimestamp{"event":"call_incoming","call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","platform":"android","timestamp":1777180605}
call_terminated
A call ended.
eventcall_idcontact_idreason (timeout|hangup|decline|busy)timestamp{"event":"call_terminated","call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","reason":"hangup","timestamp":1777180720}
offline_sync_preview
Catch-up starting.
eventtotalmessagesnotificationsreceiptsapp_data_changes{"event":"offline_sync_preview","total":142,"messages":120,"notifications":8,"receipts":14,"app_data_changes":0}
offline_sync_completed
Catch-up finished.
eventcount{"event":"offline_sync_completed","count":142}
profile_picture_updated
The avatar changed.
eventremove (bool)picture_id?timestamp{"event":"profile_picture_updated","remove":false,"picture_id":"12345678901","timestamp":1777180605}
The same handler, in six languages.
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();
}