open-wa v5 is alpha. Use v4.76.0 for mature production systems unless you are validating v5.
The Client APIAPI ExplorerLicensing

Webhook payloads

See the JSON your webhook receives, how to verify it, and which fields to store.

Payload Envelope Wally

Webhook payloads

Use this page when you are building the service that receives open-wa webhook events. It shows the current v5 envelope, how to enable delivery, how to verify requests, and which fields to store first.

Enablement flow

Use the source-backed v5 webhook integration plugin from config. The CLI still accepts --webhook, but current source warns that CLI webhook registration parity is not restored, so config is the reliable path for event delivery in this alpha:

// wa.config.ts
const webhookSecret = process.env.OPEN_WA_WEBHOOK_SECRET;

if (!webhookSecret) {
  throw new Error('OPEN_WA_WEBHOOK_SECRET is required');
}

export default {
  port: 8080,
  plugins: ['@open-wa/integration-webhook'],
  pluginConfig: {
    webhook: {
      url: 'https://your-app.example/webhooks/open-wa',
      events: ['message.received', 'session.state.changed', 'group.participants.changed.global'],
      headers: {
        'X-Webhook-Secret': webhookSecret,
      },
      retries: 3,
      retryDelay: 1000,
      timeout: 30000,
    },
  },
};

Verify it in this order:

  1. Start your receiving service and make sure it accepts POST requests.
  2. Start open-wa with the config file that loads @open-wa/integration-webhook.
  3. Check logs for the webhook target URL and selected events.
  4. Send a WhatsApp message to the connected session.
  5. Confirm your endpoint receives a JSON body with webhookId, sessionId, event, payload, and timestamp.
  6. Return a 2xx response quickly. Slow or failed responses trigger retries.

Current v5 envelope

The outer envelope is the same for every event. The payload field changes based on event.

{
  "webhookId": "0f5d6a52-2ff2-43d5-9d0d-9c0c8dd5f9d1",
  "sessionId": "sales",
  "event": "message.received",
  "payload": {
    "ctx": {
      "correlationId": "evt_01",
      "ts": 1700000000000
    },
    "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
}

Copy these three fields first

Field to storeCurrent v5 pathNotes
Senderpayload.message.fromDirect chats usually end in @c.us; groups usually end in @g.us.
Text or captionpayload.message.body or payload.message.captionMedia messages can have an empty body and a useful caption.
Message idpayload.message.idUse with sessionId for dedupe keys.

Endpoint handler examples

Every webhook delivery is a JSON POST. Your endpoint should authenticate the request, log the raw body while developing, process only the events it cares about, then return a 2xx response.

Express

import express from 'express';

type WebhookEnvelope = {
  webhookId: string;
  sessionId: string;
  event: string;
  payload: unknown;
  timestamp: number;
};

type MessageFields = {
  id?: string;
  from?: string;
  body?: string;
  caption?: string;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function parseEnvelope(value: unknown): WebhookEnvelope | null {
  if (!isRecord(value)) return null;
  if (typeof value.webhookId !== 'string') return null;
  if (typeof value.sessionId !== 'string') return null;
  if (typeof value.event !== 'string') return null;
  if (typeof value.timestamp !== 'number') return null;

  return {
    webhookId: value.webhookId,
    sessionId: value.sessionId,
    event: value.event,
    payload: value.payload,
    timestamp: value.timestamp,
  };
}

function getMessageFields(envelope: WebhookEnvelope): MessageFields {
  if (!isRecord(envelope.payload)) return {};
  const message = envelope.payload.message;
  if (!isRecord(message)) return {};

  return {
    id: typeof message.id === 'string' ? message.id : undefined,
    from: typeof message.from === 'string' ? message.from : undefined,
    body: typeof message.body === 'string' ? message.body : undefined,
    caption: typeof message.caption === 'string' ? message.caption : undefined,
  };
}

const app = express();
const rawBodies = new WeakMap<object, string>();

app.use(express.json({
  limit: '2mb',
  verify: (req, _res, buf) => {
    rawBodies.set(req, buf.toString('utf8'));
  },
}));

app.post('/webhooks/open-wa', (req, res) => {
  console.log('raw webhook body:', rawBodies.get(req) ?? '(empty body)');

  if (req.header('X-Webhook-Secret') !== process.env.OPEN_WA_WEBHOOK_SECRET) {
    return res.status(401).send('Unauthorized');
  }

  const envelope = parseEnvelope(req.body);

  if (!envelope) {
    return res.status(400).send('Invalid open-wa webhook envelope');
  }

  const message = getMessageFields(envelope);

  if (envelope.event === 'message.received') {
    console.log(`[${envelope.sessionId}] sender: ${message.from ?? 'unknown'}`);
    console.log('text:', message.body || message.caption || '(no text)');
    console.log('message id:', message.id ?? 'unknown');
  }

  res.sendStatus(204);
});

app.listen(3000, () => {
  console.log('Webhook receiver listening on http://localhost:3000');
});

Send one local request before connecting open-wa to the receiver:

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":"false_1234567890@c.us_ABC123","from":"1234567890@c.us","body":"Hello, I need help with my order"}},"timestamp":1700000000123}'

Fastify

import Fastify from 'fastify';

type WebhookEnvelope = {
  sessionId: string;
  event: string;
  payload: {
    message?: {
      id?: string;
      from?: string;
      body?: string;
      caption?: string;
    };
  };
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function parseEnvelope(value: unknown): WebhookEnvelope | null {
  if (!isRecord(value)) return null;
  if (typeof value.sessionId !== 'string') return null;
  if (typeof value.event !== 'string') return null;
  if (!isRecord(value.payload)) return null;

  const message = isRecord(value.payload.message) ? value.payload.message : undefined;

  return {
    sessionId: value.sessionId,
    event: value.event,
    payload: {
      message: message ? {
        id: typeof message.id === 'string' ? message.id : undefined,
        from: typeof message.from === 'string' ? message.from : undefined,
        body: typeof message.body === 'string' ? message.body : undefined,
        caption: typeof message.caption === 'string' ? message.caption : undefined,
      } : undefined,
    },
  };
}

const fastify = Fastify({ logger: true });

fastify.post('/webhooks/open-wa', async (request, reply) => {
  if (request.headers['x-webhook-secret'] !== process.env.OPEN_WA_WEBHOOK_SECRET) {
    return reply.code(401).send({ error: 'Unauthorized' });
  }

  const body = parseEnvelope(request.body);

  if (!body) {
    return reply.code(400).send({ error: 'Invalid open-wa webhook envelope' });
  }

  if (body.event === 'message.received') {
    request.log.info({
      sessionId: body.sessionId,
      sender: body.payload.message?.from,
      text: body.payload.message?.body || body.payload.message?.caption,
      messageId: body.payload.message?.id,
    }, 'message webhook received');
  }

  return reply.code(204).send();
});

await fastify.listen({ port: 3000 });

Plain Node.js

import { createServer } from 'node:http';

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function getNextState(value: unknown): string {
  if (!isRecord(value)) return 'unknown';
  if (!isRecord(value.payload)) return 'unknown';
  if (!isRecord(value.payload.details)) return 'unknown';
  return typeof value.payload.details.next === 'string' ? value.payload.details.next : 'unknown';
}

const server = createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhooks/open-wa') {
    res.writeHead(404).end();
    return;
  }

  if (req.headers['x-webhook-secret'] !== process.env.OPEN_WA_WEBHOOK_SECRET) {
    res.writeHead(401).end('Unauthorized');
    return;
  }

  let rawBody = '';

  for await (const chunk of req) {
    rawBody += chunk;
  }

  console.log('raw webhook body:', rawBody);

  const body: unknown = JSON.parse(rawBody);

  if (isRecord(body) && body.event === 'session.state.changed') {
    console.log('Session state:', getNextState(body));
  }

  res.writeHead(204).end();
});

