open-wa
DocsWebhook payloads

Webhook payloads

Receive, authenticate, validate, and deduplicate open-wa webhook events.

Webhook payloads

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 8080

The 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 fieldv5 pathUse
Senderpayload.message.fromRoute to a contact or conversation.
Textpayload.message.body or payload.message.captionHandle text and media captions.
Message IDpayload.message.idBuild 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.mjs

The 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:

BehaviorDefault
Concurrent requests10
In-memory waiting queue1000 deliveries
Full queuebackpressure (wait for capacity)
Request timeout30000ms
Retries after the first request3
Retry delays1000ms, 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

EventPayload focus
message.receivedpayload.message for an inbound message.
message.anypayload.message for inbound or outbound messages.
message.deletedpayload.messageId, payload.chatId, and optional payload.by.
ack.changedpayload.ack acknowledgment details.
session.state.changedpayload.details.prev and payload.details.next.
group.participants.changed.globalpayload.change participant changes.

Generated type reference

Prop

Type

Prop

Type

Prop

Type

Was this helpful?

Your answer includes the page path and docs version.

On this page