Skip to main content

Webhooks

Receive real-time events from WhatsApp via HTTP webhooks.

Register Webhook

Register a webhook for a session.

POST /api/v1/sessions/{session_id}/webhooks

Request Body

{
"url": "https://example.com/webhook",
"events": ["message", "connected", "disconnected"],
"secret": "your-webhook-secret"
}
FieldTypeRequiredDescription
urlstringYesWebhook endpoint URL
eventsarrayNoEvents to subscribe (default: all)
secretstringNoHMAC-SHA256 secret for signature

Response

{
"id": "webhook-uuid",
"url": "https://example.com/webhook",
"events": ["message", "connected", "disconnected"],
"enabled": true
}

List Webhooks

Get all webhooks for a session.

GET /api/v1/sessions/{session_id}/webhooks

Response

{
"webhooks": [
{
"url": "https://example.com/webhook",
"events": ["all"],
"secret": null,
"enabled": true
}
],
"count": 1
}

Delete Webhook

Remove a webhook.

DELETE /api/v1/sessions/{session_id}/webhooks/{webhook_id}

Re-enable Webhook

Webhooks are auto-disabled after 100 consecutive delivery failures (see Auto-disable below). Use this endpoint to flip a disabled webhook back to enabled=true and clear the disable metadata once the target endpoint is fixed.

POST /api/v1/sessions/{session_id}/webhooks/{webhook_id}/enable

Response

{
"success": true,
"message": "Webhook re-enabled"
}

Circuit Breaker & Auto-disable

The dispatcher tracks per-URL delivery failures and reacts in two stages so a single dead endpoint can't flood the log or block the runtime.

  • OPEN (5 min cooldown) — after 25 consecutive failures the URL enters an OPEN circuit. Dispatch is skipped for 5 minutes; log lines drop to a single circuit OPEN warning instead of one per attempt.
  • Auto-disable (permanent) — after 100 consecutive failures the webhook row is switched to enabled=false, disabled_at gets the current timestamp, and disabled_reason records the last error string. In-memory registrations pointing at that URL are purged from every session at the same time. The dispatcher will never try the URL again until it is re-enabled via POST /webhooks/{id}/enable.

Schema columns

The webhooks table carries two additional columns:

  • disabled_at (TIMESTAMPTZ / VARCHAR(30) / TEXT depending on backend) — when the auto-disable fired, NULL otherwise
  • disabled_reason (TEXT) — the last error surfaced before the auto-disable, for later triage

Both are cleared by POST /webhooks/{id}/enable.


Retries & Dead-letter Queue

A delivery that gets a non-2xx response (or a transport error) is retried with exponential backoff in a background task — the event pipeline never blocks on a slow or dead target. The retry policy is configurable via environment variables:

VariableDefaultDescription
WEBHOOK_RETRY_MAX_ATTEMPTS3Total delivery attempts per event before dead-lettering. Backoff: immediate, +5 s, +30 s, then doubling (60 s, 120 s, …) capped at 5 min
WEBHOOK_RETRY_ON_4XXtrueAlso retry 4xx responses. A 401/403 window is usually a fixable consumer-side misconfig, and dropping those events loses messages. Set to false to restore the old "4xx is permanent" behavior (408/429 are retried either way)
WEBHOOK_DLQ_CAPACITY100Per-session in-memory DLQ ring size; oldest entries are evicted past the cap

Deliveries that exhaust every attempt land in a per-session dead-letter queue. Each entry keeps everything needed to replay: session id, webhook URL, event name, the verbatim payload, failure reason, attempt count and timestamp.

The in-memory DLQ is lost on restart. Failures are also recorded in the DB webhook_dlq table, which is the durable record.

List DLQ Entries

GET /api/v1/sessions/{session_id}/webhooks/dlq

Newest first.

{
"entries": [
{
"id": "dlq-entry-uuid",
"session_id": "my-session",
"webhook_url": "https://example.com/webhook",
"event": "message",
"payload": "{\"session_id\":\"my-session\",\"event\":\"message\",...}",
"last_error": "HTTP 401 Unauthorized",
"attempts": 3,
"failed_at": 1767143203
}
],
"count": 1
}

Replay a DLQ Entry

POST /api/v1/sessions/{session_id}/webhooks/dlq/{entry_id}/replay

Re-delivers the stored payload verbatim through the same signing and retry path as a fresh event (including the HMAC secret captured at failure time, so the signature scheme is identical). The entry is removed up front; if the re-delivery also exhausts its retries it lands back in the DLQ as a new entry. Returns 400 if the URL's circuit breaker is currently OPEN — the entry stays queued in that case.

{
"success": true,
"message": "Webhook DLQ entry replay scheduled"
}

Event Types

Core Events

