Skip to main content
Version: v0.23.1

Sessions & multi-session

Drive a WhatsApp account from OpenWA by running it as a session: a linked WhatsApp number with its own connection, chats, and message history. This guide takes a session through its full lifecycle — create, start, link, monitor, stop, delete — and shows how to run many sessions on one instance and address each one by id.

Prerequisites
  • A running OpenWA instance reachable at http://localhost:2785/api (see Installation).
  • An API key with the operator role for write actions (create, start, stop, delete) and for linking — fetching the QR (GET /qr) and requesting a pairing code both require an operator key too. Plain read routes (list/get a session, stats) accept any valid key. See Authentication.
  • A phone with WhatsApp installed, to scan the QR or enter a pairing code.

All examples use the local base URL http://localhost:2785/api and send the key in the X-API-Key header. In production, swap in your own domain over TLS — the /api prefix is unchanged. Replace YOUR_API_KEY with a real key; see Authentication for how to obtain one.

The session lifecycle

A session moves through a fixed set of states. The status field is always lowercase on the wire.

StatusMeaning
createdThe session record exists, but the WhatsApp engine has not started.
initializingThe engine is booting and opening the WhatsApp connection — or, on the Baileys engine, waiting out reconnect backoff after a transient drop.
qr_readyA QR code (or pairing code) is available to scan or enter.
authenticatingThe link was accepted; the session is finishing the handshake.
readyConnected. The session can send and receive messages.
action_requiredConnected, but the engine needs a human action before the session is fully usable — for example a whatsapp-web.js "What's new on WhatsApp Web" onboarding modal that auto-dismissal could not clear. lastError carries a readable reason (since v0.12.0).
disconnectedStopped on purpose, or the connection dropped. Can be started again.
failedStartup or authentication failed. lastError carries the reason.

The status values are the same regardless of the WhatsApp engine in use (Baileys or whatsapp-web.js); the engine is selected per instance, not per session.

Auto-reconnect and shutdown drain

When auto-reconnect is off (budget 0), a dropped link moves the session to disconnected with the reason Auto-reconnect is disabled — it no longer reports a misleading "reconnection failed after 0 attempts". A disconnect during a graceful shutdown drain (SIGTERM/SIGINT) is left at disconnected rather than scheduling a reconnect that would race the teardown; a later start or auto-restore re-initializes it cleanly.

Auto-reconnect mechanics

By default a dropped session retries without a limit: exponential backoff starting at 5 seconds, capped at 1 hour, with the attempt counter reset after 5 stable minutes. config.maxReconnectAttempts opts out — 0 disables reconnect entirely, and 120 caps the attempts (out-of-range values are clamped into that range). A session that exhausts a finite budget lands in failed, and its dead engine is evicted from the registry — the same teardown the terminal-error path uses — so it frees its concurrency slot and the next start() boots clean instead of being rejected as already started.

Both reconnect settings, and auto-reject, can be changed on a session that is already linked — see Read and change a session's config.

A watchdog probes every ready engine every 60 seconds; two consecutive probe failures count as a disconnect, so a silently dead engine re-enters the reconnect pipeline within about 2 minutes. On the Baileys engine, a transient socket close also reports initializing for the whole reconnect backoff — matching the whatsapp-web.js engine — and restores ready on the next open, so liveness probes and sends fail fast instead of passing against a dead socket (previously the session kept reporting ready for up to the 60-second backoff cap). Every 5th consecutive attempt emits a session.reconnect_loop webhook event, a warning log, and a Prometheus counter tick (openwa_session_reconnect_loop_alerts_total) — wire one of those into your monitoring so a looping session does not go unnoticed.

On the Baileys engine, two close codes are terminal and never retried: 440 (connectionReplaced — another live instance holds the account) and 403 (forbidden — the account is banned or blocked).

1. Create a session

POST /api/sessions registers a new session. The only required field is a unique name: 3–50 characters, letters, numbers, and hyphens only. A new session starts in created and is not connected yet.

curl -X POST "http://localhost:2785/api/sessions" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "my-bot" }'

Response 201 Created — this route returns the raw session entity, so it also echoes config, proxyUrl, and proxyType (read routes strip those):

