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.
- 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
2xxon 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, until the delivery has been attempted retryCount times in total. Backoff is exponential on the queued path (QUEUE_ENABLED=true) and linear on the direct path OpenWA uses otherwise: WEBHOOK_RETRY_DELAY multiplied by the attempt number, so 5 seconds then 10 seconds by default.
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
}'
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Endpoint 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+). |
events | string[] | no | Event names to subscribe to (see the event catalog), or ["*"] for all. At least one entry. Defaults to ["message.received"]. |
secret | string | no | HMAC 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. |
headers | object | no | Custom headers added to every delivery. Write-only. Reserved names (Content-Type, any X-OpenWA-*) are dropped — you cannot forge a system header. |
filters | object | no | Optional pre-dispatch filter (see Filter events before delivery). Omit or set null to fire on every subscribed event. |
retryCount | number | no | Total delivery attempts, 0–5. Defaults to 3, meaning one attempt plus two retries. 0 and 1 both mean a single attempt with no retry. |
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.
| Action | Method + path | Role |
|---|---|---|
| List for a session | GET /api/sessions/{sessionId}/webhooks | operator |
| List across your key's sessions | GET /api/webhooks | operator |
| List failed deliveries | GET /api/webhooks/delivery-failures | admin |
| Get one | GET /api/sessions/{sessionId}/webhooks/{id} | operator |
| Update | PUT /api/sessions/{sessionId}/webhooks/{id} | operator |
| Send a test delivery | POST /api/sessions/{sessionId}/webhooks/{id}/test | operator |
| Delete | DELETE /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. The trail follows deliveries, not queue jobs: a webhook job removed from the BullMQ queue by hand (the Bull Board dashboard's empty action, or its obliterate action, which since v0.23.3 can be forced past jobs that are still running) never reaches a final attempt, so no failure row is written for it and the crash reconciler will not replay it either.
curl -H "X-API-Key: $ADMIN_API_KEY" \
"$BASE/webhooks/delivery-failures?sessionId=$SESSION&limit=20"
| Query parameter | Notes |
|---|---|
sessionId | Narrow 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. |
limit | 1–1000, default 1000. |
offset | Records 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": 3,
"lastStatusCode": 502,
"lastError": "HTTP 502: Bad Gateway",
"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:
| Event | Fires when |
|---|---|
message.received | An inbound message arrives. |
message.sent | An outbound message is sent from this session. |
message.ack | A delivery or read receipt updates an outbound message. |
message.failed | A receipt resolves to failed. |
message.revoked | A message is deleted or recalled. |
message.edited | A message's text, or a media message's caption, is edited (v0.9.0+; both engines). The payload carries the new text and a hasMedia flag, never replacement media: WhatsApp cannot edit the attachment itself. |
message.reaction | A reaction is added, changed, or removed. |
status.received | A contact's Status (Story) is received. |
session.qr | A new pairing QR is generated. |
session.authenticated | The session pairs and is ready. |
session.disconnected | The session disconnects. |
session.status | The session status transitions. |
session.reconnect_loop | A session is stuck in a reconnect loop; fires once per 5 consecutive scheduled attempts (v0.10.0+). |
session.restriction | WhatsApp imposes (or lifts) an account restriction on the session (v0.14.0+). |
group.join | A participant joins a group the session belongs to. |
group.leave | A participant leaves a group the session belongs to. |
group.update | A group's metadata changes (subject, description, settings). A participant joining or leaving is group.join or group.leave instead, and an admin promotion or demotion produces no event at all, so do not wait on this one for roster activity. |
group.join_request | Someone requests to join a group the session administers (v0.15.0+). Fires only when the group has admin-approval enabled. |
presence.update | A subscribed contact's presence changes (online/offline/typing) (v0.14.0+). Baileys only. |
call.received | An incoming WhatsApp call is detected. |
call.accepted | An incoming call is picked up (v0.14.0+). Baileys only. |
call.rejected | An incoming call is declined (v0.14.0+). Baileys only. |
call.missed | An 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,
"kind": "individual",
"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 }
A receiver that expects media.data to always hold base64 must handle the marker instead: fetch the media when you need it, or raise the caps. The two caps compound: WEBHOOK_MAX_PAYLOAD_BYTES is measured on the serialized JSON body after WEBHOOK_MEDIA_INLINE_MAX_BYTES has been applied, and base64 inflates media by a third, so at the shipped defaults (1 MiB each) media above roughly 768 KiB of decoded bytes already reaches you as the marker. With the payload cap left at its default, raising the inline cap alone changes nothing, because the payload gate then sheds the media anyway: raise both, keeping the payload cap at least 4/3 of the inline cap plus room for the rest of the event, for example WEBHOOK_MEDIA_INLINE_MAX_BYTES=5242880 with WEBHOOK_MAX_PAYLOAD_BYTES=7340032. Set the inline cap to 0 to never inline media.
Fetch it from the per-message media route rather than the message list. The list carries its own inline budget (MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES) and honours inlineMedia=false, so it can hand you the very same omitted marker; the per-message route serves the bytes.
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
| Header | Meaning |
|---|---|
X-OpenWA-Event | The event name (mirrors event in the body). |
X-OpenWA-Idempotency-Key | Content-derived key, stable across retries — deduplicate on this. |
X-OpenWA-Delivery-Id | Fresh dlv_<uuid> per delivery — for tracing, not deduplication. |
X-OpenWA-Retry-Count | Attempt number; 0 is the first attempt. |
X-OpenWA-Signature | sha256=<hex> HMAC — present only when the webhook has a secret. |
User-Agent | OpenWA-Webhook/1.0.0. |
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.
A delivery that returns non-2xx, times out (default 10 seconds), or fails at the network level is retried until it has been attempted retryCount times in total, so the default 3 gives your endpoint one attempt plus two retries, not four POSTs. Backoff is exponential on the queued path and linear on the direct path (see How delivery works). 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);
- Verify
X-OpenWA-Signaturebefore trusting or parsing the body. - Compute the HMAC over the exact raw body bytes your server received.
- Compare in constant time.
- Return
401for an invalid signature, and2xxonly once the event is safely accepted. - Deduplicate on
X-OpenWA-Idempotency-Key.
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.
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 }, plus a reactions map holding the post-apply snapshot of every reaction on the message whenever that message is in the store — 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:
| Field | Type | Operators | value |
|---|---|---|---|
sender | contact id | is, isNot | array of ids or bare phone numbers |
recipient | contact id | is, isNot | array of ids or bare phone numbers |
mentions | contact id list | is, isNot | array of ids — matches if any mentioned id is in the list |
type | message type | is, isNot | array of text, image, video, audio, voice, document, sticker, location, contact, poll, call, revoked, order, product, masked, unknown (order and product v0.23.5+) |
body | text | contains, equals | a string; set "caseSensitive": true to match case |
isGroup | boolean | is | true or false |
kind | chat kind | is, isNot | array of individual, group, channel, status, broadcast, unknown (v0.23.4+) |
fromMe | boolean | is | true or false |
hasMedia | boolean | is | true 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.
kind is the full discriminator isGroup cannot be. Everything that is not a group collapses to isGroup: false, so a channel (newsletter), a status update and a broadcast list are indistinguishable from a one-to-one chat there. Excluding channel traffic while keeping direct messages needs { "field": "kind", "operator": "isNot", "value": ["channel"] }. The value is read from the event payload when it carries one, and derived from chatId otherwise, which is what makes it work on the message.edited, message.reaction and message.revoked events that carry only the chat id. isGroup is unchanged and still supported.
hasMedia widened in v0.23.4 without any change to the filter itself: a message whose media download failed now keeps its media envelope with omitted: true instead of losing the field, so it matches hasMedia: true where it used to be skipped. A rule written to catch every media message catches more of them than before.
type gained order and product in v0.23.5: a cart the customer placed and a shared product card used to arrive as unknown, so a rule written on unknown stops catching them.
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. poll became a valid type value in v0.23.2; before that the dashboard offered it in its picker while the save was refused as invalid, so a rule or webhook built on it on an older gateway never saved.
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.
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
| Status | Cause | Fix |
|---|---|---|
400 Bad Request | URL 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 Unauthorized | Missing, invalid, or out-of-scope X-API-Key. | Send a valid key scoped to this session. See Authentication. |
403 Forbidden | Key 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 Found | The 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 Requests | Global 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
- Authentication — issue the operator key these routes require.
- Sending messages — act on the events you receive.
- n8n integration — consume OpenWA webhooks in a no-code workflow.
- API reference — the full webhook endpoint schemas.