EventDescription
allSubscribe to all events
messageNew message received
receiptMessage receipt (delivered, read)
presenceContact online/offline status
chat_presenceTyping indicator
connectedConnected to WhatsApp
disconnectedDisconnected from WhatsApp
logged_outLogged out from WhatsApp
qr_codeQR code generated
pair_codePair code generated

Group Events

EventDescription
group_updateGroup info changed
joined_groupJoined a new group

Contact & Profile Events

EventDescription
picture_updateProfile picture changed
user_about_updateUser about/status text changed
push_name_updateDisplay name changed
contact_updateContact information updated
device_list_updateLinked devices changed

Chat Events

EventDescription
pin_updateMessage pinned or unpinned
mute_updateChat muted or unmuted
archive_updateChat archived or unarchived
mark_chat_as_readChat marked as read

System Events

EventDescription
undecryptable_messageReceived a message that could not be decrypted
client_outdatedClient version is outdated
offline_sync_previewPreview of offline messages available
offline_sync_completedOffline message sync completed
account_lockedWhatsApp locked the account; auto-reconnect paused (v0.12.0+)
call_log_syncCall-log app-state mutation received
stream_errorStream-level error from WhatsApp (e.g. 429 rate limit)
enc_decrypt_failedA message failed to decrypt; carries which message and why

Webhook Payload

All webhook payloads follow this format:

{
"session_id": "my-session",
"event": "message",
"timestamp": 1767143203,
"data": {
// Event-specific data
}
}

Message-event envelopes carry one extra top-level field (v0.12.0+):

FieldDescription
offlinetrue when the message was redelivered from the disconnect window (received while the session was offline, replayed on reconnect); false for live arrivals

Message Event

{
"session_id": "my-session",
"event": "message",
"timestamp": 1767143203,
"offline": false,
"data": {
"from": "628123456789@s.whatsapp.net",
"from_phone": "628123456789",
"chat": "628123456789@s.whatsapp.net",
"message_id": "3EB0ABC123...",
"is_from_me": false,
"push_name": "Sender",
"message_type": "text",
"text": "hello",
"caption": null,
"media_mimetype": null,
"media": null,
"location": null,
"is_group": false,
"participant": "628123456789@s.whatsapp.net"
}
}

from vs from_phone (v0.9.7+)

