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):
| Status | Meaning | What to do |
|---|---|---|
created | The session record exists but the engine has not started | Start the session |
initializing | The engine is starting up | Wait; if it never advances, check logs |
qr_ready | A QR code is waiting to be scanned | Fetch and scan the QR within ~60 s |
authenticating | QR scanned, linking the device | Wait; if stuck, see the section below |
ready | Connected and able to send/receive | None — this is the goal state |
disconnected | The link dropped | Reconnect; check the phone's linked devices |
failed | A terminal engine error occurred | Read 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 failure | Cause | Fix |
|---|---|---|
REDIS_ENABLED rejected | A non-canonical value (a typo like ture, or 1/yes) — previously the rate-limit store and cache silently downgraded to in-memory | Set it to exactly true or false (blank reads as unset) |
| A numeric variable rejected | A numeric env var spelled as exponent or hex (1e6, 0x100) — it validated as one number while the app configured another | Use plain decimal digits |
WEBHOOK_MAX_PAYLOAD_BYTES rejected | A 0 cap would reject every webhook dispatch | Set 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.
| Cause | Fix |
|---|---|
| Expired QR | Fetch a fresh one — a QR is valid for about 60 seconds |
| Corrupted auth folder | Delete the session's auth data and re-scan |
| Browser crash (whatsapp-web.js) | Restart the container |
| Network / firewall block | Verify 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
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
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.
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)
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
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
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
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_loopwebhook events or theopenwa_session_reconnect_loop_alerts_totalmetric 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:
| Cause | Status | Fix |
|---|---|---|
| Invalid recipient format | 400 | Use the JID format 628123456789@c.us |
| Session not connected | 409 | Wait for ready or reconnect the session |
| Rate limited | 429 | Slow your send rate (see Rate limiting) |
| Media too large | 413 | Reduce the file or raise the media cap |
| Number not on WhatsApp | 2xx, no delivery | Verify 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:
| Recipient | JID format | Example |
|---|---|---|
| Individual | <number>@c.us | 628123456789@c.us |
| Group | <groupId>@g.us | 120363123456789@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.
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:
| Variable | Default | Controls |
|---|---|---|
WEBHOOK_TIMEOUT | 10000 | Per-attempt timeout, in milliseconds |
WEBHOOK_MAX_RETRIES | 3 | Retry attempts after the first failure |
WEBHOOK_RETRY_DELAY | 5000 | Delay between attempts, in milliseconds |
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:
| Route | Used to answer | Answers now |
|---|---|---|
GET /api/sessions/{sessionId}/groups | 200 [] — identical to "you are in no groups" | 503 |
GET /api/sessions/{sessionId}/groups/{groupId} and /groups/join-info | A bare 500 | 503 |
GET /api/sessions/{sessionId}/contacts/{contactId}/profile-picture | 200 { "url": null } — identical to "no picture set" | 503 |
GET /api/sessions/{sessionId}/contacts/check/{number} | 200 { "exists": false } | 503 |
POST /api/sessions/{sessionId}/chats/read | A bare 500 | 503 |
| Channel lookup, subscribe, unsubscribe, delete, mute | A bare 500 | 503 |
| Channel owner transfer and admin demote — Baileys only | Nothing; both routes are new in v0.16.0 | 503 |
| Group and profile writes, chat actions, label writes, call rejection | 200 — success for a change nothing confirmed | 503 |
| Group participant writes — add, remove, promote, demote | 200 with a results array, identical to a batch nothing confirmed | 503 |
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.status → ready 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.
| Tier | Default limit | Window |
|---|---|---|
| Short | 10 requests | 1 second |
| Medium | 100 requests | 60 seconds |
| Long | 1000 requests | 1 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"
}
| Code | Meaning | Common cause | Fix |
|---|---|---|---|
400 | Bad Request | Invalid body, params, or JID format; a media-conversion input URL that is blocked by the SSRF guard, unreachable, or oversized | Check the request payload; for media conversion, point the url at a reachable, public host and keep it under MEDIA_CONVERSION_MAX_OUTPUT_BYTES |
401 | Unauthorized | Missing or invalid API key | Send a valid X-API-Key header |
404 | Not Found | Unknown session, message, or route | Verify the id exists |
409 | Conflict | Session id already exists, or session not connected | Use a new id, or wait for ready |
413 | Payload Too Large | Media or body exceeds the cap | Reduce size or raise the limit |
429 | Too Many Requests | Rate 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 field | Back off and retry; for a pacing refusal, wait out retryAfterSeconds rather than treating it like the global one-second throttle |
500 | Internal Server Error | Unhandled server error | Check the logs and report it |
501 | Not Implemented | Operation 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 exists | Use 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 |
502 | Bad Gateway | A 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 |
503 | Service Unavailable | Dependency 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).
| Code | Meaning | Fix |
|---|---|---|
SEND_BLOCKED | Destination address was refused (SSRF/allow-list block) | Check the recipient and any allow/deny list |
SEND_FAILED | The engine rejected the send | Read the accompanying message for the cause |
SEND_PACING_LIMITED | The 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 fault | Wait 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 |
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.
| Code | HTTP status | When | Fix |
|---|---|---|---|
SESSION_NAME_TEARDOWN_PENDING | 409 | POST /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_INCOMPLETE | 502 | POST /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_INCOMPLETE | 502 | POST /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 written | Retryable — 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:
- Re-check this page for your symptom.
- Read the logs:
docker compose logs openwa --tail=200. - Try a restart and, if relevant, a clean re-scan.
- 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
- API conventions — auth, pagination, and the error envelope in detail
- Full API reference — every endpoint, field, and status code
- Glossary — definitions for JID, session states, adapters, and more