Skip to main content
Version: v0.23.1

Receive events with webhooks

Webhooks push OpenWA events to your server over HTTP as they happen — an inbound message, a delivery receipt, a session going online — so you never have to poll the API. Each webhook belongs to one session and receives a POST for every subscribed event.

This guide shows you how to register, list, update, test, and delete webhooks; what a delivery looks like on the wire; how to verify its signature; and how to filter events before they ever reach your endpoint.

Prerequisites
  • A running session — see Connect a session.
  • An API key with the operator role or higher. Every webhook route is operator-guarded, except the delivery-failure log, which requires admin. See Authentication to mint one.
  • A publicly reachable HTTPS endpoint that returns 2xx on success.

The examples assume these shell variables:

export BASE="http://localhost:2785/api"
export API_KEY="YOUR_API_KEY"
export SESSION="8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a"

In production, BASE is your own domain over TLS; the /api prefix is unchanged.

How delivery works

An event flows from the WhatsApp engine to the dispatcher, which keeps only the webhooks whose events list and filters match. Each match is POSTed to your URL with signing and tracing headers. A non-2xx response, a timeout, or a network error triggers a retry with exponential backoff, up to the webhook's retryCount.

The serialized delivery body is capped at WEBHOOK_MAX_PAYLOAD_BYTES (default 1 MiB, validated as a positive integer at boot). An oversized body first sheds any inline media — the event is delivered with the omitted marker instead of being dropped — and is re-checked. A body still over the cap is not sent: the delivery is recorded as undelivered (queryable at GET /api/webhooks/delivery-failures). This is a breaking change from releases that sent oversized bodies as-is.

On shutdown, parked direct deliveries (accepted but not yet dispatched) are dead-lettered, and in-flight deliveries are drained for up to WEBHOOK_SHUTDOWN_DRAIN_MS (default 5 seconds); anything still running after that is logged as abandoned — not dead-lettered, since your endpoint may already have received it. At startup OpenWA logs a warning when WEBHOOK_SHUTDOWN_DRAIN_MS is shorter than WEBHOOK_TIMEOUT (default 10 seconds), because that combination silently truncates in-flight deliveries on shutdown.

Register a webhook

Send a POST to /api/sessions/{sessionId}/webhooks. Only url is required; events defaults to ["message.received"] when omitted.

curl -X POST "$BASE/sessions/$SESSION/webhooks" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhook",
"events": ["message.received", "session.status"],
"secret": "a-long-random-string",
"retryCount": 3
}'
FieldTypeRequiredNotes
urlstringyesEndpoint that receives deliveries. Validated as a URL and SSRF-guarded — an internal or blocked host is rejected with 400, as is a URL embedding credentials (user:pass@host), on create and update (v0.19.0+).
eventsstring[]noEvent names to subscribe to (see the event catalog), or ["*"] for all. At least one entry. Defaults to ["message.received"].
secretstringnoHMAC signing key, 16–255 characters. Since v0.20.0 a secret shorter than 16 characters is rejected when the webhook is created, and since v0.21.0 the update route enforces the same floor (an empty string still clears signing); secrets saved by earlier releases keep signing. Write-only — never returned by any response.
headersobjectnoCustom headers added to every delivery. Write-only. Reserved names (Content-Type, any X-OpenWA-*) are dropped — you cannot forge a system header.
filtersobjectnoOptional pre-dispatch filter (see Filter events before delivery). Omit or set null to fire on every subscribed event.
retryCountnumbernoDelivery attempts on failure, 05. Defaults to 3.

The response (201 Created) echoes the saved webhook. Note that secret and headers are intentionally absent:

{
"id": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"sessionId": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"url": "https://your-server.com/webhook",
"events": ["message.received", "session.status"],
"filters": null,
"active": true,
"retryCount": 3,
"lastTriggeredAt": null,
"createdAt": "2026-06-25T10:00:00.000Z",
"updatedAt": "2026-06-25T10:00:00.000Z"
}

Save the id — you need it to get, update, test, or delete the webhook.

A session can hold at most WEBHOOK_MAX_PER_SESSION webhooks (default 16). A new registration above the cap is rejected with 400; webhooks that already exceed the cap are grandfathered — they keep working and are not deleted. Set the variable to 0 for no cap.

List, update, test, and delete

All management routes are scoped to a session, so one session cannot read or act on another session's webhooks by id — a wrong-session id resolves to 404.