{
"id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"name": "my-bot",
"status": "created",
"phone": null,
"pushName": null,
"config": {},
"proxyUrl": null,
"proxyType": null,
"connectedAt": null,
"lastActiveAt": null,
"createdAt": "2026-06-25T09:00:00.000Z",
"updatedAt": "2026-06-25T09:00:00.000Z"
}

Save the id. Every later call for this session uses it in the path.

Duplicate names conflict

Creating a session with a name that already exists returns 409 Conflict. A name that breaks the format rule (or any extra, undeclared body field) returns 400 Bad Request.

To route a session's WhatsApp traffic through a proxy, pass proxyUrl and proxyType (http, https, socks4, or socks5) at create time:

{ "name": "my-bot", "proxyUrl": "http://proxy.example.com:8080", "proxyType": "http" }

On the whatsapp-web.js engine, authenticated HTTP/HTTPS proxies work — pass the credentials in the proxyUrl and the engine hands them to Chromium's proxyAuthentication (a plain --proxy-server ignores credentials). SOCKS proxies that require a username/password are not supported on this engine: Chromium cannot authenticate a SOCKS proxy at all, so OpenWA logs a warning instead of failing with an opaque navigation timeout. IP-authorized proxies (no credentials) work over every proxy type.

On the Baileys engine, proxyUrl — http, https, socks4, or socks5, credentialed form included — is applied through a Node-layer agent on both the WhatsApp WebSocket and media up/downloads (since v0.11.1). Credentials authenticate on the socket itself, so the Chromium first-CONNECT 407 limitation that can bite the whatsapp-web.js proxy-auth path does not apply here. An unusable proxy value fails the session start instead of silently falling back to a direct connection.

2. Start the session

Creating a session does not connect it. Call POST /api/sessions/:id/start to boot the engine:

curl -X POST "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/start" \
-H "X-API-Key: YOUR_API_KEY"

Response 200 — the status moves to initializing, then to qr_ready once a code is available:

{
"id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"name": "my-bot",
"status": "initializing",
"phone": null,
"pushName": null,
"connectedAt": null,
"lastActive": null,
"createdAt": "2026-06-25T09:00:00.000Z",
"updatedAt": "2026-06-25T09:05:00.000Z",
"lastError": null
}

Starting a session that is already running (or already starting) returns 400 Bad Request.

Link the WhatsApp account in one of two ways. Both require the session to be in qr_ready. Use a QR code for a quick manual link, or a pairing code when you cannot show a QR.

Option A — QR code

Fetch the QR as a PNG data URL with GET /api/sessions/:id/qr, render it, and scan it from your phone under WhatsApp → Linked Devices → Link a device:

curl "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/qr" \
-H "X-API-Key: YOUR_API_KEY"

Response 200:

{
"qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...",
"status": "qr_ready"
}

Drop qrCode straight into an <img src="…"> to display it. If the session has not reached qr_ready yet (or is already authenticated), the route returns 400 Bad Request — start the session first, then retry. A stale QR is no longer emitted while a whatsapp-web.js session is tearing down, so a buffered QR event can no longer flip a disconnecting session back to qr_ready. The Dashboard renders the QR for you on its Sessions page.

Option B — pairing code

Request an 8-character pairing code tied to a phone number, then enter it on the phone under Linked Devices → Link with phone number. The number must be digits only in international format (country code + number, no +, spaces, or dashes), 6–15 digits:

curl -X POST "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/pairing-code" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phoneNumber": "628123456789" }'

Response 201:

{ "pairingCode": "ABCD1234", "status": "qr_ready" }

After the link is accepted, the session transitions through authenticating to ready. Poll the session (next step) to confirm.

4. Confirm the session is ready

Read a single session with GET /api/sessions/:id:

curl "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a" \
-H "X-API-Key: YOUR_API_KEY"

Response 200 — once status is ready, the linked phone and pushName are populated and the session can send and receive:

{
"id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"name": "my-bot",
"status": "ready",
"phone": "628123456789",
"pushName": "My Bot",
"connectedAt": "2026-06-25T08:14:02.000Z",
"lastActive": "2026-06-25T09:01:55.000Z",
"createdAt": "2026-06-20T11:30:00.000Z",
"updatedAt": "2026-06-25T09:01:55.000Z",
"lastError": null,
"restriction": null,
"engineLoaded": true
}

