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.
- OpenWA running and reachable (default
http://localhost:2785/api). See Installation. - An API key for the
X-API-Keyheader. 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.
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!,
});
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.
| Option | Type | Required | Default | Purpose |
|---|---|---|---|---|
baseUrl | string | Yes | — | Gateway base URL, e.g. http://localhost:2785. Trailing slash trimmed; path prefix kept. |
apiKey | string | Yes | — | Sent as X-API-Key on every request. |
timeoutMs | number | No | 30000 | Per-request timeout in milliseconds. |
defaultHeaders | Record<string,string> | No | {} | Headers merged onto every request. The X-API-Key and JSON Content-Type always win. |
fetch | FetchLike | No | global fetch | Injectable 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.
await client.sessions.create({ name: 'my-session' });
// Start it and bring the WhatsApp connection up.
await client.sessions.start('my-session');
start resolves to a SessionResponse. Its status walks through created → initializing → qr_ready → authenticating → ready as the connection comes up.
{
"id": "my-session",
"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('my-session');
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('my-session') 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('my-session', {
phoneNumber: '628123456789', // digits only, international format
});
console.log(pairing);
// { pairingCode: 'ABCD1234', status: 'qr_ready' }
Stop a session
await client.sessions.stop('my-session'); // graceful disconnect
Use client.sessions.forceKill('my-session') only when a session is stuck and stop does not return. See the Sessions guide and First session for the full lifecycle.
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('my-session', {
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('my-session', {
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('my-session', {
chatId: '628123456789@c.us',
messageId: 'true_628123456789@c.us_3EB0123456789',
emoji: '👍',
});
console.log(reacted);
// { success: true }
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('my-session', {
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('my-session', {
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('my-session', {
chatId: '628123456789@c.us',
latitude: -6.2,
longitude: 106.8,
description: 'Our office',
});
All three resolve to the same { messageId, timestamp } shape.
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('my-session', {
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/my-session/messages/batch/batch_01HZ...'
// }
Poll progress with batchStatus:
const status = await client.messages.batchStatus('my-session', 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('my-session', batch.batchId) — this requires an OPERATOR-level key.
Handle errors
The SDK throws a typed error on any non-2xx response and never retries automatically, so backoff stays under your control. 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('my-session', {
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 class | Status | When it's thrown |
|---|---|---|
OpenWAAuthError | 401 | Missing or invalid API key |
OpenWAForbiddenError | 403 | Key's role is insufficient (e.g. an OPERATOR-only route) |
OpenWANotFoundError | 404 | Resource not found |
OpenWAConflictError | 409 | Conflict — typically the engine is not ready |
OpenWARateLimitError | 429 | Rate limited |
OpenWANotImplementedError | 501 | The active engine doesn't support this operation |
OpenWAApiError | any other | Generic non-2xx (e.g. 400 validation) |
OpenWATimeoutError | — | Request exceeded the configured timeout |
OpenWATimeoutError extends OpenWAError directly, not OpenWAApiError, so it has no .status or .body.
Because the SDK never retries, wrap calls in your own backoff for 429. The injectable fetch option is the place to slot in retry or observability middleware.
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('my-session', {
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('my-session', webhook.id);
console.log(test);
// { success: true, statusCode: 200 }
Subscribe to any of these events, or use '*' for all of them:
| Event | Fires when |
|---|---|
message.received | An inbound message arrives |
message.sent | An outbound message is sent |
message.ack | A delivery/read receipt updates |
message.failed | An outbound message fails |
message.revoked | A message is deleted for everyone |
message.reaction | A reaction is added or removed |
session.status | The session lifecycle status changes |
session.qr | A new QR code is available |
session.authenticated | The session authenticates |
session.disconnected | The session disconnects |
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.
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
| Symptom | Cause | Fix |
|---|---|---|
OpenWAAuthError (401) on every call | Missing or wrong API key | Check apiKey; confirm with client.auth(). |
OpenWAForbiddenError (403) | Key lacks the OPERATOR role for a write | Use an operator key, or scope the action. See Authentication. |
OpenWAConflictError (409) on send | Session not ready yet | Poll sessions.get(id) until status === 'ready', then send. |
OpenWANotFoundError (404) on send | Session id or chatId is wrong | Verify the session exists and the JID ends in @c.us or @g.us. |
OpenWATimeoutError | Request exceeded timeoutMs | Raise timeoutMs, or add backoff via the injectable fetch. |
| Webhook never fires | Receiver unreachable | Run webhooks.test(sessionId, id) and check the returned statusCode. |