Webhook payloads
Receive, authenticate, validate, and deduplicate open-wa webhook events.
Read the generated webhook event contracts or download AsyncAPI 3.1 for the wire envelope and payload schemas.
This page is the reference for the v5 webhook integration. It shows the envelope, a bounded authenticated receiver, and the delivery limits that apply to the default configuration.
Enable delivery
Install the package in the project that runs the Easy API, then create wa.config.mjs:
npm install @open-wa/integration-webhook@5.1.0// wa.config.mjs
const webhookSecret = process.env.OPEN_WA_WEBHOOK_SECRET?.trim();
if (!webhookSecret) throw new Error('OPEN_WA_WEBHOOK_SECRET is required');
export default {
sessionId: 'sales',
host: '127.0.0.1',
port: 8080,
plugins: ['@open-wa/integration-webhook'],
pluginConfig: {
webhook: {
url: 'https://receiver.example/webhooks/open-wa',
events: ['message.received', 'session.state.changed'],
headers: { 'X-Webhook-Secret': webhookSecret },
concurrency: 10,
queueCapacity: 1000,
overload: 'backpressure',
retries: 3,
retryDelay: 1000,
timeout: 30000,
// Set enabled: true only when this process can write the journal path.
durability: {
enabled: false,
path: '.openwa/webhook-deliveries.sqlite',
replayLimit: 1000,
},
},
},
};Start the CLI from the directory containing the config:
export OPEN_WA_WEBHOOK_SECRET='use-a-long-random-value'
npx @open-wa/wa-automate@5.1.0 --config ./wa.config.mjs --session-id sales --host 127.0.0.1 --port 8080The receiver must be listening before open-wa starts sending events. A successful delivery is any HTTP 2xx response; return it after storing the event or its deduplication key, then do slower work asynchronously.
Envelope
Every delivery has the same outer fields. payload is the event-specific value; current v5 does not use the older data field.
{
"webhookId": "0f5d6a52-2ff2-43d5-9d0d-9c0c8dd5f9d1",
"sessionId": "sales",
"event": "message.received",
"payload": {
"message": {
"id": "false_1234567890@c.us_ABC123",
"from": "1234567890@c.us",
"to": "0987654321@c.us",
"body": "Hello, I need help with my order",
"caption": "",
"type": "chat",
"timestamp": 1700000000,
"fromMe": false,
"isGroupMsg": false,
"isMedia": false
}
},
"timestamp": 1700000000123
}Store only the envelope fields and payload data required for the receiving workflow. webhookId identifies the plugin instance for that process; it is shared across that instance's events, so don't use it as a per-event deduplication key. For message workflows, the first useful application fields are:
| Application field | v5 path | Use |
|---|---|---|
| Sender | payload.message.from | Route to a contact or conversation. |
| Text | payload.message.body or payload.message.caption | Handle text and media captions. |
| Message ID | payload.message.id | Build an idempotency key. |
Canonical receiver
This receiver rejects a missing secret at startup, authenticates before parsing the body, limits JSON to 2 MiB, returns explicit errors for malformed input, and logs identifiers rather than message content. It is the same receiver recipe used in Webhooks for Business.
// receiver.mjs
import express from 'express';
import Database from 'better-sqlite3';
import { createHash, timingSafeEqual } from 'node:crypto';
const expectedSecret = process.env.OPEN_WA_WEBHOOK_SECRET?.trim();
if (!expectedSecret) {
throw new Error('OPEN_WA_WEBHOOK_SECRET must be set before the receiver starts');
}
const expectedBytes = Buffer.from(expectedSecret, 'utf8');
const inbox = new Database('./openwa-webhook-inbox.sqlite');
inbox.exec(`
CREATE TABLE IF NOT EXISTS webhook_inbox (
idempotency_key TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
event TEXT NOT NULL,
received_at INTEGER NOT NULL,
envelope TEXT NOT NULL
)
`);
const insertInbox = inbox.prepare(`
INSERT OR IGNORE INTO webhook_inbox
(idempotency_key, session_id, event, received_at, envelope)
VALUES (?, ?, ?, ?, ?)
`);
const app = express();
const rawBodies = new WeakMap();
const allowRawDiagnostics = process.env.NODE_ENV !== 'production'
&& process.env.DEBUG_WEBHOOK_RAW === '1';
function authenticate(req, res, next) {
const supplied = req.get('X-Webhook-Secret');
if (!supplied || supplied.length !== expectedSecret.length) {
return res.status(401).send('Unauthorized');
}
const suppliedBytes = Buffer.from(supplied, 'utf8');
if (suppliedBytes.length !== expectedBytes.length || !timingSafeEqual(suppliedBytes, expectedBytes)) {
return res.status(401).send('Unauthorized');
}
next();
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function parseEnvelope(value) {
if (!isRecord(value)) return null;
if (typeof value.webhookId !== 'string' || !value.webhookId) return null;
if (typeof value.sessionId !== 'string' || !value.sessionId) return null;
if (typeof value.event !== 'string' || !value.event) return null;
if (typeof value.timestamp !== 'number' || !Number.isFinite(value.timestamp)) return null;
if (!('payload' in value)) return null;
return value;
}
app.post('/webhooks/open-wa', authenticate, express.json({
limit: '2mb',
// Temporary local diagnosis only. This is disabled when NODE_ENV is production.
verify: (req, _res, buffer) => {
if (allowRawDiagnostics) rawBodies.set(req, buffer.toString('utf8'));
},
}), (req, res) => {
const envelope = parseEnvelope(req.body);
if (!envelope) return res.status(400).send('Invalid open-wa webhook envelope');
if (allowRawDiagnostics) {
console.warn('[LOCAL ONLY] raw webhook body:', rawBodies.get(req) ?? '(empty body)');
}
const messageId = envelope.payload?.message?.id;
const idempotencyKey = req.get('Idempotency-Key')
|| (typeof messageId === 'string' && messageId
? `${envelope.sessionId}:${envelope.event}:${messageId}`
: createHash('sha256').update(JSON.stringify(envelope)).digest('hex'));
// Insert the authenticated event before acknowledging it. A duplicate key is a no-op.
const result = insertInbox.run(
idempotencyKey,
envelope.sessionId,
envelope.event,
Date.now(),
JSON.stringify(envelope),
);
console.info('webhook inbox updated', {
sessionId: envelope.sessionId,
event: envelope.event,
duplicate: result.changes === 0,
});
res.sendStatus(204);
});
app.use((error, _req, res, _next) => {
if (error?.type === 'entity.too.large') return res.status(413).send('Webhook body too large');
if (error?.type === 'entity.parse.failed') return res.status(400).send('Malformed JSON');
console.error('webhook receiver error', { name: error?.name ?? 'Error' });
res.status(500).send('Webhook receiver error');
});
app.listen(3000, () => console.log('Webhook receiver listening on http://localhost:3000'));Install and run the receiver in its own project:
npm install express better-sqlite3
export OPEN_WA_WEBHOOK_SECRET='use-a-long-random-value'
node receiver.mjsThe following requests exercise the rejection paths before you connect WhatsApp:
# Missing secret: HTTP 401
curl -i -X POST http://localhost:3000/webhooks/open-wa \
-H 'Content-Type: application/json' \
-d '{}'
# Authenticated but malformed JSON: HTTP 400
curl -i -X POST http://localhost:3000/webhooks/open-wa \
-H 'Content-Type: application/json' \
-H "X-Webhook-Secret: $OPEN_WA_WEBHOOK_SECRET" \
-d '{'
# Valid envelope: HTTP 204
curl -i -X POST http://localhost:3000/webhooks/open-wa \
-H 'Content-Type: application/json' \
-H "X-Webhook-Secret: $OPEN_WA_WEBHOOK_SECRET" \
-d '{"webhookId":"local-test","sessionId":"sales","event":"message.received","payload":{"message":{"id":"local-1","from":"1234567890@c.us","body":"Hello"}},"timestamp":1700000000123}'The current plugin sends configured headers as-is; it does not generate an HMAC signature. With durability.enabled: true, it also sends an Idempotency-Key for the durable delivery row. If a gateway adds an HMAC header, verify the raw body before parsing and reject a signature whose decoded length differs from the expected digest before calling timingSafeEqual.
Delivery limits and recovery
The defaults are finite and bounded:
| Behavior | Default |
|---|---|
| Concurrent requests | 10 |
| In-memory waiting queue | 1000 deliveries |
| Full queue | backpressure (wait for capacity) |
| Request timeout | 30000ms |
| Retries after the first request | 3 |
| Retry delays | 1000ms, 2000ms, 4000ms |
Network errors, timeouts, and non-2xx responses consume attempts. After the final attempt the event is logged as failed and the default in-memory configuration keeps no record for restart or automatic replay. This is a finite retry policy, not a guarantee of eventual delivery.
Set durability.enabled: true to journal queued deliveries in SQLite. Pending rows are replayed on a later startup up to replayLimit; rows that exhaust retries are marked dead letter and the integration has no built-in inspection or replay route. The journal improves restart recovery but still permits duplicates, so the receiver should insert the supplied Idempotency-Key into its inbox before responding. When durability is disabled, this example derives a message key from sessionId:event:payload.message.id or hashes the exact envelope for other events.
Common event values
| Event | Payload focus |
|---|---|
message.received | payload.message for an inbound message. |
message.any | payload.message for inbound or outbound messages. |
message.deleted | payload.messageId, payload.chatId, and optional payload.by. |
ack.changed | payload.ack acknowledgment details. |
session.state.changed | payload.details.prev and payload.details.next. |
group.participants.changed.global | payload.change participant changes. |
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| The receiver gets no requests | The integration is not in plugins, or the event is not in events. | Add @open-wa/integration-webhook to plugins. Add the event name to events. |
| The log shows connection errors | The receiver was not listening when open-wa started. | Start the receiver first. Check that open-wa can connect to the url value. |
The receiver returns 401 | The X-Webhook-Secret value does not match. | Use the same secret in the sender and in the receiver. |
| The same event arrives two times | A timeout or a non-2xx response caused a retry. | Return 2xx quickly. Store the Idempotency-Key value or the message ID, and ignore duplicates. |
| Events stop after a restart | The in-memory queue does not survive a restart. | Set durability.enabled: true to journal deliveries in SQLite. |
For complete receivers, read webhooks for business and build a WhatsApp bot in Python.
Generated type reference
Prop
Type
Prop
Type
Prop
Type
Was this helpful?
Your answer includes the page path and docs version.
