# 12 - Troubleshooting & FAQ

## 12.1 Quick Diagnostics

### Health Check Commands

```bash
# Basic health check
curl http://localhost:2785/api/health

# Readiness (DB) check
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/health/ready

# Check specific session
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/sessions/{sessionId}

# Check all services
docker compose ps
docker compose logs --tail=50

# System resources
docker stats openwa-api
```

### Diagnostic Flowchart

```mermaid
flowchart TD
    START[Issue Detected] --> Q1{API responding?}
    Q1 -->|No| A1[Check container status]
    Q1 -->|Yes| Q2{Session connected?}

    A1 --> A1a{Container running?}
    A1a -->|No| FIX1[docker compose up -d]
    A1a -->|Yes| A1b[Check logs for errors]

    Q2 -->|No| A2{QR scanned?}
    Q2 -->|Yes| Q3{Messages sending?}

    A2 -->|No| FIX2[Rescan QR code]
    A2 -->|Yes| A2a[Check auth files]

    Q3 -->|No| A3{Rate limited?}
    Q3 -->|Yes| DONE[System OK]

    A3 -->|Yes| FIX3[Wait or reduce rate]
    A3 -->|No| A3a[Check message format]
```

## 12.2 Podman Compatibility

### Issue: `FileNotFoundError` / Docker socket missing

**Symptoms:**

```text
docker.errors.DockerException: Error while fetching server API version:
  ('Connection aborted.', FileNotFoundError(2, 'No such file or directory'))
```

**Cause:** The system uses Podman (not Docker Engine). Podman's rootless socket is inactive by default.

**Fix:**

```bash
systemctl --user start podman.socket
systemctl --user enable podman.socket
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
```

Add the `export` to `~/.bashrc` to make it permanent.

---

### Issue: `short-name did not resolve to an alias`

**Symptoms:**

```text
Error: creating build container: short-name "nginx:alpine" did not resolve to an alias
and no unqualified-search registries are defined
```

**Cause:** Podman rootless mode does not fall back to Docker Hub for unqualified image names.

**Fix:** All `FROM` directives in the `Dockerfile` must use fully-qualified names:

```dockerfile
FROM docker.io/node:22-slim
```

---

### Issue: Healthcheck always `unhealthy` on Node 22 + Podman

**Symptoms:** Container starts successfully but stays `unhealthy`; logs show:

```text
SyntaxError: Unexpected end of input
at evalTypeScript (node:internal/process/execution:256:22)
```

**Cause:** Node 22 routes `node -e` through its TypeScript evaluator which rejects arrow-function
syntax. Podman also splits quoted shell commands on whitespace, truncating the `-e` argument.

**Fix:** Use `curl` for the healthcheck instead of `node -e`:

```dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:2785/api/health || exit 1
```

```yaml
# docker-compose.dev.yml
healthcheck:
  test: ['CMD', 'curl', '-f', 'http://localhost:2785/api/health']
```

Ensure `curl` is installed in the production stage:

```dockerfile
RUN apt-get install -y ... curl ...
```

---

## 12.3 Connection Issues

### Issue: Container Won't Start

**Symptoms:**

- `docker compose up` fails
- Container exits immediately
- "Port already in use" error

**Solutions:**

```bash
# Check what's using the port
lsof -i :2785
# or
netstat -tlnp | grep 2785

# Kill process using port
kill -9 $(lsof -t -i:2785)

# Check Docker logs (service name in the shipped production compose; it is `openwa` in
# docker-compose.dev.yml)
docker compose logs openwa-api

# Common fixes
docker system prune -f         # Clean up dangling images/containers
git pull                       # The shipped compose BUILDS openwa-api from source —
docker compose up -d --build   # `docker compose pull` never updates it
```

> Do **not** reach for `docker compose down --volumes` here. It deletes the `openwa-data` volume,
> which holds the linked WhatsApp session profiles, the auth/audit database and every API key — a
> port conflict never requires it.

### Issue: Dashboard Renders a Blank White Screen

**Symptoms:**

- The API is healthy (`curl http://<host>:2785/api/health` returns `200`) but the dashboard is blank
- The startup log says `🖥️ Dashboard: serving bundled UI at …` — the UI _is_ being served
- The browser console shows script-loading errors; DevTools → Network shows the `/assets/*.js`
  requests going to `https://` even though you opened the page over `http://`
- You reach the instance directly over plain HTTP (a host:port allocation, a private network, a
  panel like Pterodactyl) rather than through a TLS-terminating reverse proxy

**Cause:** In production OpenWA sends the CSP `upgrade-insecure-requests` directive, which tells the
browser to upgrade every sub-resource fetch to HTTPS. That is correct behind a TLS proxy. Over plain
HTTP the browser upgrades the dashboard's own script requests to `https://`, the non-TLS server
cannot answer them, no JavaScript runs, and React never mounts — a blank page. The failure happens
in the browser, so the server log stays clean.

**Solution:**

```bash
# Opt out, then fully restart the container (not just reload)
CSP_UPGRADE_INSECURE_REQUESTS=false

# Confirm it actually reached the process
docker compose exec openwa-api printenv NODE_ENV CSP_UPGRADE_INSECURE_REQUESTS
```

A production boot that serves the dashboard with the opt-out unset prints a warning naming this
setting. If you are behind a TLS proxy, ignore that warning — the directive is doing its job.

> The alternative is to front OpenWA with a TLS-terminating reverse proxy (the shipped
> `docker-compose.yml` topology), which serves the dashboard over HTTPS and makes the upgrade a
> no-op.

### Issue: Session Won't Connect

**Symptoms:**

- QR code generated but session stays `initializing`
- Session ends in `failed` (with a `lastError`) after scanning the QR
- Session stuck at `qr_ready` or `authenticating` and never reaches `ready`

**Diagnostic:**

```bash
# Check session status
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/sessions/{sessionId}

# Check WhatsApp engine logs
docker compose logs openwa-api 2>&1 | grep -i "whatsapp\|puppeteer\|browser"

# Check auth folder. Both engines key it on the session NAME, but the location differs:
#   whatsapp-web.js → SESSION_DATA_PATH (default /app/data/sessions), dir `session-<name>`
#   baileys         → BAILEYS_AUTH_DIR  (default /app/data/baileys),  dir `<name>` (no prefix)
docker compose exec openwa-api ls -la /app/data/sessions/session-<name>/   # whatsapp-web.js
docker compose exec openwa-api ls -la /app/data/baileys/<name>/            # baileys
```

**Solutions:**

