Skip to main content
Version: v0.23.1

Troubleshooting & FAQ

Find the symptom, read the cause, apply the fix. This page covers the failures operators hit most — container, session connect, QR timeout, disconnects, send failures, webhooks, auth, media, memory, and SQLite locks — then lists every HTTP status code and application error code the API returns.

For first-time setup, see Installation and Configuration. Terms used here are defined in the Glossary.

Quick diagnostics

Run these first — they isolate which layer is failing (process, database, engine, or a single session).

# 1. Is the API process up? (no auth required)
curl http://localhost:2785/api/health

# 2. Are the database and adapters ready? (no auth required)
curl http://localhost:2785/api/health/ready

# 3. Inspect one session's status
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:2785/api/sessions/SESSION_ID

# 4. Container status and recent logs
docker compose ps
docker compose logs openwa --tail=100

YOUR_API_KEY is the key you set in your environment; see Authentication for how it is issued. SESSION_ID is the session id returned when you create a session — a generated UUID, not the name you chose. Lookups (and the auth-folder paths below) use this UUID, so paste the id from the create response, not your session name.

A healthy session returns "status": "ready". Any other value points you at a section below.

Session states

Most connection problems are a session stuck in a non-ready state. A session moves through these seven values (lowercase, as returned by the API):

StatusMeaningWhat to do
createdThe session record exists but the engine has not startedStart the session
initializingThe engine is starting upWait; if it never advances, check logs
qr_readyA QR code is waiting to be scannedFetch and scan the QR within ~60 s
authenticatingQR scanned, linking the deviceWait; if stuck, see the section below
readyConnected and able to send/receiveNone — this is the goal state
disconnectedThe link droppedReconnect; check the phone's linked devices
failedA terminal engine error occurredRead lastError, fix, recreate

When status is failed, the session response includes a human-readable lastError. See Sessions for the full lifecycle.

Connection issues

Container won't start

Problem: docker compose up fails, the container exits immediately, or you see "port already in use."

Cause: Another process is bound to port 2785, or a previous run left a broken container or volume state.

Fix:

# Find and free whatever holds the port
lsof -i :2785
kill -9 $(lsof -t -i:2785)

# Read why the container exited
docker compose logs openwa

# Pull the current image and restart cleanly
docker compose pull
docker compose up -d

If it still exits, run docker compose logs openwa and match the first error to a section below. Three config shapes that previously booted with a silent downgrade now fail boot with a validation error — if the log names one of these, fix the value and restart:

Logged failureCauseFix
REDIS_ENABLED rejectedA non-canonical value (a typo like ture, or 1/yes) — previously the rate-limit store and cache silently downgraded to in-memorySet it to exactly true or false (blank reads as unset)
A numeric variable rejectedA numeric env var spelled as exponent or hex (1e6, 0x100) — it validated as one number while the app configured anotherUse plain decimal digits
WEBHOOK_MAX_PAYLOAD_BYTES rejectedA 0 cap would reject every webhook dispatchSet a positive byte count, or unset for the 1 MiB default

The gateway exits at boot naming AUDIT_RETENTION_DAYS

Problem: After upgrading to v0.17.0, startup fails with Invalid environment configuration: and a line reading AUDIT_RETENTION_DAYS must be an integer (got "30d"). The same value booted before the upgrade.

Cause: Since v0.17.0 AUDIT_RETENTION_DAYS is checked at boot and must be a plain decimal integer of any sign. It used to be parsed only where it was read, which silently coerced anything else: 30d and 90.5 became 30 and 90, +90 became 90, and a word such as ninety fell back to the 90-day default without reporting anything. The check permits a sign because 0 and any negative value are documented switches that disable audit-log pruning entirely.

Fix: Set a plain integer — AUDIT_RETENTION_DAYS=90 — or 0 to keep audit logs forever, or remove the variable to take the 90-day default. An empty or whitespace-only value reads as unset, so a ${AUDIT_RETENTION_DAYS:-} forward in a compose file is legal and means "default". Boot validation collects every environment error into one message, so fix each line the log lists before restarting.

Session won't connect (stuck at initializing or qr_ready)

Problem: A QR code is generated but the session never reaches ready, or it falls back to disconnected after you scan.