ActionMethod + pathRole
List for a sessionGET /api/sessions/{sessionId}/webhooksoperator
List across your key's sessionsGET /api/webhooksoperator
List failed deliveriesGET /api/webhooks/delivery-failuresadmin
Get oneGET /api/sessions/{sessionId}/webhooks/{id}operator
UpdatePUT /api/sessions/{sessionId}/webhooks/{id}operator
Send a test deliveryPOST /api/sessions/{sessionId}/webhooks/{id}/testoperator
DeleteDELETE /api/sessions/{sessionId}/webhooks/{id}operator

Cross-session diagnostics

Two routes sit outside the per-session tree and answer questions about every webhook at once.

GET /api/webhooks returns every webhook the calling key can see as a bare array, newest first, with limit (1–1000, default 1000) and offset for paging. A session-scoped key sees only the webhooks of its allowed sessions; an admin or otherwise unscoped key sees all. Each entry is the same shape a create returns — secret and headers stay write-only here too.

GET /api/webhooks/delivery-failures is the dead-letter trail: deliveries that exhausted every retry, plus those never attempted at all (recorded with attempts: 0 — an over-budget payload, a dispatch-queue overflow, or a rejection by the shutdown drain). It requires an admin key; an operator key gets a 403, which reads like the route does not exist.

curl -H "X-API-Key: $ADMIN_API_KEY" \
"$BASE/webhooks/delivery-failures?sessionId=$SESSION&limit=20"
Query parameterNotes
sessionIdNarrow to one session. A value outside the key's allowedSessions returns [] rather than an error, so the route is not an existence oracle for other sessions.
limit1–1000, default 1000.
offsetRecords to skip, default 0.

Response 200 — a bare array ordered by createdAt descending:

[
{
"id": "8c0b1f2e-3d4a-5b6c-7d8e-9f0a1b2c3d4e",
"webhookId": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"sessionId": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"event": "message.received",
"url": "https://your-server.com/webhook",
"idempotencyKey": "msg_8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a_true_628123456789@c.us_3EB0ABC123_f1e2d3c4-b5a6-7890-1234-567890abcdef",
"deliveryId": "dlv_0f8c1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
"attempts": 4,
"lastStatusCode": 502,
"lastError": "Request failed with status code 502",
"createdAt": "2026-06-25T11:59:00.000Z"
}
]

lastStatusCode is null when the failure was a network or timeout error rather than a non-2xx response. Since v0.20.0 the lastError text redacts the target's host:port. idempotencyKey and deliveryId let you correlate a lost event against your own receiver logs. Rows older than WEBHOOK_FAILURE_RETENTION_DAYS (default 90; set it to 0 or less to keep everything) are pruned daily.

A delivery still inside its retry window is not here yet — only abandoned ones are recorded. For attempts in flight, read the server log.

Update a webhook

PUT accepts the same fields as create, plus active to enable or disable delivery without deleting the webhook. Every field is optional — send only what changes. Pausing a webhook stops dispatch immediately:

curl -X PUT "$BASE/sessions/$SESSION/webhooks/f1e2d3c4-b5a6-7890-1234-567890abcdef" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "active": false }'

The response (200 OK) is the updated webhook in the same shape as create.

Test a webhook

Fire a synthetic delivery to confirm your endpoint is reachable and your signature check works. The test sends a payload with event: "test" and reports the outcome:

curl -X POST "$BASE/sessions/$SESSION/webhooks/f1e2d3c4-b5a6-7890-1234-567890abcdef/test" \
-H "X-API-Key: $API_KEY"
{ "success": true, "statusCode": 200 }

On failure, success is false and error carries the reason (for example a timeout or a refused connection).

Delete a webhook

curl -X DELETE "$BASE/sessions/$SESSION/webhooks/f1e2d3c4-b5a6-7890-1234-567890abcdef" \
-H "X-API-Key: $API_KEY" -i

A successful delete returns 204 No Content with an empty body.

Event catalog

A webhook fires for an event when its events array contains the event name or "*". These are the events OpenWA emits:

