Skip to main content
Version: v0.7.6

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.

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.

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

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
session.statusThe session lifecycle status changes
session.qrA new QR code is available
session.authenticatedThe session authenticates
session.disconnectedThe 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.

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