| Cause                 | Solution                             |
| --------------------- | ------------------------------------ |
| Expired QR            | Generate new QR (valid 60 seconds)   |
| Auth folder corrupted | Delete and rescan                    |
| Browser crash         | Restart container                    |
| Network issues        | Check firewall/proxy                 |
| WhatsApp blocked      | Set a per-session proxy (`proxyUrl`) |

```bash
# Clear auth and restart (the profile dir carries the session NAME, not its UUID id).
# Remove the one that matches the session's engine — deleting the other path is a silent no-op.
docker compose exec openwa-api rm -rf /app/data/sessions/session-<name>   # whatsapp-web.js
docker compose exec openwa-api rm -rf /app/data/baileys/<name>            # baileys
docker compose restart openwa-api
```

> The service name above is the one in the shipped production `docker-compose.yml` (`openwa-api`),
> which mounts `/app/data` from a **named volume** — there is no `./data` on the host to inspect;
> reach into the container (`docker compose exec openwa-api ls /app/data/sessions`) instead. In
> `docker-compose.dev.yml` the app service is called `openwa` and `./data` is bind-mounted, so the
> same paths can be read directly from the host. Host-relative `./data/...` commands elsewhere in
> this document assume a source install (`npm run start:dev`) or that dev bind mount.

Proxy egress (if WhatsApp is blocked on your network) is configured **per session** via the
`proxyUrl`/`proxyType` fields on `POST /api/sessions` — it is **not** an environment variable, and an
unreachable proxy silently blocks the WhatsApp WebSocket (see the _No QR code appears, or `/start`
returns `504`_ entry below).

### Issue: No QR code appears, or `POST /api/sessions/:sessionId/start` returns `504`

**Symptoms:**

- `POST /api/sessions/:sessionId/start` returns `504 Gateway Timeout`
  (`WhatsApp Web authentication timed out...`)
- No QR code is ever produced — `GET /api/sessions/:sessionId/qr` never has one
- Engine log shows `Session engine failed: auth timeout` after ~30s

**Cause:** The session was created with a `proxyUrl` that doesn't resolve to a real, reachable proxy
(e.g. the `http://proxy.example.com:8080` placeholder copied from an example). The engine launches
Chromium pinned to that proxy, the WhatsApp WebSocket can never connect, no QR is produced, and the
auth poll times out.

**Fix:** Don't set a proxy unless your network actually requires one. Recreate the session without
`proxyUrl`, or set it to a real, reachable proxy server:

```bash
# No proxy needed (the common case):
curl -X POST "$BASE/api/sessions" -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "my-bot" }'
```

> ℹ️ Proxy egress for the `whatsapp-web.js` engine is configured **per session** via the
> `proxyUrl`/`proxyType` fields on `POST /api/sessions` — not via environment variables.

### Issue: Session stuck at `authenticating`, never reaches `ready`

> **Engine:** This issue applies to the `whatsapp-web.js` engine only. If you are using `ENGINE_TYPE=baileys`, skip this section.

**Symptoms:** After scanning the QR the phone links the device, but the session stays at
`authenticating` indefinitely and never becomes `ready`. `GET /sessions/:sessionId/qr` returns 400 while
stuck. Often seen on ARM64 (e.g. Raspberry Pi) after upgrading to v0.2.x.

**Cause:** whatsapp-web.js auto-selects a WhatsApp Web client version, and an incompatible version
stalls the post-link sync. (If you also see `chrome_crashpad_handler: --database is required` _and the
session never starts at all_, that is a different problem — see "Session fails to launch …" below.)

**Fix:** OpenWA reconciles a missed `ready` event when WhatsApp Web is connected, the injected
runtime is available, and whatsapp-web.js has populated the linked account identity. If your
environment still hits a WA-Web compatibility hang, pin a known-good WA-Web version with
`WWEBJS_WEB_VERSION`:

```bash
# Optional workaround — substitute a build that the registry currently serves:
WWEBJS_WEB_VERSION=<a build from that registry's html/ folder>
```

