Skip to main content
Version: v0.23.1

API conventions

The rules that hold across every OpenWA endpoint: where the API lives, how you authenticate, what a response looks like, and which status codes you will hit. Read this once, then use the API reference for per-endpoint detail.

This documentation targets OpenWA v0.23.1.

Base URL and the /api prefix

Every REST route is mounted under a global /api prefix on port 2785.

http://localhost:2785/api

In local development that is http://localhost:2785/api. In production, OpenWA serves plain HTTP and expects you to terminate TLS at a reverse proxy; substitute your own origin and keep the /api prefix:

https://wa.example.com/api

All examples on this site use the local base URL.

Versioning

The API is not URL-versioned — there is no /v1 segment. The version is the OpenWA release you run. Check it any time with the health endpoint. The endpoint itself is public, but since v0.19.0 the version field is disclosed only to callers presenting a valid API key — an unauthenticated probe still gets 200 with status and timestamp, and no version to fingerprint:

curl http://localhost:2785/api/health -H "X-API-Key: YOUR_API_KEY"
{ "status": "ok", "timestamp": "2026-06-25T12:34:56.789Z", "version": "0.23.1" }

Pin your deployment to a known release and read the field reference for that version. When a field or default is version-sensitive, this documentation says so.

Since v0.16.0 the contract itself changed shape in one place, without changing any URL. The group-list and status routes used to appear twice in openapi.json under different path-parameter names, each key carrying one verb of the same URL; they are now one entry each. GET /sessions/{id}/groups (the pre-v0.16.0 spelling) became GET /sessions/{sessionId}/groups, and the status read and delete both take {id} in place of {contactId} and {statusId}. The status media route keeps {statusId}, since nothing collided there. v0.19.0 finished the respell: the twenty-two session routes that still addressed the session as {id} — the chats family, start, stop, qr, pairing-code, presence, and the rest — now spell it {sessionId} throughout the contract, and the Prometheus route labels follow. No URL and no request behaviour changed, so a hand-written HTTP caller needs no change at all. If you generate a client from openapi.json, regenerate it: parameter names move with the template, so generated method and argument names derived from the old names will change.

Authentication

Every route is protected by an API-key guard unless it is explicitly public (health checks, and the metrics endpoint which uses a separate bearer token). Send your key in the X-API-Key request header:

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

YOUR_API_KEY is the key OpenWA seeds on first run (printed to the startup log and written to data/.api-key). Use that admin key to mint scoped, lower-privilege keys through the auth resource. The key is bearer-equivalent — anyone holding it can act as you — so never send it over plaintext http:// outside local development, and never put it in a URL.

REST auth is header-only

A query-parameter API key (?apiKey=) is not accepted on REST routes. The header is the only way in.

For the full sign-in walkthrough and how to create scoped keys, see the authentication guide.

Roles

Each key carries one of three roles, enforced as a minimum-rank hierarchy (viewer < operator < admin):

RoleCan do
viewerRead-only routes (list sessions, read message history, view contacts)
operatorEverything a viewer can, plus write and action routes (send messages, manage groups, manage webhooks)
adminEverything, plus key management and global settings

A key may also be scoped to specific sessions and source IPs. The scope and IP checks run before the role check, so a request outside a key's allowed sessions or IPs is rejected with 401 even when the role would otherwise pass.

Request format

Send a JSON body on POST, PUT, and PATCH requests, with a Content-Type header:

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

Request bodies are strictly validated. A field that is not declared on the endpoint's schema is rejected — an unknown field returns 400, it is not silently ignored. Validation failures also return 400 with a message array listing each field-level problem.

Pagination

List endpoints that can return large result sets accept limit and offset query parameters. Defaults and ceilings are per-endpoint — for example, session chats and groups default to limit=1000, while message history defaults to limit=50 and clamps to a maximum of 100. There is no global pagination envelope; check each endpoint in the API reference for its exact bounds.

curl "http://localhost:2785/api/sessions/SESSION_ID/messages?limit=50&offset=0" \
-H "X-API-Key: YOUR_API_KEY"

Response format

OpenWA returns the raw handler payload — there is no { success, data, meta } wrapper. Read fields directly off the response.

A resource route returns the object as-is:

{
"id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"name": "my-bot",
"status": "ready"
}

Most list routes return a bare JSON array:

[
{ "id": "…", "name": "session-a" },
{ "id": "…", "name": "session-b" }
]

