Webhook payloads
Receive, authenticate, validate, and deduplicate open-wa webhook events.
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. |
Generated type reference
Prop
Type
Prop
Type
Prop
Type
Was this helpful?
Your answer includes the page path and docs version.