Cause: An expired QR, a corrupted auth folder, a browser crash, or an outbound network/firewall block.

Fix: Match the cause, then re-scan.

CauseFix
Expired QRFetch a fresh one — a QR is valid for about 60 seconds
Corrupted auth folderDelete the session's auth data and re-scan
Browser crash (whatsapp-web.js)Restart the container
Network / firewall blockVerify outbound connectivity and any proxy
# Both paths are keyed by the session NAME, not its id — the id addresses the API,
# the name addresses the folder on disk.

# whatsapp-web.js engine (default): auth lives under SESSION_DATA_PATH
# (default ./data/sessions), in a session-<name> subfolder
rm -rf ./data/sessions/session-SESSION_NAME

# Baileys engine: auth lives under BAILEYS_AUTH_DIR (default ./data/baileys)
rm -rf ./data/baileys/SESSION_NAME

docker compose restart openwa
warning

Deleting an auth folder unlinks that session from WhatsApp. You must scan a new QR to reconnect. Deleting the session itself (DELETE /api/sessions/:id) purges both engines' auth directories, not just the active engine's — a link left behind under the other engine cannot silently re-link after an engine switchback, so you do not need to clear the inactive engine's folder by hand.

Session stuck at authenticating, never reaches ready

Engine-specific

This affects the whatsapp-web.js engine only. With ENGINE_TYPE=baileys, skip this section.

Problem: After scanning, the phone links the device but the session stays at authenticating indefinitely. Common on ARM64 hosts (for example a Raspberry Pi).

Cause: The WhatsApp Web build in use is incompatible and stalls the post-link sync. With WWEBJS_WEB_VERSION unset, auto, or latest, OpenWA resolves a settled build from the third-party wppconnect-team/wa-version registry and pins its HTML — a bad pin produces this failure.

Fix: Pin a known-good WhatsApp Web version, then restart the container:

WWEBJS_WEB_VERSION=2.3000.1040641150-alpha

Browse available versions in the html/ folder of wppconnect-team/wa-version. Unset the variable (or set auto/latest) to return to the registry-pinned default; set it to off to use the first-party build served by WhatsApp.

What the default pin executes

The registry-pinned HTML executes inside the authenticated web.whatsapp.com origin without an integrity check — the pin exists to avoid the scan→stuck→disconnect-loop class of failures, not as a verified artifact. To control the supply chain yourself, serve an operator-controlled copy via WWEBJS_WEB_VERSION_REMOTE_PATH, or set WWEBJS_WEB_VERSION=off for WhatsApp's own build. At pin time OpenWA logs a one-time WARN naming the resolved version, its source URL, and these opt-outs.

QR generation times out on slow first boot (WSL2 / low-resource)

Engine-specific

whatsapp-web.js only.

Problem: On the first launch, no QR appears and the session fails after about 30 seconds — often inside WSL2 or a resource-constrained container.

Cause: whatsapp-web.js waits a fixed 30000 ms for WhatsApp Web to finish loading before it generates the QR. A slow first boot can exceed that window.

Fix: Raise the boot/inject wait (in milliseconds) and restart:

WWEBJS_AUTH_TIMEOUT_MS=120000 # allow up to 2 minutes

Leave it unset to keep the 30000 ms default.

Session fails to launch — chrome_crashpad_handler: --database is required

Engine-specific

whatsapp-web.js (Chromium/Puppeteer) only.

Problem: The session never starts. The log shows Failed to launch the browser process with chrome_crashpad_handler: --database is required. Seen on hardened, read_only containers.

Cause: Chromium resolves its home directory from the system passwd entry and ignores $HOME. The non-root runtime user has no home directory, so on a read-only rootfs Chromium aborts at launch.

Fix: Give Chromium writable config and cache directories. The bundled image and docker-compose.yml already do this on a tmpfs /tmp. For a custom container, set both paths to a writable, existing location and mount a writable /tmp:

XDG_CONFIG_HOME=/tmp/.config
XDG_CACHE_HOME=/tmp/.cache
# Pre-create both as the runtime user, and mount a writable tmpfs at /tmp:
# compose: tmpfs: ["/tmp"]
# k8s: an emptyDir volume mounted at /tmp
danger