lastError is non-null only when status is failed (or action_required, where it carries the human-readable reason). restriction is non-null only when WhatsApp itself has placed a limit on the account — see Account restrictions below. engineLoaded reports whether the gateway actually holds a live engine for this session — status: disconnected alone cannot say whether a start is the right call (a stopped session has no engine; a session mid-reconnect backoff still has one registered and answers start with 400). Read routes strip config, proxyUrl, and proxyType, and rename the internal lastActiveAt field to lastActive. engineLoaded is a live field added in v0.12.1 — a gateway older than that omits it.

Watch status changes instead of polling

Rather than polling on a loop, subscribe to live session.status, session.qr, session.authenticated, and session.disconnected events through Webhooks, or read the same events from the WebSocket channel when your client can hold an outbound connection instead of exposing an inbound endpoint. The status values in those events match the lowercase enum above.

Once the session is ready, send your first message — see Sending Messages.

LID-addressed contacts resolve transparently

WhatsApp has begun addressing some individual chats by privacy id (@lid) instead of the phone-number WID (@c.us). A send to an @lid recipient works without extra handling — the engine resolves the recipient to its current WhatsApp id before sending, across text, media, location, contact, and sticker messages, and falls back to the original id if resolution is unavailable. You do not need to detect or rewrite @lid addresses yourself.

5. Stop or delete a session

Stop to disconnect WhatsApp but keep the session record, so you can start it again later without re-linking:

curl -X POST "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/stop" \
-H "X-API-Key: YOUR_API_KEY"

The status becomes disconnected. Since v0.19.0, a stop whose graceful disconnect fails escalates to a force-destroy instead of hanging — a wedged browser no longer leaks until the next start. Only when both the graceful disconnect and the forced teardown fail does the route answer a retryable 502 (code: 'SESSION_STOP_INCOMPLETE', session left disconnected, no success audit entry). To force a session down directly, use POST /api/sessions/:id/force-kill, which SIGKILLs the stuck engine and tears it down. A session that fails terminally or cannot re-initialize no longer strands its browser process or wedges at "already started" — the dead or half-built engine is evicted from the session registry and its Chromium is force-killed automatically, freeing the concurrency slot for a clean restart. Since v0.12.0, force-killing a session with no live engine returns 400 instead of 200 — read it as "there was nothing to kill". To reconcile a stale row, call POST /api/sessions/:id/stop instead, which treats an already-stopped session as a successful no-op.

Logout to unlink the device from the WhatsApp account — a protocol-level unlink, unlike stop or delete:

curl -X POST "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/logout" \
-H "X-API-Key: YOUR_API_KEY"

stop() disconnects but keeps the stored credentials (a later start reconnects without a QR), and delete() additionally purges the on-disk auth directories and the session row — but neither tells WhatsApp anything, so the device stays listed under the account's Linked Devices on the phone until you remove it by hand. logout sends the engine-native unlink (remove-companion-device on Baileys, Client.logout() on whatsapp-web.js) and then tears the session down locally.

StatusMeaning
200Engine-native unlink and required local credential cleanup both completed; the session is stopped, phone is cleared, and the audit log records session_logged_out. Reconnecting after this always requires a fresh QR scan or pairing code.
400The session is not started — there is no engine to send the unlink through; the row is left untouched
404Session not found
502The session was stopped locally and phone cleared, but the unlink did not complete (no send, no acknowledgement, timeout/transport error, or local-cleanup failure). Retryable — the body carries code: 'SESSION_LOGOUT_INCOMPLETE', and no success audit row is written. Start the session again and retry.

logout requires the session to be started and needs an operator key, like the other write actions.

Delete to remove the session entirely with DELETE /api/sessions/:id. It returns 204 No Content with an empty body:

curl -X DELETE "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a" \
-H "X-API-Key: YOUR_API_KEY"
Delete is destructive — and purges both engines' credentials

Deleting a session removes its database record and tears down everything tied to it: the message history, bulk batches, webhooks, templates, and stored Baileys messages. It also purges both engines' on-disk auth directories (data/baileys/<name> for Baileys, data/sessions/session-<name> for whatsapp-web.js), keyed by session name and regardless of which engine last ran the session — a session that ever ran under both engines no longer keeps the other engine's credentials on disk, where they could silently re-link on an engine switchback (and were carried into backups). Recreating a session under the same name therefore forces a fresh link. Start-time purge is deliberately unchanged, so trialling the other engine on a live session keeps the previous link for rollback. To pause an integration and resume it later with the same linked account, stop the session instead of deleting it.