EventFires when
message.receivedAn inbound message arrives.
message.sentAn outbound message is sent from this session.
message.ackA delivery or read receipt updates an outbound message.
message.failedA receipt resolves to failed.
message.revokedA message is deleted or recalled.
message.editedA message's text or media is edited (v0.9.0+; both engines).
message.reactionA reaction is added, changed, or removed.
status.receivedA contact's Status (Story) is received.
session.qrA new pairing QR is generated.
session.authenticatedThe session pairs and is ready.
session.disconnectedThe session disconnects.
session.statusThe session status transitions.
session.reconnect_loopA session is stuck in a reconnect loop; fires once per 5 consecutive scheduled attempts (v0.10.0+).
session.restrictionWhatsApp imposes (or lifts) an account restriction on the session (v0.14.0+).
group.joinA participant joins a group the session belongs to.
group.leaveA participant leaves a group the session belongs to.
group.updateA group's metadata or participant roster changes.
group.join_requestSomeone requests to join a group the session administers (v0.15.0+). Fires only when the group has admin-approval enabled.
presence.updateA subscribed contact's presence changes (online/offline/typing) (v0.14.0+). Baileys only.
call.receivedAn incoming WhatsApp call is detected.
call.acceptedAn incoming call is picked up (v0.14.0+). Baileys only.
call.rejectedAn incoming call is declined (v0.14.0+). Baileys only.
call.missedAn incoming call rings out (v0.14.0+). Baileys only.

Four of these have no producer on the whatsapp-web.js engine: presence.update and the three call outcomes. Subscribing to them there is accepted and then simply never fires. presence.update at least announces itself — its prerequisite, POST /api/sessions/{sessionId}/presence/subscribe, answers 501 on that engine — while the three call outcomes give no signal at all, because whatsapp-web.js sees a call ring but never learns how it ended.

Delivery payload

Every delivery is a POST with this JSON body:

{
"event": "message.received",
"timestamp": "2026-02-02T10:00:00.000Z",
"sessionId": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"idempotencyKey": "msg_8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a_true_628123456789@c.us_3EB0ABC123_f1e2d3c4-b5a6-7890-1234-567890abcdef",
"deliveryId": "dlv_550e8400-e29b-41d4-a716-446655440000",
"data": {
"id": "true_628123456789@c.us_3EB0ABC123",
"from": "628123456789@c.us",
"to": "628987654321@c.us",
"chatId": "628123456789@c.us",
"body": "Hello from OpenWA!",
"type": "text",
"timestamp": 1719312000,
"fromMe": false,
"isGroup": false,
"author": "628123456789@c.us"
}
}

event, timestamp, sessionId, idempotencyKey, and deliveryId are always present. The key is composed <prefix>_<sessionId>_<messageId>_<webhookId> — salted with the webhook's own id so two subscriptions to the same event cannot collide at your dedup boundary, which also means the same message delivered to two webhooks carries two different keys. data holds the event-specific payload — for message events that is the message object shown above. For session.reconnect_loop, data is { "sessionId": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a", "attempts": 5, "nextDelayMs": 300000 }: the consecutive attempt count and the delay before the next scheduled retry. The HMAC signature is not in the body; it rides in the X-OpenWA-Signature header.

Media in the payload

For message events with media, data.media.data carries the file as inline base64 only up to WEBHOOK_MEDIA_INLINE_MAX_BYTES (default 1 MiB, measured on the decoded bytes). Above that, the media travels as the omitted marker — the same shape as an undownloaded inbound media item:

{ "mimetype": "image/jpeg", "omitted": true, "sizeBytes": 1572864 }
Inline base64 is no longer guaranteed

A receiver that expects media.data to always hold base64 must handle the marker instead: fetch the media through message history when you need it, or raise WEBHOOK_MEDIA_INLINE_MAX_BYTES. Set it to 0 to never inline media.

The payload-size gate in How delivery works reuses this marker: an oversized body sheds its inline media before enqueue, so the event is delivered as a marker rather than dropped. Since v0.21.0 the same cap and marker also bound the message.received and message.sent events broadcast on the Socket.IO channel: a large blob is shed to the marker instead of being broadcast in full to every subscribed socket.

Delivery headers

HeaderMeaning
X-OpenWA-EventThe event name (mirrors event in the body).
X-OpenWA-Idempotency-KeyContent-derived key, stable across retries — deduplicate on this.
X-OpenWA-Delivery-IdFresh dlv_<uuid> per delivery — for tracing, not deduplication.
X-OpenWA-Retry-CountAttempt number; 0 is the first attempt.
X-OpenWA-Signaturesha256=<hex> HMAC — present only when the webhook has a secret.
User-AgentOpenWA-Webhook/1.0.0.
Plugin hooks cannot rewrite identity fields