Do not work around this by removing --no-sandbox hardening or using seccomp:unconfined. It does not help and it widens the attack surface.

Session fails with Execution context was destroyed

Engine-specific

whatsapp-web.js (Chromium/Puppeteer) only.

Problem: A whatsapp-web.js session fails with a bare Puppeteer Execution context was destroyed error and no next step. The failure is usually preceded by a long uptime or a memory-tight host.

Cause: A stale or corrupted browser profile — the Chromium instance can no longer evaluate JavaScript in WhatsApp Web's page context, often after a crash or an OOM kill that left the profile half-written.

Fix: The session card (and the server log) carries a short advisory naming the stale browser profile. Clear the session's Chromium profile and let the engine relaunch it — the profile lives under SESSION_DATA_PATH (default ./data/sessions) in the session's LocalAuth-adjacent Chromium directories, so the simplest recovery is to delete the session's auth folder and re-scan, exactly as for a corrupted auth folder above. Since v0.14.2 the advisory is surfaced on the session card too, so you do not need to dig through server logs to learn what the error means.

Frequent disconnections

Problem: A session drops to disconnected every few hours and needs frequent re-scans.

Cause: A logout from the phone's linked devices, memory pressure, an unstable network, or a blocked IP address.

Fix:

  • Open WhatsApp on the phone → Linked devices and confirm the device is still linked.
  • Give the container more RAM (see High memory usage).
  • Check outbound connectivity from the host.
  • If your hosting IP is being blocked by WhatsApp, route through a residential proxy.
  • Remember the session retries forever by default — watch for session.reconnect_loop webhook events or the openwa_session_reconnect_loop_alerts_total metric to spot a session stuck in a loop.
  • A Baileys session dying with connection replaced (440) means a second instance holds the account — stop the other instance, then restart this one.
  • Account rejected (403) means the number is likely banned — reconnecting won't help.

Messaging issues

Messages not sending

Problem: The send call returns 2xx but the message isn't delivered, or it returns an error.

Cause: Most often a bad recipient format, a disconnected session, rate limiting, or oversized media.

Fix: Match the status code:

CauseStatusFix
Invalid recipient format400Use the JID format 628123456789@c.us
Session not connected409Wait for ready or reconnect the session
Rate limited429Slow your send rate (see Rate limiting)
Media too large413Reduce the file or raise the media cap
Number not on WhatsApp2xx, no deliveryVerify the number first — the check answers 503 when WhatsApp did not reply, so treat that as unknown rather than "not on WhatsApp". A first send to a cold contact can also be dropped silently server-side (tracked in core issue #830) — WhatsApp policy, not an OpenWA bug; see Ban risk & safe sending

Verify a number is on WhatsApp before sending:

curl -H "X-API-Key: YOUR_API_KEY" \
"http://localhost:2785/api/sessions/SESSION_ID/contacts/check/628123456789"
{
"number": "628123456789",
"exists": true,
"whatsappId": "628123456789@c.us"
}

Since v0.14.5 this route answers 503 when WhatsApp did not reply to the lookup, instead of reporting exists: false. Treat the 503 as "unknown" and retry — a false here is now always a real answer about the number.

Recipient JIDs follow a fixed shape:

RecipientJID formatExample
Individual<number>@c.us628123456789@c.us
Group<groupId>@g.us120363123456789@g.us

See Sending messages for the full message API and Groups for group sends.

A send carrying quotedMessageId returns 404

Problem: A send-* call that carries quotedMessageId returns 404 Not Found with Message <id> not found, while the same call without the field succeeds. On whatsapp-web.js this case returned 500 before v0.17.0.

Cause: The id does not resolve to a message the engine can quote — it is mistyped, belongs to a different chat or session, or has fallen outside the engine's lookup window. Baileys has answered 404 here all along, from its local message store. Since v0.17.0 whatsapp-web.js answers 404 too, instead of surfacing the raw page error as an opaque 500; the gateway also stops the library from quietly sending the message unquoted and reporting success.

Fix: Quote an id exactly as the send response or the webhook reported it, from the same chat and session. If the message is too old to resolve, drop quotedMessageId and send unquoted — the field is optional on all nine send-* bodies that accept it. Because the failure is now a typed 4xx, it no longer counts toward the per-session send breaker, so retrying a stale quote id can no longer latch the cooldown that would 429 unrelated sends. One upstream gap remains on whatsapp-web.js and cannot be switched off: if the quoted message resolves but the page decides it is not replyable, the message is sent without the quote and the call still returns 201.

Webhook not firing

Problem: Messages arrive in WhatsApp but your webhook endpoint is never called.

Cause: No webhook is configured for the session, a filter is suppressing the event, the URL is unreachable from the container, or your endpoint returns a non-2xx and exhausts the retries.

Fix:

# 1. Confirm a webhook is registered for the session — note its id. `active` must be
# true, `events` must list the event (or "*"), and `filters` must not exclude it.
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:2785/api/sessions/SESSION_ID/webhooks

# 2. Force a delivery with the built-in test endpoint (WEBHOOK_ID from step 1).
# It POSTs a test payload to your URL and returns the delivery result.
curl -X POST -H "X-API-Key: YOUR_API_KEY" \
http://localhost:2785/api/sessions/SESSION_ID/webhooks/WEBHOOK_ID/test

# 3. Read the dead-letter log: deliveries that exhausted every retry, most recent
# first. Requires an ADMIN key — an operator key gets a 403.
curl -H "X-API-Key: YOUR_ADMIN_API_KEY" \
"http://localhost:2785/api/webhooks/delivery-failures?sessionId=SESSION_ID&limit=20"

# 4. Attempts still inside their retry window appear only in the server logs
docker compose logs openwa --tail=200 | grep -i webhook

# 5. Confirm your endpoint accepts an unauthenticated POST quickly
curl -X POST https://your-app.example.com/webhook \
-H "Content-Type: application/json" \
-d '{"test": true}'

Read steps 1 and 3 together. A delivery-failure row carrying an HTTP lastStatusCode means OpenWA delivered and your receiver rejected it — fix the receiver. An empty list with lastTriggeredAt still null means nothing was ever delivered and nothing permanently failed: the event either never matched this webhook, or was never emitted for the session at all. lastTriggeredAt stays null until a real 2xx delivery — the Test button never sets it, so a green test proves the endpoint is reachable and nothing more.

A filter can suppress an event silently

The delivery-failure log records only deliveries that were attempted, so an event a filter dropped leaves no row. A condition on a field the event's payload does not carry cannot match, and so suppresses that event entirely — a sender condition, for example, drops every message.ack, message.failed, and message.reaction. Set LOG_LEVEL=debug and look for webhook_filter_suppressed, which names the event and the payload fields that were actually available. See Filter events before delivery.

Your endpoint must return a 2xx quickly. OpenWA retries failed deliveries with a fixed-delay backoff, controlled by these environment variables:

VariableDefaultControls
WEBHOOK_TIMEOUT10000Per-attempt timeout, in milliseconds
WEBHOOK_MAX_RETRIES3Retry attempts after the first failure
WEBHOOK_RETRY_DELAY5000Delay between attempts, in milliseconds
tip

If the container can reach the public internet but not your endpoint, the URL is probably resolving to a host-only address. Use host.docker.internal (Docker Desktop) or the service name on a shared Docker network rather than localhost.

See Webhooks for the event list and payload shapes.

Media upload fails

Problem: A send returns 413 Payload Too Large, or media won't upload.

Cause: OpenWA enforces two separate limits. A decoded media blob may not exceed MEDIA_DOWNLOAD_MAX_BYTES (default 50 MiB), and the whole HTTP request body is bounded by BODY_SIZE_LIMIT. A base64 payload counts against both.

Fix:

  • Prefer sending media by URL for large files — the engine downloads it server-side instead of inflating your request body.
  • If you must send large base64 media, raise both limits and restart:
MEDIA_DOWNLOAD_MAX_BYTES=104857600 # 100 MiB media cap
BODY_SIZE_LIMIT=120mb # whole-request body limit
BODY_SIZE_LIMIT reaches the container only on v0.10.2+

Under the bundled Compose files, a BODY_SIZE_LIMIT set in .env is passed through to the container only on v0.10.2 and later. On earlier versions the value stayed on the host and the app's 25 MB default applied — upgrade before tuning this limit.

A request that exceeds the media cap returns 413 Payload Too Large with the standard error envelope — a statusCode, a message, and an error string. There is no code field on this response; branch on the status, not on a payload code.

Engine query failures

Unanswered WhatsApp query (503)

Problem: A call that used to return 200 — with an empty list, exists: false, url: null, or a plain "done" — now returns 503 Service Unavailable. Or one that used to fail with an opaque 500 now returns 503 instead.

Cause: The socket is up, the query went out, and WhatsApp never replied within the 30-second request budget the gateway owns. Since v0.14.5 that outcome has a status of its own instead of being folded into a normal-looking answer. A 503 here is a statement about the query, not about the resource:

RouteUsed to answerAnswers now
GET /api/sessions/{sessionId}/groups200 [] — identical to "you are in no groups"503
GET /api/sessions/{sessionId}/groups/{groupId} and /groups/join-infoA bare 500503
GET /api/sessions/{sessionId}/contacts/{contactId}/profile-picture200 { "url": null } — identical to "no picture set"503
GET /api/sessions/{sessionId}/contacts/check/{number}200 { "exists": false }503
POST /api/sessions/{sessionId}/chats/readA bare 500503
Channel lookup, subscribe, unsubscribe, delete, muteA bare 500503
Channel owner transfer and admin demote — Baileys onlyNothing; both routes are new in v0.16.0503
Group and profile writes, chat actions, label writes, call rejection200 — success for a change nothing confirmed503
Group participant writes — add, remove, promote, demote200 with a results array, identical to a batch nothing confirmed503

One route answers 503 without owning a budget at all. Listing the chats that carry a label (GET /api/sessions/{sessionId}/labels/{labelId}/chats) fails this way when the whatsapp-web.js page connection dies mid-read — there is no deadline to expire, only a connection that stopped answering. It has behaved this way since v0.14.0; only the published contract was late in saying so. The fix below still applies.

Fix: Retry the call. Reads are safe to repeat, and every write bounded this way is safe to repeat too — the change may or may not have landed, which is exactly what the status says. If 503 persists on a session, the connection is the real problem: check status and engineLoaded on the session and reconnect it. Note that a genuinely slow write that lands just after the deadline is reported as unconfirmed; that is the deliberate trade for never reporting an unacknowledged change as done.

Creating a group, creating a channel, and the media send path are deliberately left unbounded and never answer 503, so a client that retries on 503 cannot leave a duplicate group, channel, or message behind. That absence is the guarantee; the status a failure on those three carries is not contractual, so verify the outcome rather than branching on a specific code.

A group or channel call returns 5xx where it returned 4xx

Problem: After upgrading to v0.14.5 or later, code that branched on 403, 400, or 404 from the Baileys group and channel routes sees a 5xx instead.

Cause: The helper that decided whether a Baileys failure was a refusal by WhatsApp or a death of the transport matched every failure, so a closed socket was reported as a client error: the group and channel writes answered 403 admin rights or permissions may be missing, POST /api/sessions/{sessionId}/groups/join answered 400 with the same "invite code may be invalid" message as a genuinely bad code, and GET /api/sessions/{sessionId}/groups/join-info answered 404. A transport failure now propagates as a 5xx.

Fix: Keep the existing 4xx handling for genuine refusals and treat 5xx as a retryable transport failure. If your client retried on 403 to work around this, stop — the retry now belongs on the 5xx. If it surfaced "invalid invite code" to end users, it will now correctly report an outage instead. The three profile writes were never affected; they do not go through that helper.

Engine operations return 409 during a WhatsApp Web page reload

Problem: After upgrading to v0.15.0, engine routes that used to return 500 (or the five chat write endpoints that returned 200 {success:false}) during a WhatsApp Web page reload now return 409 Conflict.

Cause: WhatsApp Web periodically reloads its own page (measured ~5 minutes after a fresh pairing, among others). While whatsapp-web.js re-injects into the reloaded page, every engine route now answers the documented retryable 409 naming the reload. Before v0.15.0, the same window surfaced raw TypeError 500s on most routes, and the five chat write endpoints — read, unread, archive (unarchive is the same endpoint with archive: false), typing and clear — answered 200 {success:false}.

Fix: Retry on a timer. Do not wait for a session.statusready event: the session never leaves ready across a reload, so that event is never emitted and a listener waiting for one waits forever. Re-issue the operation every few seconds for up to about a minute — the re-inject grace window is NAVIGATION_REINJECT_GRACE_MS, 60000 ms by default — and treat a 409 that outlasts three of those windows as a page that is not healing. Past that cap the liveness watchdog stops being suppressed and eventually handles the session as disconnected, and that transition does emit session.status.

The send breaker counts only failures that reached WhatsApp, so a typed 4xx such as this 409 does not add to it. That exemption is not new in v0.15.0 — it has held since the breaker shipped in v0.14.0; what v0.15.0 changed is that the reload failure became a typed 409 instead of a raw 500, which the exemption then covers. The breaker only runs at all when you set SEND_PACING_ENABLED=true; it is off by default, and its cooldown is 15 minutes.

Creating a group returns 501

Problem: After upgrading to v0.16.0, POST /api/sessions/{sessionId}/groups returns 501 Not Implemented with Operation not supported by the active engine: createGroup on a whatsapp-web.js session.

Cause: whatsapp-web.js still declares createGroup, but its page code reaches a WhatsApp Web internal that no longer exists, so every call already failed — as an opaque 500 rather than a stated refusal. Since v0.16.0 the adapter refuses up front instead of attempting the creation. whatsapp-web.js is the default engine, the one you get with ENGINE_TYPE unset, so a default deployment sees this. Bare and @c.us-qualified participant ids fail identically, and the gap cannot be patched around — the missing function belongs to the WhatsApp Web page, not to the library.

Fix: Create groups on the Baileys engine: set ENGINE_TYPE=baileys for the session that creates them. The route, its body, and its Baileys behaviour are unchanged, and every other group route works on both engines. Note that a whatsapp-web.js session which is not yet ready answers 409, not 501 — the readiness check runs first, so wait for ready and retry before concluding the engine refused.

Authentication issues

Every request returns 401

Problem: All authenticated endpoints return 401 Unauthorized, even ones that worked before.

Cause: The X-API-Key header is missing, misspelled, or carries the wrong key.

Fix: Send the header on every request to an authenticated route. The header name is exact and case-sensitive in its documented form:

# Wrong — no key, returns 401
curl http://localhost:2785/api/sessions

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

The health endpoints (/api/health, /api/health/live, /api/health/ready) are unauthenticated by design — use them to confirm the server is up without a key. See Authentication for how keys are issued and rotated.

Performance issues

High memory usage

Problem: The container uses a large amount of RAM, or the host OOM-kills it.

Cause: With the default whatsapp-web.js engine, each session runs its own Chromium instance (roughly 300–500 MB RAM). Total memory scales with session count.

Fix: Cap memory, trim Chromium flags, or switch to the lighter engine.

services:
openwa:
deploy:
resources:
limits:
memory: 2G
environment:
# whatsapp-web.js engine only
- PUPPETEER_ARGS=--disable-dev-shm-usage,--disable-gpu,--no-sandbox

Switching to ENGINE_TYPE=baileys removes Chromium entirely and dramatically lowers per-session memory. See Scaling for capacity planning.

Database locked (SQLite)

Problem: Writes fail intermittently with SQLITE_BUSY or "database is locked," and response times spike.

Cause: SQLite serializes writes. Under concurrent sessions or a write-heavy workload, writers contend for the single database lock.

Fix: Ensure write-ahead logging is on, then plan a move to PostgreSQL.

# Data DB defaults to ./data/openwa.sqlite (auth/audit DB is ./data/main.sqlite)
sqlite3 ./data/openwa.sqlite "PRAGMA journal_mode;" # expect: wal
sqlite3 ./data/openwa.sqlite "PRAGMA journal_mode=WAL;"

Past roughly 5 concurrent sessions, or for any write-heavy deployment, switch the Database adapter to PostgreSQL. See the Migration guide.

Docker issues

Volume permission denied

Problem: "Permission denied" when writing to the data directory; auth files don't persist across restarts.

Cause: The host directory is owned by a different user than the container's runtime user.

Fix:

sudo chown -R $(id -u):$(id -g) ./data
docker compose restart openwa

Podman: Docker socket missing or container stays unhealthy

Problem: On Podman you see FileNotFoundError for the Docker socket, or the container starts but stays unhealthy.

Cause: Podman's rootless socket is inactive by default, and a few Docker conventions (unqualified image names, node -e healthchecks) behave differently under Podman.

Fix: Start the Podman socket and export DOCKER_HOST:

systemctl --user start podman.socket
systemctl --user enable podman.socket
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock

When building a custom image under Podman, use fully-qualified image names (for example docker.io/node:22-slim) and a curl-based healthcheck rather than node -e.

Rate limiting

OpenWA applies a single global rate limit across all clients (keyed on the resolved client IP), enforced in three tiers. Exceeding any tier returns 429 Too Many Requests.

TierDefault limitWindow
Short10 requests1 second
Medium100 requests60 seconds
Long1000 requests1 hour

There is no per-session rate-limit endpoint. To raise the limits, set the corresponding configuration values and restart. On a 429, back off and retry.

Error code reference

HTTP status codes

The API uses the standard NestJS error envelope. Every error response is JSON of the shape:

{
"statusCode": 404,
"message": "Session \"SESSION_ID\" not found",
"error": "Not Found"
}
CodeMeaningCommon causeFix
400Bad RequestInvalid body, params, or JID format; a media-conversion input URL that is blocked by the SSRF guard, unreachable, or oversizedCheck the request payload; for media conversion, point the url at a reachable, public host and keep it under MEDIA_CONVERSION_MAX_OUTPUT_BYTES
401UnauthorizedMissing or invalid API keySend a valid X-API-Key header
404Not FoundUnknown session, message, or routeVerify the id exists
409ConflictSession id already exists, or session not connectedUse a new id, or wait for ready
413Payload Too LargeMedia or body exceeds the capReduce size or raise the limit
429Too Many RequestsRate limit exceeded, or send pacing (opt-in, SEND_PACING_ENABLED) refused the send — the pacing refusal carries code: 'SEND_PACING_LIMITED' and a retryAfterSeconds body fieldBack off and retry; for a pacing refusal, wait out retryAfterSeconds rather than treating it like the global one-second throttle
500Internal Server ErrorUnhandled server errorCheck the logs and report it
501Not ImplementedOperation unsupported by the active engine. Since v0.13.0, catalog reads and send-product work on the Baileys engine; whatsapp-web.js has no catalog API (send-catalog never worked on either engine and was removed in v0.19.0). Three more are documented since v0.14.5: listing channels and reading channel messages refuse on Baileys, and subscribing to a channel by invite code refuses on whatsapp-web.js. Eleven further routes now declare the 501 they were already answering — every label READ refuses on Baileys, which has no label query of any kind, and every media send to an <id>@newsletter recipient refuses on whatsapp-web.js, whose page method for it was removed by a WhatsApp Web update (text to a channel still works). Three more refuse on whatsapp-web.js since v0.16.0: creating a group, transferring channel ownership, and demoting a channel admin — each reaches a WhatsApp Web internal that no longer existsUse the Baileys engine for catalog operations; use whatsapp-web.js to read labels or to list and read channels, and Baileys to subscribe by invite or to edit labels; send text rather than media to a channel; use Baileys to create a group, transfer channel ownership, or demote a channel admin; otherwise use a supported engine or operation
502Bad GatewayA session lifecycle teardown could not complete on both sides — SESSION_STOP_INCOMPLETE on stop (v0.19.0+, both the graceful disconnect and the forced teardown failed; session left disconnected) or SESSION_LOGOUT_INCOMPLETE on logout (v0.12.0+)Retryable — retry the call, or use force-kill for a stop that will not complete; see Session lifecycle error codes
503Service UnavailableDependency or session temporarily down; server-side media conversion (POST /api/sessions/:sessionId/media/convert/{voice,video}) is disabled, the ffmpeg binary cannot be run, or its conversion queue is saturated; or WhatsApp did not answer an engine query within its 30-second request budget (see Unanswered WhatsApp query)Retry after the dependency recovers; for media conversion, retry shortly or convert client-side and post the result; for an unanswered query, retry the call

Bulk-send result codes

These codes are not HTTP statuses. They appear only on per-message results inside a bulk batch, retrieved from GET /api/sessions/{sessionId}/messages/batch/{batchId}. Each failed entry carries a { code, message } object so you can branch on why one message in the batch failed. An ordinary POST .../messages send does not return these codes — it returns a normal HTTP status (see the table above).

CodeMeaningFix
SEND_BLOCKEDDestination address was refused (SSRF/allow-list block)Check the recipient and any allow/deny list
SEND_FAILEDThe engine rejected the sendRead the accompanying message for the cause
SEND_PACING_LIMITEDThe opt-in send-pacing governor (SEND_PACING_ENABLED) refused this message — a warm-up ramp, daily cap, failure breaker, or cold-reachout budget said no. Surfaced as SEND_PACING_LIMITED rather than SEND_FAILED since v0.14.0 so you can branch on a refusal that is intentional and time-bounded, not an engine faultWait out the refusal window (the per-send HTTP 429 carries retryAfterSeconds); slow the batch, or — for a cold-reachout cap — send to recipients who have messaged in first
note

Engine-not-ready errors surface as 409 Conflict ("Session is not connected"), and referencing a message outside the engine's lookup window surfaces as 404 Not Found. Both follow the standard HTTP envelope above.

Session lifecycle error codes

These codes ride inside the standard HTTP error envelope's message field, on the session lifecycle routes. Branch on them for deterministic handling.

CodeHTTP statusWhenFix
SESSION_NAME_TEARDOWN_PENDING409POST /sessions/:id/start or DELETE /sessions/:id while a name-keyed credential teardown is still in flight (v0.11.1+)Retryable — wait a short delay and retry; no destructive side effect ran before the refusal
SESSION_LOGOUT_INCOMPLETE502POST /sessions/:id/logout: the session was stopped locally and phone cleared, but the unlink did not complete (no send, no acknowledgement, timeout/transport error, or local-cleanup failure). No success audit row is written (v0.12.0+)Start the session again and retry the logout
SESSION_STOP_INCOMPLETE502POST /sessions/:id/stop: since v0.19.0 a failed graceful disconnect escalates to a force-destroy, so this fires only when both the graceful disconnect and the forced teardown failed. The session is left disconnected and no success audit row is writtenRetryable — retry the stop, or use POST /sessions/:id/force-kill directly

Frequently asked questions

How many sessions can I run on one instance? It depends on host resources and the engine. With the default whatsapp-web.js engine (~300–500 MB RAM per session): roughly 3–5 sessions on 2 GB, 8–10 on 4 GB, 15–20 on 8 GB. ENGINE_TYPE=baileys is browser-free and fits many more on the same hardware. See Scaling.

How do I keep my number from getting banned? OpenWA uses the unofficial WhatsApp Web protocol, so there is inherent risk — use a dedicated number, avoid unsolicited bulk messaging, and ramp volume gradually. See Ban risk & safe sending for the full guidance.

Can I use a WhatsApp Business account? Yes — both personal and WhatsApp Business app accounts work. The official WhatsApp Business API (Meta Cloud API) is a different product and is not what OpenWA uses.

How do I send to a group? Use the group JID (...@g.us) as the chatId. See Groups.

How do I run behind a reverse proxy? Forward WebSocket upgrades and set generous idle timeouts — OpenWA keeps a long-lived connection per dashboard client on the same port as the REST API. See Deployment.

How do I integrate with n8n? See the n8n integration guide.

What webhook events can I subscribe to? Message events (message.received, message.sent, message.ack, message.failed, message.revoked, message.edited, message.reaction), status events (status.received), session events (session.status, session.qr, session.authenticated, session.disconnected, session.reconnect_loop), group events (group.join, group.leave, group.update), and call events (call.received). See Webhooks.

Getting help

Before opening an issue:

  1. Re-check this page for your symptom.
  2. Read the logs: docker compose logs openwa --tail=200.
  3. Try a restart and, if relevant, a clean re-scan.
  4. Search existing issues — someone may have hit it already.

When you report, include your OpenWA version (v0.23.1), deployment (Docker/OS), engine (ENGINE_TYPE), Database adapter, session count, reproduction steps, and sanitized logs.

Next steps