A few paginated list routes instead return a small wrapper such as { "messages": [...], "total": 42 }. The per-endpoint reference states the exact shape.

Timestamps

OpenWA uses three timestamp representations — know which a field is:

Field kindRepresentationExample
Message timestampsUnix epoch seconds (a number)1719312000
Group membership requests — requestedAt on GET /sessions/{sessionId}/groups/{groupId}/membership-requestsUnix epoch seconds (a number)1754700000
muteUntil on POST /sessions/{sessionId}/chats/muteUnix epoch milliseconds (a number)1800000000000
startTime on POST /sessions/{sessionId}/calls/linkUnix epoch milliseconds (a number)1800000000000
Entity audit fields (createdAt, updatedAt, expiresAt, startedAt)ISO-8601 UTC string2026-06-25T09:20:00.000Z

The two millisecond fields are the ones to get right: a seconds-scale value passes validation and answers 200, but it lands in 1970, so the mute expires immediately and the call link is scheduled in the past. Both are required — omitting them is a 400 rather than a guessed default. muteUntil additionally accepts null, which means unmute now.

Error format

Errors use the NestJS default envelope. The HTTP status sits on the status line and is mirrored in statusCode; there is no application-specific error code field:

{
"statusCode": 404,
"message": "Session 'my-bot' not found",
"error": "Not Found"
}

For validation failures (400), message is an array of field-level strings instead of a single string:

{
"statusCode": 400,
"message": ["name must be longer than or equal to 3 characters"],
"error": "Bad Request"
}

Status codes

These are the codes you will actually encounter:

StatusMeaningWhen
200 OKSuccessA successful GET, or a write that the controller maps to 200
201 CreatedCreatedA successful POST (the NestJS default for POST)
202 AcceptedAcceptedA bulk send was queued for background processing
204 No ContentSuccess, empty bodyA successful DELETE
400 Bad RequestValidation or precondition failedA bad or unknown body field, an invalid value, or a business precondition such as "session not started". The platform caps also surface as 400 — a group batch over 256 participants, a template render over TEMPLATE_RENDER_MAX_CHARS, a webhook registration over WEBHOOK_MAX_PER_SESSION, or a plugin install over plain http:// without a #sha256= pin
401 UnauthorizedAuth failedMissing, invalid, expired, or revoked key; a blocked source IP; or a key used outside its session scope
403 ForbiddenRole too lowA valid, in-scope key whose role is below the route's requirement
403 ForbiddenWhatsApp refused itThe request was well formed and reached WhatsApp, which turned it down — usually a group or channel write the account has no admin rights for
404 Not FoundNo such resourceThe session, message, group, channel, label, webhook, or batch does not exist
409 ConflictConflicting stateA duplicate value, such as creating a session with a name that already exists, or an action the resource's current state does not allow
409 ConflictSession not connectedAn engine exists for the session but is not ready — disconnected, reconnecting, or still initializing — so the request never reached WhatsApp. Wait for ready and retry. A session that was never started answers 400 instead — see Session not connected and 409
413 Payload Too LargeMedia too bigBase64 media exceeds the media byte cap (default 50 MiB)
500 Internal Server ErrorServer or engine errorThe send failed at the WhatsApp engine, or an unexpected server error
501 Not ImplementedThe engine cannot do itThe operation is part of the engine contract but the engine currently running has no implementation for it — Baileys has no label queries; whatsapp-web.js cannot edit labels, send media to a channel, create a group (v0.16.0+), transfer channel ownership, or demote a channel admin
502 Bad GatewayLifecycle teardown incompleteA session lifecycle route's teardown could not complete on both sides. Retryable, with a code in the body naming the route: SESSION_STOP_INCOMPLETE on stop (v0.19.0+ — fires only when the graceful disconnect and the forced teardown both failed; the session is left disconnected), SESSION_LOGOUT_INCOMPLETE on logout (v0.12.0+ — stopped locally but the unlink did not complete)
503 Service UnavailableNot readyThe readiness probe failed, or the app is draining during shutdown
503 Service UnavailableOverloadedThe aggregate in-flight request-body budget is exhausted (INFLIGHT_BODY_BUDGET_BYTES, default 4 × the per-request BODY_SIZE_LIMIT), or one client IP has spent its own share of it (half by default, resolved through TRUSTED_PROXIES; without it every caller behind a reverse proxy shares one share, since v0.21.0). The response carries Retry-After and Connection: close; the body is never read — wait for the hint and retry
503 Service UnavailableWhatsApp never answeredA route reached a WhatsApp engine call that owns a request budget, and the budget expired with no reply. See Transport failures and 503
Rate limiting