The webhook:before plugin hook can transform data, but event, sessionId, and timestamp are re-asserted to the server's values after the hook runs — the signed body always matches the X-OpenWA-* headers.

Design for at-least-once delivery

Delivery is at-least-once, not exactly-once. The WhatsApp engine can re-fire an event, and a failed delivery is retried, so the same logical event can reach your endpoint more than once.

Make your handler idempotent by deduplicating on X-OpenWA-Idempotency-Key. The key is content-derived: every retry of the same event reuses the same key, while a distinct occurrence (a new message, a fresh session transition) gets a distinct key. Record processed keys and skip a key you have already handled.

Retries

A delivery that returns non-2xx, times out (default 10 seconds), or fails at the network level is retried up to retryCount times with exponential backoff. Each retry carries the same X-OpenWA-Idempotency-Key and an incremented X-OpenWA-Retry-Count. Return 2xx only once you have safely accepted the event.

Once your endpoint answers 2xx, the delivery is successful. A bookkeeping failure on OpenWA's side afterwards (for example, recording lastTriggeredAt) is logged but does not flip the outcome — earlier releases could re-POST such a delivery and file a dead-letter row for an event you had already received.

The delivery is also durable across a hard crash (v0.23.0+): it is recorded before it is attempted, and a bounded sweep replays whatever a crash stranded, under the same stored X-OpenWA-Idempotency-Key. A replay after a restart is therefore the same logical event, one more reason the deduplication above is not optional. Settled delivery records are pruned after WEBHOOK_OUTBOX_RETENTION_DAYS (default 7 days); a record that can still be replayed is never pruned on age.

Verify the HMAC signature

When a webhook has a secret, every delivery includes:

X-OpenWA-Signature: sha256=<hex>

The hex is an HMAC-SHA256 over the exact raw request body bytes, keyed with your secret. To verify, recompute the HMAC over the raw body you received — not a re-serialized parse, which can reorder keys and break the comparison — and compare in constant time. Reject anything that does not match before processing the event.

const crypto = require('crypto');
const express = require('express');

const app = express();
const WEBHOOK_SECRET = process.env.OPENWA_WEBHOOK_SECRET;

function verifyOpenWASignature(rawBody, signature, secret) {
if (!signature || !secret) return false;

const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

const received = Buffer.from(signature);
const computed = Buffer.from(expected);
if (received.length !== computed.length) return false;

return crypto.timingSafeEqual(received, computed);
}

// express.raw keeps req.body as the exact bytes received, so the HMAC matches.
app.post('/openwa/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-OpenWA-Signature');

if (!verifyOpenWASignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}

const event = JSON.parse(req.body.toString('utf8'));

// Deduplicate before doing real work.
const key = req.header('X-OpenWA-Idempotency-Key');
// if (alreadyProcessed(key)) return res.status(200).send('OK');

// Handle event.data here, then acknowledge.
return res.status(200).send('OK');
});

app.listen(3000);
Verification checklist
  • Verify X-OpenWA-Signature before trusting or parsing the body.
  • Compute the HMAC over the exact raw body bytes your server received.
  • Compare in constant time.
  • Return 401 for an invalid signature, and 2xx only once the event is safely accepted.
  • Deduplicate on X-OpenWA-Idempotency-Key.
Set a secret in production

If no secret is configured, the X-OpenWA-Signature header is omitted entirely and you cannot tell a real delivery from a forged one. Always set a long, random secret for production webhooks.

Filter events before delivery

A webhook can carry a filters object so OpenWA only delivers events that match — keeping noise off your endpoint and saving you the verification round-trip. Filters are evaluated before dispatch. All conditions are combined with AND: every condition must match for the webhook to fire. Omitting filters (or setting it to null) delivers every subscribed event.

{
"url": "https://your-server.com/webhook",
"events": ["message.received"],
"filters": {
"conditions": [
{ "field": "sender", "operator": "is", "value": ["628123456789@c.us"] },
{ "field": "body", "operator": "contains", "value": "invoice" }
]
}
}

Filters apply to message events only in this release. A condition on a non-message event is skipped, so the webhook still fires for those events.

A condition on a field the event does not carry suppresses that event

