WABridges
← Docs OVERVIEW DELIVERY VERIFY EVENTS HANDLING
API Reference

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
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.

webhook POST
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.

delivery · per event
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.

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.

headers on every delivery
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 or re-encoding), compare in constant time, and reject stale timestamps (e.g. older than 5 minutes).

node
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

Every event type. One JSON shape.

Everything the number does reaches you here. Jump to a payload, or read them in order.

POST https://your-app.com/hook
EVENT 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?
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}
EVENT connected

The session is live.

eventphone
payload
{"event":"connected","phone":"15550001234"}
EVENT disconnected

The session dropped.

eventreason? (logged_out|stream_replaced)detail?
payload
{"event":"disconnected"}
EVENT typing

Composing or paused.

eventcontact_idchat_idstate (composing|paused)mode (text|voice)
payload
{"event":"typing","contact_id":"15550001234@s.whatsapp.net","chat_id":"15550001234@s.whatsapp.net","state":"composing","mode":"text"}
EVENT presence

Online, or last seen.

eventcontact_idunavailable (bool)last_seen?
payload
{"event":"presence","contact_id":"15550001234@s.whatsapp.net","last_seen":1777180605,"unavailable":true}
EVENT poll_vote

Someone voted.

eventcontact_idphonechat_idpoll_idmessage_idnamefrom_metimestampselected_options
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"]}
EVENT event_response

Going, maybe, not going.

eventcontact_idphonechat_idevent_idmessage_idnamefrom_metimestampresponse (going|not_going|maybe)extra_guest_count
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}
EVENT call_incoming

A call started.

eventcall_idcontact_idplatformtimestamp
payload
{"event":"call_incoming","call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","platform":"android","timestamp":1777180605}
EVENT call_terminated

A call ended.

eventcall_idcontact_idreason (timeout|hangup|decline|busy)timestamp
payload
{"event":"call_terminated","call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","reason":"hangup","timestamp":1777180720}
EVENT offline_sync_preview

Catch-up starting.

eventtotalmessagesnotificationsreceiptsapp_data_changes
payload
{"event":"offline_sync_preview","total":142,"messages":120,"notifications":8,"receipts":14,"app_data_changes":0}
EVENT offline_sync_completed

Catch-up finished.

eventcount
payload
{"event":"offline_sync_completed","count":142}
EVENT profile_picture_updated

The avatar changed.

eventremove (bool)picture_id?timestamp
payload
{"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)

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