WABridges
← Docs OVERVIEW DELIVERY VERIFY EVENTS HANDLING
API Reference
Docs / API Reference / Webhooks

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:

PROVISION WITH WEBHOOK
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.

COMMON SHAPE
POST https://yourbackend.com/hook
Content-Type: application/json

{"event":"message", ...event-specific fields}

DELIVERY BEHAVIOR

MethodPOST
Content-Typeapplication/json
Expected response200 OK (body ignored)
Timeout10 seconds per attempt
RetriesUp to 5 attempts with backoff (10s / 30s / 1m / 3m) on network errors, 429, and 5xx. Other 4xx are not retried.
OrderingGuaranteed - events are delivered one at a time, in order
DedupeX-Webhook-Event-Id header, stable across retries. X-Webhook-Attempt is the 1-based attempt.

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:

BearerAuthorization: Bearer <webhook_secret>
SignatureX-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/re-encoding), compare in constant time, and reject stale timestamps (e.g. older than 5 minutes).

JAVASCRIPT
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
})
PYTHON
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

WEBHOOK message

Fields: event, message_id, contact_id, phone, chat_id, name, body, type, media_type, is_group, from_me, timestamp

EXAMPLE PAYLOAD
{"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}
WEBHOOK connected

Fields: event, phone

EXAMPLE PAYLOAD
{"event":"connected","phone":"15550001234"}
WEBHOOK disconnected

Fields: event, reason? (logged_out|stream_replaced), detail?

EXAMPLE PAYLOAD
{"event":"disconnected"}
WEBHOOK typing

Fields: event, contact_id, chat_id, state (composing|paused), mode (text|voice)

EXAMPLE PAYLOAD
{"event":"typing","contact_id":"15550001234@s.whatsapp.net","chat_id":"15550001234@s.whatsapp.net","state":"composing","mode":"text"}
WEBHOOK poll_vote

Fields: event, contact_id, phone, chat_id, poll_id, message_id, name, from_me, timestamp, selected_options

EXAMPLE PAYLOAD
{"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"]}
WEBHOOK 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

EXAMPLE PAYLOAD
{"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}
WEBHOOK call_incoming

Fields: event, call_id, contact_id, platform, timestamp

EXAMPLE PAYLOAD
{"event":"call_incoming","call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","platform":"android","timestamp":1777180605}
WEBHOOK call_terminated

Fields: event, call_id, contact_id, reason (timeout|hangup|decline|busy), timestamp

EXAMPLE PAYLOAD
{"event":"call_terminated","call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","reason":"hangup","timestamp":1777180720}
WEBHOOK offline_sync_preview

Fields: event, total, messages, notifications, receipts, app_data_changes

EXAMPLE PAYLOAD
{"event":"offline_sync_preview","total":142,"messages":120,"notifications":8,"receipts":14,"app_data_changes":0}
WEBHOOK offline_sync_completed

Fields: event, count

EXAMPLE PAYLOAD
{"event":"offline_sync_completed","count":142}
WEBHOOK profile_picture_updated

Fields: event, remove (bool), picture_id?, timestamp

EXAMPLE PAYLOAD
{"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)

JAVASCRIPT
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)

PYTHON
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
<?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)

RUBY
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

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)

RUST
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();
}