Read and change a session's config

Three settings are tunable on a session that is already running: whether incoming calls are auto-rejected, and the two auto-reconnect knobs. Since v0.14.5 they can be changed in place — before that they were fixed at create time, so turning one on meant creating a new session and linking the account again.

GET /api/sessions/:id/config returns the effective configuration. The values are resolved through the same clamp the engine applies, so a session created with an out-of-range value reports what will actually happen rather than what was written:

curl "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/config" \
-H "X-API-Key: YOUR_API_KEY"

Response 200:

{
"autoRejectCalls": false,
"maxReconnectAttempts": null,
"reconnectBaseDelay": 5000
}
FieldTypeDefaultMeaning
autoRejectCallsbooleanfalseDecline every incoming call as soon as it rings. The call.received webhook event still fires first, so a consumer sees the call either way.
maxReconnectAttemptsnumber | nullnullCap on consecutive reconnect attempts. null means unlimited; 0 disables auto-reconnect outright.
reconnectBaseDelaynumber5000Base of the exponential reconnect backoff, in milliseconds.

Reading the config accepts any valid key. Writing it — PATCH /api/sessions/:id/config — needs an operator key, like the other write actions.

The patch is a three-state merge

PATCH merges the keys you send into the stored config rather than replacing it, and each of the three fields carries three distinct meanings:

What the body carries for a fieldWhat happens
The key is absentThe stored value is left unchanged.
The key is present with an explicit nullThe key is cleared, and the field returns to its default.
The key is present with a valueThe value is set.
curl -X PATCH "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/config" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "autoRejectCalls": true }'

Response 200 — the full effective configuration after the merge, not just the field you sent:

{
"autoRejectCalls": true,
"maxReconnectAttempts": null,
"reconnectBaseDelay": 5000
}
Unlimited reconnects are reachable only through an explicit null

maxReconnectAttempts accepts 020, and every one of those values is a finite cap — 0 does not mean unlimited, it turns auto-reconnect off. The default, unlimited, has no in-range number that expresses it, so the only way back to it is an explicit null:

curl -X PATCH "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/config" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "maxReconnectAttempts": null }'

Omitting the key instead leaves the existing cap in place. A client that strips null fields when it serializes JSON cannot make this call at all.

reconnectBaseDelay accepts 1000300000 ms. A value outside either range returns 400, as does an unknown body field; an unknown session id returns 404. Every accepted patch writes a session_config_updated audit row recording the resulting configuration — the merged state, not the request.

When a change takes effect

No restart happens and none is required, but the three fields do not land at the same moment:

FieldTakes effect
autoRejectCallsOn the next incoming call — the value is re-read per call, so the write alone is what applies it.
maxReconnectAttemptsOn the next session start. A reconnect sequence already in flight keeps the budget it began with.
reconnectBaseDelayOn the next session start, for the same reason.

The Dashboard exposes autoRejectCalls as a toggle on the session detail panel, so that one setting does not need an API call at all.

Running multiple sessions

One OpenWA instance runs several sessions concurrently — there is nothing to enable. Create each session with its own unique name, start it, and link it to a different WhatsApp number. Each session is fully isolated: its own connection, chats, message history, and webhooks.

An optional MAX_CONCURRENT_SESSIONS env var caps how many WhatsApp engines may run or initialize at once (default 0 = unlimited). A session that fails terminally still holds its slot until you stop or delete it — stop failed sessions to free capacity. Below the cap, the practical limit is the host's memory: the live engine for each session is held in-process, so plan capacity by RAM. The whatsapp-web.js engine is heavier (it drives a headless Chromium) than the browser-free Baileys engine. For provisioning guidance, see Scaling.

Address a session by id

Every session-scoped route is keyed by the session id in the path. The same call against two different ids acts on two different WhatsApp accounts:

GET /api/sessions/{sessionId}
POST /api/sessions/{sessionId}/messages/send-text
GET /api/sessions/{sessionId}/groups
POST /api/sessions/{sessionId}/webhooks
GET /api/sessions/{sessionId}/catalog

