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​

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 in GET /webhooks the 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:

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
pairing_code_errorA 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​

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

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

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_typeSent when the user replies toselected_idselected_text
interactive_responseQuick reply, native-flow list, or a WhatsApp FlowButton or row id from the reply's params_jsonLabel shown on the reply
buttons_responseLegacy buttonsButton idButton label
list_responseLegacy listRow idRow title
template_button_replyA template's quick-reply buttonButton idButton label
  • text is set to the chosen label, or the id when there is no label. Consumers that only read text still see the choice.
  • quoted_message_id is your interactive message, so you can tell which prompt was answered.
  • native_flow is only set for interactive_response. params is the reply's params_json, parsed. For a submitted WhatsApp Flow it holds the whole form, and selected_id may be null.
  • All three fields are null on 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
}
}
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