A global rate limiter applies per client IP across three named throttlers — short (10 requests / 1 s), medium (100 / 60 s), and long (1000 / 1 h). Exceeding any one of them returns 429 Too Many Requests.

Rate-limit state rides in headers suffixed with the throttler name; the unsuffixed variants are never sent:

HeaderMeaning
X-RateLimit-Limit-<name>Budget for that window, e.g. X-RateLimit-Limit-short: 10
X-RateLimit-Remaining-<name>Requests left in the current window
X-RateLimit-Reset-<name>Seconds until the window resets
Retry-After-<name>On 429, seconds to wait before retrying

The integration ingress routes use a fourth throttler, instance, with the same header pattern. All suffixed headers are exposed through CORS, so browser clients can read them. The limits are configurable through environment variables, and the health and metrics routes are exempt. See the configuration reference for the env overrides.

The exempt set is exactly four routes: GET /api/health, GET /api/health/live, GET /api/health/ready, and GET /api/metrics. Exempt means no throttler runs on them and no rate-limit headers are sent (v0.16.0+). All four are also public — no X-API-Key — so if the port is reachable from the internet, rate-limit them at your reverse proxy; nothing in the gateway limits them. GET /api/metrics is separately gated by METRICS_TOKEN: it answers 404 while that variable is unset and 401 when it is set and the bearer token is missing or wrong. See deployment for the proxy setup.

Session not connected and 409

Every engine method checks that a live, connected engine is there before it touches the socket or the page, and answers 409 Conflict when it is not. 91 of the 189 operations can answer this, which is every route that reaches an engine at all.

409 is narrower than it first looks, because a session that is not connected can fail in two different places:

What is trueWhere it is caughtStatus
The session has never been started, so no engine exists for itThe service, before the engine is fetched400, "Session is not started"
An engine exists but is not ready — still initializing, reconnecting, or disconnectedThe engine itself, on entry to the method409

The two are sequential rather than alternative, so an operation that can answer one can usually answer the other, and most engine routes declare both: 400 means nothing has been started, 409 means something is running but cannot talk to WhatsApp yet. Neither sent anything, and neither changed anything — the difference is only whether you need to start the session or wait for it.

Ten other operations answer 409 for an unrelated reason — creating a session whose name is taken, installing a plugin that is already installed, importing data that would orphan a running engine, or starting a session while a name-keyed credential teardown is still in flight. Those are conflicts of state rather than of connectivity, and the two sets do not overlap: no route answers 409 for both reasons, so the description on each operation tells you which one you are looking at.

Note that starting an already-running session is a 400, not a 409 — the guard runs before any conflict fence is reached.

note

409 and 503 divide the failure cleanly. A 409 means the request never reached WhatsApp — there was no engine to send it through. A 503 means it did reach WhatsApp, which then said nothing back. The first is fixed by starting the session; the second by retrying.

Transport failures and 503

A route that talks to WhatsApp can get no answer at all: the socket is up, the query goes out, and nothing comes back. Since v0.14.5 that outcome has a status of its own. 503 Service Unavailable means the gateway stopped waiting for a confirmation that never came — it is a statement about the query, not about the resource.

Every engine call whose outcome cannot be read from its own return value now runs against a 30-second request budget, and 48 of the API's 189 operations declare a 503 in the contract for that reason. Four more declare it on their own terms: the readiness probe, the two media-conversion routes, and listing the chats that carry a label — that last one owns no budget at all and answers 503 when the whatsapp-web.js page connection dies mid-read, as it has since v0.14.0. Before this, an unanswered query was folded into whatever the engine happened to return, which was usually indistinguishable from a real answer:

RouteWhat an unanswered query used to returnWhat it returns now
GET /sessions/{sessionId}/groups200 with [] — the same body an account in no groups gets503
GET /sessions/{sessionId}/contacts/check/{number}200 with exists: false503
GET /sessions/{sessionId}/contacts/{contactId}/profile-picture200 with url: null — the same body a contact with no picture gets503
Group and profile writes, chat actions, label writes, call rejection200, reporting success for a change nothing acknowledged503
Group participant writes — add, remove, promote, demote200 carrying a results array, indistinguishable from a batch nothing acknowledged503
Group metadata reads, channel operations, marking a chat readA bare 500 with the underlying message discarded503