The product catalog routes are keyed the same way, so a business number's catalog belongs to the session that linked it. So to send from a specific number, put that session's id in the URL. To restrict a key to a subset of sessions, set its allowedSessions list (see Authentication) — a scoped key only ever touches the sessions it should, and a request outside that scope is rejected with 401.

Monitor every session at once

GET /api/sessions/stats/overview returns an aggregate view across the key's sessions — useful for a health panel when many run on one instance:

curl "http://localhost:2785/api/sessions/stats/overview" \
-H "X-API-Key: YOUR_API_KEY"

Response 200:

{
"total": 4,
"active": 2,
"ready": 2,
"disconnected": 1,
"byStatus": { "ready": 2, "disconnected": 1, "created": 1 },
"memoryUsage": { "heapUsed": 142, "heapTotal": 210, "rss": 318 }
}

active counts running engines; byStatus is keyed by the lowercase status values; memoryUsage values are in megabytes. A scoped key sees only the sessions in its allowedSessions. To list every session (not just counts), use GET /api/sessions, which returns a bare array ordered by createdAt descending. For per-session message stats, GET /api/stats/sessions/:id returns top chats with a lastActive formatted as YYYY-MM-DD HH:MM:SS consistently on both SQLite and PostgreSQL.

Own global presence (v0.15.0+)

Set whether the linked account appears online or offline to other WhatsApp users with PUT /api/sessions/:id/presence. This is connection-scoped — issue it again after a reconnect.

# Appear online (suppresses the phone's own notifications)
curl -X PUT "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/presence" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "available": true }'
# Go offline (hands notifications back to the phone)
curl -X PUT "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/presence" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "available": false }'

Response 200 for both directions:

{
"success": true
}
FieldTypeMeaning
availablebooleantrue marks the session online; false marks it offline. Required. Only a real boolean or the exact strings "true" / "false" are read — any other value fails validation with 400 rather than being coerced.

Publishing presence needs an operator key, like the other write actions. The statuses:

StatusMeaning
200Presence published
400The session was never started, so there is no engine to publish through — or available failed validation
404Session not found
409An engine exists but is not ready (disconnected, reconnecting, or initializing), so the request never reached WhatsApp — wait for ready and retry

An always-online headless bot suppresses the phone's own push notifications. Setting available: false hands them back. Both engines support this route.

Connection-scoped

Presence is not persisted. After a reconnect the session falls back to its engine default, so re-issue the call in your session.statusready handler if you need a permanent posture.

Chat operations (v0.16.0+)

Two chat-level write routes hang off the session: pin a chat to the top of the list, and mute its notifications. Both need an operator key (an admin key also passes; a viewer key is refused with 403 Insufficient permissions. Required: operator), and both address the chat by its JID in the form localpart@host628123456789@c.us for a contact, 120363021234567890@g.us for a group. A value without an @ is rejected with 400 chatId must be a valid chat JID in the form localpart@host. The session in the path is parsed as a UUID on both routes, so a session name is a 400 before the body is looked at.

The failure statuses are the same on both: 400 when the session was never started (no engine to send through) or the body is invalid, 404 when the session id is unknown, 409 when an engine exists but is not ready (disconnected, reconnecting, or initializing), and 503 when WhatsApp did not answer within the request budget — after a 503 the change may or may not have been applied. See API conventions for the shared meaning of those statuses.

Pin or unpin a chat

POST /api/sessions/:id/chats/pin pins a chat to the top of the list, or releases one that is already pinned.

# Pin a group chat to the top of the list
curl -X POST "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/chats/pin" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "chatId": "120363021234567890@g.us", "pin": true }'

Response 200:

{
"success": true
}
FieldTypeMeaning
chatIdstringJID of the chat to act on, in the form localpart@host. Required; null is not accepted.
pinbooleantrue pins the chat to the top of the list, false unpins it. Required. Only a real boolean or the exact strings "true" / "false" are read — any other value fails validation with 400 rather than being coerced, so a stray string cannot be taken as true and pin a chat you asked to release.

Both engines support this route.

success: false is the three-pin cap, not "chat not found"