server.listen(3000);

Expected local test output, illustrative and abbreviated:

Webhook receiver listening on http://localhost:3000
raw webhook body: {"webhookId":"...","sessionId":"sales","event":"message.received","payload":{...}}
[sales] sender: 1234567890@c.us
text: Hello, I need help with my order
message id: false_1234567890@c.us_ABC123

The local curl request should return HTTP/1.1 204 No Content. A 401 means the shared secret header did not match. A malformed envelope should return 400. Any non-2xx response during real webhook delivery is treated as a failed attempt and can be retried, so fix receiver validation before pointing production events at it.

Auth/signature

The webhook integration sends any headers you place in headers on every delivery. Use that for a shared secret, bearer token, or gateway-specific auth header.

const webhookToken = process.env.OPEN_WA_WEBHOOK_TOKEN;
const webhookSecret = process.env.OPEN_WA_WEBHOOK_SECRET;

if (!webhookToken || !webhookSecret) {
  throw new Error('Webhook token and secret are required');
}

pluginConfig: {
  webhook: {
    url: 'https://your-app.example/webhooks/open-wa',
    headers: {
      Authorization: `Bearer ${webhookToken}`,
      'X-Webhook-Secret': webhookSecret,
    },
  },
}

The current webhook plugin does not generate a request signature by itself. If you need HMAC-style verification, add a small signing proxy in front of your receiver or sign the request in your own infrastructure, then verify the raw body before parsing it.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifySignature(rawBody: string, signature: string, secret: string) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = signature.replace(/^sha256=/, '');

  return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

Keep webhook URLs on HTTPS. Message payloads can include names, phone-number-based chat IDs, message text, and media metadata.

Retries

Webhook delivery uses native fetch. A delivery is treated as successful when your endpoint returns a 2xx response. Network errors, timeouts, and non-2xx responses are treated as failures.

By default, open-wa tries the first delivery plus 3 retries. The retry delay uses exponential backoff from retryDelay, which defaults to 1000 milliseconds:

AttemptDelay before attempt
First deliverynone
Retry 11000ms
Retry 22000ms
Retry 34000ms

After the final failed attempt, the failure is logged and the event is dropped from the in-memory delivery queue. If your receiver needs more protection, store the incoming webhook first, acknowledge it, then process it asynchronously.

Next step: once the one-request local test returns 204, configure open-wa with the receiver URL, send one real WhatsApp message, and compare your raw log with the envelope fields above.

Delivery guarantees

Webhook delivery is at least once. That means your endpoint may receive the same event more than once, especially when a previous response timed out or a retry happened after partial processing.

Ordering is best effort. Message events are emitted by the runtime in order, but the webhook deliverer can send multiple requests concurrently. If strict order matters, set concurrency: 1 and still design for duplicates.

Use idempotent processing. A practical dedupe key for messages is:

${sessionId}:${event}:${payload.message.id}

For non-message events, choose the stable identifier inside that event payload, such as payload.messageId, payload.chatId, or timestamp.

Event filtering

Set events to all to forward every public runtime event. Set it to an array to forward only the events your endpoint needs.

pluginConfig: {
  webhook: {
    url: 'https://your-app.example/webhooks/open-wa',
    events: ['message.received', 'ack.changed', 'session.state.changed'],
  },
}

Filtering happens before delivery. Events that do not match the list are ignored and never enter the webhook queue.

Sample JSON payloads per type

Message

{
  "webhookId": "0f5d6a52-2ff2-43d5-9d0d-9c0c8dd5f9d1",
  "sessionId": "sales",
  "event": "message.received",
  "payload": {
    "ctx": {
      "correlationId": "evt_01",
      "ts": 1700000000000
    },
    "message": {
      "id": "false_1234567890@c.us_ABC123",
      "from": "1234567890@c.us",
      "to": "0987654321@c.us",
      "body": "Hello, I need help with my order",
      "type": "chat",
      "timestamp": 1700000000,
      "fromMe": false,
      "isGroupMsg": false,
      "isMedia": false
    }
  },
  "timestamp": 1700000000123
}

Media message

{
  "webhookId": "0f5d6a52-2ff2-43d5-9d0d-9c0c8dd5f9d1",
  "sessionId": "sales",
  "event": "message.received",
  "payload": {
    "message": {
      "id": "false_1234567890@c.us_DEF456",
      "from": "1234567890@c.us",
      "body": "",
      "caption": "Photo of the receipt",
      "type": "image",
      "mimetype": "image/jpeg",
      "timestamp": 1700000001,
      "fromMe": false,
      "isMedia": true
    }
  },
  "timestamp": 1700000001123
}

Group participant change

{
  "webhookId": "0f5d6a52-2ff2-43d5-9d0d-9c0c8dd5f9d1",
  "sessionId": "sales",
  "event": "group.participants.changed.global",
  "payload": {
    "ctx": {
      "correlationId": "evt_02",
      "ts": 1700000002000
    },
    "change": {
      "groupId": "1234567890-1111111111@g.us",
      "action": "add",
      "participantIds": ["447700900123@c.us"],
      "by": "447700900456@c.us"
    }
  },
  "timestamp": 1700000002123
}

Session state

{
  "webhookId": "0f5d6a52-2ff2-43d5-9d0d-9c0c8dd5f9d1",
  "sessionId": "sales",
  "event": "session.state.changed",
  "payload": {
    "ctx": {
      "correlationId": "evt_03",
      "ts": 1700000003000
    },
    "details": {
      "prev": "AUTHENTICATING",
      "next": "READY",
      "reason": "session connected"
    }
  },
  "timestamp": 1700000003123
}

Plain-language field explanations

Envelope fields

FieldMeaning
webhookIdA generated ID for this configured webhook target. It stays useful for tracing logs from one runtime process.
sessionIdThe open-wa session that produced the event, such as sales or support.
eventThe public runtime event name. Use this to route the payload to the right handler.
payloadThe actual event data. Its shape depends on event.
timestampWhen open-wa queued the webhook delivery, in Unix milliseconds.

Common message fields

FieldMeaning
payload.message.idThe WhatsApp message ID. Store it if you need deduplication.
payload.message.fromThe chat that sent the message. Direct chats usually end in @c.us.
payload.message.toThe receiving chat or session chat ID when it is present.
payload.message.bodyThe text body. Media messages may have an empty body and use caption instead.
payload.message.captionThe text caption attached to a media message.
payload.message.typeThe WhatsApp message kind, such as chat, image, audio, video, or document.
payload.message.timestampWhen WhatsApp says the message was sent, usually in Unix seconds.
payload.message.fromMetrue when the connected session sent the message.
payload.message.isGroupMsgtrue when the message belongs to a group chat.
payload.message.isMediatrue when the payload represents a media message.

Webhook config

Prop

Type

Webhook registration model

Prop

Type

Webhook payload envelope

Prop

Type

Wally the Walrus typing

Was this helpful?

Wally and his cute companion coffee mug are coding day and night to keep this up-to-date!

On this page