Skip to main content
Version: v0.23.1

Use the JavaScript/TypeScript SDK

Drive OpenWA from Node with the official @rmyndharis/openwa client: authenticate once, manage session lifecycle, send messages and media, branch on typed errors, and receive inbound events through webhooks. Every method maps to one REST endpoint, so what you learn here transfers directly to the API reference.

Prerequisites
  • OpenWA running and reachable (default http://localhost:2785/api). See Installation.
  • An API key for the X-API-Key header. See Authentication for how to obtain one.
  • The SDK installed: npm install @rmyndharis/openwa (Node 18+). See the SDK overview.

The Python and PHP SDKs expose the same resources and methods in their language's idiom (snake_case + dicts in Python, camelCase + arrays in PHP), so these recipes carry over. That convention covers method names only — the dict and array keys stay camelCase in both, exactly as they go on the wire, so a Python call reads client.chats.mute(session_id, {"chatId": ..., "muteUntil": ...}), never chat_id or mute_until.

Construct the client

Create one OpenWAClient and reuse it. baseUrl and apiKey are required — the constructor throws synchronously if either is missing.

import { OpenWAClient } from '@rmyndharis/openwa';

const client = new OpenWAClient({
baseUrl: 'http://localhost:2785',
apiKey: process.env.OPENWA_API_KEY!,
});

const sessionId = '8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a'; // from POST /sessions, or the dashboard

Every session-scoped call on this page takes that sessionId — the server-generated UUID a session is addressed by. The session's name is a label you choose at creation time and the API never accepts it in place of the id.

The client sends X-API-Key on every request; you never set it per call. Pass baseUrl without the /api suffix — every SDK path already includes the /api prefix, so your baseUrl must not. In production, point baseUrl at your domain over TLS (for example https://wa.example.com); a path prefix such as /v1 behind a reverse proxy is preserved.

OptionTypeRequiredDefaultPurpose
baseUrlstringYesGateway base URL, e.g. http://localhost:2785. Trailing slash trimmed; path prefix kept.
apiKeystringYesSent as X-API-Key on every request.
timeoutMsnumberNo30000Per-request timeout in milliseconds.
defaultHeadersRecord<string,string>No{}Headers merged onto every request. The X-API-Key and JSON Content-Type always win.
fetchFetchLikeNoglobal fetchInjectable transport — the place to add retry or observability middleware.

Verify your API key

Call auth() to confirm the key is valid and discover the role it resolves to. The role decides which write operations you may call.

const result = await client.auth();
console.log(result);
// { valid: true, role: 'operator' }

Any operation that creates or mutates server state requires an OPERATOR-level key — including sending any message (text, media, location, contact, reply, forward, react, delete, bulk), creating sessions and webhooks, cancelling a bulk batch, and group admin actions. A VIEWER key is read-only: it can call the plain GET reads (listing and fetching sessions, messages, contacts, groups) but nothing that sends or changes state. A VIEWER key calling a write throws OpenWAForbiddenError (HTTP 403). See Authentication for how keys, roles, and per-session scoping work.

Bring a session online

A session is one WhatsApp connection. Before it can send anything it has to be created, started, and authenticated by scanning a QR code or entering a pairing code on the phone.

Every state-changing call here requires an OPERATOR-level key — create, start, stop, forceKill, getQrCode, and requestPairingCode. Only the plain GET reads (get, list) work with a VIEWER key.

// Create the session (OPERATOR key). The name is 3–50 chars, alphanumeric + hyphens.
const session = await client.sessions.create({ name: 'my-session' });

// Start it and bring the WhatsApp connection up. Every call after `create` takes
// the generated id, never the name — keep it from the create response.
await client.sessions.start(session.id);
note

The name exists only to create the session and to keep it unique. Everything afterwards is addressed by the server-generated id, a UUID. Passing a name fails everywhere: the session routes reject the path segment as a non-UUID, and every other route fails the engine lookup with Session '<name>' is not active. Both answer 400 — only the status and catalog routes report the same mistake as 404. Nothing resolves a name.

start resolves to a SessionResponse. Its status walks through created → initializing → qr_ready → authenticating → ready as the connection comes up.

{
"id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"name": "my-session",
"status": "qr_ready",
"phone": null,
"createdAt": "2026-06-26T10:00:00.000Z",
"updatedAt": "2026-06-26T10:00:05.000Z"
}

Authenticate with a QR code

getQrCode returns the current code straight from the engine. qrCode is a PNG data URL — render it as an image for the user to scan.

const qr = await client.sessions.getQrCode(session.id);
console.log(qr);
// { qrCode: 'data:image/png;base64,iVBORw0KGgo...', status: 'qr_ready' }

The code rotates; re-fetch it if the user is slow. Once scanned, the session's status advances to ready. Poll client.sessions.get(session.id) until status === 'ready' before sending.

Authenticate with a pairing code

For headless setups, request an 8-character pairing code instead of a QR. The user types it into WhatsApp under Link a device → Link with phone number.

const pairing = await client.sessions.requestPairingCode(session.id, {
phoneNumber: '628123456789', // digits only, international format
});
console.log(pairing);
// { pairingCode: 'ABCD1234', status: 'qr_ready' }

Stop a session

await client.sessions.stop(session.id); // graceful disconnect

Use client.sessions.forceKill(session.id) only when a session is stuck and stop does not return. See the Sessions guide and First session for the full lifecycle.

Read and change a session's config

getConfig reads the configuration a session is actually running with, and updateConfig patches it in place — no restart, no re-link, no second QR scan. Three keys are read: autoRejectCalls, maxReconnectAttempts (0–20), and reconnectBaseDelay (1000–300000 ms). getConfig works with a VIEWER key; updateConfig needs an OPERATOR one.

const config = await client.sessions.getConfig(sessionId);
console.log(config);
// { autoRejectCalls: false, maxReconnectAttempts: null, reconnectBaseDelay: 5000 }

await client.sessions.updateConfig(sessionId, { autoRejectCalls: true });

Both calls report the effective configuration, so maxReconnectAttempts: null on the way out means unlimited, not unset. autoRejectCalls is re-read on every incoming call and so applies immediately; the two reconnect settings are read once per start and apply from the next start.

The update body has three states per field, not two: leave the key out and the value is untouched, send a value and it is set, send null and it is cleared back to the default. The null is not decoration — restoring maxReconnectAttempts to unlimited is the one thing no in-range number can express.

// Cap reconnects at 5, then later restore unlimited.
await client.sessions.updateConfig(sessionId, { maxReconnectAttempts: 5 });
await client.sessions.updateConfig(sessionId, { maxReconnectAttempts: null });
Go and Java need an explicit clear flag to send that null

Neither language can express the null case by leaving a field nil: Go's omitempty omits a nil pointer rather than writing null, and Gson drops nulls by default — so in both, an unset field sends nothing and the server leaves the value unchanged. Each carries a matching clear* flag, and a clear wins over a value set on the same field.

_, err := client.Sessions.UpdateConfig(ctx, sessionId, openwa.UpdateSessionConfigRequest{
ClearMaxReconnectAttempts: true, // sends {"maxReconnectAttempts": null}
})
client.sessions.updateConfig(sessionId,
UpdateSessionConfigRequest.builder()
.clearMaxReconnectAttempts()
.build());

ClearAutoRejectCalls / clearAutoRejectCalls() and ClearReconnectBaseDelay / clearReconnectBaseDelay() do the same for the other two fields. JavaScript, Python, and PHP send null directly — no flag involved.

List calls are bounded-paginated

The list endpoints — sessions.list, messages list, chats, groups, and contacts — accept limit and offset query parameters. limit is capped server-side (sessions, groups, and chats at 1–1000, default 1000; messages at a default of 50). Page through large result sets with offset rather than fetching everything in one call.

Send messages

Each send method takes the session id first, then a typed body. The methods that dispatch a message — sendText, the media senders, sendLocation, sendContact, reply, and forward — resolve to a MessageResponse: { messageId, timestamp }, where timestamp is a Unix time in seconds. The two that act on an existing message, react and delete, instead resolve to { success: boolean }.

const sent = await client.messages.sendText(sessionId, {
chatId: '628123456789@c.us',
text: 'Hello from OpenWA!', // max 4096 characters
});
console.log(sent);
// { messageId: 'true_628123456789@c.us_3EB0123456789', timestamp: 1706868000 }

chatId is a WhatsApp JID: <number>@c.us for an individual, <id>@g.us for a group. WhatsApp text formatting works inline: *bold*, _italic_, ~strikethrough~, and `monospace`.

Reply to or react to a specific message by its id:

// Reply, quoting an earlier message — returns a MessageResponse.
const replied = await client.messages.reply(sessionId, {
chatId: '628123456789@c.us',
quotedMessageId: 'true_628123456789@c.us_3EB0123456789',
text: 'Replying to that',
});
console.log(replied);
// { messageId: 'true_628123456789@c.us_3EB0987654321', timestamp: 1706868060 }

// React (an empty emoji string removes the reaction) — returns { success }.
const reacted = await client.messages.react(sessionId, {
chatId: '628123456789@c.us',
messageId: 'true_628123456789@c.us_3EB0123456789',
emoji: '👍',
});
console.log(reacted);
// { success: true }

// Any send can quote as well — quotedMessageId turns it into a reply.
const quotedImage = await client.messages.sendImage('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', {
chatId: '628123456789@c.us',
url: 'https://example.com/photo.jpg',
quotedMessageId: 'true_628123456789@c.us_3EB0123456789',
});
console.log(quotedImage);
// { messageId: 'true_628123456789@c.us_3EB0555555555', timestamp: 1706868120 }

Since v0.17.0 every send request type carries an optional quotedMessageId — text, the five media senders, location, contact, and poll — so a media, location, contact, or poll reply no longer has to go through reply(). It is typed in the JavaScript, Python, Go, and Java SDKs; the PHP client takes untyped arrays, so you add the key to the array and it reaches the wire unchanged. On reply() the field stays required; everywhere else it is optional. The value is engine-specific: whatsapp-web.js takes the serialized message id, Baileys the raw key id of a message it has already stored.

See the Sending messages guide for the complete set.

Send media and location

sendImage, sendVideo, sendAudio, sendDocument, and sendSticker share one body shape. Provide either a url or a base64 payload — they are mutually exclusive, and base64 requires a mimetype. filename is required for documents.

// Image by URL with a caption.
await client.messages.sendImage(sessionId, {
chatId: '628123456789@c.us',
url: 'https://example.com/photo.jpg',
caption: 'Check this out', // max 1024 characters
});

// Document from base64 bytes.
await client.messages.sendDocument(sessionId, {
chatId: '628123456789@c.us',
base64: 'JVBERi0xLjQKJ...', // raw base64, no data-URL prefix
mimetype: 'application/pdf',
filename: 'invoice.pdf',
});

Location takes coordinates and an optional label:

await client.messages.sendLocation(sessionId, {
chatId: '628123456789@c.us',
latitude: -6.2,
longitude: 106.8,
description: 'Our office',
});

All three resolve to the same { messageId, timestamp } shape.

Send polls, voice notes, and mentions

Native polls

POST /messages/send-poll posts a native WhatsApp poll. Pass 2–12 options; the poll is single-choice unless you set allowMultipleAnswers: true. Both engines support it.

const poll = await client.messages.sendPoll(sessionId, {
chatId: '628123456789@c.us',
name: 'Where should we meet?', // the poll question, max 255 chars
options: ['Park', 'Beach', 'Downtown'], // 2–12, each max 100 chars
allowMultipleAnswers: true, // optional; default single choice
});
console.log(poll);
// { messageId: 'true_628123456789@c.us_3EB0...', timestamp: 1706868120 }

Inbound polls arrive as message type poll.

PTT voice notes

sendAudio accepts an optional ptt boolean. When true, the audio is delivered as a real WhatsApp voice note — the microphone bubble with a waveform — instead of a plain audio file. Voice notes require audio/ogg; codecs=opus; when ptt is set without a mimetype, the server defaults to that, so supply OGG/Opus bytes for reliable playback. The message is stored as type voice.

await client.messages.sendAudio(sessionId, {
chatId: '628123456789@c.us',
url: 'https://example.com/clip.ogg',
ptt: true, // deliver as a voice note
});

@mentions

sendText and the media senders accept an optional mentions array of @c.us WIDs. The text (or caption for media) must also contain the literal @<number> token for each mention, or WhatsApp will not render the mention.

await client.messages.sendText(sessionId, {
chatId: '1203630000@g.us', // a group
text: 'Hey @628123456789, can you take this?',
mentions: ['628123456789@c.us'],
});

On inbound messages, the Baileys engine surfaces tagged WIDs as mentionedIds.

Post a Status update

Status posting is Baileys-only — whatsapp-web.js returns 501 Not Implemented. The status resource exposes text, image, and video status posts under POST /api/sessions/:id/status/send-text|send-image|send-video. Each requires a recipients array of 1–256 JIDs (@c.us or @lid).

// Text status to up to 256 recipients.
await client.status.sendText(sessionId, {
text: 'Out for the holidays 🌴',
recipients: ['628123456789@c.us', '628987654321@c.us'],
});

sendImage and sendVideo take the same media body shape as the message senders plus recipients.

Send in bulk

For high-volume sends, sendBulk enqueues a batch (up to 100 messages) and returns immediately with a batch id and a poll URL — the messages are dispatched asynchronously with a pacing delay. Each item carries a type and a content object keyed by that type.

const batch = await client.messages.sendBulk(sessionId, {
messages: [
{ chatId: '628111111111@c.us', type: 'text', content: { text: 'Hi A' } },
{ chatId: '628222222222@c.us', type: 'text', content: { text: 'Hi B' } },
],
options: {
delayBetweenMessages: 3000, // ms, minimum 1000; default 3000
randomizeDelay: true,
},
});
console.log(batch);
// {
// batchId: 'batch_01HZ...',
// status: 'processing',
// totalMessages: 2,
// estimatedCompletionTime: '2026-06-26T10:00:06.000Z',
// statusUrl: '/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/messages/batch/batch_01HZ...'
// }

Poll progress with batchStatus:

const status = await client.messages.batchStatus(sessionId, batch.batchId);
console.log(status.progress);
// { total: 2, sent: 1, failed: 0, pending: 1, cancelled: 0 }

To stop an in-flight batch, call client.messages.cancelBatch(sessionId, batch.batchId) — this requires an OPERATOR-level key.

Chats, presence, calls, and channel admin (v0.16.0+)

Ten client methods landed in v0.16.0, spread across six resources. Each maps to one REST route; the eight that change state need an OPERATOR-level key.

MethodBody / argumentsResolves to
chats.pin(sessionId, body)PinChatRequest { chatId: Jid; pin: boolean }SuccessResult
chats.mute(sessionId, body)MuteChatRequest { chatId: Jid; muteUntil: number | null }SuccessResult
sessions.setOnlinePresence(sessionId, body)SetOwnPresenceRequest { available: boolean }SuccessResult
groups.getMembershipRequests(sessionId, groupId)GroupMembershipRequest[]
groups.approveMembershipRequests(sessionId, groupId, participants?)string[], optionalParticipantsResult
groups.rejectMembershipRequests(sessionId, groupId, participants?)string[], optionalParticipantsResult
contacts.listBlocked(sessionId)string[]
calls.createLink(sessionId, body)CreateCallLinkRequest { type: 'audio' | 'video'; startTime: number }CallLinkResponse { link: string }
channels.demoteAdmin(sessionId, channelId, body)DemoteChannelAdminRequest { userId: Jid }SuccessResult
channels.transferOwnership(sessionId, channelId, body)TransferChannelOwnershipRequest { newOwnerId: Jid }SuccessResult

SuccessResult is { success: boolean; message?: string }. ParticipantsResult extends it with a results array carrying one outcome per participant.

Pin and mute a chat

// Pin the chat to the top of the list; pass false to release it.
await client.chats.pin('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', {
chatId: '628123456789@c.us',
pin: true,
});

// Mute for the next eight hours — muteUntil is epoch MILLISECONDS.
await client.chats.mute('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', {
chatId: '628123456789@c.us',
muteUntil: Date.now() + 8 * 60 * 60 * 1000,
});

// Unmute: send null.
await client.chats.mute('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', {
chatId: '628123456789@c.us',
muteUntil: null,
});
muteUntil is milliseconds, and it is required

A seconds-scale timestamp is an instant in 1970, so the mute expires the moment it is set while the call still answers 200 — the failure is silent. The field is also required rather than optional, because the two readings of an omitted value, unmute now and mute indefinitely, are opposites: null means unmute.

Set the account's presence

// The ACCOUNT's presence, not a chat's — returns { success }.
await client.sessions.setOnlinePresence('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', {
available: false,
});

available: false hands notification delivery back to the phone. This is the linked account's global presence; per-chat typing and recording indicators are still client.chats.sendState.

const blocked = await client.contacts.listBlocked('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a');
console.log(blocked);
// ['628987654321@c.us']

const call = await client.calls.createLink('8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a', {
type: 'video',
startTime: Date.now(), // absolute epoch milliseconds; Date.now() means "now"
});
console.log(call.link);

Both type and startTime are required. A refusal on WhatsApp's side answers 403.

Membership requests and channel admin

getMembershipRequests, approveMembershipRequests, and rejectMembershipRequests wrap the group join queue. Omitting participants acts on every pending request, and a partial refusal still resolves — read results rather than trusting success alone. See Groups.

const pending = await client.groups.getMembershipRequests(
'8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a',
'120363021234567890@g.us',
);

await client.groups.approveMembershipRequests(
'8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a',
'120363021234567890@g.us',
pending.map((request) => request.participantId),
);

channels.demoteAdmin and channels.transferOwnership administer a channel this account owns. Both throw OpenWANotImplementedError (501) on a whatsapp-web.js session — they are implemented on Baileys only — and the transfer is irreversible: the account cannot take the channel back through the API afterwards. See Channels.

await client.channels.demoteAdmin(
'8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a',
'120363000000000000@newsletter',
{ userId: '628123456789@c.us' },
);

Handle errors

The SDK throws a typed error on any non-2xx response and does not retry automatically, so backoff stays under your control. The same holds for the Python, PHP, and Java SDKs; the Go SDK is the one exception and ships an opt-in retry policy, described below. Every error extends OpenWAError, and the API-error classes carry .status (HTTP code) and .body (the parsed JSON envelope), so you can branch with instanceof or inspect the fields directly.

import {
OpenWAConflictError,
OpenWANotFoundError,
OpenWARateLimitError,
OpenWATimeoutError,
OpenWAApiError,
} from '@rmyndharis/openwa';

try {
await client.messages.sendText(sessionId, {
chatId: '628123456789@c.us',
text: 'Hello!',
});
} catch (err) {
if (err instanceof OpenWAConflictError) {
// 409 — engine not ready; the session may still be connecting.
} else if (err instanceof OpenWANotFoundError) {
// 404 — session or chat does not exist.
} else if (err instanceof OpenWARateLimitError) {
// 429 — back off and retry yourself.
} else if (err instanceof OpenWATimeoutError) {
// request exceeded timeoutMs.
} else if (err instanceof OpenWAApiError) {
console.error(`API error ${err.status}:`, err.body);
} else {
throw err; // network/transport error
}
}
Error classStatusWhen it's thrown
OpenWAAuthError401Missing or invalid API key
OpenWAForbiddenError403Key's role is insufficient (e.g. an OPERATOR-only route)
OpenWANotFoundError404Resource not found
OpenWAConflictError409Conflict — typically the engine is not ready
OpenWARateLimitError429Rate limited
OpenWANotImplementedError501The active engine doesn't support this operation
OpenWAServiceUnavailableError503The engine did not confirm the operation in time
OpenWAApiErrorany otherGeneric non-2xx (e.g. 400 validation)
OpenWATimeoutErrorRequest exceeded the configured timeout

OpenWATimeoutError extends OpenWAError directly, not OpenWAApiError, so it has no .status or .body.

Add your own retries

Because this SDK does not retry, wrap calls in your own backoff for 429. The injectable fetch option is the place to slot in retry or observability middleware.

OpenWAServiceUnavailableError (503) is the other one worth retrying — it means the engine never confirmed, not that the request was refused. When you write the retry yourself, do not repeat it blindly on a non-idempotent send. You do not have to hand-check every route for that: the gateway deliberately leaves group creation, channel creation, and media sends unbounded so they never answer a 503 in the first place. Retry only what the gateway is willing to answer 503 for, and confine your own retries to 429 and 503.

The Go SDK can do this for you

Go is the deliberate exception to "no automatic retries". Pass openwa.WithRetry at construction and the client retries on its own; leave it out and it never does.

client, err := openwa.New(baseURL, apiKey, openwa.WithRetry(openwa.DefaultRetryPolicy()))

DefaultRetryPolicy() is 3 retries after the first attempt, a 200 ms base delay doubling to a 5 s cap, on 429, 500, 502, 503 and 504, honouring Retry-After. RetryPolicy is a plain struct, so every one of those is yours to change.

Two rules narrow that policy for a POST — which is every send — and neither is reachable through RetryableStatuses:

  • After a network error a POST is never replayed. The SDK cannot tell whether the gateway processed the request before the connection dropped, and it has no idempotency key to deduplicate with. Only GET, HEAD, OPTIONS, PUT and DELETE are retried there.
  • On a retryable status a POST is replayed only for 429 and 503 — the two that prove the server declined the request before acting on it. A 500, 502 or 504 can arrive after the message was already sent, so a POST is not replayed on those even if you list them.

So a Go caller who enables retries does re-send a POST on a 503, up to three more times by default. That is the same interlock the paragraph above describes, read from the other end: the gateway leaves group creation, channel creation, and media sends unbounded precisely because 503 is a status the Go SDK retries for POST, so the operations a replay could duplicate are the ones that never answer 503. Because those two rules sit outside the policy, tuning RetryPolicy cannot break that interlock — but custom middleware added with WithMiddleware can, so keep your own retry logic off POST.

Receive events with webhooks

The SDK is a request/response client — there is no client.on(...) and no streaming. To receive inbound messages, delivery acks, and session events, register a webhook that points at an HTTP endpoint you host, then read the delivered payloads there.

Create a webhook with the webhooks resource (requires an OPERATOR-level key):

const webhook = await client.webhooks.create(sessionId, {
url: 'https://my-app.example.com/openwa/events',
events: ['message.received', 'message.ack', 'session.disconnected'],
secret: process.env.WEBHOOK_SECRET, // signs deliveries as X-OpenWA-Signature
});
console.log(webhook.id, webhook.active);
// 'wh_01HZ...' true

// Send a test delivery to confirm your receiver is reachable.
const test = await client.webhooks.test(sessionId, webhook.id);
console.log(test);
// { success: true, statusCode: 200 }

Subscribe to any of these events, or use '*' for all of them:

EventFires when
message.receivedAn inbound message arrives
message.sentAn outbound message is sent
message.ackA delivery/read receipt updates
message.failedAn outbound message fails
message.revokedA message is deleted for everyone
message.reactionA reaction is added or removed
message.editedA message is edited (v0.9.0+, both engines)
status.receivedA contact's Status (Story) is received
session.statusThe session lifecycle status changes
session.qrA new QR code is available
session.authenticatedThe session authenticates
session.disconnectedThe session disconnects
session.reconnect_loopA session is stuck reconnecting; fires once per 5 consecutive attempts (v0.10.0+)
session.restrictionWhatsApp imposes or lifts an account restriction (v0.14.0+)
presence.updateA subscribed contact's presence changes (v0.14.0+). Baileys only.
group.joinA participant joins a group the session belongs to
group.leaveA participant leaves a group the session belongs to
group.updateA group's metadata or participant roster changes
group.join_requestSomeone requests to join a group the session administers (v0.15.0+)
call.receivedAn incoming call is detected
call.acceptedAn incoming call is picked up (v0.14.0+). Baileys only.
call.rejectedAn incoming call is declined (v0.14.0+). Baileys only.
call.missedAn incoming call rings out (v0.14.0+). Baileys only.

Four of these have no producer on the whatsapp-web.js engine — presence.update and the three call outcomes. Subscribing to them there is accepted and then never fires. See the Webhooks guide for why.

If you set a secret, each delivery is signed as X-OpenWA-Signature: sha256=<hmac> — verify it before trusting the payload. The read responses from webhooks.list/get deliberately omit the secret, so store it yourself. See the Webhooks guide for payload shapes, signature verification, and retry behavior.

Audit webhooks across sessions

webhooks.list(sessionId) covers one session. webhooks.listAll() returns every webhook the key can see across all of them, paginated with limit and offset — it requires an OPERATOR-level key.

const all = await client.webhooks.listAll({ limit: 50, offset: 0 });

When a webhook stops arriving, webhooks.deliveryFailures() reads the delivery-failure log. It requires an ADMIN-level key and accepts a sessionId filter alongside limit and offset.

const failures = await client.webhooks.deliveryFailures({
sessionId,
limit: 20,
});
The log records attempts, not suppressions

It holds deliveries that were attempted and failed. A delivery a webhook's own filters suppressed was never attempted, so it never appears here — an empty log is not evidence the event was delivered. Check the webhook's active flag, its events, and its filters first. This response has no published schema, so the SDKs hand it back unshaped: unknown in TypeScript, Any in Python, any in Go, Object in Java, and a plain decoded value in PHP.

Prefer no-code? Use n8n

If you'd rather not host a receiver, the n8n integration manages the webhook for you and exposes incoming events as a workflow trigger.

Troubleshooting

SymptomCauseFix
OpenWAAuthError (401) on every callMissing or wrong API keyCheck apiKey; confirm with client.auth().
OpenWAForbiddenError (403)Key lacks the OPERATOR role for a writeUse an operator key, or scope the action. See Authentication.
OpenWAConflictError (409) on sendSession not ready yetPoll sessions.get(id) until status === 'ready', then send.
OpenWANotFoundError (404) on sendSession id or chatId is wrongVerify the session exists and the JID ends in @c.us or @g.us.
OpenWATimeoutErrorRequest exceeded timeoutMsRaise timeoutMs, or add backoff via the injectable fetch.
Webhook never firesReceiver unreachableRun webhooks.test(sessionId, id) and check the returned statusCode.

Next steps