WhatsApp allows at most three pinned chats. success: false means that cap refused a real chat, and only the whatsapp-web.js engine reports the refusal — Baileys cannot observe the cap and always answers success: true. Unpinning always succeeds.

A chat the session cannot resolve is a different answer entirely: on whatsapp-web.js it is a 400 (Chat <chatId> does not exist on this session), while Baileys cannot resolve chats ahead of the write and answers success: true for a chat that does not exist. So this route cannot be used to test whether a chat exists, and the same request can legitimately answer differently on the two engines.

Mute or unmute a chat

POST /api/sessions/:id/chats/mute silences a chat until a timestamp you choose, or lifts an existing mute.

# Mute until an absolute epoch-milliseconds timestamp
curl -X POST "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/chats/mute" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "chatId": "120363021234567890@g.us", "muteUntil": 1800000000000 }'
# Unmute now — an explicit null, not an omitted field
curl -X POST "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/chats/mute" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "chatId": "120363021234567890@g.us", "muteUntil": null }'

Response 200 for both directions:

{
"success": true
}
FieldTypeMeaning
chatIdstringJID of the chat to act on, in the form localpart@host. Required; null is not accepted.
muteUntilnumber | nullAbsolute epoch milliseconds at which the mute expires, or null to unmute now. Required.

Unlike the pin route there is no declined outcome here — a 200 always carries { "success": true }. The mute change is not keyed to the chat's last message on either engine, so a chat with no known history mutes like any other. Both engines support this route. A chat the session cannot resolve is a 400 on the whatsapp-web.js engine; the Baileys engine writes the mute without resolving the chat first and answers success: true for a chat that does not exist, the same split the pin route's caution above describes.

muteUntil is required, and it is milliseconds

Omitting muteUntil returns 400; it is not guessed. The two plausible readings of a missing field — unmute now, or mute forever — are opposites, so the gateway refuses to pick one. Only an explicit null unmutes. There is no "mute forever" sentinel either: to mute indefinitely, send a far-future timestamp.

The value is epoch milliseconds, unlike the epoch seconds OpenWA uses for message timestamps (see API conventions). A seconds-scale value passes validation and still answers 200, but it points at an instant in 1970, so the mute expires the moment it is written.

POST /api/sessions/:id/calls/link asks WhatsApp for a link that anyone can open to join a call with the linked account. Success is 200, not 201, and the body carries the finished URL. The route needs an operator key.

# A video call link for a scheduled call
curl -X POST "http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/calls/link" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "video", "startTime": 1800000000000 }'

Response 200:

{
"link": "https://call.whatsapp.com/video/XxXxXxXxXxXxXx"
}
FieldTypeMeaning
type"audio" | "video"Which kind of call the link opens. Required. "video" yields a https://call.whatsapp.com/video/<token> URL; "audio" yields https://call.whatsapp.com/voice/<token> — WhatsApp's own path segment for an audio call is voice, not audio.
startTimenumberAbsolute epoch milliseconds at which the call is scheduled to start. Required: the whatsapp-web.js engine generates an event-linked call and has no notion of "no start time", so a link for right now is Date.now() rather than an omitted field.

Both fields are required and neither accepts null. An invalid type or startTime, or a session that was never started, returns 400; a session whose engine is present but not ready returns 409; and a request WhatsApp did not answer within the budget returns 503, after which the link may or may not have been created. Both engines support this route — they differ only in the unit they are called with internally (Baileys in seconds, whatsapp-web.js as a Date), which is invisible on the wire: the API takes milliseconds on both.

A failed generation is a 403, not a 200 with an empty link

When WhatsApp produces no link, the gateway answers 403 WhatsApp generated no link for this request instead of a success carrying an empty link. Both engines can reach that case — whatsapp-web.js resolves an empty string on a generation failure, and Baileys can resolve an empty token — and a bare https://call.whatsapp.com/video/ prefix with nothing after it would be a dead link that looks real. So every 200 carries a usable URL, and the failure to branch on is 403.

Lifecycle with the SDK

The @rmyndharis/openwa SDK wraps the same routes. Construct the client with your gateway's base URL (no /api suffix — the SDK adds it):

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

const client = new OpenWAClient({
baseUrl: 'http://localhost:2785',
apiKey: 'YOUR_API_KEY',
});