Filter fields are resolved out of the event's own payload, and not every message event carries every field. A condition on an absent field cannot match, so it drops the event entirely rather than being inert: message.ack and message.failed carry { id, messageId, status, ack } and message.reaction carries { messageId, chatId, reaction, senderId } — none of the three has a sender or a body, so a single sender condition suppresses all of them.

Scope the subscription with events rather than relying on a filter to be ignored on the events it cannot read.

Each condition is { field, operator, value, caseSensitive? }. The allowed operators and value type depend on the field:

FieldTypeOperatorsvalue
sendercontact idis, isNotarray of ids or bare phone numbers
recipientcontact idis, isNotarray of ids or bare phone numbers
mentionscontact id listis, isNotarray of ids — matches if any mentioned id is in the list
typemessage typeis, isNotarray of text, image, video, audio, voice, document, sticker, location, contact, revoked, unknown
bodytextcontains, equalsa string; set "caseSensitive": true to match case
isGroupbooleanistrue or false
fromMebooleanistrue or false
hasMediabooleanistrue or false

Id matching is dialect-aware: a bare phone number, a @c.us JID, and the underlying @lid for the same contact all match each other, so "628123456789" and "628123456789@c.us" are equivalent.

A filter is validated on save. An unknown field, an operator the field does not support, a wrong value type, more than 20 conditions, more than 100 values in one condition, or a body string over 1000 characters returns 400 with a field-level message.

A suppressed delivery says so

A subscribed webhook that a filter drops used to leave no trace at all: nothing is delivered, no metric moves, and the delivery-failure log records only deliveries that were attempted. Since v0.14.6 each suppression is logged at debug level, so a webhook that has gone quiet can be explained without guessing:

Webhook filters suppressed a delivery
action=webhook_filter_suppressed event=message.ack
subscribed=1 suppressed=1 payloadFields=ack,id,messageId,status

payloadFields is the alphabetically sorted list of fields the event's payload actually carried — usually the answer on its own, because the common cause is a condition naming a field that is not in that list. Set LOG_LEVEL=debug while investigating; the line is not emitted at the default level, since a filter suppressing an event is the normal outcome of a filter doing its job.

Filter matching itself is unchanged: a filter that fired before still fires, and one that suppressed still suppresses.

Build filters visually

The dashboard ships a condition builder for these filters, so you can compose and preview them without hand-writing the JSON.

Use the SDK

The @rmyndharis/openwa JavaScript SDK wraps the session-scoped webhook routes — create, list, get, update, test, and delete — and, since v0.14.6, the two cross-session routes as well. Pass the host as baseUrl (without /api — the SDK adds it):

import { OpenWAClient } from '@rmyndharis/openwa';

const client = new OpenWAClient({
baseUrl: 'http://localhost:2785',
apiKey: process.env.OPENWA_API_KEY,
});

const webhook = await client.webhooks.create('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', {
url: 'https://your-server.com/webhook',
events: ['message.received', 'session.status'],
secret: process.env.OPENWA_WEBHOOK_SECRET,
});

await client.webhooks.test('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', webhook.id);
await client.webhooks.update('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', webhook.id, { active: false });
// client.webhooks.list / get / delete are also available.

// Cross-session: every webhook the key can see, and the dead-letter log (admin key).
await client.webhooks.listAll({ limit: 100 });
await client.webhooks.deliveryFailures({ sessionId: '8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', limit: 20 });

Common errors

StatusCauseFix
400 Bad RequestURL is internal or SSRF-blocked, embeds credentials (user:pass@host, v0.19.0+), an invalid event name, a malformed filter, an unknown body field, or the session is at its webhook cap (WEBHOOK_MAX_PER_SESSION, default 16).Use a public HTTPS URL with no credentials in it; check the message array for the offending field; delete a webhook before registering another.
401 UnauthorizedMissing, invalid, or out-of-scope X-API-Key.Send a valid key scoped to this session. See Authentication.
403 ForbiddenKey role is below operator — or below admin on GET /api/webhooks/delivery-failures.Use an operator or admin key; use an admin key for the delivery-failure log.
404 Not FoundThe session or webhook id does not exist, or the webhook belongs to a different session.Confirm both sessionId and the webhook id.
429 Too Many RequestsGlobal rate limit exceeded.Honor the per-throttler Retry-After-* header and back off. See Rate limiting.

Your own endpoint must return 2xx to acknowledge a delivery. Anything else — including a 401 from your signature check — counts as a failure and is retried.

Next steps