Skip to main content
Version: v0.23.5

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

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.5" }

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.

v0.23.4 changed a schema the same way, again without touching a URL. GET /sessions/{sessionId}/labels/{labelId}/chats used to answer with a LabelChatDto, a narrower object of its own; it now answers with the same ChatSummaryDto as the chats list, and LabelChatDto is gone from the contract. A hand-written caller reads three extra fields and nothing else changes, but a generated client loses a type by that name, so regenerate rather than patching the call site. ChatSummaryDto itself gained three required fields in the same release, which is the other half of the same change.

Authentication​

Every route is protected by an API-key guard unless it is explicitly public: the health checks, GET /api/infra/health, the metrics endpoint (which uses a separate bearer token), and the Integration Fabric ingress routes, which authenticate a provider signature instead of a key. 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"

offset counts rows, so a message arriving between two pages shifts the window and a walk can repeat a row or skip one. Since v0.23.4 GET /sessions/{sessionId}/messages also takes after, a keyset cursor carrying the id of the previous page's last row:

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

after anchors the window to a row instead of a count, so a concurrent write cannot move it. It replaces offset rather than adding to it: send both and offset is ignored rather than refused, so an SDK that always sends offset=0 keeps working. A blank after= reads as absent. A cursor naming no message in this session answers 400 Unknown cursor '<id>' for this session rather than an empty page, which would otherwise be indistinguishable from reaching the end of the history. total means the same thing either way: rows matching the filters, not rows remaining.

Since v0.23.4 every paged list also breaks ties on a second sort key, so a walk returns each row exactly once. That covers the message, session, webhook and delivery-failure lists and GET /search; before it, a non-unique sort key could repeat a row on one page and drop it from the next on PostgreSQL. On SQLite, the default database, the tiebreaker is the stored insertion sequence, so messages sharing one second come back in arrival order instead of shuffled; PostgreSQL orders such a group by id. offset is still a count and still shifts under concurrent writes, which is exactly what after exists to avoid.

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
muteExpiration on a chat summary (v0.23.4+)Unix epoch milliseconds (a number)1786003600000
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 request 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.

muteExpiration is the one millisecond field in a response, and it shares a chat object with timestamp, which is seconds, so read the two with different scales. It carries the same unit as muteUntil, so a finite value round-trips back into a mute request unchanged. The exception is 0, which reports an indefinite mute: muteUntil demands a real future instant and rejects it, so re-apply an indefinite mute with a far-future timestamp.

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, except on POST /sessions/{sessionId}/pairing-code, which answers this until the session reads qr_ready, and again from the moment a code is accepted until it reads ready (a session that already reads ready is linked, and answers 400 there). 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)
415 Unsupported Media TypeCompressed request bodyThe request carries a Content-Encoding other than identity. Middleware refuses it before routing and never reads the body, because the in-flight body budget counts wire bytes and cannot bound what a compressed body inflates to. Send the body uncompressed. This and the overload 503 below are the two statuses any operation can emit, so neither is declared per route in the contract
500 Internal Server ErrorServer or engine errorThe send failed at the WhatsApp engine, or an unexpected server error. On the operations listed as deliberately unbounded below, a 500 is also the contract's way of saying a dead page left the outcome unknown: verify before replaying rather than treating it as a bug
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
501 Not ImplementedSearch cannot runGET /api/search when no search provider is registered (what SEARCH_PROVIDER=none produces), or when the built-in provider finds no full-text index in the database. Nothing to do with the engine
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)
502 Bad GatewaySearch provider returned an unusable resultGET /api/search only, and only while a plugin search provider is active: the result shape it returned failed the host's validation, so nothing trustworthy could be forwarded. The built-in provider never answers this
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
503 Service UnavailableSearch provider did not answerGET /api/search only, and only while a plugin search provider is active: its worker is not running, the query passed the 10-second search timeout, or the handler reported a failure. Retryable. The built-in provider never answers this
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 carry two throttlers of their own, instance and ingress-ip, 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. 98 of the 200 operations declare it in the contract. GET /sessions/{sessionId}/qr is not one of them: it reads the QR the engine has already cached rather than querying WhatsApp, so a session that is not at qr_ready answers 400 there, and since v0.23.3 the route declares no 409 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.

Thirteen 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, revoking the last usable admin key, 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 57 of the API's 200 operations declare a 503 in the contract for that reason. Fifteen more own no budget at all and answer 503 when the whatsapp-web.js page connection dies mid-request. Listing a session's chats and listing the chats that carry a label have declared it since v0.15.0, and the label-chats behaviour predates the declaration. v0.23.4 gave the same status to the other thirteen: seven message operations, two contact reads, three label reads and the status delete, each of which used to report a dead page as a bare 500. Four declare 503 on their own terms: the readiness probe, the two media-conversion routes, and GET /api/search when its active plugin search provider does not answer. 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, the media send path, the four status posts, and creating a call link. What the contract guarantees is the absence: those never answer 503, so a client that retries on 503 cannot leave a duplicate group, channel, message, status or call link behind. The channel create, the four status posts and the call-link create joined that set in v0.23.4: a dead whatsapp-web.js page there answers 500 where it used to answer 503, and POST /sessions/{sessionId}/calls/link dropped the 503 it declared. The status a failure does carry is not contractual, since none of them 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.