from is the raw JID WhatsApp used to address the message, verbatim — for LID-only contacts that is a 12300954140784@lid value, not a phone number, and no amount of waiting for a later webhook or saving the contact changes that; the auto-resolve behavior only applies to /messages/* sends, not to inbound webhooks.

from_phone is waxum's best-effort resolution of that sender to a plain phone number ("628123456789", no JID suffix), added specifically so consumers don't have to do this themselves:

  • If from is already a phone-number JID, from_phone is that same number — no lookup needed.
  • If from is a @lid, waxum looks it up in the LID↔phone mapping the WhatsApp client already learns passively (from usync, message sender attributes, history sync, etc. — no extra network call on waxum's side). This is usually already warm for contacts that have messaged you before.
  • If the mapping isn't known yet, from_phone is null. This can happen for a LID's very first message before WhatsApp has surfaced the phone number through any of those passive channels.

from_phone: null is a real "not known yet" outcome, not a bug — build fallback handling for it if your integration depends on the phone number.

Location Message (v0.6.2+)

When message_type is location or a live location is received, the location field carries the GPS payload.

{
"session_id": "my-session",
"event": "message",
"data": {
"from": "628123456789@s.whatsapp.net",
"message_id": "AC...",
"message_type": "location",
"text": null,
"location": {
"latitude": -6.98,
"longitude": 109.13,
"name": "Hotel X",
"address": "Jl. Sudirman No. 1",
"url": "https://maps.google.com/?q=-6.98,109.13",
"accuracy_meters": 12,
"speed_mps": 0,
"is_live": false
}
}
}

Live locations carry the same latitude/longitude plus sequence_number, optional caption, and is_live: true. The body streams as a series of message events with the same message_id.

Connected Event

{
"session_id": "my-session",
"event": "connected",
"timestamp": 1767143203,
"data": {}
}

Picture Update Event

{
"session_id": "my-session",
"event": "picture_update",
"timestamp": 1767143203,
"data": {
"jid": "628123456789@s.whatsapp.net",
"action": "set"
}
}

Pin Update Event

{
"session_id": "my-session",
"event": "pin_update",
"timestamp": 1767143203,
"data": {
"chat_jid": "628123456789@s.whatsapp.net",
"pinned": true
}
}

Account Locked Event

Emitted when WhatsApp locks the account (v0.12.0+). Auto-reconnect is paused with an escalating cooldown — repeated locks inside the same process lifetime walk the schedule, and the last step repeats once the list is exhausted. A manual POST /sessions/{id}/connect lifts the cooldown and reconnects immediately.

{
"session_id": "my-session",
"event": "account_locked",
"timestamp": 1767143203,
"data": {
"reason": "AccountLocked",
"cooldown_secs": 300,
"cooldown_until": 1767143503
}
}
FieldDescription
reasonLock reason from WhatsApp
cooldown_secsCooldown applied for this lock, in seconds
cooldown_untilUnix timestamp when the cooldown ends

The cooldown schedule is configurable via the ACCOUNT_LOCK_BACKOFF_SECS environment variable — a comma-separated list of seconds, default 300,900,3600 (5 min → 15 min → 60 min).

Call Log Sync Event

Emitted when a call-log app-state mutation arrives.

{
"session_id": "my-session",
"event": "call_log_sync",
"timestamp": 1767143203,
"data": {
"call_creator_jid": "628123456789@s.whatsapp.net",
"call_id": "ABCD1234...",
"from_me": false,
"timestamp": 1767143100,
"from_full_sync": false,
"record": "CallLogRecord { ... }"
}
}
FieldDescription
call_creator_jidJID that initiated the call
call_idWhatsApp call ID
from_metrue when the call was placed by this account
timestampUnix timestamp of the call-log record
from_full_synctrue when the record arrived via a full (snapshot) app-state sync
recordDebug representation of the full call-log record

Stream Error Event

Emitted on stream-level errors from WhatsApp — e.g. a 429 rate-limit rejection.

{
"session_id": "my-session",
"event": "stream_error",
"timestamp": 1767143203,
"data": {
"code": "429"
}
}

Enc Decrypt Failed Event

Emitted when a specific message fails to decrypt, with which message and why.

{
"session_id": "my-session",
"event": "enc_decrypt_failed",
"timestamp": 1767143203,
"data": {
"chat": "628123456789@s.whatsapp.net",
"sender": "628123456789@s.whatsapp.net",
"message_id": "3EB0ABC123...",
"enc_index": 0,
"enc_type": "skmsg",
"reason": "NoSession"
}
}
FieldDescription
chatChat JID the failed message belongs to
senderSender JID
message_idWhatsApp message ID that failed to decrypt
enc_indexIndex of the failing encryption layer
enc_typeEncryption type of the failing payload (e.g. skmsg)
reasonFailure reason (e.g. NoSession)

Signature Verification

If you provide a secret, Waxum will sign the payload with HMAC-SHA256.

Three headers are sent together:

X-Webhook-Timestamp: 1767143203
X-Webhook-Signature: sha256=abc123...
X-Webhook-Signature-Version: v2

Signature scheme v2 (v0.9.8+): the signature covers {timestamp}.{raw body} (the X-Webhook-Timestamp value and the raw request body joined with a literal .), not the raw body alone — a captured (url, timestamp, signature, body) tuple has a valid signature forever otherwise, since nothing about it changes on replay. The HMAC-SHA256 digest is hex-encoded and prefixed with sha256=. Reject anything where X-Webhook-Timestamp is further than a few minutes from your own clock before checking the signature; that's what actually stops replay, the signature alone only proves the payload wasn't tampered with.

X-Webhook-Signature-Version tells you which scheme produced the signature: v2 is the timestamp-prefixed scheme above. Deliveries from waxum < v0.9.8 signed the raw body alone and carried no version header — if you support both, treat a missing header as the legacy v1 scheme.

Verification Example (Node.js)

const crypto = require('crypto');

const MAX_CLOCK_SKEW_SECONDS = 300; // 5 minutes

function verifySignature(payload, timestamp, signature, secret) {
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(skew) || skew > MAX_CLOCK_SKEW_SECONDS) {
return false; // too old (or too far in the future) -- possible replay
}

const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}

// Express middleware
app.post('/webhook', (req, res) => {
const timestamp = req.headers['x-webhook-timestamp'];
const signature = req.headers['x-webhook-signature'];
const payload = JSON.stringify(req.body);

if (!verifySignature(payload, timestamp, signature, 'your-secret')) {
return res.status(401).send('Invalid signature');
}

// Process webhook
console.log(req.body);
res.sendStatus(200);
});

Verification Example (Python)

import hmac
import hashlib
import time

MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes

def verify_signature(payload, timestamp, signature, secret):
try:
skew = abs(time.time() - int(timestamp))
except (TypeError, ValueError):
return False
if skew > MAX_CLOCK_SKEW_SECONDS:
return False # too old (or too far in the future) -- possible replay

expected = 'sha256=' + hmac.new(
secret.encode(),
f'{timestamp}.{payload}'.encode(),
hashlib.sha256
).hexdigest()

return hmac.compare_digest(signature, expected)

Best Practices

  1. Always verify signatures in production
  2. Respond quickly (within 5 seconds) to avoid timeouts
  3. Use HTTPS for webhook endpoints
  4. Handle duplicates - webhooks may be retried
  5. Log everything for debugging