Restart the container after changing it. Pick the build from
[wppconnect-team/wa-version](https://github.com/wppconnect-team/wa-version) (the `html/` folder) — a
build the registry no longer serves is fetched, missed, and silently ignored, leaving you on the
default behaviour rather than the pin you asked for. With
`WWEBJS_WEB_VERSION` unset, `latest`, or `auto` (the default), OpenWA auto-resolves a settled build
from that registry and pins its HTML — note this HTML is fetched from a third-party repository and
executed inside the `web.whatsapp.com` origin without an integrity check. Set
`WWEBJS_WEB_VERSION=off` to disable pinning and use the first-party build served by WhatsApp.

### Issue: QR generation times out on slow first boot (WSL2 / low-resource)

> **Engine:** This issue applies to the `whatsapp-web.js` engine only. If you are using `ENGINE_TYPE=baileys`, skip this section.

**Symptoms:** On the first launch the session never produces a QR code and fails after ~30 seconds,
often inside WSL2 or a resource-constrained container while WhatsApp Web is still loading.

**Cause:** whatsapp-web.js waits a fixed 30000ms for WhatsApp Web to finish its initial load before
generating the QR. On a slow first boot that window can expire before the page is ready.

**Fix:** raise the boot/inject wait (milliseconds) with `WWEBJS_AUTH_TIMEOUT_MS`:

```bash
# Allow up to 2 minutes for the first-boot init wait:
WWEBJS_AUTH_TIMEOUT_MS=120000
```

Restart the container after setting it. Leave it unset to keep the default (30000ms).

### Issue: Session fails to launch with `chrome_crashpad_handler: --database is required`

> **Engine:** This issue applies to the `whatsapp-web.js` engine only (Chromium/Puppeteer-based). It does not affect `ENGINE_TYPE=baileys`.

**Symptoms:** The session never starts; the engine log shows `Failed to launch the browser process` with
`chrome_crashpad_handler: --database is required`, and the host kernel log shows a Chromium
`trap int3` / `Trace/breakpoint trap (core dumped)`. Seen on hardened, `read_only` containers.

**Cause:** Chromium resolves its home directory from the passwd entry (glibc `getpwuid()`) and **ignores
`$HOME`**. The non-root `openwa` user has no home dir, so Chromium tries to use `/home/openwa`, which does
not exist on the read-only rootfs — and aborts at launch. (Setting `HOME=` does **not** help, and
`--crash-dumps-dir` is a no-op for the crashpad database on Debian/Ubuntu system Chromium.)

**Fix:** Give Chromium writable, pre-created config/cache dirs via `XDG_CONFIG_HOME` / `XDG_CACHE_HOME`.
The bundled image and `docker-compose.yml` already do this (the entrypoint creates them on the tmpfs `/tmp`,
owned by `openwa`). If you run a custom container, ensure both are set to a writable, existing path:

```bash
XDG_CONFIG_HOME=/tmp/.config
XDG_CACHE_HOME=/tmp/.cache
# and create them owned by the runtime user before launch:
#   mkdir -p /tmp/.config /tmp/.cache && chown <user> /tmp/.config /tmp/.cache
```

On a `read_only` rootfs you **must** also mount a writable tmpfs/emptyDir at `/tmp` (compose:
`tmpfs: [/tmp]`; k8s: an `emptyDir` at `/tmp`) — otherwise the entrypoint cannot create these dirs and
will exit at startup with a clear `FATAL:` message rather than crash-looping later.

Do **not** work around this by dropping `--no-sandbox` security hardening or using `seccomp:unconfined`
(confirmed not to help, and it widens the attack surface).

### Issue: Session fails to launch with `Failed to launch the browser process: Code: null`

> **Engine:** This issue applies to the `whatsapp-web.js` engine only (Chromium/Puppeteer-based). It does not affect `ENGINE_TYPE=baileys`.

**Symptoms:** The session fails within a few seconds of clicking **Start**; no QR code is ever produced. The
session's `lastError` and the container log both show:

```text
Failed to launch the browser process:  Code: null
```

often accompanied by a wall of `ERROR:dbus/bus.cc` / `crashpad ... /sys/devices/system/cpu/...` lines.
**Those dbus/crashpad lines are non-fatal noise** that headless Chromium always prints inside a container —
ignore them. The actual signal is `Code: null`, which means the browser process was killed during startup
before it could report an exit code. The cause is _not_ in the log — it's a host/container resource limit,
and there are three distinct ones. Diagnose which one before changing anything:

**Cause A — per-container PID limit hit (most common under multi-session).**
whatsapp-web.js runs a full Chromium instance per session, and Chromium is multi-process (browser + renderer

- GPU + zygote + utilities); WhatsApp Web is itself process-heavy (service workers, iframes). A handful of
  concurrent sessions can approach the container's `pids_limit`, and the next session's Chromium gets killed
  mid-spawn when a `fork()` returns `EAGAIN`. This is silent in the log.

_Diagnose:_ watch the PIDS column while you click **Start**:

```bash
docker stats openwa-api   # watch the PIDS column — does it climb toward the limit right before the failure?
```

_Fix:_ raise the ceiling. The bundled `docker-compose.yml` exposes it as `OPENWA_PIDS_LIMIT` (default `2048`,
which fits ~8-10 sessions with startup-spike headroom):

```bash
OPENWA_PIDS_LIMIT=4096   # in your .env, then docker compose up -d
```

Do **not** set `-1` (unlimited) — the PID ceiling is a fork-bomb guard and should stay finite. Baileys
(no Chromium) uses only a handful of PIDs regardless, so raising this is a no-op there.

**Cause B — out-of-memory kill.**
The container's `mem_limit` (or the host VM, e.g. Docker Desktop on macOS/Windows) ran out of RAM while
Chromium was starting. The OOM killer sends `SIGKILL`, which Puppeteer reports as `Code: null`.

_Diagnose:_ check the host kernel log for an OOM kill:

```bash
dmesg -T | grep -i "killed process"          # Linux host
# Docker Desktop: check the VM via the app, or nudge OPENWA_MEM_LIMIT up and retry
```

_Fix:_ raise the ceiling (`OPENWA_MEM_LIMIT=4g` in your `.env`, or Docker Desktop → Settings → Resources →
Memory for the VM).

**Cause C — the XDG/crashpad home-dir crash.**
If `Code: null` is accompanied by `chrome_crashpad_handler: --database is required`, that is a different,
specific failure (Chromium can't resolve its home directory on a read-only rootfs) — see the entry
immediately above this one for the fix. The bundled image already handles this; it only resurfaces on a
custom container that drops the `XDG_CONFIG_HOME` / `XDG_CACHE_HOME` setup or the writable `/tmp` tmpfs.

**Cause D — Debian 12 OS Chromium SIGTRAP in non-root Pods.**
If `Code: null` happens on Kubernetes, and the host kernel logs or `dmesg` shows `Trace/breakpoint trap (core dumped)` with exit code 133, the underlying Debian 12 OS `chromium` package has crashed due to strict non-root or seccomp constraints (even with `--no-zygote` or `Unconfined` seccomp).
_Fix:_ On amd64, do not use the `chromium` package from Debian's `apt` — it SIGTRAPs under strict non-root/seccomp. Instead, download Chrome for Testing via Puppeteer during the Docker build (`./node_modules/.bin/puppeteer browsers install 'chrome@146.0.7680.31'`) and point `PUPPETEER_EXECUTABLE_PATH` to it. (Chrome for Testing has no linux-arm64 build, so arm64 keeps Debian's `chromium`, which ships a native arm64 binary.) The official `Dockerfile` implements this mixed approach.

**Quick triage:** run `docker stats openwa-api`, click **Start**, and watch which resource spikes toward its
limit the instant before the failure — that tells you A vs B. If neither moves and you see the crashpad
`--database` line, it's C. If running in K8s as non-root with the Debian `chromium` package, it is likely D.

### Issue: `Execution context was destroyed` on the first start after an upgrade

> **Engine:** This issue applies to the `whatsapp-web.js` engine only (Chromium/Puppeteer-based). It does not affect `ENGINE_TYPE=baileys`.

**Symptoms:** A `whatsapp-web.js` session that was already authenticated fails within seconds of
**Start** after upgrading OpenWA — no QR is produced — and the session's `lastError` / container log
show:

```text
Protocol error (Runtime.callFunctionOn): Execution context was destroyed.
```

**Cause:** The session's persistent browser profile (`<SESSION_DATA_PATH>/session-<name>`, created by
whatsapp-web.js's `LocalAuth`) was built with a different Chromium/Chrome binary than the one the new
image runs. A browser profile carries binary-bound state (page caches, GPU shader caches, IndexedDB /
Local Storage version markers) that is not safely portable across Chromium major versions or binary
flavours; loading the stale profile destroys the page context during `Client.inject()`. The dominant
trigger today is the **v0.8.12** amd64 switch from Debian's `chromium` package to Chrome for Testing
(#663), but the same symptom can follow any future change to the bundled browser binary. The error
reads like a Puppeteer bug and gives no hint that the profile is the cause — the adapter now logs an
advisory when it detects this error, and the session's `lastError` (the message the dashboard shows on
the session card) carries a short form of it, so the pointer survives without reading the container log.

**Fix:** delete the affected session's profile dir and start the session again to scan a new QR. The
profile cannot be salvaged — clearing only the cache subdirs (`Cache`, `GPUCache`, `Code Cache`, …) is
**not** enough, the taint is deeper than the caches — so a one-time re-authentication is required.

The profile dir is named after the session **name**, while the REST API addresses a session by its
**id** (a UUID) — so the two placeholders below are different values:

```bash
docker exec openwa-api rm -rf /app/data/sessions/session-<name>
# then POST /sessions/<id>/force-kill and POST /sessions/<id>/start (the session's UUID id), and scan the new QR
```

Re-creating the session (`DELETE /sessions/<id>`) also purges its profile dir; create it again and
scan. Messages are unaffected — they live in the database, not the browser profile — so nothing is lost
except the WhatsApp pairing, which must be re-scanned.

`force-kill` requires a started session — it returns `400` when no live engine is registered (there
is nothing to SIGKILL). A browser left wedged by an earlier teardown is reaped automatically by the
next `start()` (an orphan sweep keyed on the session's browser marker runs at every engine launch),
so the stop → start sequence alone is sufficient once the engine is gone.

### Issue: Freshly paired session logs out at the first five-minute reload

> **Engine:** This issue applies to the `whatsapp-web.js` engine only.

**Symptoms:** The session reaches `ready` normally, then at almost exactly five minutes WhatsApp Web
reloads, briefly returns to `CONNECTED`/`hasSynced`, and navigates to
`?post_logout=1&logout_reason=0`. The phone silently removes the companion from Linked devices and
OpenWA returns to a fresh QR because `whatsapp-web.js` deletes LocalAuth credentials on `LOGOUT`.

**Cause:** OpenWA used to backfill active WhatsApp Status posts immediately in its `ready` callback by
fetching `status@broadcast`. Some accounts tolerate that request, but affected accounts have the new
companion revoked at WhatsApp Web's first scheduled reload. A minimal `whatsapp-web.js` client using
the same container, browser, account and Web build remains linked when it does not perform this eager
fetch.

**Fix:** Status backfill-on-ready is disabled by default. Keep `STATUS_SEED_ON_READY=false` for
affected accounts. Live status events still work; only the one-time history backfill of statuses that
predate the connection is skipped. Operators who have tested their accounts and need the backfill can
opt in with `STATUS_SEED_ON_READY=true`.

### Issue: Session stuck at `action_required` ("What's new" onboarding modal)

> **Engine:** This issue applies to the `whatsapp-web.js` engine only (Chromium/Puppeteer-based). It does not affect `ENGINE_TYPE=baileys`.

**Symptoms:** A freshly linked session leaves `ready` for `action_required` within its first minutes,
every send returns `409` ("session not connected"), and the session's `lastError` says WhatsApp keeps
showing its onboarding modal after repeated attempts to dismiss it.

**Cause:** New WhatsApp accounts are shown a "What's new" modal after linking that must be
acknowledged before the companion device is allowed to stay linked. The adapter auto-dismisses it
and only gives up after five clicks that fail to land — at that point a human must click through it
once, so the session stops instead of being silently unlinked by WhatsApp about five minutes later.

> **If the modal is not in English:** the detector matches the English button label (`Continue`) and
> heading ("What's new"). The language WhatsApp Web renders in follows the browser locale, which OpenWA
> does not set, so it is whatever the browser the container launches defaults to
> (`PUPPETEER_EXECUTABLE_PATH` — Chrome for Testing on amd64, Debian's `chromium` on arm64). You can
> pin it yourself by appending `--lang=en-US` to `PUPPETEER_ARGS` — that variable **replaces** the
> default list rather than adding to it, so repeat the existing flags too (dropping `--no-sandbox` in
> a container stops Chromium launching at all). If your deployment does get a
> localised modal, it is **not** auto-dismissed and the session never reaches `action_required` —
> instead it links normally, then drops to `disconnected` with reason `LOGOUT` a few minutes later and
> the device disappears from the phone's Linked devices list. That miss is no longer silent: when the
> watcher finds a visible dialog it cannot match, it logs a warning (`action:
onboarding_dialog_unrecognized`) carrying the dialog's heading and button labels — the label to add
> via `WWEBJS_ONBOARDING_CONTINUE_LABELS`, and the heading worth reporting — minutes before the unlink
> would happen. Because that path wipes the stored
> credentials, the automatic reconnect comes back with a fresh QR on its own, so the session is
> usually already sitting at `qr_ready` rather than needing a manual start. Acknowledge the modal once
> in a browser signed in as that account, then scan the QR. It does not recur — the modal is shown
> once per account.

> **While a session sits in `action_required`** the liveness watchdog keeps probing it, but only to
> report: a failed probe is logged (`action: watchdog_probe_failed_observe_only`, once per
> unresponsive stretch) and never reconnects the session or changes its status. So a page that died
> while waiting for you is visible in the logs, and the status still means what it says. If you see
> that warning, the page is gone and the stop → start below is required rather than optional.

**Fix:** acknowledge the modal once (open WhatsApp Web in the account holder's own browser and click
through the "What's new" screen), **then restart the session** (`POST /sessions/:sessionId/stop` →
`POST /sessions/:sessionId/start`). Acknowledging alone does not return the session to `ready` — the status
is deliberately sticky — but the restart re-drives the engine from the stored credentials, so no new
QR scan is needed. If the modal never actually appeared (a false trip is possible but rare), the
same stop → start clears it.

### Issue: Frequent Disconnections

**Symptoms:**

- Session disconnects every few hours
- `disconnected` status in logs
- Need to rescan QR frequently

**Causes & Solutions:**

```mermaid
flowchart LR
    A[Frequent Disconnects] --> B{Cause}
    B --> C[WhatsApp logout]
    B --> D[Memory issues]
    B --> E[Network unstable]
    B --> F[IP blocked]

    C --> C1[Check phone]
    D --> D1[Increase RAM]
    E --> E1[Check connectivity]
    F --> F1[Use residential proxy]
```

**Configuration fixes:**

The reconnect backoff is configured **per session**, not by environment variables — pass it in the
`config` object on `POST /api/sessions`:

```json
{
  "name": "my-bot",
  "config": {
    "reconnectBaseDelay": 5000,
    "maxReconnectAttempts": 10
  }
}
```

`reconnectBaseDelay` is the exponential-backoff base in milliseconds (clamped to 1000–300000,
default 5000). `maxReconnectAttempts` is clamped to 0–20 — `0` disables auto-reconnect entirely, and
leaving it unset means unlimited retries with the delay parking at a 1-hour cap. Subscribe to the
`session.reconnect_loop` webhook to be alerted on every 5th consecutive attempt.

On a slow host, raise the first-boot init wait with `WWEBJS_AUTH_TIMEOUT_MS` (see _QR generation
times out on slow first boot_ above).

## 12.4 Messaging Issues

### Issue: Messages Not Sending

**Symptoms:**

- API returns 200 but message not delivered
- "Message send failed" errors
- Messages stuck in queue

**Diagnostic:**

```bash
# Check message history
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/sessions/{sessionId}/messages/{chatId}/history

# Check queue / infra status (ADMIN)
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/infra/status

# Rate limiting is global (throttler, env-configured) — there is no per-session rate-limit endpoint
```

**Common Causes:**

| Cause                  | Symptom                | Solution                    |
| ---------------------- | ---------------------- | --------------------------- |
| Invalid phone number   | 400 error              | Format: `628123456789@c.us` |
| Rate limited           | 429 error              | Reduce sending rate         |
| Session disconnected   | 503 error              | Reconnect session           |
| Media too large        | 413 error              | Compress or reduce size     |
| Number not on WhatsApp | Message fails silently | Verify number first         |

**Phone Number Validation:**

```typescript
// Correct format
const validFormats = [
  '628123456789@c.us',      // Indonesian
  '14155552671@c.us',       // US
  '628123456789-1234@g.us', // Group ID
];

// API to check if number exists
// GET /api/sessions/{id}/contacts/check/{number}
curl -H "X-API-Key: $API_KEY" \
  "http://localhost:2785/api/sessions/default/contacts/check/628123456789"
```

### Issue: Sends return 500 "engine returned no message", and chats or media fail with `r: r`

**Symptoms:**

- `POST /api/sessions/{id}/messages/send-text` returns `{"statusCode":500,"message":"Internal server error"}` — but the message _is_ delivered
- Logs show `the engine returned no message for this send, so it may not have been delivered`
- Unrelated operations fail with the minified error `r: r`: `GET /api/sessions/{id}/chats`, media downloads, typing indicators
- Startup logs may contain `The installed whatsapp-web.js is missing the message-id backport…`

**Cause:** WhatsApp Web 2.3000.x renamed the internal message-id property that whatsapp-web.js
reads. OpenWA ships a backport that restores it, applied at install time by
`scripts/patch-wwebjs-201832.js`. When the install cannot run it — neither GNU `patch` nor `git`
available, or `npm install --ignore-scripts` — whatsapp-web.js stays unpatched and every operation
that reads a message id fails. Source installs only; the Docker image always applies the backport.

**Solution:**

```bash
# Is the backport missing? Works on every platform, including Windows without grep.
node -e "console.log(require('fs').readFileSync('node_modules/whatsapp-web.js/src/structures/Base.js','utf8').includes('_normalizeId')?'PATCHED':'NOT PATCHED')"

# Apply it, then restart
node scripts/patch-wwebjs-201832.js
```

If that reports a partially patched tree, reinstall the dependency first:

```bash
rm -rf node_modules/whatsapp-web.js && npm ci
```

> Pinning `WWEBJS_WEB_VERSION` does **not** work around this — the rename is present in every
> current WhatsApp Web build, so no pin avoids it.

### Issue: Media Upload Fails

**Symptoms:**

- "File too large" error
- "Unsupported media type" error
- Upload timeout

**Solutions:**

```bash
# Media size cap — covers remote-URL sends, inbound media, and outbound base64 sends.
# Default 50 MiB; oversized base64 is rejected with 413 (Payload Too Large).
MEDIA_DOWNLOAD_MAX_BYTES=52428800

# Max request body — base64 media rides inside the JSON body, so raise this too.
# Default 25mb.
BODY_SIZE_LIMIT=25mb

# Note: send the body uncompressed. A request carrying Content-Encoding: gzip (or deflate/br)
# is refused with 415 — the aggregate in-flight cap counts bytes on the wire, so a compressed
# body would be admitted small and then inflated past the memory that cap exists to bound.

# Note: one client IP may hold at most half the aggregate in-flight budget, and is refused with
# 503 + Retry-After past that even while the gateway as a whole has room. Behind a reverse proxy,
# set TRUSTED_PROXIES: without it every caller resolves to the proxy's own address and shares a
# single half, which looks like a 503 at half the budget you configured.

# Supported formats
# Images: jpg, jpeg, png, gif, webp
# Videos: mp4, 3gp
# Audio: mp3, ogg, wav, opus
# Documents: pdf, doc, docx, xls, xlsx, ppt, pptx
```

**Media Compression:**

```bash
# Send an image (by URL or base64)
curl -X POST http://localhost:2785/api/sessions/{id}/messages/send-image \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chatId": "628123456789@c.us",
    "url": "https://example.com/image.jpg"
  }'
```

### Issue: Webhook Not Receiving Messages

**Symptoms:**

- Messages received but webhook not triggered
- Webhook URL returns errors
- Duplicate webhook calls

**Diagnostic:**

```bash
# Check webhook configuration — `active` must be true, `events` must list the event (or "*"),
# and `filters` must not exclude it. `lastTriggeredAt` stays null until a real 2xx delivery:
# the Test button never sets it, so a green Test proves nothing about real events.
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/sessions/{sessionId}/webhooks

# Abandoned deliveries, most recent first: those that exhausted every retry, plus those never
# attempted at all (recorded with `attempts: 0`). Requires an ADMIN key — an OPERATOR key gets
# a 403, which reads like the endpoint does not exist. Rows older than
# WEBHOOK_FAILURE_RETENTION_DAYS (default 90) are pruned.
curl -H "X-API-Key: $ADMIN_API_KEY" \
  "http://localhost:2785/api/webhooks/delivery-failures?sessionId={sessionId}&limit=20"

# Attempts still in flight (not yet exhausted) appear only in the server logs:
docker compose logs openwa-api 2>&1 | grep -i webhook

# Test webhook endpoint
curl -X POST http://your-webhook-url \
  -H "Content-Type: application/json" \
  -d '{"test": true}'
```

**Solutions:**

Read the two lists together before changing anything. A delivery-failure row carrying an HTTP
`lastStatusCode` means the gateway delivered and your receiver rejected it — fix the receiver. An
empty list with `lastTriggeredAt` still null means nothing has ever been delivered and nothing has
permanently failed: the event either never matched this webhook (`active`, `events`, `filters`) or
was never emitted for the session at all.

Webhooks are rows created through the API — there is no webhook config file:

```bash
curl -X POST http://localhost:2785/api/sessions/{sessionId}/webhooks \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhook",
    "events": ["message.received", "message.ack", "session.status"],
    "secret": "your-signing-secret",
    "headers": { "Authorization": "Bearer your-token" },
    "retryCount": 3
  }'
```

`retryCount` (0–5, default 3) is per webhook. The delivery timings are process-wide environment
variables:

```bash
WEBHOOK_TIMEOUT=10000      # per-attempt HTTP timeout in ms (default 10000)
WEBHOOK_RETRY_DELAY=5000   # base retry backoff in ms (default 5000)
```

### Issue: An inbound sender arrives as an `@lid` id instead of a phone number

**Symptoms:**

- `message.received` carries `from` (or `author`, in a group) as `162878178984075@lid` rather than `628123456789@c.us`
- The number cannot be matched against your own contact records, and replies have to be addressed by the `@lid` id
- `contact.number` on the payload repeats the lid digits, so it is not the phone number either

**Cause:** WhatsApp identifies some accounts by a privacy id (`@lid`) instead of their phone number,
and the message itself carries no phone number to read. Mapping one back costs a lookup against the
engine, so the gateway does not do it on every message unless you ask for it.

**Solution:**

```bash
# Resolve a single id on demand — works whether or not the flag below is set
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/sessions/{sessionId}/contacts/{contactId}/phone

# Or have every inbound message carry it: adds `senderPhone` to the message.received webhook
# and the websocket event. Set it in the `.env` next to docker-compose.yml (both compose files
# already forward the variable), then restart the process — an env change is not picked up by a
# session reload.
RESOLVE_LID_TO_PHONE=true
```

`senderPhone` is `null` when the engine cannot map the id — an `@lid` the account has never seen has
no mapping to return. Both engines support the lookup.

## 12.5 Performance Issues

### Issue: High Memory Usage

**Symptoms:**

- Container using > 1GB RAM per session
- OOM (Out of Memory) kills
- Slow response times

**Diagnostic:**

```bash
# Check memory usage
docker stats openwa-api --no-stream

# Check process memory (Prometheus text; read openwa_process_resident_memory_bytes)
curl -H "Authorization: Bearer $METRICS_TOKEN" \
  http://localhost:2785/api/metrics

# Expected: ~300-500MB per session (whatsapp-web.js / Chromium engine)
# With ENGINE_TYPE=baileys the footprint is significantly lower (no Chromium)
```

**Solutions:**

```yaml
# docker-compose.yml - Set memory limits
services:
  openwa-api:
    # The shipped compose already exposes this as mem_limit: ${OPENWA_MEM_LIMIT:-2g}
    mem_limit: 2g
    environment:
      # Optimize Puppeteer (whatsapp-web.js engine only)
      - PUPPETEER_ARGS=--disable-dev-shm-usage,--disable-gpu,--no-sandbox
```

**Memory Optimization Tips:**

| Optimization              | Impact                 | Trade-off               |
| ------------------------- | ---------------------- | ----------------------- |
| Reduce message history    | -20% RAM               | Less searchable history |
| Headless Chrome flags     | -15% RAM (wwebjs only) | None                    |
| Limit concurrent sessions | Linear                 | Fewer sessions          |

### Issue: Slow API Response

**Symptoms:**

- API takes > 1 second to respond
- Timeout errors
- High latency for simple operations

**Diagnostic:**

```bash
# Measure API response time
time curl http://localhost:2785/api/health

# Check database readiness (no dedicated DB metric)
curl http://localhost:2785/api/health/ready

# Check queue / infra status (ADMIN)
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/infra/status
```

**Solutions:**

```sql
-- Indexes ship with the schema (migrations) — there is nothing to add by hand. `messages`
-- carries ("sessionId", "createdAt"), ("chatId"), ("status"), ("createdAt") and a unique
-- ("sessionId", "waMessageId").

-- PostgreSQL: refresh planner statistics on the hot tables
ANALYZE sessions;
ANALYZE messages;
```

Pooling and caching are environment variables — OpenWA has no config file:

```bash
# Connection pool + timeouts (applied to the PostgreSQL data connection)
DATABASE_POOL_SIZE=10                 # max pooled connections (default 10)
DATABASE_IDLE_TIMEOUT_MS=30000        # idle client eviction (default 30000)
DATABASE_CONNECTION_TIMEOUT_MS=10000  # wait for a free connection (default 10000)
DATABASE_STATEMENT_TIMEOUT_MS=30000   # server-side per-query cap (default 30000)

# Redis caching (per-key TTLs are fixed in code — there is no cache TTL env var)
REDIS_ENABLED=true
REDIS_HOST=localhost
REDIS_PORT=6379
```

## 12.6 Database Issues

### Issue: Database Locked (SQLite)

**Symptoms:**

- "SQLITE_BUSY" errors
- "database is locked" messages
- Write operations failing

**Solutions:**

```bash
# Check for long-running queries (default SQLite file; override with DATABASE_NAME)
sqlite3 ./data/openwa.sqlite ".timeout 30000"

# Check WAL mode
sqlite3 ./data/openwa.sqlite "PRAGMA journal_mode;"
# Default is: delete (rollback journal) — OpenWA does not force WAL

# Optionally enable WAL mode to reduce writer/reader lock contention
sqlite3 ./data/openwa.sqlite "PRAGMA journal_mode=WAL;"
```

There is no `DATABASE_SQLITE_BUSY_TIMEOUT`-style env knob — busy handling comes from the
`better-sqlite3` driver defaults. If locks persist under concurrent sessions, migrate to PostgreSQL.

**When to Migrate to PostgreSQL:**

```mermaid
flowchart TD
    A[SQLite Issues] --> B{Concurrent sessions?}
    B -->|> 5| C[Migrate to PostgreSQL]
    B -->|≤ 5| D{Write-heavy?}
    D -->|Yes| C
    D -->|No| E[Optimize SQLite]

    E --> E1[Enable WAL mode]
```

### Issue: Database Migration Failed

**Symptoms:**

- "Migration failed" errors
- Schema mismatch
- Missing tables

**Solutions:**

```bash
# Show migration status (executed + pending)
npm run migration:show

# Run all pending migrations
npm run migration:run

# Rollback last migration
npm run migration:revert

# Schema is managed by migrations (there is no schema:sync)
# The auth/audit DB has parallel :main variants, e.g.:
npm run migration:run:main
```

**PostgreSQL crash-loop on boot after upgrade** — if logs show `column "id" is of type uuid but default expression is of type character varying` or `foreign key constraint ... cannot be implemented ... incompatible types: character varying and uuid`, the deployment was previously bootstrapped with `DATABASE_SYNCHRONIZE=true` (native `uuid` columns vs the migrations' `varchar`). A guard migration converts the columns automatically on the next boot; for large `messages` tables, run the migration against the stopped app (`npm run migration:run`) during a maintenance window. See [14.5 / 14.9 — PostgreSQL crash-loop after upgrading a `DATABASE_SYNCHRONIZE=true` deployment](./14-migration-guide.md). `DATABASE_SYNCHRONIZE=true` is unsupported on PostgreSQL for production.

## 12.7 Docker Issues

### Issue: Volume Permissions

**Symptoms:**

- "Permission denied" errors
- Can't write to data directory
- Auth files not persisting

**Solutions:**

```bash
# Check current permissions
ls -la ./data/

# Fix ownership (use your user ID)
sudo chown -R $(id -u):$(id -g) ./data/

# Or use Docker's user mapping
# docker-compose.yml
services:
  openwa-api:
    user: "1000:1000"  # Your UID:GID
```

### Issue: Container Networking

**Symptoms:**

- Can't connect to database container
- Webhook calls fail from container
- "Connection refused" errors

**Solutions:**

```yaml
# docker-compose.yml - Ensure proper networking
services:
  openwa-api:
    networks:
      - openwa-network
    extra_hosts:
      - 'host.docker.internal:host-gateway' # Access host from container

  postgres:
    networks:
      - openwa-network

networks:
  openwa-network:
    driver: bridge
```

```bash
# Test connectivity from container
docker exec openwa-api ping postgres
docker exec openwa-api curl http://host.docker.internal:8080
```

## 12.8 Frequently Asked Questions

### General Questions

**Q: Is OpenWA safe to use?**

> A: OpenWA uses unofficial WhatsApp Web API. While we implement best practices to avoid detection, there's inherent risk of account restrictions. We recommend:
>
> - Use dedicated phone number (not personal)
> - Don't send spam or bulk unsolicited messages
> - Follow WhatsApp's Terms of Service
> - Implement rate limiting

**Q: How many sessions can I run?**

> A: Depends on your server resources and the engine in use. With the default `whatsapp-web.js` engine (Chromium-based), each session uses ~300-500MB RAM:
>
> - 2GB RAM: 3-5 sessions
> - 4GB RAM: 8-10 sessions
> - 8GB RAM: 15-20 sessions
>
> With `ENGINE_TYPE=baileys` (browser-free), RAM per session is significantly lower — you can run more sessions on the same hardware. Exact figures depend on message volume and group membership.

**Q: Can I run 10+ sessions in one container? Will they get banned for sharing one IP?**

> A: Yes — there is no hard session limit; the practical ceiling is RAM/CPU (see the table above). Sharing one IP across sessions is not itself a ban trigger: carrier NAT already puts hundreds of ordinary users on a single IP, so WhatsApp cannot treat a shared IP as a violation. Ten sessions on one residential IP behave like ten phones on one home WiFi. What actually matters:
>
> - **IP reputation** — cheap datacenter IPs are flagged more aggressively than residential ones. A residential proxy (per-session, via the proxy settings) can help; it is not a license to spam.
> - **Sending behavior** — bans follow message patterns (volume, identical templates, cold reachouts), not session count. See "How to avoid getting banned?" below.

**Q: Can I use WhatsApp Business account?**

> A: Yes, OpenWA works with both personal and WhatsApp Business accounts. Note that WhatsApp Business API (official Meta API) is different and not supported.

**Q: How to avoid getting banned?**

> Best practices:
>
> - Don't send > 200 messages/day for new numbers
> - Gradually increase volume
> - Avoid identical messages to multiple recipients
> - Use random delays between messages
> - Don't send to numbers that haven't messaged you first

### Technical Questions

**Q: How to send messages to groups?**

```bash
# Get group list
curl -H "X-API-Key: $API_KEY" \
  http://localhost:2785/api/sessions/{sessionId}/groups

# Send to group
curl -X POST http://localhost:2785/api/sessions/{id}/messages/send-text \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chatId": "120363123456789@g.us",
    "text": "Hello group!"
  }'
```

**Q: How to handle message replies?**

```bash
# Reply to specific message
curl -X POST http://localhost:2785/api/sessions/{id}/messages/reply \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chatId": "628123456789@c.us",
    "quotedMessageId": "ABC123_DEF456",
    "text": "This is a reply"
  }'
```

**Q: How to use with n8n?**

> See [n8n Integration Guide](./22-n8n-integration.md). Quick setup:
>
> 1. Add HTTP Request node
> 2. Set URL: `http://openwa:2785/api/sessions/{id}/messages/send-text`
> 3. Add header: `X-API-Key: your-key`
> 4. Configure webhook trigger for incoming messages

**Q: How to run behind reverse proxy (nginx)?**

```nginx
# nginx.conf
server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        proxy_pass http://localhost:2785;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;

        # Timeouts for long-polling
        proxy_read_timeout 300;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
    }
}
```

**Then set `TRUSTED_PROXIES` in your `.env`.** With it empty, OpenWA correctly refuses to trust the
spoofable `X-Forwarded-For` header, which has a side effect worth knowing: every client appears as
the proxy's address, so ALL traffic shares one rate-limit bucket (a single abuser rate-limits
everyone) and per-key IP allowlists (`allowedIps`) evaluate the proxy for every caller. Name the
proxy to key limits per client. For the bundled compose (whose published port traverses Docker's
NAT, so the container sees the bridge gateway, not 127.0.0.1) use the compose network subnet, e.g.
`TRUSTED_PROXIES=172.18.0.0/16`; a bare-metal nginx talking to the process directly can name
`127.0.0.1`. OpenWA logs a one-time warning at the first proxied request when the header is present
but the list is empty.

**Q: How to run behind Traefik / Coolify?**

Traefik forwards WebSocket upgrades automatically, so OpenWA's single-port Socket.IO channel works with a normal HTTP router. Two things keep a public deployment stable:

**1. Let Traefik reach the container over the Docker network — don't _also_ publish the host port.** This is the most common cause of intermittent `504`s on Coolify/Traefik. If OpenWA publishes its port to the host (`ports: ["2785:2785"]`) **and** Traefik also routes to it, every request additionally traverses Docker's userland `docker-proxy`. OpenWA holds a long-lived Socket.IO connection per client (HTTP long-poll → WebSocket upgrade), so those held-open connections accumulate across both hops and gradually exhaust the connection pool to the single upstream — the Dashboard, API, and real-time channel then `504` together "after some time", while `curl http://localhost:2785/api/health/ready` keeps returning `200`. Front it with Traefik on a shared network and **expose** the port internally instead of **publishing** it:

```yaml
services:
  openwa:
    image: ghcr.io/rmyndharis/openwa:latest
    expose:
      - '2785' # internal only — drop any public `ports:` mapping when Traefik is on this network
    networks: [proxy]
    labels:
      - traefik.enable=true
      - traefik.http.routers.openwa.rule=Host(`api.example.com`)
      - traefik.http.routers.openwa.entrypoints=websecure
      - traefik.http.routers.openwa.tls.certresolver=le
      - traefik.http.services.openwa.loadbalancer.server.port=2785
networks:
  proxy:
    external: true # the network your Traefik already runs on
```

On **Coolify**, this means not mapping the port to the host and letting Coolify's built-in Traefik route to the service over its proxy network. (The bundled `docker-compose.yml` binds to `127.0.0.1:2785` for _local_ access only — fine for a single box, but for a Traefik-fronted public deployment use the network path above.)

**2. Generous idle timeouts**, so Traefik doesn't cut the persistent Socket.IO connection — raise the entrypoint's responding/idle timeouts:

```yaml
# traefik static config
entryPoints:
  websecure:
    address: ':443'
    transport:
      respondingTimeouts:
        readTimeout: 600s
        idleTimeout: 600s
```

Remember OpenWA is **single-port**: the Dashboard, REST API, and Socket.IO all share `:2785` behind one router, so a choked upstream takes all three down at once. A Dashboard stuck on "Connecting…" while `localhost` is healthy is the proxy hop, not the app.

**Q: How to backup sessions automatically?**

```bash
# Add to crontab, for example: 0 */6 * * * cd /path/to/openwa && ./scripts/backup.sh
BACKUP_DIR=/backups/openwa ./scripts/backup.sh
```

The shipped script also covers `main.sqlite`, the selected data store, whatsapp-web.js state,
`BAILEYS_AUTH_DIR` (default `./data/baileys`), media, plugin packages/state, and generated secrets. Apply
retention/encryption to completed archives externally; see the [backup and restore runbooks](./11-operational-runbooks.md#runbook-database-backup).

### Webhook Questions

**Q: What events can I subscribe to?**

```yaml
available_events:
  # Messages
  - message.received # New incoming message
  - message.sent # Message sent
  - message.ack # Message status update (sent, delivered, read)
  - message.failed # Receipt resolved to failed
  - message.revoked # Message deleted
  - message.reaction # Reaction added, changed, or removed
  - message.edited # Message body or media caption edited

  # Status
  - status.received # A contact posted a status/story (opt-in: must be listed explicitly or via "*")

  # Session
  - session.status # Session status change
  - session.qr # New QR code generated
  - session.authenticated # Session authenticated
  - session.disconnected # Session disconnected
  - session.reconnect_loop # Every 5th consecutive reconnect attempt (payload: sessionId, attempts, nextDelayMs)
  - session.restriction # WhatsApp restricted the account, or lifted it (payload: sessionId, active, kind, code, expiresAt)
  - presence.update # A subscribed chat's presence changed (payload: sessionId, chatId, participants, groupOnlineCount)
  - call.accepted # A ringing call was answered (Baileys only; payload: sessionId, callId, from, outcome, isVideo, isGroup, timestamp)
  - call.rejected # A ringing call was declined (Baileys only)
  - call.missed # A ringing call was never picked up (Baileys only)

  # Groups
  - group.join # Participant(s) added/joined
  - group.leave # Participant(s) left/removed
  - group.update # Group subject/description/announce/locked changed
  - group.join_request # Someone asked to join a group this session administers

  # Calls
  - call.received # Incoming call ringing (payload: callId, from, isVideo, isGroup, timestamp)
```

**Q: Webhook payload format?**

```json
{
  "event": "message.received",
  "timestamp": "2026-02-02T10:30:00Z",
  "sessionId": "sess_abc123",
  "idempotencyKey": "msg_sess_abc123_ABC123_DEF456_f1e2d3c4-b5a6-7890-1234-567890abcdef",
  "deliveryId": "dlv_550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "id": "ABC123_DEF456",
    "from": "628123456789@c.us",
    "to": "628987654321@c.us",
    "body": "Hello!",
    "type": "text",
    "timestamp": 1706868600,
    "isGroup": false,
    "author": null,
    "hasMedia": false,
    "media": null
  }
}
```

## 12.9 Error Code Reference

### HTTP Error Codes

| Code | Meaning             | Common Cause             | Solution                  |
| ---- | ------------------- | ------------------------ | ------------------------- |
| 400  | Bad Request         | Invalid parameters       | Check request body/params |
| 401  | Unauthorized        | Missing/invalid API key  | Add X-API-Key header      |
| 403  | Forbidden           | Insufficient permissions | Check API key permissions |
| 404  | Not Found           | Invalid session/endpoint | Verify session exists     |
| 409  | Conflict            | Session already exists   | Use different session ID  |
| 413  | Payload Too Large   | File too large           | Reduce file size          |
| 429  | Too Many Requests   | Rate limited             | Reduce request rate       |
| 500  | Internal Error      | Server error             | Check logs                |
| 503  | Service Unavailable | Session disconnected     | Reconnect session         |

### Error Body Shape

There are no machine-readable WhatsApp error codes. Errors use the NestJS default shape, so match on
`statusCode` (above) and read `message` for the human-readable cause:

```json
{
  "statusCode": 404,
  "message": "Session with id 'a1b2c3d4-...' not found",
  "error": "Not Found"
}
```

## 12.10 Getting Help

### Before Asking for Help

1. **Check this FAQ** - Most common issues are covered
2. **Check logs** - `docker compose logs openwa-api --tail=100`
3. **Try basic troubleshooting** - Restart, clear cache, etc.
4. **Search GitHub issues** - Your issue might be already reported

### Reporting Issues

When creating GitHub issue, include:

```markdown
## Environment

- OpenWA version: x.x.x
- Docker version: x.x.x
- OS: Ubuntu 22.04 / macOS / Windows
- Database: SQLite / PostgreSQL
- Sessions count: X

## Issue Description

[Clear description of the problem]

## Steps to Reproduce

1. Step one
2. Step two
3. ...

## Expected Behavior

[What should happen]

## Actual Behavior

[What actually happens]

## Logs
```

[Paste relevant logs here]

````

## Configuration
```yaml
# Sanitized docker-compose.yml or .env
````

```

### Community Resources

- **GitHub Issues**: [github.com/rmyndharis/OpenWA/issues](https://github.com/rmyndharis/OpenWA/issues)
- **Discussions**: [github.com/rmyndharis/OpenWA/discussions](https://github.com/rmyndharis/OpenWA/discussions)
- **Discord**: [discord.gg/openwa](https://discord.gg/openwa) (if available)
- **Stack Overflow**: Tag with `openwa`
---

<div align="center">

[← 11 - Operational Runbooks](./11-operational-runbooks.md) · [Documentation Index](./README.md) · [Next: 13 - Horizontal Scaling Guide →](./13-horizontal-scaling.md)

</div>
```
