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"
}
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Webhook endpoint URL |
events | array | No | Events to subscribe (default: all) |
secret | string | No | HMAC-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
Turns a webhook you disabled back on. It takes effect at once and clears any suspension on its URL.
POST /api/v1/sessions/{session_id}/webhooks/{webhook_id}/enable
Response
{
"success": true,
"message": "Webhook re-enabled"
}
Suspension after failed deliveries
waxum never removes or disables a webhook on its own. When a receiver keeps failing, deliveries to that URL are suspended and retried:
- After 25 consecutive failed deliveries the URL is suspended for 5 minutes.
- When the time is up, exactly one event is delivered as a probe. If it succeeds, delivery resumes. If it fails, the URL is suspended again for twice as long, up to 1 hour.
- The webhook stays registered,
enabled: true, and inGET /webhooksthe whole time.
While a URL is suspended, events for it are not attempted and are not
queued; skipped_while_suspended counts them. Deliveries that were
attempted and failed are in the dead-letter queue
and can be replayed once the receiver is back.
GET /sessions/{id}/status shows the state per webhook under
diagnostics.webhooks[]: circuit_open, consecutive_failures,
retry_in_seconds, last_error and skipped_while_suspended. waxum
also emits webhook_suspended and webhook_resumed on the console's
activity feed and in its log.
:::note Changed in v0.14.0
Up to v0.13.6 the 100th consecutive failure set enabled=false in the
database and dropped the webhook from memory for good. Failures were
counted per in-flight request, so one burst of events against a
receiver that was down for a few seconds was enough. On upgrade, waxum
re-enables every webhook that was switched off this way.
:::
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:
| Variable | Default | Description |
|---|---|---|
WEBHOOK_RETRY_MAX_ATTEMPTS | 3 | Total 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_4XX | true | Also 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_CAPACITY | 100 | Per-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_dlqtable, 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
| Event | Description |
|---|---|
all | Subscribe to all events |
message | New message received |
receipt | Message receipt (delivered, read) |
presence | Contact online/offline status |
chat_presence | Typing indicator |
connected | Connected to WhatsApp |
disconnected | Disconnected from WhatsApp |
logged_out | Logged out from WhatsApp |
qr_code | QR code generated |
pair_code | Pair code generated |
pairing_code_error | A pair-code request failed. data has error (text), rejection (WhatsApp's refusal, e.g. RateOverlimit, or null when the failure was local), throttled (boolean) and retry_after_seconds (or null). Since v0.13.6; empty before. |
Group Events
| Event | Description |
|---|---|
group_update | Group info changed |
joined_group | Joined a new group |
Contact & Profile Events
| Event | Description |
|---|---|
picture_update | Profile picture changed |
user_about_update | User about/status text changed |
push_name_update | Display name changed |
contact_update | Contact information updated |
device_list_update | Linked devices changed |
Chat Events
| Event | Description |
|---|---|
pin_update | Message pinned or unpinned |
mute_update | Chat muted or unmuted |
archive_update | Chat archived or unarchived |
mark_chat_as_read | Chat marked as read |
System Events
| Event | Description |
|---|---|
undecryptable_message | Received a message that could not be decrypted |
client_outdated | Client version is outdated |
offline_sync_preview | Preview of offline messages available |
offline_sync_completed | Offline message sync completed |
account_locked | WhatsApp locked the account; auto-reconnect paused (v0.12.0+) |
call_log_sync | Call-log app-state mutation received |
stream_error | Stream-level error from WhatsApp (e.g. 429 rate limit) |
enc_decrypt_failed | A 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+):
| Field | Description |
|---|---|
offline | true when the message was redelivered from the disconnect window (received while the session was offline, replayed on reconnect); false for live arrivals |
offline only says how a message reached waxum. It is not a filter.
Handle every message event regardless of it. Filtering on
offline: true would drop every live message.
WhatsApp sometimes delivers the same message twice: once in the offline
replay, and again live when the phone comes online. Since v0.13.0,
waxum forwards each message only once per session, keyed on chat and
message_id, over the last 4096 messages. This memory is lost on
restart, so a redelivery that spans a restart can still repeat. If you
need exactly-once processing, also deduplicate on data.message_id on
your side.
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",
"chat_phone": "628123456789",
"quoted_message_id": null,
"quoted_sender_jid": null,
"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
fromis already a phone-number JID,from_phoneis that same number — no lookup needed. - If
fromis a@lid, waxum looks it up in the LID↔phone mapping the WhatsApp client already learns passively (fromusync, 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_phoneisnull. 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.
chat vs chat_phone (v0.12.9+)
chat_phone is the same best-effort resolution as from_phone, but
for chat instead of from. This matters most for messages you sent
via the API (is_from_me: true): from/from_phone there are your
own session's number, and chat — the recipient — is the field you
actually want a phone number for. On a LID-only recipient, chat is
a @lid value and chat_phone follows the same "resolved if known,
otherwise null" rules as from_phone. For a group chat, chat is
a @g.us JID and chat_phone is always null.
quoted_message_id / quoted_sender_jid (v0.12.9+)
Present when the incoming message is a WhatsApp reply (quoting an
earlier message). quoted_message_id is the quoted message's id
(ContextInfo.stanzaId); quoted_sender_jid is who sent it
(ContextInfo.participant). Both are null when the message is not a
reply, and quoted_sender_jid can be null even on a reply if
WhatsApp omitted the field on the wire.
Neither is resolved against locally stored history — this webhook
event carries only what WhatsApp attached to the message itself, not a
join against your own message log. Look quoted_message_id up against
chat history (which carries the
same two fields on every row) or your own store if you need the quoted
message's actual content.
Button and list replies (v0.13.1+)
When a user taps a button or picks a list row, the event says which option they chose:
{
"message_type": "interactive_response",
"text": "✅ Setuju",
"selected_id": "approve",
"selected_text": "✅ Setuju",
"native_flow": { "name": "quick_reply", "params": { "id": "approve" } },
"quoted_message_id": "3EB0D7A57AA45E7D72FE2B"
}
message_type | Sent when the user replies to | selected_id | selected_text |
|---|---|---|---|
interactive_response | Quick reply, native-flow list, or a WhatsApp Flow | Button or row id from the reply's params_json | Label shown on the reply |
buttons_response | Legacy buttons | Button id | Button label |
list_response | Legacy list | Row id | Row title |
template_button_reply | A template's quick-reply button | Button id | Button label |
textis set to the chosen label, or the id when there is no label. Consumers that only readtextstill see the choice.quoted_message_idis your interactive message, so you can tell which prompt was answered.native_flowis only set forinteractive_response.paramsis the reply'sparams_json, parsed. For a submitted WhatsApp Flow it holds the whole form, andselected_idmay benull.- All three fields are
nullon ordinary messages.
Before v0.13.1 these replies arrived as message_type: "unknown" with
text: null.
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
}
}
| Field | Description |
|---|---|
reason | Lock reason from WhatsApp |
cooldown_secs | Cooldown applied for this lock, in seconds |
cooldown_until | Unix 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 { ... }"
}
}
| Field | Description |
|---|---|
call_creator_jid | JID that initiated the call |
call_id | WhatsApp call ID |
from_me | true when the call was placed by this account |
timestamp | Unix timestamp of the call-log record |
from_full_sync | true when the record arrived via a full (snapshot) app-state sync |
record | Debug 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"
}
}
| Field | Description |
|---|---|
chat | Chat JID the failed message belongs to |
sender | Sender JID |
message_id | WhatsApp message ID that failed to decrypt |
enc_index | Index of the failing encryption layer |
enc_type | Encryption type of the failing payload (e.g. skmsg) |
reason | Failure 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
- Always verify signatures in production
- Respond quickly (within 5 seconds) to avoid timeouts
- Use HTTPS for webhook endpoints
- Handle duplicates - webhooks may be retried
- Log everything for debugging