Skip to main content
Version: v0.7.6

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. 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="my-session"

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.

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.
eventsstring[]noEvent names to subscribe to (see the event catalog), or ["*"] for all. At least one entry. Defaults to ["message.received"].
secretstringnoHMAC signing key, max 255 characters. 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": "my-session",
"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.

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 + path
List for a sessionGET /api/sessions/{sessionId}/webhooks
List across your key's sessionsGET /api/webhooks
Get oneGET /api/sessions/{sessionId}/webhooks/{id}
UpdatePUT /api/sessions/{sessionId}/webhooks/{id}
Send a test deliveryPOST /api/sessions/{sessionId}/webhooks/{id}/test
DeleteDELETE /api/sessions/{sessionId}/webhooks/{id}

GET /api/webhooks returns every webhook the calling key can see; a session-scoped key sees only the webhooks of its allowed sessions, while an admin or unscoped key sees all.

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.reactionA reaction is added, changed, or removed.
session.qrA new pairing QR is generated.
session.authenticatedThe session pairs and is ready.
session.disconnectedThe session disconnects.
session.statusThe session status transitions.
Reserved events

group.join, group.leave, and group.update are accepted in an events list and validate successfully, but no engine path emits them yet — registering for them is harmless, but they are never delivered.

Delivery payload

Every delivery is a POST with this JSON body:

{
"event": "message.received",
"timestamp": "2026-02-02T10:00:00.000Z",
"sessionId": "my-session",
"idempotencyKey": "msg_my-session_3EB0ABC123",
"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. data holds the event-specific payload — for message events that is the message object shown above. The HMAC signature is not in the body; it rides in the X-OpenWA-Signature header.

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.

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.

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.

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.

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. The cross-session GET /api/webhooks list route is not yet in the SDK; call it directly if you need it. 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('my-session', {
url: 'https://your-server.com/webhook',
events: ['message.received', 'session.status'],
secret: process.env.OPENWA_WEBHOOK_SECRET,
});

await client.webhooks.test('my-session', webhook.id);
await client.webhooks.update('my-session', webhook.id, { active: false });
// client.webhooks.list / get / delete are also available.

Common errors

StatusCauseFix
400 Bad RequestURL is internal or SSRF-blocked, an invalid event name, a malformed filter, or an unknown body field.Use a public HTTPS URL; check the message array for the offending field.
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.Use an operator or admin key.
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 Retry-After header and back off.

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