On the four participant writes, 503 is the one status that separates WhatsApp never answered from the per-participant refusals a 200 reports inside results. A batch where some participants were refused is a successful call with a mixed outcome; read results for it. A 503 says the batch has no outcome to read.

A 503 is retryable. On a read, repeat the call. On a write, the change may or may not have been applied — but every write bounded this way is safe to repeat, which is why it was given a retryable status. The response is the ordinary error envelope; only the overload 503 above carries Retry-After.

A few engine calls are deliberately left unbounded because they are not safe to repeat: creating a group, creating a channel, and the media send path. What the contract guarantees is the absence: those never answer 503, so a client that retries on 503 cannot leave a duplicate group, channel, or message behind. The status a failure there does carry is not contractual — none of the three declares a 5xx in the spec — so treat any non-2xx from them as an outcome to verify rather than a code to branch on.

Breaking in v0.14.5: a dead connection is no longer reported as a client error

On the Baileys engine, the helper that decided whether a failure was a refusal by WhatsApp or a death of the transport matched every failure, so a closed socket was reported as a permissions or input problem:

  • the group and channel writes answered 403 admin rights or permissions may be missing,
  • POST /sessions/{sessionId}/groups/join answered 400 — the same "invite code may be invalid" as a genuinely bad code,
  • GET /sessions/{sessionId}/groups/join-info answered 404.

A transport failure now propagates as a 5xx instead. This changes the status code you receive for those cases. Keep your existing 4xx handling for genuine refusals and treat 5xx as a retryable transport failure. A caller that retried on 403 to work around this should stop doing so, and one that surfaced "invalid invite code" to an end user will now correctly report an outage. The three profile writes were never affected — they do not go through that helper.

Breaking in v0.15.0: engine operations during a WhatsApp Web page reload answer 409 instead of 500

WhatsApp Web periodically reloads its own page (~5 minutes after a fresh pairing, among other triggers). During the bounded window while whatsapp-web.js re-injects, every engine route now answers a retryable 409 naming the reload. Before v0.15.0, most routes surfaced raw TypeError 500s, and the five chat write endpoints — read, unread, archive (unarchive is the same endpoint with archive: false), typing and clear — answered 200 {success:false}. Retry on a timer rather than waiting for a ready event: the session never leaves ready across the reload, so no session.status event is emitted. The window is bounded by NAVIGATION_REINJECT_GRACE_MS, a build-time constant of 60000 ms, not configurable — see troubleshooting for the retry shape. The send breaker counts only failures that reached WhatsApp, so a typed 4xx such as this 409 does not add to it; that exemption dates from v0.14.0, when the breaker shipped, and what changed in v0.15.0 is the status the reload produces.

Breaking in v0.16.0: creating a group answers 501 on whatsapp-web.js

POST /sessions/{sessionId}/groups now refuses up front on the whatsapp-web.js engine — which is the default, the one you get with ENGINE_TYPE unset — with 501 and the message Operation not supported by the active engine: createGroup. Before v0.16.0 the creation was attempted and failed as an opaque 500: whatsapp-web.js still declares createGroup, but its page code reaches a WhatsApp Web internal that no longer exists, so every call already failed. The practical impact is therefore smaller than it reads — nothing that used to work stopped working, the failure just became a stated refusal instead of an unexplained server error. Action required: create groups through the Baileys engine (ENGINE_TYPE=baileys), which is unaffected. The route, its body, and its Baileys behaviour are unchanged. The readiness check still runs first, so a whatsapp-web.js session that is not yet ready answers 409 rather than 501.

A worked request

Send a text message and read the response:

curl -X POST http://localhost:2785/api/sessions/SESSION_ID/messages/send-text \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "chatId": "628123456789@c.us", "text": "Hello from OpenWA!" }'
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }

The response is the raw payload: messageId is the engine's WhatsApp message id, and timestamp is epoch seconds.

Request lifecycle

Every authenticated request passes the same guards before reaching a handler:

Next steps

  • Browse every endpoint, field, and schema in the API reference.
  • Get a key and connect a session in the quick start.
  • Drive the API from JavaScript with the SDK.