const session = await client.sessions.create({ name: 'my-bot' });
await client.sessions.start(session.id);

// Show the QR, then poll until the session reports ready.
const { qrCode } = await client.sessions.getQrCode(session.id);
console.log('Scan this QR:', qrCode);

let status = (await client.sessions.get(session.id)).status;
while (status !== 'ready' && status !== 'failed') {
await new Promise((r) => setTimeout(r, 2000));
status = (await client.sessions.get(session.id)).status;
}
console.log('Session status:', status); // "ready"

To link by phone instead of QR, call client.sessions.requestPairingCode(session.id, { phoneNumber: '628123456789' }). Aggregate stats are client.sessions.stats(). The config routes are client.sessions.getConfig(id) and client.sessions.updateConfig(id, { autoRejectCalls: true }). See SDK usage for the full surface.

Account restrictions

WhatsApp itself can impose limits on a linked account that are not faults on our side and not engine failures — they are server-side enforcement the gateway observes and surfaces. Since v0.14.0 these appear on the session instead of as a generic send or connect error, so you can branch on them.

A restriction is reported on the session's restriction field (added to the response of every read route and to the create/start payloads), with the shape:

FieldTypeNotes
kindreachout_timelock | tos_block | proxy_blockWhat WhatsApp is restricting. reachout_timelock leaves the session connected and existing chats working, blocking only the start of new conversations. tos_block and proxy_block are connection-level refusals, so they never coexist with status: ready.
codestringThe engine's own token for the cause, passed through verbatim so you can search for it and so a value newer than this gateway is still surfaced rather than flattened.
expiresAtstring (date-time) | nullWhen enforcement ends, if the engine states it. Only reachout timelocks carry an expiry; absent means the engine gave no end time, not that the restriction is permanent.

When no restriction is in force, restriction is null. The field reflects live engine state and is never persisted — it is re-established on the next connect.

The same change is delivered through two live channels:

  • Webhook: a session.restriction event fires on each onset and lift. The event is idempotency-keyed per session and per restriction fact, and salted so a restriction that lifts and later returns is not deduped away as a replay.
  • WebSocket: session.restriction is socket-subscribable as well as webhook-delivered (since v0.14.0). The dashboard session card picks up a restriction (or its lift) live from the socket and shows a badge, with no page reload.

The audit log records session_restriction_lifted when one ends. OpenWA cannot lift a restriction WhatsApp has imposed — for tos_block in particular, the account itself needs an appeal through WhatsApp's own channels.

Common errors

StatusWhenFix
400 Bad RequestInvalid name, duplicate phoneNumber format, extra body field, QR fetched before qr_ready, a malformed session :id on PostgreSQL (validated as a UUID at the boundary), force-kill on a session with no live engine, or logout on a session that is not startedMatch the field rules; start the session before fetching the QR; pass the id returned by create; for a stale row use stop instead of force-kill; start the session before logging out
401 UnauthorizedMissing/invalid X-API-Key, or a key used outside its allowedSessions scopeSend a valid key scoped to this session
403 ForbiddenA valid key whose role is below operator on an operator-gated route — the write actions (create, start, stop, delete, logout) plus fetching the QR (GET /qr) and requesting a pairing code. Since v0.12.0 a session-restricted key is also refused on deployment-global surfaces (most notably PUT /api/plugins/:id/sessions)Use an operator (or higher) key; use an unrestricted ADMIN key to manage plugin activation
404 Not FoundThe session id does not exist (on SQLite a malformed id also returns 404, since it is treated as text)Check the id from the create response
409 ConflictA session with that name already exists (a name that loses a create race to an identical one also returns 409, not 500). Since v0.11.1, POST /sessions/:id/start and DELETE /sessions/:id can also answer retryable 409 with code: 'SESSION_NAME_TEARDOWN_PENDING' while a name-keyed credential teardown is still in flightPick a unique name, or reuse the existing session; for the teardown 409, wait a short delay and retry — no destructive side effect ran before the refusal

For a wider catalogue of failures and recovery steps, see Troubleshooting.

Next steps

  • Sending messages — text, media, and the recipient JID format.
  • Webhooks — react to inbound messages and session.status changes instead of polling.
  • First session — the end-to-end walkthrough from zero.
  • API reference — every session field, query parameter, and status code.