open-wa
DocsWebhooks for Business

Webhooks for Business

Connect WhatsApp events to a bounded, authenticated backend receiver.

Webhooks for Business

Use the v5 webhook integration when a CRM, helpdesk, queue, or backend should receive WhatsApp events. The sender makes HTTP POST requests; your receiver owns authentication, persistence, deduplication, and any slower business work.

Prerequisites

  • An Easy API project running @open-wa/wa-automate@5.1.0.
  • A public HTTPS receiver URL that can accept POST /webhooks/open-wa.
  • One non-empty random value shared only by the sender and receiver as OPEN_WA_WEBHOOK_SECRET.

The current package is @open-wa/integration-webhook@5.1.0. The CLI --webhook flag is parsed for compatibility but does not register the v5 delivery plugin.

Configure the sender

npm install @open-wa/integration-webhook@5.1.0
export OPEN_WA_WEBHOOK_SECRET='use-a-long-random-value'

Create wa.config.mjs in the directory from which you will run the CLI:

// 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 },
      retries: 3,
      retryDelay: 1000,
      timeout: 30000,
      queueCapacity: 1000,
      overload: 'backpressure',
      // Set enabled: true only when restart replay is required and this process
      // can write the journal path.
      durability: {
        enabled: false,
        path: '.openwa/webhook-deliveries.sqlite',
        replayLimit: 1000,
      },
    },
  },
};

Start the session with the config file:

npx @open-wa/wa-automate@5.1.0 --config ./wa.config.mjs --session-id sales --host 127.0.0.1 --port 8080

Run the receiver

The receiver below is the canonical recipe also shown in Webhook payloads. It validates its secret at startup, checks the header before parsing, bounds the body, returns useful failure codes, and logs only routing identifiers by default.

// 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 the receiver dependencies and run it separately from the Easy API:

npm install express better-sqlite3
export OPEN_WA_WEBHOOK_SECRET='use-a-long-random-value'
node receiver.mjs

Before connecting WhatsApp, confirm the receiver rejects unsafe requests and accepts one valid envelope:

# Missing secret -> HTTP 401
curl -i -X POST http://localhost:3000/webhooks/open-wa \
  -H 'Content-Type: application/json' -d '{}'

# Malformed JSON with the right secret -> 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}'

Map an event

The v5 envelope uses payload, not the older data field. Start with the message sender, text or caption, and message ID:

Business valuev5 path
Senderpayload.message.from
Textpayload.message.body
Media captionpayload.message.caption
Deduplication keysessionId:event:payload.message.id

For Zapier or Make, map those paths from the parsed JSON body. In n8n, a Code node can normalize them:

return [{
  json: {
    sessionId: $json.sessionId,
    event: $json.event,
    sender: $json.payload?.message?.from,
    text: $json.payload?.message?.body || $json.payload?.message?.caption,
    messageId: $json.payload?.message?.id,
  },
}];

Delivery limits

The sender treats network errors, timeouts, and non-2xx responses as failures. The configuration above makes one request plus three retries, with delays of 1000ms, 2000ms, and 4000ms; requests time out after 30000ms, and the in-memory waiting queue holds up to 1000 deliveries with 10 concurrent sends.

After retry exhaustion, the default in-memory configuration logs the failure and keeps no record for automatic replay. With durability.enabled: true, pending rows in .openwa/webhook-deliveries.sqlite replay after a later startup up to replayLimit, and exhausted rows are marked dead letter. Durable requests carry an Idempotency-Key; when durability is disabled, this receiver derives a message key from the session, event, and message ID, or hashes the exact envelope for other event types. Duplicates remain possible, and neither retry mode promises eventual delivery.

Was this helpful?

Your answer includes the page path and docs version.

On this page