# Technical Documentation: rmyndharis/OpenWA

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/rmyndharis/OpenWA)

### Overview (`README.md`)

<p align="center">
  <img src="docs/logo/openwa_logo.webp" alt="OpenWA Logo" width="200"/>
</p>

<h1 align="center">OpenWA</h1>
<p align="center">
  <strong>Open Source WhatsApp API Gateway</strong>
</p>

<p align="center">
  <a href="#-features">Features</a> •
  <a href="#-quick-start">Quick Start</a> •
  <a href="#-documentation">Docs</a> •
  <a href="#-api-examples">API</a> •
  <a href="#-contributing">Contributing</a>
</p>

<p align="center">
  <a href="https://github.com/rmyndharis/OpenWA/actions/workflows/ci.yml"><img src="https://github.com/rmyndharis/OpenWA/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"/></a>
  <img src="https://img.shields.io/github/package-json/v/rmyndharis/OpenWA?label=version&color=blue" alt="Version"/>
  <img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License"/>
  <img src="https://img.shields.io/badge/node-22_LTS-brightgreen.svg" alt="Node"/>
  <img src="https://img.shields.io/github/package-json/dependency-version/rmyndharis/OpenWA/@nestjs/core?label=NestJS&color=red" alt="NestJS"/>
  <img src="https://img.shields.io/badge/docker-ready-blue.svg" alt="Docker"/>
  <img src="https://img.shields.io/github/package-json/dependency-version/rmyndharis/OpenWA/dev/typescript?label=TypeScript&color=3178C6" alt="TypeScript"/>
  <a href="https://buymeacoffee.com/rmyndharis"><img src="https://img.shields.io/badge/Buy_Me_a_Coffee-support-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy Me a Coffee"/></a>
</p>

---

## ✨ Why OpenWA?

**OpenWA** is a free, open-source WhatsApp API Gateway designed for developers who need full control over their messaging infrastructure—without vendor lock-in or hidden paywalls.

Built on a **pluggable architecture**, OpenWA lets you select database engines (SQLite/PostgreSQL), media storage backends (Local/S3), and cache layers (disabled/Redis) through configuration rather than application-code changes. The storage backend is the live store for status media and, when chat-media archiving is enabled, archived chat media; other message media is returned inline to API and webhook consumers. Of the media, `scripts/backup.sh` archives only the local media directory, so an S3 bucket needs a backup of its own.

|                               |                                                                                                                                          |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| 🔓 **100% Open Source**       | No licensing fees, no feature locks, full source code access                                                                             |
| 🏗️ **Pluggable Architecture** | Swap adapters for database, storage, and cache via config                                                                                |
| 🖥️ **Full Dashboard**         | Modern React UI for session, webhook, and API key management                                                                             |
| 🔹 **Multi-Session Ready**    | Run multiple WhatsApp sessions concurrently on one instance                                                                              |
| 🐳 **Docker Native**          | Production-ready with zero configuration                                                                                                 |
| 🧩 **Official Plugins**       | Chatwoot, Typebot & more as sandboxed plugins on the Integration Fabric — [OpenWA-plugins](https://github.com/rmyndharis/OpenWA-plugins) |
| 🔗 **n8n Integration**        | Community nodes for workflow automation                                                                                                  |
| 🧩 **Community Adapters**     | Third-party integrations (e.g. ioBroker) — see [docs](./docs/23-community-integrations.md)                                               |
| 🔐 **Session-scoped keys**    | Operator and viewer (reader) tokens can be limited to chosen sessions — or all sessions if none are selected                             |
| 🔒 **Chat-scoped keys**       | Those same tokens can also be limited to chosen chats — a few groups and contacts — so an agent on a shared account sees only its own    |

### Session-scoped operator & viewer tokens

When you create or edit an **operator** or **viewer** API key in the dashboard, you can tick the WhatsApp sessions that key may use.

- **No sessions selected** — the key can access every session, including ones created later.
- **One or more sessions selected** — the key can only list, read, and (for operator) manage those sessions. A request naming any other session returns `403` ("API key not authorized for this session"); session-filtered lists (sessions, audit, webhook delivery failures) return that key's rows rather than an error; and the key-management routes and the queue dashboard, which name no session at all, return `403`.

Admin keys stay unscoped in the dashboard so they can keep managing other API keys. The HTTP API still accepts `allowedSessions` on any role if you need that from a client.

### Chat-scoped operator & viewer tokens

A session-scoped key still reaches every chat on the sessions it may use. A key can be narrowed further, to **chats** (a chosen set of groups and individual contacts), with `allowedChats` on `POST /auth/api-keys` or `PUT /auth/api-keys/{id}`. The dashboard sets it on operator and viewer keys (one chat id or phone number per line) and shows each key's chat count in the list.

- **No chats selected** — the key can reach every chat on its sessions.
- **One or more selected** — the key reaches only those chats. Every authenticated REST route not explicitly marked as safe for a chat-scoped key refuses it with `403` (a request naming a session outside `allowedSessions` is refused first, with `403 "API key not authorized for this session"`), including routes added in later releases: the refusal is the default. Inside its chats an operator key can do what the marked routes allow, which is more than reading and sending: it can also delete or clear a chat, leave or rename a group, and block the contact. It cannot change who belongs to a group: adding, removing, promoting or demoting participants, answering join requests and reading or resetting the invite link all stay closed.
- **The two scopes are independent** — a key may be limited to sessions, to chats, to both, or to neither.

This lets you point an **AI agent or third-party integration at a shared account** without handing it every chat. Give the agent a key scoped to the few groups (or DMs) it is meant to handle: it can send and reply there, but it cannot list your other chats, read any other DM, message a contact outside its set, or reach the queue dashboard. It reads its chats' stored messages on either engine through `GET /sessions/{sessionId}/messages?chatId=`, where `chatId` is required for such a key, and live history through `GET /sessions/{sessionId}/messages/{chatId}/history` on whatsapp-web.js only. It can subscribe to the `/events` WebSocket, which delivers only events for its chats. It can still read the session's own status (`GET /sessions/{sessionId}`) so an integration can tell whether it is connected.

Identity is matched through the lid mapping table: a contact allowlisted by phone number also matches the same person's `@lid` privacy id once the table maps the two, and an unmapped `@lid` is refused rather than guessed. A lid's digits are never mistaken for a phone number, so `555000111@lid` does not admit `555000111@c.us`.

The default covers REST routes only. Surfaces that authenticate outside the REST guard do not inherit it, so each one that can return chat data applies the fence with its own check: the `/events` WebSocket delivers only events inside the key's chats, the MCP mount authorizes each tool call against them, and the Bull Board queue dashboard refuses the key. Five list routes are usable, each filtered to the key's chats before paging: `GET /sessions/{sessionId}/chats`, `GET /sessions/{sessionId}/groups` (id, name and community parent id only), `GET /sessions/{sessionId}/contacts`, `GET /sessions/{sessionId}/labels/{labelId}/chats` and `GET /api/search` (the built-in provider; a search plugin answers `403`).

The API also accepts `allowedChats` on an admin key, but no admin-only route is open to a chat-scoped key, and the last usable admin key cannot be scoped this way.

None of this changes the ban-risk guidance below. It limits what a _key_ can reach, not what WhatsApp makes of the account.

---

## ⚠️ Before you connect a number — please read

OpenWA is an unofficial, community-maintained gateway. It connects to WhatsApp through **reverse-engineered clients** (the [`whatsapp-web.js`](https://github.com/pedroslopez/whatsapp-web.js) project and [`@whiskeysockets/baileys`](https://github.com/WhiskeySockets/Baileys)), **not** through Meta's official Cloud API. This has real consequences you should understand before you link a phone number.

### What this means in practice

- **There is always a non-zero risk of account restriction or ban.** WhatsApp's anti-abuse systems actively look for unofficial automation. No amount of code quality on our side can make that risk zero.
- **Pick the right number.** Never connect your primary personal or business number to an automated gateway. Use a **dedicated number** you can afford to lose. If you're running this for paying clients, pass that guidance on to them.
- **The two engines trade off differently:**

  | Engine            | Ban-risk profile                                                                                        | Resource cost                     |
  | ----------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------- |
  | `whatsapp-web.js` | Lower — drives a real headless Chromium that looks like genuine WhatsApp Web traffic.                   | High RAM (~300–500 MB / session). |
  | `baileys`         | Higher — speaks the multi-device WebSocket protocol directly and is easier for WhatsApp to fingerprint. | Low RAM (~30–80 MB / session).    |

  If account safety is your top priority and you can afford the memory, prefer `whatsapp-web.js`. If you need density and accept the trade-off, use `baileys`.

### Safe-sending guidelines

These are practical guardrails, not guarantees — but they materially reduce the chance of WhatsApp flagging the account:

1. **Warm up fresh numbers.** For the first several days, behave like a normal human user: scan the QR, exchange a handful of messages with saved contacts, join a group or two, set a profile photo. Don't blast on day one.
2. **Don't cold-blast strangers.** Sending the first-ever message to a large batch of numbers that have never messaged you is the single most reliable way to get restricted — on either engine.
3. **Pace sends per session.** Set `SEND_PACING_ENABLED=true` (off by default) for a per-session daily cap: an allowance that grows with the session's age (`SEND_PACING_WARMUP_SCHEDULE`), a separate cap on new conversations (`SEND_PACING_COLD_DAILY_CAP`) and a consecutive-failure breaker; [R002 in the risk guide](docs/16-risk-management.md#r002-user-account-banned) lists what it counts. No per-minute cap is enforced, so spacing within a day is up to the caller: bulk sends wait `delayBetweenMessages` between messages, and single text sends pause behind a typing indicator (`SIMULATE_TYPING`, on by default). A few messages per minute per session is sustainable; "thousands in an hour" is not. The `RATE_LIMIT_*` variables are API abuse protection, counted per route and client IP, not a send cap: they throttle dashboard and read traffic too.
4. **Use opted-in recipients.** The safest workloads are replies and alerts to people who already expect to hear from you (OTP to your own users, order updates, support replies).
5. **Keep a fallback.** For anything auth-critical or revenue-critical, keep an SMS / email / official-Cloud-API path. Do not bet a login flow solely on an unofficial client.
6. **Mind the hosting IP.** Cheap datacenter IPs are flagged more aggressively than residential ones. A residential proxy (supported per-session via the proxy settings) can help; it is not a license to spam.

### Known platform behaviour (not bugs)

A few things that look like bugs but are actually server-side WhatsApp policy, not OpenWA defects — we track them separately so we can distinguish them from real bugs:

- **First message to a brand-new contact sometimes never arrives.** The API returns success because the message leaves OpenWA, but WhatsApp's server-side reach-out / trust policy drops it at delivery. This is independent of OpenWA. We track it in [#830](https://github.com/rmyndharis/OpenWA/issues/830).
- **Accounts that get restricted cannot be "unrestricted" by us.** If WhatsApp disables a number, you need to appeal through their channels — OpenWA has no lever to pull.
- **Some accounts cannot link a new device at all.** WhatsApp has added a passkey step to companion linking for some accounts: the phone asks to "Create a passkey" or "Continue on your other device" and the session stays at `qr_ready`. Neither engine implements that step, so switching engine or using a pairing code does not help, and no OpenWA setting changes it. Avoid logging out or re-linking a session that works, since linking it again may hit the same gate, and keep the fallback from rule 5 above. See [the FAQ entry](docs/12-troubleshooting-faq.md#issue-linking-asks-for-a-passkey-and-never-completes-both-engines); tracked in [#560](https://github.com/rmyndharis/OpenWA/issues/560).

### Compliance

For any deployment where ethical, legal, or regulatory compliance matters (healthcare, finance, large-scale commercial messaging, anything touching end users in the EU/EEA under DMA/GDPR framings), treat OpenWA as **not approved** and use Meta's [official WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api). OpenWA is an excellent fit for personal projects, internal tooling, automation hobbyists, and learning — it is not a drop-in replacement for the official API in regulated environments.

📖 For the deeper, maintainer-side risk analysis (protocol-change exposure, dependency strategy, security posture), see [Risk Management (`docs/16`)](./docs/16-risk-management.md).

---

## 🎯 Features

### Core Features

| Feature       | Status | Description                                                                  |
| ------------- | ------ | ---------------------------------------------------------------------------- |
| REST API      | ✅     | Full WhatsApp API via HTTP endpoints                                         |
| Multi-Session | ✅     | Manage multiple WhatsApp accounts                                            |
| Webhooks      | ✅     | Real-time events with HMAC signature and optional smart pre-dispatch filters |
| Web Dashboard | ✅     | Visual management interface                                                  |
| API Key Auth  | ✅     | Secure API authentication                                                    |
| Swagger Docs  | ✅     | Interactive API documentation                                                |

### Messaging

| Feature           | Status | Description                                               |
| ----------------- | ------ | --------------------------------------------------------- |
| Text Messages     | ✅     | Send/receive text messages                                |
| Media Messages    | ✅     | Images, videos, documents, audio                          |
| Message Reactions | ✅     | React to messages with emoji                              |
| Message Editing   | ✅     | Send edits + live `message.edited` events on both engines |
| Bulk Messaging    | ✅     | Send to multiple recipients                               |
| Message Status    | ✅     | Track delivery and read receipts                          |

### Advanced

| Feature             | Status | Description                                                                                                                                                                  |
| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Groups API          | ✅     | Create, manage, join (invite code), and configure groups                                                                                                                     |
| Profile Management  | ✅     | Set own display name, about text, and profile picture                                                                                                                        |
| Call Handling       | ✅     | `call.received` events (not reliable on whatsapp-web.js), reject calls and per-session auto-reject (Baileys only)                                                            |
| Channels/Newsletter | ✅     | WhatsApp Channels support                                                                                                                                                    |
| Labels Management   | ✅     | Organize chats with labels                                                                                                                                                   |
| Proxy Support       | ✅     | Per-session proxy configuration                                                                                                                                              |
| Rate Limiting       | ✅     | Configurable request limits                                                                                                                                                  |
| CIDR Whitelisting   | ✅     | IP-based access control                                                                                                                                                      |
| Chat Scoping        | ✅     | Per-key `allowedChats` allowlist (groups and contacts): a key reaches only those chats, and routes not marked safe for it refuse it                                          |
| Audit Logging       | ✅     | Audit trail for API-key, session, integration-instance, and infra admin operations (message sends and webhook deliveries are tracked in their own tables, not the audit log) |

### Infrastructure

| Feature          | Status | Description                                      |
| ---------------- | ------ | ------------------------------------------------ |
| SQLite           | ✅     | Zero-config embedded database                    |
| PostgreSQL       | ✅     | Production-grade database                        |
| Redis Cache      | ✅     | Optional performance caching                     |
| S3/MinIO Storage | ✅     | Live media store (statuses, archived chat media) |
| Docker           | ✅     | One-command deployment                           |
| Health Checks    | ✅     | Kubernetes-ready probes                          |
| Data Migration   | ✅     | Export/import between backends                   |

---

## 🚀 Quick Start

### Option A: Docker (Recommended)

```bash
# Clone and start
git clone https://github.com/rmyndharis/OpenWA.git
cd OpenWA
docker compose -f docker-compose.dev.yml up -d

# Access (the dashboard is bundled into the API image and served on the same port)
# Dashboard: http://localhost:2785
# API: http://localhost:2785/api
# Swagger: http://localhost:2785/api/docs
```

**Your API key.** The first boot generates an admin API key, prints it once in the log and stores it at
`/app/data/.api-key` inside the container. Read it with `docker exec openwa-api cat /app/data/.api-key`
(the file has no trailing newline, so the shell prompt follows the key on the same line; copy only the key),
then use it to sign in to the dashboard and as the `X-API-Key` header wherever this README shows
`YOUR_API_KEY`. Later boots log only a masked prefix. See [API Key](docs/README.md#api-key).

> **Using Podman instead of Docker?**
> Podman rootless mode requires the socket to be running and `DOCKER_HOST` to be set:
>
> ```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` line to your `~/.bashrc` to make it permanent.

### Option B: Local Development

```bash
# Clone repository
git clone https://github.com/rmyndharis/OpenWA.git
cd OpenWA

# Install the locked dependencies (includes dashboard)
npm ci

# Start API + Dashboard (config is auto-generated on first run)
npm run dev

# Access (in dev the dashboard runs on the Vite server with hot reload)
# Dashboard: http://localhost:2886
# API: http://localhost:2785/api
# Swagger: http://localhost:2785/api/docs
```

The first boot writes the admin API key to `data/.api-key` (read it with `cat data/.api-key`; the file has no trailing newline, so copy only the key).

Use `npm install` instead when intentionally changing dependencies. OpenWA's committed lockfile uses
registry artifacts only, so npm 12 works with its secure default that blocks Git dependencies; do not
disable that policy globally.

---

## 🔒 Security Architecture

### Docker Socket Proxy

The production stack never exposes `/var/run/docker.sock` directly to the application container. Instead, a dedicated `docker-proxy` sidecar (based on [`tecnativa/docker-socket-proxy`](https://github.com/Tecnativa/docker-socket-proxy)) acts as the sole gateway to the Docker daemon:

```
openwa-api  ──TCP 2375──▶  docker-proxy  ──unix──▶  /var/run/docker.sock
```

Only the operations needed for container orchestration are enabled (`CONTAINERS`, `IMAGES`, `VOLUMES`, `PING`, plus the `POST` method switch). The application connects via the `DOCKER_HOST=tcp://docker-proxy:2375` environment variable, which `DockerService` detects automatically. Note this is an operational gateway, not a fine-grained privilege boundary: with `POST` enabled the proxy admits every method to the enabled paths and cannot scope container-create payloads, so a compromised API container would be host-root-equivalent — see `SECURITY.md` for the full threat model, mitigations, and how to disable the proxy if you don't use the built-in datastore orchestration.

### Non-root Container Execution

The production image never runs the Node.js process as root. On startup, the container follows this chain:

```
dumb-init (PID 1)
  └─ docker-entrypoint.sh (root — fixes named-volume ownership via chown)
       └─ gosu openwa node dist/main  (drops to the openwa user)
```

- **dumb-init** is PID 1 and forwards signals (SIGTERM, etc.) for graceful shutdown.
- **docker-entrypoint.sh** runs as root only long enough to `chown` the named-volume mount points so the `openwa` user can write to them.
- **gosu** performs a clean `exec`-based privilege drop — no `su` or `sudo` wrappers, so the node process is the direct child of dumb-init.

Named volumes (e.g. `openwa-data`) get their ownership corrected automatically on every start, so no manual `chown` step is needed after volume creation.

The image can also start as the `openwa` user directly (uid/gid 997: `--user 997:997`, or the Helm chart's `podSecurityContext`). The entrypoint then skips the `chown` and the `gosu` drop and needs no added capabilities, provided `/app/data` is writable by that uid.

---

## 🏭 Production Deployment

For production, use the main `docker-compose.yml` with optional services:

```bash
# Basic production (SQLite, local storage)
docker compose up -d

# Also start the PostgreSQL container (configure it first, see below)
docker compose --profile postgres up -d

# Also start PostgreSQL, Redis and MinIO (configure them first, see below)
docker compose --profile full up -d
```

| Profile    | Services                                        |
| ---------- | ----------------------------------------------- |
| `postgres` | PostgreSQL database                             |
| `redis`    | Redis cache                                     |
| `minio`    | S3-compatible storage (MinIO fork `pgsty/silo`) |
| `full`     | All services above                              |

A profile only starts the container; OpenWA keeps using SQLite and local storage until it is told
to use the new service. The simplest route is **Dashboard > Infrastructure**: pick the built-in
option, save, and restart from there, and OpenWA starts the container itself. The built-in storage
option creates its own `openwa-minio` container, so do not also start the `minio` or `full` profile
for it. The same goes for built-in PostgreSQL and Redis: each built-in option creates its own
container, and the compose profiles are for the manual `.env` route below. The compose `minio`
service starts only once `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` are set in the `.env` next to
`docker-compose.yml`; if it was already started without them, run `docker rm -f openwa-minio` before
you enable built-in storage. To use a profile directly, set these in the `.env` next to
`docker-compose.yml` first:

- PostgreSQL: `DATABASE_TYPE=postgres`, `DATABASE_HOST=postgres`, `DATABASE_USERNAME=openwa`,
  `DATABASE_PASSWORD=<strong password>`
- Redis: `REDIS_ENABLED=true`, `REDIS_HOST=redis`
- MinIO: `STORAGE_TYPE=s3`, `S3_ENDPOINT=http://minio:9000`, `S3_ACCESS_KEY_ID=<user>`,
  `S3_SECRET_ACCESS_KEY=<strong password>`

PostgreSQL refuses to initialize a new volume with an empty password, MinIO refuses to start
without one, and a production boot rejects default credentials such as `openwa` or `minioadmin`.

> The dashboard is bundled into the API image and served by NestJS on the API port, so it
> needs no profile — it is always available wherever `openwa-api` runs. For TLS/public exposure,
> put your own reverse proxy (nginx, Caddy, a cloud load balancer, or a k8s Ingress) in front;
> see the nginx example in `docs/12-troubleshooting-faq.md`.

> **Development vs Production**
>
> - Development (`docker-compose.dev.yml`): SQLite, local storage, API serves the bundled dashboard
> - Production (`docker-compose.yml`): Configurable database, profiles for optional services
>
> Official GHCR images are published as multi-arch manifests for:
>
> - `linux/amd64`
> - `linux/arm64`

To run a published image instead of building from source, add a `docker-compose.override.yml` next to
`docker-compose.yml`:

```yaml
services:
  openwa-api:
    image: ghcr.io/rmyndharis/openwa:latest
```

Run `docker compose pull openwa-api && docker compose up -d --no-build`. To upgrade, update the checkout
first (`git pull`, or `git checkout v<version>` when pinning), then run the same command: the override
replaces only the image, and `docker-compose.yml` forwards an explicit variable list with no `env_file`,
so an outdated copy drops the variables a newer release adds and keeps the old service definitions. Pin a
release by replacing `latest` with its version number. Once the image is pulled, Compose runs it and
builds nothing. The service keeps its `build:` section, so if the pull fails (a mistyped tag, no registry
access) Compose falls back to building from source and tags that build with the published name;
`--no-build` makes that case fail instead. For the same reason, do not use `--build` or
`docker compose build` with this override.

## 🔌 Ports

| Service         | Port            | Description                                                                         |
| --------------- | --------------- | ----------------------------------------------------------------------------------- |
| API & Dashboard | `2785`          | REST API + bundled web dashboard (same port)                                        |
| Swagger         | `2785/api/docs` | Interactive API docs — off under `NODE_ENV=production` unless `ENABLE_SWAGGER=true` |
| Dashboard (dev) | `2886`          | Vite dev server with hot reload (`npm run dev`)                                     |

---

## 📡 API Examples

Replace `YOUR_API_KEY` with your admin key (see [Quick Start](#-quick-start) for where to find it).

### Create a Session

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

### Start Session & Get QR Code

```bash
# Start the session
curl -X POST http://localhost:2785/api/sessions/{sessionId}/start \
  -H "X-API-Key: YOUR_API_KEY"

# Get QR code (scan with WhatsApp)
curl http://localhost:2785/api/sessions/{sessionId}/qr \
  -H "X-API-Key: YOUR_API_KEY"
```

### Send a Message

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

### Setup Webhook

```bash
curl -X POST http://localhost:2785/api/sessions/{sessionId}/webhooks \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -d '{
    "url": "https://your-server.com/webhook",
    "events": ["message.received", "session.status"],
    "secret": "your-hmac-secret"
  }'
```

> **Smart filters (optional):** add a `filters` object to fire the webhook only when conditions match
> (AND), e.g. `{ "conditions": [{ "field": "sender", "operator": "is", "value": ["1234567890@c.us"] }] }`.
> Fields: `sender` / `recipient` / `chatId` / `body` / `type` / `mentions` / `fromMe` / `hasMedia` /
> `isGroup` / `kind`. A webhook with no filters behaves exactly as before. Use `chatId` to allowlist
> specific groups or DMs (e.g. `{ "field": "chatId", "operator": "is", "value": ["120…@g.us"] }`).
> See the API specification for the full schema.

## 🤖 MCP Server (AI Agents)

OpenWA can expose a **curated set of tools over the [Model Context Protocol](https://modelcontextprotocol.io)** so AI agents (Claude, Cursor, …) can drive WhatsApp. It is **off by default** and **additive** — every REST route keeps working unchanged.

Set `MCP_ENABLED=true` to mount a stateless Streamable-HTTP transport at **`POST /mcp`** on the existing server (same port, no extra process). It mounts **25 read-only tools** by default — session, message, contact, group, webhook, label and automation-rule _reads_ — because the surface is read-only unless you opt out. Add `MCP_READONLY=false` to mount all **51 tools**, adding the write tier (send, reply, group operations). Either way it is a focused surface rather than the full API, so agents aren't overwhelmed.

```bash
MCP_ENABLED=true npm run start:prod   # or set MCP_ENABLED in your .env / compose
```

Point an MCP client at it (e.g. for Claude Code, a `.mcp.json` at your project root):

```json
{
  "mcpServers": {
    "openwa": {
      "type": "http",
      "url": "http://localhost:2785/mcp",
      "headers": { "Authorization": "Bearer YOUR_API_KEY" }
    }
  }
}
```

The key can be passed as `Authorization: Bearer …` or `X-API-Key: …`. Every tool call goes through the **same API-key auth, role, and per-session scoping** as REST.

**Security guidance:**

- **Mint a dedicated, least-privilege key** for the agent — a non-admin, **session-scoped** key (`OPERATOR` role at most). The plaintext key is shown only once on creation; to rotate, create a new key and delete the old one.
- The key **must not** carry an IP allow-list (`allowedIps`) — there is no genuine client IP over MCP, so such a key is rejected.
- Set **`MCP_READONLY=true`** to mount only the read tools (no sends/writes).
- Set **`MCP_RATE_LIMIT_MAX`** (default `60`) to limit tool calls per API key per window.
- Set **`MCP_RATE_LIMIT_WINDOW_MS`** (default `60000`) to control the sliding window size in milliseconds.
- **Do not expose `/mcp` to the public internet** without a fronting auth proxy. For a self-hosted, locally-reached deployment the static API key is appropriate; public exposure should use OAuth 2.1 (not yet built).

---

## 🛠 Tech Stack

| Layer         | Technology                                              |
| ------------- | ------------------------------------------------------- |
| **Runtime**   | Node.js 22 LTS                                          |
| **Framework** | NestJS 11.x                                             |
| **Language**  | TypeScript 6.x                                          |
| **WA Engine** | whatsapp-web.js (default) / baileys — set `ENGINE_TYPE` |
| **Database**  | SQLite / PostgreSQL                                     |
| **Cache**     | Redis (optional)                                        |
| **Storage**   | Local / S3 / MinIO                                      |
| **ORM**       | TypeORM                                                 |
| **Container** | Docker + Docker Compose                                 |

---

## 📁 Project Structure

```
openwa/
├── src/
│   ├── main.ts                 # Application entry point
│   ├── app.module.ts           # Root module
│   ├── config/                 # Configuration
│   ├── common/                 # Shared utilities
│   │   ├── cache/              # Redis caching
│   │   └── storage/            # File storage (Local/S3)
│   ├── core/                   # Core systems
│   │   ├── hooks/              # Plugin hooks
│   │   └── plugins/            # Plugin system
│   ├── engine/                 # WhatsApp engine abstraction
│   └── modules/
│       ├── session/            # Session management
│       ├── message/            # Message handling
│       ├── webhook/            # Webhook management
│       ├── group/              # Groups API
│       ├── contact/            # Contacts API
│       ├── auth/               # API key authentication
│       ├── infra/              # Infrastructure management
│       └── health/             # Health checks
├── dashboard/                  # React web dashboard
├── docs/                      # Documentation
├── docker-compose.yml
├── Dockerfile
└── package.json
```

---

## 📚 Documentation

Comprehensive documentation is available in the `docs/` folder:

| Document                                                | Description                  |
| ------------------------------------------------------- | ---------------------------- |
| [Project Overview](./docs/01-project-overview.md)       | Introduction and goals       |
| [Requirements](./docs/02-requirements-specification.md) | Feature specifications       |
| [Architecture](./docs/03-system-architecture.md)        | System design                |
| [Security](./docs/04-security-design.md)                | Security implementation      |
| [Database](./docs/05-database-design.md)                | Data models and migrations   |
| [API Spec](./docs/06-api-specification.md)              | Complete API reference       |
| [Development](./docs/08-development-guidelines.md)      | Coding standards             |
| [Migration Guide](./docs/14-migration-guide.md)         | Database & storage migration |

---

## 🤝 Contributing

We welcome contributions! Here's how to get started:

1. **Fork** the repository
2. **Create** your feature branch (`git checkout -b feat/amazing-feature`)
3. **Commit** your changes (`git commit -m 'Add amazing feature'`)
4. **Push** to the branch (`git push origin feat/amazing-feature`)
5. **Open** a Pull Request

Please read our [Development Guidelines](./docs/08-development-guidelines.md) for coding standards and best practices.

---

## ☕ Support

OpenWA is free and open source. If it saves you time or helps your business, you can support its development by buying me a coffee.

<a href="https://buymeacoffee.com/rmyndharis"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="50"/></a>

---

## Disclaimer

OpenWA is an independent open-source project and is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Meta Platforms, Inc., WhatsApp LLC, or any of their subsidiaries or affiliates. The official WhatsApp website can be found at [whatsapp.com](https://www.whatsapp.com). The name "WhatsApp" as well as related names, marks, emblems, and images are registered trademarks of their respective owners.

Use it at your own risk; you are responsible for complying with WhatsApp's terms and the law where you operate.

---

## 📄 License

This project is licensed under the **MIT License** – free for personal and commercial use.

See [LICENSE](./LICENSE) for details.

---

<div align="center">

**OpenWA** – Free, Open Source WhatsApp API Gateway

[📖 Documentation](./docs/README.md) · [🔌 API Docs](http://localhost:2785/api/docs) · [🐛 Report Bug](https://github.com/rmyndharis/OpenWA/issues) · [💡 Request Feature](https://github.com/rmyndharis/OpenWA/issues)

<br/>

<sub>Made with ❤️ by <a href="https://github.com/rmyndharis">Yudhi Armyndharis</a> and the OpenWA Community</sub>

</div>

---

### Overview (`dashboard/README.md`)

# OpenWA Dashboard

<p align="center">
  <img src="../docs/logo/openwa_logo.webp" alt="OpenWA Logo" width="120"/>
</p>

Modern web dashboard for managing OpenWA WhatsApp API Gateway sessions, webhooks, and infrastructure.

## ✨ Features

- **Session Management** - Create, monitor, and control WhatsApp sessions
- **QR Code Authentication** - Real-time QR code display for device pairing
- **Webhook Configuration** - Configure and test webhook endpoints
- **API Key Management** - Generate and manage API keys
- **Infrastructure Monitoring** - View system health and storage status
- **Real-time Updates** - Live session status via WebSocket

## 🛠️ Tech Stack

| Technology       | Purpose                 |
| ---------------- | ----------------------- |
| React 19         | UI Framework            |
| TypeScript       | Type Safety             |
| Vite 8           | Build Tool              |
| React Router 7   | Client-side Routing     |
| TanStack Query   | Server State Management |
| TanStack Table   | Data Tables             |
| Socket.IO Client | Real-time Communication |
| Lucide React     | Icons                   |

## 🚀 Getting Started

### Prerequisites

- Node.js 22.22.2+ or 24.15+ (jsdom, used by the unit tests, requires it)
- npm (the lockfile and the `overrides` security pins are npm-specific)

### Development

```bash
# Install dependencies
npm install

# Start development server
npm run dev
```

Dashboard will be available at `http://localhost:2886` (Vite dev server with hot reload; it
proxies `/api` + `/socket.io` to the NestJS API on `:2785`).

In production the build (`npm run build` → `dist/`) is served by the NestJS API itself on the
same port via `@nestjs/serve-static`, so there is no separate dashboard container.

### Production Build

```bash
# Build for production
npm run build

# Preview production build
npm run preview
```

## 📁 Project Structure

```
dashboard/
├── src/
│   ├── components/     # Reusable UI components
│   ├── pages/          # Page components
│   ├── hooks/          # Custom React hooks
│   ├── services/       # API service layer
│   ├── types/          # TypeScript definitions
│   ├── utils/          # Utility functions
│   ├── App.tsx         # Root component
│   ├── App.css         # Global styles
│   └── main.tsx        # Entry point
├── public/             # Static assets
├── index.html          # HTML template
└── vite.config.ts      # Vite configuration
```

## 🔗 API Connection

By default the dashboard calls the API on the **same origin** it is served from (the
single-container setup), so no configuration is needed.

For a **split-origin** deployment (dashboard hosted separately from the API), set the API
**origin** at build time — the `/api` prefix is appended automatically:

```bash
VITE_API_URL=http://localhost:2785
# real-time events (WebSocket) origin; defaults to VITE_API_URL, else the page origin
VITE_WS_URL=http://localhost:2785
```

## 📄 License

MIT License - Part of the [OpenWA](https://github.com/rmyndharis/OpenWA) project.

---

### Overview (`docs/README.md`)

<p align="center">
  <img src="logo/openwa_logo.webp" alt="OpenWA Logo" width="200"/>
</p>

<h1 align="center">OpenWA Documentation</h1>
<p align="center">
  <strong>Open Source WhatsApp API Gateway</strong>
</p>

<p align="center">
  <a href="#features-current">Features</a> •
  <a href="#quick-start">Quick Start</a> •
  <a href="#documentation-map">Docs</a> •
  <a href="#api-example">API</a> •
  <a href="#contributing">Contributing</a>
</p>

<p align="center">
  <img src="https://img.shields.io/github/package-json/v/rmyndharis/OpenWA?label=version&color=blue" alt="Version"/>
  <img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License"/>
  <img src="https://img.shields.io/badge/node-22_LTS-brightgreen.svg" alt="Node"/>
  <img src="https://img.shields.io/github/package-json/dependency-version/rmyndharis/OpenWA/@nestjs/core?label=NestJS&color=red" alt="NestJS"/>
  <img src="https://img.shields.io/badge/docker-ready-blue.svg" alt="Docker"/>
  <img src="https://img.shields.io/github/package-json/dependency-version/rmyndharis/OpenWA/dev/typescript?label=TypeScript&color=3178C6" alt="TypeScript"/>
</p>

---

## Documentation Map

**Full Index (by number)**

| No  | Document                                                                 | Description                                                                        |
| --- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| 01  | [Project Overview](./01-project-overview.md)                             | Vision, goals, scope, current status                                               |
| 02  | [Requirements Specification](./02-requirements-specification.md)         | Functional and non-functional requirements                                         |
| 03  | [System Architecture](./03-system-architecture.md)                       | Architecture, modules, and runtime flows                                           |
| 04  | [Security Design](./04-security-design.md)                               | Auth, rate limiting, and security controls                                         |
| 05  | [Database Design](./05-database-design.md)                               | Entities and storage considerations                                                |
| 06  | [API Specification](./06-api-specification.md)                           | REST API and WebSocket protocol                                                    |
| 07  | [API Collection](./07-api-collection.md)                                 | Example requests and Postman import tips                                           |
| 08  | [Development Guidelines](./08-development-guidelines.md)                 | Coding standards and workflow                                                      |
| 09  | [Testing Strategy](./09-testing-strategy.md)                             | Test types and tooling                                                             |
| 10  | [DevOps & Infrastructure](./10-devops-infrastructure.md)                 | Docker, deployment, and environment configuration                                  |
| 11  | [Operational Runbooks](./11-operational-runbooks.md)                     | Incident, maintenance, and backup runbooks                                         |
| 12  | [Troubleshooting FAQ](./12-troubleshooting-faq.md)                       | Common issues and fixes                                                            |
| 13  | [Horizontal Scaling](./13-horizontal-scaling.md)                         | Multi-node deployment guidance                                                     |
| 14  | [Migration Guide](./14-migration-guide.md)                               | Upgrade and data migration guidance                                                |
| 15  | [Project Roadmap](./15-project-roadmap.md)                               | Near-term and long-term roadmap                                                    |
| 16  | [Risk Management](./16-risk-management.md)                               | Risks and mitigations                                                              |
| 17  | [Dashboard Design](./17-dashboard-design.md)                             | Dashboard UX overview                                                              |
| 18  | [SDK Design](./18-sdk-design.md)                                         | SDK plans and conventions                                                          |
| 19  | [Plugin Architecture](./19-plugin-architecture.md)                       | Extensibility concepts                                                             |
| 20  | [Community Guidelines](./20-community-guidelines.md)                     | Contribution and governance                                                        |
| 21  | [Glossary](./21-glossary.md)                                             | Terms and definitions                                                              |
| 22  | [n8n Integration](./22-n8n-integration.md)                               | n8n community nodes for OpenWA                                                     |
| 23  | [Community Integrations](./23-community-integrations.md)                 | Third-party adapters built on the OpenWA API                                       |
| 24  | [MCP Integration](./24-mcp-integration.md)                               | Model Context Protocol tools and auth model                                        |
| 25  | [Integration Fabric](./25-integration-fabric.md)                         | Inbound webhook substrate for plugin integrations                                  |
| 26  | [Global Search](./26-global-search.md)                                   | Cross-session message search and the provider model                                |
| 27  | [Plugin Search Providers](./27-plugin-search-providers.md)               | Writing a search-provider plugin                                                   |
| 28  | [Multitenancy](./28-multitenancy.md)                                     | Multi-tenant target design (draft proposal)                                        |
| 29  | [Engine Capability Matrix](./29-engine-capability-matrix.md)             | Per-engine capability support, gaps, and roadmap                                   |
| 30  | [Plugin Sandboxing](./30-plugin-sandboxing.md)                           | Worker isolation, capabilities, and plugin limits                                  |
| 31  | [Session Lifecycle: Invariant Catalog](./31-session-lifecycle-design.md) | The race windows the lifecycle defends, where each is defended, which spec pins it |

**Examples**

| Example                                                                        | Description                                                              |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
| [Session Phone-Number Pairing](./examples/session-phone-number-pairing.md)     | Link an existing WhatsApp account by phone number instead of scanning QR |
| [Chat History Limits](./examples/chat-history-limits.md)                       | Understand local message history vs bounded live WhatsApp history        |
| [Webhook Signature Verification](./examples/webhook-signature-verification.md) | Verify signed OpenWA webhook deliveries in Node.js and Python            |
| [n8n Appointment Booking Workflow](./examples/n8n-appointment-booking.md)      | Build an appointment-booking flow with OpenWA and n8n                    |
| [n8n to Discord Workflow](./examples/n8n/README.md)                            | Forward incoming WhatsApp messages to Discord with n8n's built-in nodes  |

## Quick Start

### Option A: Minimal Setup (SQLite, no Docker services)

```bash
# Clone repository
git clone https://github.com/rmyndharis/OpenWA.git
cd OpenWA

# Install the locked dependencies (also installs dashboard dependencies)
npm ci

# Optional: .env.minimal pins database, storage, Redis/queue, session path, engine and Puppeteer
# settings over Dashboard > Infrastructure, and turns off session auto-start
# cp .env.minimal .env

# Run the API and the dashboard
npm run dev
```

Access:

- Dashboard: `http://localhost:2886`
- API: `http://localhost:2785/api`
- Swagger: `http://localhost:2785/api/docs`
- Health: `http://localhost:2785/api/health`

### Option B: Docker (single container: API + Dashboard)

```bash
# Clone repository
git clone https://github.com/rmyndharis/OpenWA.git
cd OpenWA

# Start services
docker compose up -d
```

Access (the dashboard is bundled into the API and served on the same port):

- Dashboard: `http://localhost:2785`
- API: `http://localhost:2785/api`
- Swagger: `http://localhost:2785/api/docs` (off under this compose file's `NODE_ENV=production` unless `ENABLE_SWAGGER=true`)

### API Key

OpenWA seeds a default API key on first run and writes it to:

- `data/.api-key` (development)
- `/app/data/.api-key` inside the API container when using Docker

With Docker, read it with `docker exec openwa-api cat /app/data/.api-key`. The file has no trailing newline, so copy only the key.

The startup log prints the full key only on the boot that created it; later boots show a masked prefix. By default a cryptographically
random `owa_k1_...` admin key is generated on first run in all environments; set
`ALLOW_DEV_API_KEY=true` to seed the well-known `dev-admin-key` for local
development only. Use an admin key to create additional keys with
`POST /api/auth/api-keys` (see
[API Specification](./06-api-specification.md#649-api-keys)).

## API Example

```bash
# Create a session
curl -X POST http://localhost:2785/api/sessions \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-bot"}'

# Start the session
curl -X POST http://localhost:2785/api/sessions/{sessionId}/start \
  -H "X-API-Key: your-api-key"

# Get QR code (base64)
curl http://localhost:2785/api/sessions/{sessionId}/qr \
  -H "X-API-Key: your-api-key"

# Send a message
curl -X POST http://localhost:2785/api/sessions/{sessionId}/messages/send-text \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"chatId": "628123456789@c.us", "text": "Hello from OpenWA!"}'
```

## WebSocket Example (Socket.IO)

```javascript
import { io } from 'socket.io-client';

const socket = io('http://localhost:2785/events', {
  extraHeaders: { 'X-API-Key': 'your-api-key' },
  transports: ['websocket'],
});

socket.on('connect', () => {
  socket.emit('message', {
    type: 'subscribe',
    sessionId: '550e8400-e29b-41d4-a716-446655440000',
    events: ['message.received', 'session.status'],
    requestId: 'req_001',
  });
});

socket.on('message', msg => {
  if (msg.type === 'event') {
    console.log('Event:', msg.payload.event, msg.payload.data);
  }
});
```

## Features (Current)

| Feature                             | Status                                       |
| ----------------------------------- | -------------------------------------------- |
| REST API for WhatsApp               | Ready                                        |
| WebSocket Events (Socket.IO)        | Ready                                        |
| Multi-session Support               | Ready                                        |
| Web Dashboard                       | Ready                                        |
| Docker Deployment                   | Ready                                        |
| Webhooks with HMAC Signature        | Ready                                        |
| SQLite / PostgreSQL Storage         | Ready                                        |
| API Key Authentication & Roles      | Ready                                        |
| CIDR IP Whitelisting                | Ready                                        |
| Rate Limiting                       | Ready                                        |
| Audit Logging                       | Ready                                        |
| Groups / Contacts / Labels API      | Ready                                        |
| Channels / Status API               | Experimental (engine-limited)                |
| Catalog / Product API               | Baileys only (whatsapp-web.js answers `501`) |
| Pluggable Engine (wwebjs / Baileys) | Ready (set `ENGINE_TYPE`)                    |
| Plugin Extension System             | Ready                                        |
| Queue-based Webhook Retries         | Optional (QUEUE_ENABLED=true)                |

## Tech Stack

| Layer     | Technology                                                      |
| --------- | --------------------------------------------------------------- |
| Runtime   | Node.js 22 LTS                                                  |
| Framework | NestJS 11.x                                                     |
| Language  | TypeScript 6.x                                                  |
| WA Engine | Pluggable (`ENGINE_TYPE`): whatsapp-web.js (default) or Baileys |
| WebSocket | Socket.IO                                                       |
| Database  | SQLite (default) / PostgreSQL                                   |
| ORM       | TypeORM                                                         |
| Container | Docker + Docker Compose                                         |
| Dashboard | React + Vite + TanStack Query                                   |

## Project Structure

```
OpenWA/
├── src/                    # Backend source code
├── dashboard/              # Frontend dashboard
├── docker-compose.yml      # API (serves bundled dashboard) + optional datastores
├── docker-compose.dev.yml  # Dev-only compose
├── docs/                  # Project documentation
└── data/                   # Local runtime data (sessions, media, api key)
```

## Contributing

See [Development Guidelines](./08-development-guidelines.md) for coding standards and workflow.

## License

MIT License.

---

<div align="center">

**Start Reading: [01 - Project Overview](./01-project-overview.md)**

_OpenWA Documentation · Last updated: 2026-07-28_

</div>

---

### Overview (`sdk/README.md`)

# OpenWA SDKs

This document describes SDK 0.6.0. Send idempotency, webhook failure redrive, message-window filters, poll choices, last-message types, and the `event.response` webhook event name require OpenWA 0.25.0 or later.

Official client libraries for [OpenWA](https://github.com/rmyndharis/OpenWA), the
open-source WhatsApp API Gateway. OpenWA is an independent project, not
affiliated with or endorsed by WhatsApp or Meta.

All five SDKs are **hand-written** against the exact API surface (paths, DTOs,
response shapes) and **unit-tested with mocked HTTP transports** that assert on
the precise request URL, method, and body — so drift is caught at test time.
The JavaScript, Python, Java and Go wire types live in dedicated files
(`types.ts` / `types.py` / `model/` / `types*.go`) so they can later be
regenerated by an OpenAPI codegen pass without touching the hand-written
resource methods; the PHP SDK has no types layer and returns plain arrays.

| Language                | Package                                      | Notes                                                       |
| ----------------------- | -------------------------------------------- | ----------------------------------------------------------- |
| JavaScript / TypeScript | [`@rmyndharis/openwa`](javascript/)          | dual ESM/CJS, bundled types                                 |
| Python                  | [`rmyndharis-openwa`](python/)               | sync (httpx), PEP 561 typed                                 |
| PHP                     | [`rmyndharis/openwa`](php/)                  | sync (Guzzle, PHP 8.1+)                                     |
| Java                    | [`com.rmyndharis:openwa`](java/)             | sync (java.net.http + Gson, Java 17)                        |
| Go                      | [`github.com/rmyndharis/OpenWA/sdk/go`](go/) | stdlib-only, context-first, injectable transport (Go 1.22+) |

## Coverage

All five SDKs expose the same fluent resource surface:

| Resource    | Methods                                                                                                                                                                                                                                                                                    |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sessions`  | list, get, getConfig, updateConfig, getProxy, updateProxy, create, delete, start, stop, logout, forceKill, getQrCode, requestPairingCode, setOnlinePresence, stats                                                                                                                         |
| `messages`  | list, sendText, sendImage/Video/Audio/Document/Sticker, sendLocation, sendContact, sendTemplate, sendPoll, reply, clickButton, forward, react, delete, editMessage, history, reactions, media, pin, unpin, star, votePoll, sendBulk, batchStatus, cancelBatch                              |
| `contacts`  | list, get, check, profilePicture, profilePictures, phone, upsert, delete, block, unblock, listBlocked                                                                                                                                                                                      |
| `groups`    | list, get, create, joinGroup, joinInfo, add/remove/promote/demoteParticipants, setSubject, setDescription, get/updateGroupSettings, leave, getPicture, setPicture, deletePicture, inviteCode, revokeInviteCode, getMembershipRequests, approveMembershipRequests, rejectMembershipRequests |
| `webhooks`  | list, listAll, deliveryFailures, redriveDeliveryFailures, get, create, update, delete, test                                                                                                                                                                                                |
| `chats`     | list, markRead, markUnread, archive, pin, mute, clearMessages, delete, sendState, subscribePresence, getPresence                                                                                                                                                                           |
| `labels`    | list, get, chats, forChat, upsert, delete, addToChat, removeFromChat _(WhatsApp Business)_                                                                                                                                                                                                 |
| `channels`  | list, get, messages, create, delete, mute, subscribe, unsubscribe, demoteAdmin, transferOwnership _(Newsletters)_                                                                                                                                                                          |
| `catalog`   | info, products, product, sendProduct _(WhatsApp Business)_                                                                                                                                                                                                                                 |
| `status`    | list, fromContact, media, sendText, sendImage, sendVideo, sendVoice, delete _(Stories)_                                                                                                                                                                                                    |
| `search`    | search _(Operator)_                                                                                                                                                                                                                                                                        |
| `templates` | list, get, create, update, delete                                                                                                                                                                                                                                                          |
| `profile`   | setProfileName, setProfileStatus, setProfilePicture, deleteProfilePicture _(OPERATOR)_                                                                                                                                                                                                     |
| `calls`     | rejectCall, createLink _(OPERATOR)_                                                                                                                                                                                                                                                        |
| `media`     | conversionStatus, convertVoice _(OPERATOR)_, convertVideo _(OPERATOR)_                                                                                                                                                                                                                     |
| `health`    | check, live, ready                                                                                                                                                                                                                                                                         |
| `auth`      | validate (`client.auth()`; `client.Auth.Validate` in Go)                                                                                                                                                                                                                                   |

> ⚠️ Endpoints requiring an `OPERATOR`-level API key are noted in the inline
> docs. Deliberately **not** exposed, matching `docs/18-sdk-design.md` exactly:
> `auth/api-keys` (key validation itself is the `auth` row above), `audit`,
> `settings`, `stats`, `automation`, `infra`,
> `plugins`, the `integration` management routes, `metrics`, `mcp`, `ingress`
> and `docker`. These two lists have to agree — they did not, in both
> directions, and a list that disagrees with its own design doc reads as an
> accidental omission rather than a decision.
>
> Everything else the gateway publishes is exposed. That sentence used to be an
> unqualified "all user-facing resources are", which was false for the session
> config routes and the two cross-session webhook reads until they were added.
>
> One entry in the list above is special: `mcp` is absent from the generated
> `openapi.json` itself, not just from the SDKs — `POST /mcp` is mounted directly
> on the Express adapter, outside the Nest `/api` routing the Swagger scanner
> sees, so the exporter cannot emit it. Do not hand-add it to `openapi.json`
> (`npm run openapi:check` regenerates the file and the diff fails); the route is
> documented by hand in `docs/06-api-specification.md`, and the docs-contract
> gate names `POST /mcp` as the one heading allowed outside the contract.

Every example below addresses a session by its id: the UUID that
`sessions.create()` returns, not the name passed to it. Create a session once (a
second `create` with the same name answers `409`); afterwards, find its id with
`sessions.list` filtered by `name`.

## JavaScript / TypeScript

```bash
npm install @rmyndharis/openwa
```

```typescript
import { OpenWAClient } from '@rmyndharis/openwa';

const client = new OpenWAClient({
  baseUrl: 'http://localhost:2785',
  apiKey: 'owa_k1_…',
});

const session = await client.sessions.create({ name: 'my-session' });
await client.sessions.start(session.id);
// Link the account before sending: scan sessions.getQrCode (or use sessions.requestPairingCode for a number with no linked session),
// then wait for status 'ready'. An unlinked session answers the send with 409.
const result = await client.messages.sendText(session.id, {
  chatId: '628123456789@c.us',
  text: 'Hello from the OpenWA SDK!',
});
console.log(result.messageId);
```

Errors are typed — branch with `instanceof`:

```typescript
import { OpenWANotFoundError, OpenWAConflictError } from '@rmyndharis/openwa';
try {
  await client.messages.sendText(/* … */);
} catch (e) {
  if (e instanceof OpenWAConflictError) {
    /* a conflicting state (409): typically a session not ready, or a keyed send pending or unconfirmed */
  }
}
```

> Requires Node 18+ (uses the global `fetch`). Pass a custom `fetch` to the
> client constructor to intercept or observability-wrap requests.

## Python

```bash
pip install rmyndharis-openwa
```

```python
from openwa import OpenWAClient, OpenWANotFoundError

client = OpenWAClient(
    base_url="http://localhost:2785",
    api_key="owa_k1_…",
)

session = client.sessions.create({"name": "my-session"})
client.sessions.start(session["id"])
# Link the account before sending: scan sessions.get_qr_code (or use sessions.request_pairing_code for a number with no linked session),
# then wait for status "ready". An unlinked session answers the send with 409.
result = client.messages.send_text(session["id"], {
    "chatId": "628123456789@c.us",
    "text": "Hello from the OpenWA Python SDK!",
})
print(result["messageId"])
```

Pass `transport=httpx.MockTransport(handler)` for testing — no global
monkey-patching required.

## PHP

```bash
composer require rmyndharis/openwa
```

```php
<?php
use OpenWA\Client;

$client = new Client([
    'baseUrl' => 'http://localhost:2785',
    'apiKey'  => 'owa_k1_…',
]);

$session = $client->sessions->create(['name' => 'my-session']);
$client->sessions->start($session['id']);
// Link the account before sending: scan sessions->getQrCode (or use sessions->requestPairingCode for a number with no linked session),
// then wait for status 'ready'. An unlinked session answers the send with 409.
$result = $client->messages->sendText($session['id'], [
    'chatId' => '628123456789@c.us',
    'text'   => 'Hello from the OpenWA PHP SDK!',
]);
echo $result['messageId'];
```

Requires PHP 8.1+ and Guzzle 7. For testing, inject a Guzzle client whose
handler is a `MockHandler` — no global state, no network.

## Java

```xml
<dependency>
  <groupId>com.rmyndharis</groupId>
  <artifactId>openwa</artifactId>
  <version>0.6.0</version>
</dependency>
```

```java
import com.rmyndharis.openwa.OpenWAClient;
import com.rmyndharis.openwa.model.CreateSessionRequest;
import com.rmyndharis.openwa.model.MessageResponse;
import com.rmyndharis.openwa.model.SendTextRequest;
import com.rmyndharis.openwa.model.SessionResponse;

OpenWAClient client = new OpenWAClient("http://localhost:2785", "owa_k1_…");

SessionResponse session = client.sessions.create(CreateSessionRequest.builder().name("my-session").build());
client.sessions.start(session.id());
// Link the account before sending: scan sessions.getQrCode (or use sessions.requestPairingCode for a number with no linked session),
// then wait for status READY. An unlinked session answers the send with 409.
MessageResponse result = client.messages.sendText(session.id(),
    SendTextRequest.builder()
        .chatId("628123456789@c.us")
        .text("Hello from the OpenWA Java SDK!")
        .build());
System.out.println(result.messageId());
```

Requires Java 17+. Errors are a typed, unchecked hierarchy — branch with
`instanceof OpenWANotFoundError` / `OpenWAConflictError`. For testing, inject a
custom `HttpTransport` that records the request — no network. See
[`java/README.md`](java/README.md) for the full guide.

## Go

```bash
go get github.com/rmyndharis/OpenWA/sdk/go
```

```go
import (
    "context"
    "fmt"
    "log"

    openwa "github.com/rmyndharis/OpenWA/sdk/go"
)

client, err := openwa.New("http://localhost:2785", "owa_k1_…")
if err != nil {
    log.Fatal(err)
}

ctx := context.Background()
session, err := client.Sessions.Create(ctx, openwa.CreateSessionRequest{Name: "my-session"})
if err != nil {
    log.Fatal(err)
}
if _, err := client.Sessions.Start(ctx, session.ID); err != nil {
    log.Fatal(err)
}
// Link the account before sending: scan Sessions.QRCode (or use Sessions.RequestPairingCode for a number with no linked session),
// then wait for status "ready". An unlinked session answers the send with 409.
res, err := client.Messages.SendText(ctx, session.ID, openwa.SendTextRequest{
    ChatID: "628123456789@c.us",
    Text:   "Hello from the OpenWA Go SDK!",
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(res.MessageID)
```

Requires Go 1.22+. Stdlib-only, context-first. Errors are typed — match with
`errors.Is(err, openwa.ErrConflict)` or unwrap `*openwa.APIError` with
`errors.As`. Inject an `http.RoundTripper` with `openwa.WithTransport(...)` for
testing, retry, tracing, or metrics. See [`go/README.md`](go/README.md).

## Reliability & security

- **Use HTTPS in production.** The API key is sent as `X-API-Key` on every
  request and is bearer-equivalent — never send it over plaintext `http://`
  outside local development.
- **No automatic retries by default.** A failed request raises/throws
  immediately; wrap calls in your own backoff if you need retries (especially
  for `429`). The injectable transport (`fetch` / `transport` / `httpClient`) is
  the extension point for retry or observability middleware. The Go client is
  the exception: it ships an opt-in policy (`WithRetry(DefaultRetryPolicy())`)
  that retries idempotent requests on network errors and `429`/`5xx`, retries a
  `POST`/`PATCH` only on `429`/`503` (never after a network error), never
  retries a send-pacing `429` (`code: "SEND_PACING_LIMITED"`), honors
  `Retry-After`, and rewinds request bodies —
  still off unless you ask for it.
- **Webhook signatures.** Every SDK verifies a delivery's `X-OpenWA-Signature`
  against the raw request body: `verifyWebhookSignature` (JavaScript),
  `verify_webhook_signature` (Python), `VerifyWebhookSignature` (Go),
  `WebhookSignature.verify` (Java) and `WebhookSignature::verify` (PHP). The
  JavaScript, Python, Go and Java SDKs also type the delivery body as
  `WebhookDelivery`.
- **Redirects are never followed.** A `3xx` surfaces to the caller rather than
  being followed, so the API key is never re-sent to a redirect target.
- **Default timeout** is 30s (configurable). `0` turns it off in the JavaScript,
  Python and PHP clients; the Go client reads `0` as the 30s default and the Java
  builder refuses it. The JavaScript, PHP, Go and Java clients bound the whole call,
  response body included. The Python client's is per network operation
  (connect, read, write, pool), so a response that keeps trickling bytes is not
  cut off. Path segments (chat / message ids) are percent-encoded; a base-URL path
  prefix (e.g. behind a proxy at `/v1`) is preserved.

## Development

```bash
# JavaScript
cd sdk/javascript && npm test && npm run build && npm run smoke
# Python
cd sdk/python && python -m pytest -q
# PHP
cd sdk/php && composer install && ./vendor/bin/phpunit
# Java
cd sdk/java && mvn -B verify
# Go
cd sdk/go && go test -race ./... && go vet ./...
```

Each test suite mocks the HTTP layer and asserts on the exact path, so the
regression that originally shipped a broken `messages/text` path (the real path
is `messages/send-text`) can never recur silently.

---

### Overview (`charts/openwa/README.md`)

# openwa

Helm chart for [OpenWA](https://github.com/rmyndharis/OpenWA) — WhatsApp API.

> **Single instance only.** A session lease stops two pods from launching the same session,
> but API keys and the audit log live in each pod's own `main.sqlite` (a key revoked or
> narrowed on one pod stays valid on the others), and WS rate-limit buckets and in-flight
> bulk batches are process-local. Keep `replicaCount: 1`. See
> [docs/13-horizontal-scaling.md](../../docs/13-horizontal-scaling.md).

## Install

```bash
helm install openwa ./charts/openwa \
  --set secretEnv.API_MASTER_KEY=$(openssl rand -base64 32)
```

With `secretEnv.API_MASTER_KEY` left empty the app bootstraps a key into
`/app/data/.api-key` on the PVC; the post-install NOTES show how to read it.

## Configuration

`env` (→ ConfigMap) and `secretEnv` (→ Secret) are free-form maps: any variable
from the repo's `.env.example` works, e.g.:

```bash
helm install openwa ./charts/openwa \
  --set env.DATABASE_TYPE=postgres \
  --set env.DATABASE_HOST=postgres.default.svc \
  --set secretEnv.DATABASE_USERNAME=openwa \
  --set secretEnv.DATABASE_PASSWORD=...
```

Or bring your own Secret: `--set existingSecret=my-openwa-secret`.

The container port is fixed at 2785; do not set PORT in env (probes and the
Service targetPort are pinned to it) — service.port changes the Service port.

The kubelet startup probe allows `startupProbe.periodSeconds` x (`startupProbe.failureThreshold` - 1)
seconds for a boot, 295 by default. The first boot after an upgrade can build indexes on a large
`messages` table before the port opens: raise `startupProbe.failureThreshold`, or run the migrations
first ([docs/14-migration-guide.md](../../docs/14-migration-guide.md), section 14.5).

Upgrade with `helm upgrade openwa ./charts/openwa --reset-then-reuse-values` (Helm 3.14 or later), or with
`-f values.yaml`. Plain `--reuse-values` renders the new templates with the old release's defaults, which
for a release installed from 0.24.0 keeps a 45 s `terminationGracePeriodSeconds` and a capability list
without `KILL`, so every pod stop ends in `SIGKILL`.

All other values (`persistence`, `resources`, `ingress`, `serviceMonitor`, …) are
documented inline in [values.yaml](values.yaml). The chart does NOT bundle
PostgreSQL/Redis/MinIO — point `env` at your own, or stay on the SQLite default.

---

### Overview (`sdk/go/README.md`)

# OpenWA Go SDK

This document describes SDK 0.6.0. Send idempotency, webhook failure redrive, message-window filters, poll choices, last-message types, and the `event.response` webhook event name require OpenWA 0.25.0 or later.

Idiomatic Go client for the [OpenWA](https://github.com/rmyndharis/OpenWA) WhatsApp
API Gateway. Stdlib-only (no dependencies), context-first, with typed errors and
an injectable transport pipeline.

OpenWA is an independent project, not affiliated with or endorsed by WhatsApp or
Meta.

```bash
go get github.com/rmyndharis/OpenWA/sdk/go
```

Requires Go 1.22+.

## Quick start

```go
package main

import (
	"context"
	"log"

	openwa "github.com/rmyndharis/OpenWA/sdk/go"
)

func main() {
	client, err := openwa.New("http://localhost:2785", "owa_k1_…")
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()
	// Sessions are addressed by the UUID that Create returns, not by name. Create a
	// session once; afterwards, find its ID with Sessions.List and a Name filter.
	session, err := client.Sessions.Create(ctx, openwa.CreateSessionRequest{Name: "my-session"})
	if err != nil {
		log.Fatal(err)
	}
	if _, err := client.Sessions.Start(ctx, session.ID); err != nil {
		log.Fatal(err)
	}

	// Link the account before sending: scan Sessions.QRCode (or use Sessions.RequestPairingCode for a number with no linked session),
	// then wait for status "ready". An unlinked session answers the send with 409.
	res, err := client.Messages.SendText(ctx, session.ID, openwa.SendTextRequest{
		ChatID: "628123456789@c.us",
		Text:   "Hello from the OpenWA Go SDK!",
	})
	if err != nil {
		log.Fatal(err)
	}
	log.Println(res.MessageID)
}
```

## Design

- **Client entry point** — `openwa.New(baseURL, apiKey, opts...)` returns a
  `*Client`. Required credentials are positional; everything else is a functional
  Option. The client is safe for concurrent use.
- **Services by domain** — the API is grouped onto exported fields:
  `client.Sessions`, `client.Messages`, `client.Contacts`, `client.Groups`,
  `client.Webhooks`, `client.Chats`, `client.Status`, `client.Labels`,
  `client.Channels`, `client.Catalog`, `client.Templates`, `client.Health`,
  `client.Search`, `client.Auth`, `client.Profile`, `client.Calls`,
  `client.Media`.
- **Context-first** — every network method takes `ctx context.Context` as its
  first argument; the context bounds the request (and any retries).
- **Functional options + DI** — inject dependencies instead of relying on
  globals: `WithHTTPClient`, `WithTransport`, `WithLogger`, `WithRetry`,
  `WithMiddleware`, `WithTimeout`, `WithUserAgent`, `WithHeader`.
- **Typed errors** — match with `errors.Is` against the sentinels, or unwrap the
  concrete `*APIError` with `errors.As`.

## Configuration

| Option                  | Purpose                                                        |
| ----------------------- | -------------------------------------------------------------- |
| `WithTimeout(d)`        | Per-request timeout (default 30s).                             |
| `WithHTTPClient(hc)`    | Inject a preconfigured `*http.Client` (pool, jar, timeout).    |
| `WithTransport(rt)`     | Inject the base `http.RoundTripper` (proxy, TLS, test double). |
| `WithLogger(l)`         | Inject a `Logger` (default: no-op).                            |
| `WithRetry(p)`          | Enable automatic retries (off by default).                     |
| `WithMiddleware(mw...)` | Add transport middleware (tracing, metrics, auth).             |
| `WithUserAgent(ua)`     | Override the `User-Agent`.                                     |
| `WithHeader(k, v)`      | Add a default header on every request.                         |
| `WithInsecureHTTP()`    | Suppress the plaintext-`http://` warning.                      |

## Typed errors

```go
res, err := client.Messages.SendText(ctx, sessionID, req)
switch {
case errors.Is(err, openwa.ErrConflict):
	// 409 — a conflicting state, typically a session that is not "ready" yet:
	// retry once it is. A keyed send can also answer 409 for a pending or
	// uncertain outcome; APIError.Code tells them apart ("Send idempotency"
	// below says what to do).
case errors.Is(err, openwa.ErrNotFound):
	// 404 — unknown session/resource.
case err != nil:
	var apiErr *openwa.APIError
	if errors.As(err, &apiErr) {
		log.Printf("API %d: %s (body: %v)", apiErr.StatusCode, apiErr.Message, apiErr.Body)
	}
}
```

Sentinels: `ErrBadRequest` (400), `ErrUnauthorized` (401),
`ErrForbidden` (403), `ErrNotFound` (404), `ErrConflict` (409),
`ErrRateLimited` (429), `ErrNotImplemented` (501),
`ErrServiceUnavailable` (503). 503 is transient, but a catalog 503 can persist
because WhatsApp may never answer that query, so bound any retry. A 429 from
the global rate limiter lifts when its window expires (seconds for the
per-second tier, up to an hour for the hourly tier by default);
`APIError.RetryAfter` carries its `Retry-After` header, which `WithRetry` also
honors. A 429 whose `APIError.Code` is `"SEND_PACING_LIMITED"` is usually not
transient: do not retry it before `RetryAfter`, which then comes from the body:
a few seconds when only sends still in flight caused it, the rest of the failure
breaker's cooldown (`SEND_PACING_BREAKER_COOLDOWN_MS`, 15 minutes by default)
after a run of send failures, otherwise up to the next UTC day. `APIError.Header`
holds the response headers. A timeout surfaces as `*openwa.TimeoutError`. A 503
does not prove a write was never carried out: the
engine answers it when WhatsApp did not confirm in time, and the change may still
have been applied, so re-read the state before repeating it. In a routed
deployment a forward that fails before reaching the owner node answers 503, one
that fails after the request reached it answers 502 or 504, and a 503 from the
owner itself is relayed unchanged.

## Retries

Off by default. Opt in with a policy. Idempotent requests (GET, HEAD, OPTIONS,
PUT, DELETE) are retried on network errors and on the policy's statuses (default
429/500/502/503/504). A POST or PATCH (every send endpoint is a POST) is never
retried after a network error and is retried only on 429 or 503 (when the policy
lists them): a 500/502/504 can arrive after the message was already sent, so
replaying it could send it twice. A 429 whose body has `code: "SEND_PACING_LIMITED"` is
never retried, whatever the method: its delay is the body's `retryAfterSeconds`, which can be
hours. Backoff is exponential, `Retry-After` is honored, and request bodies are safely rewound
on each attempt.

```go
client, _ := openwa.New(baseURL, apiKey,
	openwa.WithRetry(openwa.DefaultRetryPolicy()),
)
```

## Middleware / transport pipeline

Inject cross-cutting concerns (tracing, metrics, custom auth) as `Middleware`.
The first middleware is the outermost layer. The SDK's own auth, logging, and
retry layers sit inside yours, so every attempt is authenticated and observable.

```go
tracing := func(next http.RoundTripper) http.RoundTripper {
	return openwa.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
		// start span, inject headers…
		return next.RoundTrip(req)
	})
}
client, _ := openwa.New(baseURL, apiKey, openwa.WithMiddleware(tracing))
```

## Dependency injection & testing

Inject a mock `http.RoundTripper` — no network, no global state:

```go
type mockRT struct{}
func (mockRT) RoundTrip(r *http.Request) (*http.Response, error) {
	return &http.Response{
		StatusCode: 200,
		Body:       io.NopCloser(strings.NewReader(`{"messageId":"m1","timestamp":1}`)),
		Header:     http.Header{},
	}, nil
}

client, _ := openwa.New("https://api.test", "key", openwa.WithTransport(mockRT{}))
```

## Escape hatch

For endpoints the typed services don't cover, use `client.Do`:

```go
var out map[string]any
err := client.Do(ctx, "GET", "/api/some/new/path", nil, nil, &out)
```

## Receiving webhooks

A webhook configured with a secret signs each delivery in its
`X-OpenWA-Signature` header. Check it with `VerifyWebhookSignature` against the
raw request body, exactly as received, and decode the JSON only after the check
passes: a re-serialized body can differ byte for byte and will not verify. The
helper returns `false` for a missing, malformed or non-matching signature.
`WebhookDelivery` types the decoded body.

```go
http.HandleFunc("/openwa/webhook", func(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if !openwa.VerifyWebhookSignature(body, r.Header.Get("X-OpenWA-Signature"), secret) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	var delivery openwa.WebhookDelivery
	if err := json.Unmarshal(body, &delivery); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	// Process delivery.Event and delivery.Data here.
	w.WriteHeader(http.StatusOK)
})
```

## Security & reliability

- **Use HTTPS in production.** The API key is sent as `X-API-Key` on every
  request and is bearer-equivalent. Over plaintext `http://` to a non-localhost
  host the SDK logs a warning (silence it with `WithInsecureHTTP`).
- **Redirects are never followed** — a `3xx` surfaces as an `*APIError` rather
  than re-sending the API key to the redirect target.
- Path segments (chat/message ids) are percent-encoded; a base-URL path prefix
  (e.g. behind a proxy at `/v1`) is preserved.
- **Empty and dot ids are refused.** An empty, `.` or `..` id returns an error
  and nothing is sent, so a proxy that resolves dot segments cannot turn the
  call into one on the parent resource. `Client.Do` refuses a `.` or `..`
  segment the same way but sends an empty one (a trailing slash) as written.

## Development

```bash
cd sdk/go
go test -race -cover ./...
go vet ./...
```

The `TestRouting` table asserts the exact method and path of every service call,
so a wrong path (the historical `/messages/text` vs `/messages/send-text`) fails
at test time.

## Releasing

There is no publish workflow, and none is possible: Go has no registry to push
to. The module proxy serves whatever a repository tag points at, so **tagging
is the release**.

The tag must carry the module's directory prefix, because the module lives in a
subdirectory rather than at the repository root:

```bash
# Correct — `sdk/go/` prefix, matching `module github.com/rmyndharis/OpenWA/sdk/go`
git tag sdk/go/v0.6.0 && git push origin sdk/go/v0.6.0
```

A bare `v0.6.0` tag is the _app_ version and does nothing for this module.
Without a prefixed tag, `go get` resolves a pseudo-version
(`v0.0.0-<date>-<commit>`) — usable, but callers cannot pin a release.

Cutting a release:

1. Bump `DefaultUserAgent` in `options.go` (it carries the SDK version and is
   sent on every request, so it drifts silently if only the tag moves).
2. Land that on `main`.
3. Tag that commit `sdk/go/v<version>` and push the tag.

> **A published version is immutable.** Once the module proxy has served
> `sdk/go/vX.Y.Z` it caches it permanently — deleting or moving the tag does not
> take it back, and the only remedy is to publish a higher version (and, if the
> bad one must be discouraged, a `retract` directive in `go.mod`). Tag a commit
> that is already green on `main`.

## Send idempotency

The example below requires SDK 0.6.0 or later and OpenWA 0.25.0 or later. SDK 0.5.1 does not expose this per-send key argument.

Supply a unique caller-owned key for one logical send and reuse it when retrying that same call. Keys contain 1-255 visible ASCII characters; unrelated calls must use different keys. The SDK forwards the key without changing its retry policy or generating one automatically. On the twelve routes below `net/http` may still send a keyed request once more after a dropped keep-alive connection; the gateway deduplicates it, so the call returns the first answer or a `409` `IDEMPOTENCY_KEY_IN_PROGRESS` to retry later with the same key.

```go
ctx := openwa.WithIdempotencyKey(context.Background(), "order-123")
_, err := client.Messages.SendText(ctx, sessionID, openwa.SendTextRequest{ChatID: "628123@c.us", Text: "Hello"})
```

The gateway supports text, image, video, audio, document, sticker, location, contact, template, poll, reply and forward sends. Completed calls replay their response; a 409 with the error `code` `IDEMPOTENCY_KEY_IN_PROGRESS` means the first request is still running, so retry with the same key later; a 409 `IDEMPOTENCY_OUTCOME_UNKNOWN` means the message may have been sent, so check the chat and, if it did not arrive, send again with a new key (the `code` tells them apart from the 409 for a session that is not connected). A changed request with the same key returns 422. The gateway retains keys for 24 hours. Bulk, product and button operations have no such guarantee. Omit the key to preserve existing behavior.

---

### Overview (`sdk/java/README.md`)

# OpenWA Java SDK

This document describes SDK 0.6.0. Send idempotency, webhook failure redrive, message-window filters, poll choices, last-message types, and the `event.response` webhook event name require OpenWA 0.25.0 or later.

Official Java client for [OpenWA](https://github.com/rmyndharis/OpenWA), the
open-source WhatsApp API Gateway. OpenWA is an independent project, not
affiliated with or endorsed by WhatsApp or Meta.

Hand-written against the exact API surface (paths, DTOs, response shapes) and
unit-tested with a mock HTTP transport that asserts on the precise request URL,
method, and body — so contract drift is caught at test time. Synchronous,
Java 17+, one runtime dependency ([Gson](https://github.com/google/gson)).

## Install

**Maven**

```xml
<dependency>
  <groupId>com.rmyndharis</groupId>
  <artifactId>openwa</artifactId>
  <version>0.6.0</version>
</dependency>
```

**Gradle**

```groovy
implementation 'com.rmyndharis:openwa:0.6.0'
```

## Quickstart

```java
import com.rmyndharis.openwa.OpenWAClient;
import com.rmyndharis.openwa.model.CreateSessionRequest;
import com.rmyndharis.openwa.model.MessageResponse;
import com.rmyndharis.openwa.model.SendTextRequest;
import com.rmyndharis.openwa.model.SessionResponse;

OpenWAClient client = new OpenWAClient("http://localhost:2785", "owa_k1_…");

// Sessions are addressed by the UUID that create() returns, not by name. Create a session once;
// afterwards, find its id with client.sessions.list(ListSessionsQuery.builder().name("my-session").build()).
SessionResponse session = client.sessions.create(CreateSessionRequest.builder().name("my-session").build());
client.sessions.start(session.id());

// Link the account before sending: scan sessions.getQrCode (or use sessions.requestPairingCode for a number with no linked session),
// then wait for status READY. An unlinked session answers the send with 409.
MessageResponse result = client.messages.sendText(session.id(),
    SendTextRequest.builder()
        .chatId("628123456789@c.us")
        .text("Hello from the OpenWA Java SDK!")
        .build());

System.out.println(result.messageId());
```

For full control over configuration (timeout, default headers, a custom
transport), build a `ClientConfig`:

```java
import com.rmyndharis.openwa.ClientConfig;
import java.time.Duration;

OpenWAClient client = new OpenWAClient(ClientConfig.builder()
    .baseUrl("https://wa.example.com")
    .apiKey("owa_k1_…")
    .timeout(Duration.ofSeconds(15))
    .build());
```

## Resources

The client exposes the same fluent resource surface as the JavaScript, Python,
and PHP SDKs:

`sessions` · `messages` · `contacts` · `groups` · `webhooks` · `chats` ·
`labels` · `channels` · `catalog` · `status` · `templates` · `health` · `search` ·
`profile` · `calls` · `media`,
plus `client.auth()`.

Deliberately not exposed, matching `docs/18-sdk-design.md`: `auth`/api-keys,
`audit`, `settings`, `stats`, `automation`, `infra`, `plugins`, the
`integration` management routes, `metrics`, `mcp`, `ingress` and `docker`.
Everything else the gateway publishes is exposed; see
[the SDK overview](../README.md#coverage).

`UpdateWebhookRequest` omits null fields, so `filters(null)` leaves a
webhook's filters unchanged. To remove every filter, pass
`new WebhookFilters(List.of())` instead.

A response enum value newer than this SDK decodes to that enum's `UNKNOWN`
constant (for example `SessionStatus.UNKNOWN`) rather than `null`, so give a
`switch` over one a `default` branch. `WebhookEvent`, `ProxyType` and
`MessageDirection` are also sent in requests and have no `UNKNOWN`: an
unrecognised value there still decodes to `null`.

## Error handling

Errors are a typed, unchecked hierarchy — branch with `instanceof` or on
`.status()`:

```java
import com.rmyndharis.openwa.errors.OpenWAConflictError;
import com.rmyndharis.openwa.errors.OpenWANotFoundError;

try {
    client.messages.sendText(sessionId, body);
} catch (OpenWAConflictError e) {
    // 409 — a conflicting state: typically the engine is not ready, or a keyed send is pending or uncertain;
    // e.code() tells them apart (see Send idempotency)
} catch (OpenWANotFoundError e) {
    // 404 — session or chat not found
}
```

| Class                           | HTTP | Meaning                                                                                 |
| ------------------------------- | ---- | --------------------------------------------------------------------------------------- |
| `OpenWAAuthError`               | 401  | Missing or invalid API key                                                              |
| `OpenWAForbiddenError`          | 403  | API key role or scope, or WhatsApp itself, refuses the call                             |
| `OpenWANotFoundError`           | 404  | Resource not found                                                                      |
| `OpenWAConflictError`           | 409  | Conflicting state, typically engine not ready, or an idempotency outcome (see `code()`) |
| `OpenWARateLimitError`          | 429  | Rate limited                                                                            |
| `OpenWANotImplementedError`     | 501  | Active engine does not support the call                                                 |
| `OpenWAServiceUnavailableError` | 503  | Engine did not confirm in time                                                          |
| `OpenWAApiError`                | —    | Any other non-2xx (carries `.status()`)                                                 |
| `OpenWATimeoutError`            | —    | Request exceeded the configured timeout                                                 |

All extend `OpenWAError` (a `RuntimeException`). 503 is transient, but a catalog 503 can persist because WhatsApp may never answer that query, so bound any retry. A 429 from the global rate limiter lifts when its window expires (seconds for the per-second tier, up to an hour for the hourly tier by default), and `retryAfterSeconds()` carries its `Retry-After` header. A 429 whose `code()` is `"SEND_PACING_LIMITED"` is usually not transient: do not retry it before `retryAfterSeconds()`, which then comes from the body: a few seconds when only sends still in flight caused it, the rest of the failure breaker's cooldown (`SEND_PACING_BREAKER_COOLDOWN_MS`, 15 minutes by default) after a run of send failures, otherwise up to the next UTC day. `headers()` returns the response headers. A 503 does not prove a write was never carried out: the engine answers it when WhatsApp did not confirm in time, and the change may still have been applied, so re-read the state before repeating it. In a routed deployment a forward that fails before reaching the owner node answers 503, one that fails after the request reached it answers 502 or 504, and a 503 from the owner itself is relayed unchanged.

## Receiving webhooks

A webhook configured with a secret signs each delivery in its
`X-OpenWA-Signature` header. Check it with `WebhookSignature.verify` against
the raw request body, exactly as received (a `byte[]`, or a `String` read as
UTF-8), and parse the JSON only after the check passes: a re-serialized body can
differ byte for byte and will not verify. The helper returns `false` (never
throws) for a missing, malformed or non-matching signature.
`com.rmyndharis.openwa.model.WebhookDelivery` types the parsed body.

```java
import com.google.gson.Gson;
import com.rmyndharis.openwa.WebhookSignature;
import com.rmyndharis.openwa.model.WebhookDelivery;
import java.nio.charset.StandardCharsets;

// In a servlet's doPost(request, response):
byte[] rawBody = request.getInputStream().readAllBytes();
if (!WebhookSignature.verify(rawBody, request.getHeader("X-OpenWA-Signature"), secret)) {
    response.setStatus(401);
    return;
}
WebhookDelivery delivery =
    new Gson().fromJson(new String(rawBody, StandardCharsets.UTF_8), WebhookDelivery.class);
// Process delivery.event() and delivery.data() here.
```

## Reliability & security

- **Use HTTPS in production.** The API key is sent as `X-API-Key` on every
  request and is bearer-equivalent — never send it over plaintext `http://`
  outside local development.
- **No automatic retries.** A failed request throws immediately; wrap calls in
  your own backoff if you need retries (especially for `429`). Inject a custom
  `HttpTransport` for retry or observability middleware.
- **Redirects are never followed.** A `3xx` surfaces as an `OpenWAApiError`
  rather than being followed, so the API key is never re-sent to a redirect
  target.
- **Default per-request timeout** is 30 s (configurable). It bounds the whole
  exchange, response body included, so raise `timeout` for large
  `messages.media` or `status.media` downloads over a slow link. Path segments
  (chat / message ids) are percent-encoded; a base-URL path prefix (e.g. behind
  a proxy at `/v1`) is preserved.
- **Empty and dot ids are refused.** An empty, `.` or `..` id throws
  `IllegalArgumentException` and nothing is sent, so a proxy that resolves dot
  segments cannot turn the call into one on the parent resource. The raw
  `request*` methods refuse a `.` or `..` segment the same way, and a path
  that does not begin with `/`, but send an empty one (a trailing slash) as
  written.

## Development

```bash
cd sdk/java
mvn -B verify        # compile + run the full test suite
```

Tests inject a recording `HttpTransport` and assert on the exact path — so the
regression that would ship a broken `messages/text` path (the real path is
`messages/send-text`) can never recur silently.

## Releasing

Publishing to Maven Central is done by the
[`java-sdk-release.yml`](../../.github/workflows/java-sdk-release.yml) workflow,
which deploys with `mvn -B -Prelease deploy`. The `release` profile attaches the
sources/javadoc jars, GPG-signs every artifact, and auto-publishes via the
Sonatype Central Publishing plugin — a plain `mvn verify` never runs any of it.

One-time setup (repository secrets):

- `MAVEN_CENTRAL_USERNAME` / `MAVEN_CENTRAL_PASSWORD` — the two halves of a
  Sonatype Central Portal user token for the verified `com.rmyndharis`
  namespace.
- `GPG_PRIVATE_KEY` — ASCII-armored signing key.
- `GPG_PASSPHRASE` — passphrase for that key.

A `verify` job runs the tests with none of them. The `publish` job that follows
checks all four secrets before it builds anything, and a missing one **fails
the run**. That is deliberate: skipping the deploy and reporting green is
indistinguishable from a real release in the run list, so configure the secrets
before tagging rather than tagging to see what happens.

Cutting a release:

1. Bump `<version>` in `pom.xml` and land it on `main`.
2. Tag that commit `java-sdk-v<version>` (e.g. `java-sdk-v0.6.0`) and push the
   tag. The SDK has its own version line — the monorepo's `v*` tags are the app
   version and never trigger an SDK publish.
3. The workflow builds, signs, and publishes; Central syncs within a few hours.

## Send idempotency

The example below requires SDK 0.6.0 or later and OpenWA 0.25.0 or later. SDK 0.5.1 does not expose this per-send key argument.

Supply a unique caller-owned key for one logical send and reuse it when retrying that same call. Keys contain 1-255 visible ASCII characters; unrelated calls must use different keys. The SDK forwards the key without changing its retry policy or generating one automatically.

```java
client.messages.sendText(sessionId, new SendTextRequest("628123@c.us", "Hello"), "order-123");
```

The gateway supports text, image, video, audio, document, sticker, location, contact, template, poll, reply and forward sends. Completed calls replay their response; a 409 with the error `code` `IDEMPOTENCY_KEY_IN_PROGRESS` means the first request is still running, so retry with the same key later; a 409 `IDEMPOTENCY_OUTCOME_UNKNOWN` means the message may have been sent, so check the chat and, if it did not arrive, send again with a new key (the `code` tells them apart from the 409 for a session that is not connected). A changed request with the same key returns 422. The gateway retains keys for 24 hours. Bulk, product and button operations have no such guarantee. Omit the key to preserve existing behavior.

---

### Overview (`sdk/javascript/README.md`)

# @rmyndharis/openwa

This document describes SDK 0.6.0. Send idempotency, webhook failure redrive, message-window filters, poll choices, last-message types, and the `event.response` webhook event name require OpenWA 0.25.0 or later.

Official JavaScript/TypeScript SDK for [OpenWA](https://github.com/rmyndharis/OpenWA), the open-source WhatsApp API Gateway. OpenWA is an independent project, not affiliated with or endorsed by WhatsApp or Meta.

Ships dual CJS + ESM builds with bundled type declarations.

## Install

```bash
npm install @rmyndharis/openwa
```

Requires Node.js >= 18 (relies on the global `fetch`).

## Usage

```typescript
import { OpenWAClient } from '@rmyndharis/openwa';

const client = new OpenWAClient({
  baseUrl: 'https://your-gateway.example.com',
  apiKey: 'owa_k1_…',
});

// Sessions are addressed by the UUID that create() returns, not by name. Create a session once;
// afterwards, find its id with client.sessions.list({ name: 'my-session' }).
const session = await client.sessions.create({ name: 'my-session' });
await client.sessions.start(session.id);

// Link the account before sending: scan sessions.getQrCode (or use sessions.requestPairingCode for a number with no linked session),
// then wait for status 'ready'. An unlinked session answers the send with 409.
const result = await client.messages.sendText(session.id, {
  chatId: '628123456789@c.us',
  text: 'Hello from the OpenWA SDK!',
});
console.log(result.messageId);
```

CommonJS consumers use `require('@rmyndharis/openwa')` identically.

## Messaging

> Voice notes: pass `ptt: true` to `sendAudio` to send a real WhatsApp voice note (PTT). Supply `audio/ogg; codecs=opus` audio for reliable playback; the server defaults the mimetype to that when `ptt` is set without one.

## Errors

Non-2xx responses throw a typed `OpenWAApiError` subclass (`OpenWAAuthError`,
`OpenWAForbiddenError`, `OpenWANotFoundError`, `OpenWAConflictError`,
`OpenWARateLimitError`, `OpenWANotImplementedError`,
`OpenWAServiceUnavailableError` for 503), each carrying `.status` and the
parsed `.body`. Timeouts throw `OpenWATimeoutError`. The SDK does **not**
retry — wrap calls with your own backoff if needed. 503 is transient, but a
catalog 503 can persist because WhatsApp may never answer that query, so bound
any retry. A 429 from the global rate limiter lifts when its window expires
(seconds for the per-second tier, up to an hour for the hourly tier by
default), and `.retryAfterSeconds` carries its `Retry-After` header. A 429 whose
`.code` is `"SEND_PACING_LIMITED"` is usually not transient: do not retry it
before `.retryAfterSeconds`, which then comes from the body: a few seconds when
only sends still in flight caused it, the rest of the failure breaker's cooldown
(`SEND_PACING_BREAKER_COOLDOWN_MS`, 15 minutes by default) after a run of send
failures, otherwise up to the next UTC day. Every API error also exposes the
response `.headers`. A 503 does not prove a write was never carried out: the
engine answers it when WhatsApp did not confirm in time,
and the change may still have been applied, so re-read the state before
repeating it. In a routed deployment a forward that fails before reaching the
owner node answers 503, one that fails after the request reached it answers 502
or 504, and a 503 from the owner itself is relayed unchanged.

## Receiving webhooks

A webhook configured with a secret signs each delivery in its
`X-OpenWA-Signature` header. Check it with `verifyWebhookSignature` against the
raw request body, exactly as received, and parse the JSON only after the check
passes: a re-serialized body can differ byte for byte and will not verify. The
helper resolves `false` (never throws) for a missing, malformed or non-matching
signature. `WebhookDelivery` types the parsed body.

```typescript
import express from 'express';
import { verifyWebhookSignature, type WebhookDelivery } from '@rmyndharis/openwa';

const app = express();

app.post('/openwa/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!(await verifyWebhookSignature(req.body, req.get('X-OpenWA-Signature'), secret))) {
    return res.status(401).send('Invalid signature');
  }
  const delivery = JSON.parse(req.body.toString('utf8')) as WebhookDelivery;
  // Process delivery.event and delivery.data here.
  return res.status(200).send('OK');
});
```

## Releasing

Publishing to npm is done by the
[`js-sdk-release.yml`](../../.github/workflows/js-sdk-release.yml) workflow,
which authenticates with **npm Trusted Publishing (OIDC)**. There is no npm
token in the workflow or in the repository secrets: npm mints a short-lived
credential from the GitHub OIDC token, and attaches build provenance
automatically. Nothing long-lived exists to leak, expire, or migrate when
2FA-bypass tokens lose direct publish in January 2027.

One-time setup, required **before** the first tag — on npmjs.com, open the
package settings for `@rmyndharis/openwa` and add a Trusted Publisher:

- Provider: **GitHub Actions**
- Organization: `rmyndharis`
- Repository: `OpenWA`
- Workflow filename: `js-sdk-release.yml` (the extension is part of the value)

There are no repository secrets to add. Until the trusted publisher exists npm
rejects the publish, so configure it first.

Cutting a release:

1. Bump `version` in `package.json` and land it on `main`.
2. Tag that commit `js-sdk-v<version>` (e.g. `js-sdk-v0.6.0`) and push the tag.
   The SDK has its own version line — the monorepo's `v*` tags are the app
   version and never trigger an SDK publish.
3. The workflow audits the SDK lockfile (`npm audit --audit-level=high`),
   re-runs the SDK's tests, typecheck, build and dual CJS/ESM smoke check
   (packaging plus requests through each build's client), then packs the
   tarball. A second job, the only one that can mint the publish credential,
   uploads that tarball and runs no project code: the published bytes are the
   ones those gates passed, not a later rebuild. It receives the tarball as a
   workflow artifact and passes its absolute path to `npm publish`, which takes
   one file, so a missing or a second tarball fails the release.

## License

MIT

## Send idempotency

The example below requires SDK 0.6.0 or later and OpenWA 0.25.0 or later. SDK 0.5.1 does not expose this per-send key argument.

Supply a unique caller-owned key for one logical send and reuse it when retrying that same call. Keys contain 1-255 visible ASCII characters; unrelated calls must use different keys. The SDK forwards the key without changing its retry policy or generating one automatically.

```javascript
await client.messages.sendText(sessionId, { chatId: '628123@c.us', text: 'Hello' }, 'order-123');
```

The gateway supports text, image, video, audio, document, sticker, location, contact, template, poll, reply and forward sends. Completed calls replay their response; a 409 with the error `code` `IDEMPOTENCY_KEY_IN_PROGRESS` means the first request is still running, so retry with the same key later; a 409 `IDEMPOTENCY_OUTCOME_UNKNOWN` means the message may have been sent, so check the chat and, if it did not arrive, send again with a new key (the `code` tells them apart from the 409 for a session that is not connected). A changed request with the same key returns 422. The gateway retains keys for 24 hours. Bulk, product and button operations have no such guarantee. Omit the key to preserve existing behavior.

---

### Overview (`sdk/php/README.md`)

# rmyndharis/openwa

This document describes SDK 0.6.0. Send idempotency, webhook failure redrive, message-window filters, poll choices, last-message types, and the `event.response` webhook event name require OpenWA 0.25.0 or later.

Official PHP SDK for [OpenWA](https://github.com/rmyndharis/OpenWA), the open-source WhatsApp API Gateway. OpenWA is an independent project, not affiliated with or endorsed by WhatsApp or Meta.

A synchronous client built on [Guzzle](https://docs.guzzlephp.org/), PSR-4 autoloaded.

## Install

```bash
composer require rmyndharis/openwa
```

Requires PHP 8.1+ and Guzzle 7.9+ or 8.x. The namespace is `OpenWA\`.

## Usage

```php
<?php
require 'vendor/autoload.php';

use OpenWA\Client;

$client = new Client([
    'baseUrl' => 'https://your-gateway.example.com',
    'apiKey'  => 'owa_k1_…',
]);

// Sessions are addressed by the UUID that create() returns, not by name. Create a session once;
// afterwards, find its id with $client->sessions->list(['name' => 'my-session']).
$session = $client->sessions->create(['name' => 'my-session']);
$client->sessions->start($session['id']);

// Link the account before sending: scan sessions->getQrCode (or use sessions->requestPairingCode for a number with no linked session),
// then wait for status 'ready'. An unlinked session answers the send with 409.
$result = $client->messages->sendText($session['id'], [
    'chatId' => '628123456789@c.us',
    'text'   => 'Hello from the OpenWA PHP SDK!',
]);
echo $result['messageId'];
```

For tests, inject a Guzzle client whose handler is a `MockHandler` — no network, no global state:

```php
$client = new Client([
    'baseUrl'    => 'http://localhost',
    'apiKey'     => 'k',
    'httpClient' => $mockGuzzleClient,
]);
```

The optional `defaultHeaders` map accepts strings, scalar values, null, and non-empty lists of those values. Numbers, booleans, and null are converted to text. An empty list is rejected by header name before any request is sent because Guzzle 8 cannot represent a header with zero values. Use `''` for an empty header value.

## Messaging

> Voice notes: pass `'ptt' => true` to `sendAudio` to send a real WhatsApp voice note (PTT). Supply `audio/ogg; codecs=opus` audio for reliable playback; the server defaults the mimetype to that when `ptt` is set without one.

## Errors

A non-2xx response throws a typed `OpenWA\Exceptions\OpenWAApiException` subclass —
`OpenWAAuthException` (401), `OpenWAForbiddenException` (403), `OpenWANotFoundException` (404),
`OpenWAConflictException` (409), `OpenWARateLimitException` (429),
`OpenWANotImplementedException` (501), `OpenWAServiceUnavailableException` (503) — each
exposing `getStatus()` and the parsed `getBody()`. A timeout throws `OpenWATimeoutException`.
503 is transient, but a catalog 503 can persist because WhatsApp may never answer that query,
so bound any retry. A 429 from the global rate limiter lifts when its window expires (seconds for
the per-second tier, up to an hour for the hourly tier by default), and `getRetryAfterSeconds()`
carries its `Retry-After` header. A 429 whose `getErrorCode()` is `"SEND_PACING_LIMITED"` is usually
not transient: do not retry it before `getRetryAfterSeconds()`, which then comes from the body: a
few seconds when only sends still in flight caused it, the rest of the failure breaker's cooldown
(`SEND_PACING_BREAKER_COOLDOWN_MS`, 15 minutes by default) after a run of send failures, otherwise
up to the next UTC day. `getHeaders()` returns the response headers. A 503 does not prove a write
was never carried out: the engine answers it when WhatsApp did not confirm in time, and the change
may still have been applied, so re-read the state before repeating it. In a routed deployment a
forward that fails before reaching the owner node answers 503, one that fails after the request
reached it answers 502 or 504, and a 503 from the owner itself is relayed unchanged.

```php
use OpenWA\Exceptions\OpenWANotFoundException;

try {
    $client->sessions->get('00000000-0000-0000-0000-000000000000');
} catch (OpenWANotFoundException $e) {
    echo $e->getStatus();  // 404
}
```

## Receiving webhooks

A webhook configured with a secret signs each delivery in its `X-OpenWA-Signature` header. Check it
with `WebhookSignature::verify` against the raw request body, exactly as received, and decode the
JSON only after the check passes: a re-encoded body can differ byte for byte and will not verify.
The helper returns `false` for a missing, malformed or non-matching signature.

```php
use OpenWA\WebhookSignature;

$raw = file_get_contents('php://input');
if (!WebhookSignature::verify($raw, $_SERVER['HTTP_X_OPENWA_SIGNATURE'] ?? null, $secret)) {
    http_response_code(401);
    exit;
}
$delivery = json_decode($raw, true);
// Process $delivery['event'] and $delivery['data'] here.
```

## Notes

- **Use HTTPS in production** — the API key is sent as `X-API-Key` and is bearer-equivalent.
  Over plaintext `http://` to a non-localhost host the client writes a warning with `error_log()`;
  pass `'allowInsecureHttp' => true` to skip it (for example on a private Docker network).
- The SDK does **not** retry, and **never follows redirects** (so the key is never re-sent to
  a redirect target). Path segments are percent-encoded; a base-URL path prefix (e.g. behind a
  reverse proxy) is preserved.
- Escape hatch for endpoints the SDK does not wrap:
  `$client->request($method, $path, $query, $body)`.

## Releasing

Packagist installs this SDK from the mirror repository
[`rmyndharis/openwa-php`](https://github.com/rmyndharis/openwa-php), not from the
monorepo — Composer needs `composer.json` at a repository root and does not
support subdirectories. Two workflows keep that mirror correct:

- [`split-php-sdk.yml`](../../.github/workflows/split-php-sdk.yml) syncs the
  mirror's `main` on every push that touches `sdk/php/**`, which is what
  Packagist's `dev-main` follows.
- [`php-sdk-release.yml`](../../.github/workflows/php-sdk-release.yml) cuts a
  versioned release by pushing a **tag** to the mirror. Packagist derives
  versions from that repository's tags, so a tag in this monorepo alone
  publishes nothing.

There is no `version` field in `composer.json`, and there should not be —
Composer takes the version from the tag, and a hardcoded field drifts. **The tag
is the version.**

Cutting a release:

1. If the minor line changes, update `extra.branch-alias.dev-main` in
   `composer.json` (e.g. `0.1.x-dev` → `0.2.x-dev`) and land it on `main`. The
   release workflow refuses to publish when the alias does not match the tag.
2. Tag that commit `php-sdk-v<version>` (e.g. `php-sdk-v0.6.0`) and push the
   tag. The SDK has its own version line — the monorepo's `v*` tags are the app
   version and never trigger an SDK release.
3. The workflow runs the test suite, then tags the mirror `<version>` (no `v`
   prefix, matching the existing tags there). Packagist picks it up from its
   GitHub hook within a minute or two.

> **A released version is effectively immutable.** Packagist caches a tag's
> contents, so moving one does not reliably reach anyone who already resolved
> it. The workflow refuses to overwrite an existing mirror tag — bump the
> version instead of re-tagging.

Requires the `PHP_SDK_SPLIT_TOKEN` secret, the same one the mirror workflow
uses. Unlike the mirror workflow, which skips with a notice when the token is
absent, the release **fails**: a release that silently publishes nothing looks
exactly like one that worked.

## License

MIT

## Send idempotency

The example below requires SDK 0.6.0 or later and OpenWA 0.25.0 or later. SDK 0.5.1 does not expose this per-send key argument.

Supply a unique caller-owned key for one logical send and reuse it when retrying that same call. Keys contain 1-255 visible ASCII characters; unrelated calls must use different keys. The SDK forwards the key without changing its retry policy or generating one automatically.

```php
$client->messages->sendText($sessionId, ['chatId' => '628123@c.us', 'text' => 'Hello'], 'order-123');
```

The gateway supports text, image, video, audio, document, sticker, location, contact, template, poll, reply and forward sends. Completed calls replay their response; a 409 whose `getErrorCode()` is `IDEMPOTENCY_KEY_IN_PROGRESS` means the first request is still running, so retry with the same key later; a 409 `IDEMPOTENCY_OUTCOME_UNKNOWN` means the message may have been sent, so check the chat and, if it did not arrive, send again with a new key (`getErrorCode()` tells them apart from the 409 for a session that is not connected). A changed request with the same key returns 422. The gateway retains keys for 24 hours. Bulk, product and button operations have no such guarantee. Omit the key to preserve existing behavior.

---

### Overview (`sdk/python/README.md`)

# rmyndharis-openwa

This document describes SDK 0.6.0. Send idempotency, webhook failure redrive, message-window filters, poll choices, last-message types, and the `event.response` webhook event name require OpenWA 0.25.0 or later.

Official Python SDK for [OpenWA](https://github.com/rmyndharis/OpenWA), the open-source WhatsApp API Gateway. OpenWA is an independent project, not affiliated with or endorsed by WhatsApp or Meta.

A synchronous client built on [httpx](https://www.python-httpx.org/), with bundled type hints (PEP 561).

## Install

```bash
pip install rmyndharis-openwa
```

Requires Python 3.9+. The importable module is `openwa`.

## Usage

```python
from openwa import OpenWAClient

client = OpenWAClient(
    base_url="https://your-gateway.example.com",
    api_key="owa_k1_…",
)

# Sessions are addressed by the UUID that create() returns, not by name. Create a session once;
# afterwards, find its id with client.sessions.list({"name": "my-session"}).
session = client.sessions.create({"name": "my-session"})
client.sessions.start(session["id"])

# Link the account before sending: scan sessions.get_qr_code (or use sessions.request_pairing_code for a number with no linked session),
# then wait for status "ready". An unlinked session answers the send with 409.
result = client.messages.send_text(session["id"], {
    "chatId": "628123456789@c.us",
    "text": "Hello from the OpenWA Python SDK!",
})
print(result["messageId"])
```

The client is also a context manager (it closes the underlying connection pool on exit):

```python
with OpenWAClient(base_url="…", api_key="…") as client:
    client.messages.send_text(session_id, {"chatId": "…@c.us", "text": "hi"})
```

For tests, pass an httpx transport — no global monkey-patching required:

```python
import httpx
client = OpenWAClient(base_url="…", api_key="…", transport=httpx.MockTransport(handler))
```

## Search

`GET /search` is wrapped as `client.search.search(params)`. Only `q` is required;
the rest (`sessionId`, `chatId`, `direction`, `type`, `from`, `dateFrom`,
`dateTo`, `limit`, `offset`) are optional. `dateFrom` / `dateTo` are epoch-ms.
The active search provider (built-in DB full-text, or a plugin) answers; if none
is configured the server returns 501.

```python
res = client.search.search({"q": "invoice", "sessionId": session_id, "limit": 20})
for hit in res["hits"]:
    print(hit["snippet"], hit.get("score"))
```

`score` is provider-dependent and optional: the built-in provider on SQLite leaves it out for a key restricted
to sessions or chats, so read it with `get`.

## Messaging

> Voice notes: pass `ptt=True` inside the body dict to `send_audio` to send a real WhatsApp voice note (PTT). Supply `audio/ogg; codecs=opus` audio for reliable playback; the server defaults the mimetype to that when `ptt` is set without one.

## Errors

A non-2xx response raises a typed `OpenWAApiError` subclass — `OpenWAAuthError` (401),
`OpenWAForbiddenError` (403), `OpenWANotFoundError` (404), `OpenWAConflictError` (409),
`OpenWARateLimitError` (429), `OpenWANotImplementedError` (501),
`OpenWAServiceUnavailableError` (503) — each carrying `.status` and the parsed `.body`. A
timeout raises `OpenWATimeoutError`. 503 is transient, but a catalog 503 can persist because
WhatsApp may never answer that query, so bound any retry. A 429 from the global rate limiter
lifts when its window expires (seconds for the per-second tier, up to an hour for the hourly
tier by default), and `.retry_after_seconds` carries its `Retry-After` header. A 429 whose
`.code` is `"SEND_PACING_LIMITED"` is usually not transient: do not retry it before
`.retry_after_seconds`, which then comes from the body: a few seconds when only sends still in
flight caused it, the rest of the failure breaker's cooldown (`SEND_PACING_BREAKER_COOLDOWN_MS`,
15 minutes by default) after a run of send failures, otherwise up to the next UTC day. Every API
error also exposes the response `.headers`. A 503 does not prove a write was never carried out: the
engine answers it when WhatsApp did not confirm in time, and the change may still have been applied,
so re-read the state before repeating it. In a routed deployment a forward that fails before
reaching the owner node answers 503, one that fails after the request reached it answers 502 or 504,
and a 503 from the owner itself is relayed unchanged.

```python
from openwa import OpenWANotFoundError

try:
    client.sessions.get("00000000-0000-0000-0000-000000000000")
except OpenWANotFoundError as e:
    print(e.status)  # 404
```

## Notes

- **Use HTTPS in production** — the API key is sent as `X-API-Key` and is bearer-equivalent.
- The SDK does **not** retry, and **never follows redirects** (so the key is never re-sent to
  a redirect target). Path segments are percent-encoded; a base-URL path prefix (e.g. behind a
  reverse proxy) is preserved.
- Escape hatch for endpoints the SDK does not wrap:
  `client.request(method, path, query=…, body=…)`.

## Receiving webhooks

A webhook configured with a secret signs each delivery in its `X-OpenWA-Signature` header. Check it
with `verify_webhook_signature` against the raw, unparsed request body (`bytes` or `str`), exactly
as received, and parse the JSON only after the check passes: a re-serialized body can differ byte
for byte and will not verify. The helper returns `False` (never raises) for a missing, malformed or
non-matching signature. `WebhookDelivery` (in `openwa.types`) types the parsed body.

```python
import json

from flask import Flask, request
from openwa import verify_webhook_signature
from openwa.types import WebhookDelivery

app = Flask(__name__)


@app.post("/openwa/webhook")
def openwa_webhook():
    raw_body = request.get_data()
    if not verify_webhook_signature(raw_body, request.headers.get("X-OpenWA-Signature"), secret):
        return "Invalid signature", 401
    delivery: WebhookDelivery = json.loads(raw_body)
    # Process delivery["event"] and delivery["data"] here.
    return "OK", 200
```

## Releasing

Publishing to PyPI is done by the
[`python-sdk-release.yml`](../../.github/workflows/python-sdk-release.yml)
workflow, which authenticates with **PyPI Trusted Publishing (OIDC)**. There is
no PyPI token in the workflow or in the repository secrets: PyPI mints a
short-lived credential from the GitHub OIDC token, so nothing long-lived exists
to leak or rotate.

One-time setup, required **before** the first tag — on pypi.org, open the
project's publishing settings and add a GitHub trusted publisher:

- Owner: `rmyndharis`
- Repository: `OpenWA`
- Workflow name: `python-sdk-release.yml`

There are no repository secrets to add. Until the trusted publisher exists PyPI
rejects the upload, so configure it first.

Cutting a release:

1. Bump `version` in `pyproject.toml` and land it on `main`.
2. Tag that commit `py-sdk-v<version>` (e.g. `py-sdk-v0.6.0`) and push the tag.
   The SDK has its own version line — the monorepo's `v*` tags are the app
   version and never trigger an SDK publish.
3. The workflow re-runs the test suite, builds the sdist and wheel, and
   uploads. The artifacts published are the ones those tests passed against.

## License

MIT

## Send idempotency

The example below requires SDK 0.6.0 or later and OpenWA 0.25.0 or later. SDK 0.5.1 does not expose this per-send key argument.

Supply a unique caller-owned key for one logical send and reuse it when retrying that same call. Keys contain 1-255 visible ASCII characters; unrelated calls must use different keys. The SDK forwards the key without changing its retry policy or generating one automatically.

```python
client.messages.send_text(session_id, {"chatId": "628123@c.us", "text": "Hello"}, idempotency_key="order-123")
```

The gateway supports text, image, video, audio, document, sticker, location, contact, template, poll, reply and forward sends. Completed calls replay their response; a 409 with the error `code` `IDEMPOTENCY_KEY_IN_PROGRESS` means the first request is still running, so retry with the same key later; a 409 `IDEMPOTENCY_OUTCOME_UNKNOWN` means the message may have been sent, so check the chat and, if it did not arrive, send again with a new key (the `code` tells them apart from the 409 for a session that is not connected). A changed request with the same key returns 422. The gateway retains keys for 24 hours. Bulk, product and button operations have no such guarantee. Omit the key to preserve existing behavior.

---

### Overview (`docs/examples/n8n/README.md`)

# n8n Workflow: OpenWA to Discord

This example demonstrates how to receive OpenWA webhooks using n8n and forward incoming WhatsApp messages to a Discord channel using n8n's built-in HTTP Request node. No custom n8n node package is required.

## Prerequisites

1. An n8n instance.
2. A Discord Webhook URL.
3. OpenWA configured and running.

## Setup

1. **Import the Workflow**: Open n8n, click the "Import from file" option, and select the [`workflow.json`](./workflow.json) file.
2. **Set the Discord Webhook URL**: Open the Post to Discord node and replace the placeholder URL with your Discord webhook URL. Paste the URL itself rather than reading it from `$env`: n8n 2.0 and later block `$env` in expressions by default.
3. **Protect the Webhook**: Open the OpenWA Webhook node, set **Authentication** to **Header Auth**, and create a Header Auth credential with **Name** `X-Webhook-Token` and a long random **Value**. Without it, anyone who learns the URL can post into your Discord channel. Header Auth only guards the n8n URL: the message text still comes from any WhatsApp sender, so the workflow sends it with Discord's `allowed_mentions: { "parse": [] }` and a message cannot ping `@everyone`, `@here`, a role, or a user.
4. **Get the Webhook URL**: Publish the workflow (activate it on n8n 1.x) and copy the **Production URL** from the OpenWA Webhook node (e.g., `https://n8n.example.com/webhook/openwa-discord`).
5. **Register in OpenWA**: Register the Production URL for the `message.received` event, with the same header and value:

   ```bash
   curl -X POST https://openwa.example.com/api/sessions/<sessionId>/webhooks \
     -H "X-API-Key: <your API key>" \
     -H "Content-Type: application/json" \
     -d '{
       "url": "https://n8n.example.com/webhook/openwa-discord",
       "events": ["message.received"],
       "headers": { "X-Webhook-Token": "<the same random value>" }
     }'
   ```

   If n8n is only reachable at a private address (`localhost`, a Docker service name), add its host to `SSRF_ALLOWED_HOSTS` on the OpenWA server, or the registration is refused with `400`. The other webhook options, including a `secret` that signs every delivery, are in the [API Specification](../../06-api-specification.md) and [Webhook Signature Verification](../webhook-signature-verification.md).

### API Key Scope

The API key used to register the webhook in OpenWA needs the **`OPERATOR`** role (or higher, such as `ADMIN`). A `VIEWER` key cannot register webhooks.

### Webhook Payload Expected

This workflow expects the standard OpenWA webhook payload for the `message.received` event.

```json
{
  "event": "message.received",
  "timestamp": "2024-01-15T10:30:00Z",
  "sessionId": "default",
  "idempotencyKey": "msg_default_3EB0F5A2B4C..._f1e2d3c4-b5a6-7890-1234-567890abcdef",
  "deliveryId": "dlv_0f8c1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
  "data": {
    "id": "3EB0F5A2B4C...",
    "chatId": "628123456789@c.us",
    "from": "628123456789@c.us",
    "body": "Hello!",
    "type": "text",
    "timestamp": 1705312200
  }
}
```

The workflow posts `data.author || data.from` as the sender and `data.body` as the message. In a group, `from` is the group and `author` is the member who sent the message; a direct message has no `author`, so `from` is used. Discord rejects a message over 2000 characters, and n8n has already answered OpenWA by then, so nothing would retry it; the workflow therefore cuts a longer message at 2000 characters.

---

### 01 Project Overview (`docs/01-project-overview.md`)

# 01 - Project Overview

## 1.1 Executive Summary

**OpenWA** is an open-source platform that provides an HTTP API for WhatsApp integration. This project is built as a free and fully open-source alternative to paid solutions such as WAHA Plus.

### Core Values

```
┌─────────────────────────────────────────────────────────────┐
│                      OpenWA Values                          │
├─────────────────────────────────────────────────────────────┤
│  🆓 100% Free         │  No paywalled features               │
│  📖 Open Source       │  MIT License, fork friendly          │
│  🔒 Self-Hosted       │  Data stays on your own server       │
│  🚀 Production Ready  │  Scalable & reliable                 │
│  🎯 Developer First   │  Simple, intuitive API               │
└─────────────────────────────────────────────────────────────┘
```

## 1.2 Vision & Mission

### Vision

To become the most reliable open-source WhatsApp API gateway for production use.

### Mission

1. Provide a complete and free WhatsApp REST API
2. Build an active open-source community
3. Deliver excellent documentation and tooling
4. Ensure user data security and privacy

## 1.3 Problem Statement

```mermaid
flowchart LR
    subgraph Problems["❌ Current Problems"]
        P1[Existing solutions are paid]
        P2[Critical features are paywalled]
        P3[Vendor lock-in]
        P4[Data privacy concerns]
    end

    subgraph Solution["✅ OpenWA Solution"]
        S1[100% Free & Open Source]
        S2[All features included]
        S3[Self-hosted, no lock-in]
        S4[Data stays on your server]
    end

    P1 --> S1
    P2 --> S2
    P3 --> S3
    P4 --> S4
```

### Pain Points Addressed

| Pain Point                          | OpenWA Solution              |
| ----------------------------------- | ---------------------------- |
| WAHA Plus charges for multi-session | Free unlimited multi-session |
| Dashboard only in paid tiers        | Free dashboard               |
| PostgreSQL support is paid          | PostgreSQL included          |
| Limited webhook management          | Full webhook management      |
| No source code access               | Full source code available   |

## 1.4 Project Goals

### Primary Goals

```mermaid
mindmap
  root((OpenWA Goals))
    Functional
      Complete REST API
      Multi-session
      Webhook support
      Media handling
    Technical
      High performance
      Scalable
      Secure
      Well documented
    Community
      Active contributors
      Strong documentation
      Example projects
      Support forum
```

### Success Metrics

| Metric              | Target (6 months) | Target (1 year) |
| ------------------- | ----------------- | --------------- |
| GitHub Stars        | 500+              | 2000+           |
| Active Contributors | 10+               | 30+             |
| Docker Pulls        | 5,000+            | 20,000+         |
| Production Users    | 100+              | 500+            |
| API Uptime          | 99.5%             | 99.9%           |

## 1.5 Project Scope

### In Scope ✅

```
Phase 1 (MVP)
├── REST API
│   ├── Session management (create, delete, status)
│   ├── QR code authentication
│   ├── Send messages (text, image, video, audio, document)
│   ├── Receive messages via webhook
│   ├── Contact management
│   └── Basic group operations
├── Infrastructure
│   ├── Docker support
│   ├── SQLite database
│   ├── Swagger documentation
│   └── Health check endpoints
└── Documentation
    ├── API documentation
    ├── Setup guide
    └── Basic examples

Phase 2 (Production Ready)
├── Multi-session support
├── PostgreSQL support
├── Web Dashboard
├── Webhook management UI
├── Message queue (Redis/BullMQ)
├── Rate limiting
└── Authentication (API Key)

Phase 3 (Advanced)
├── Group management (create, modify, participants)
├── Channels/Newsletter support
├── Label management
├── Status/Stories
├── Proxy per session
├── Horizontal scaling design reference
└── Metrics & monitoring
```

### Out of Scope ❌

- WhatsApp Business API (official Meta API)
- Mobile app
- End-user chat interface
- Message scheduling (to be a separate plugin)
- CRM features
- Billing/payment integration

## 1.6 Stakeholders

```mermaid
flowchart TB
    subgraph Primary["Primary Stakeholders"]
        D[Developers]
        B[Small Businesses]
        S[Startups]
    end

    subgraph Secondary["Secondary Stakeholders"]
        C[Community Contributors]
        I[Integration Partners]
        O[Open Source Community]
    end

    subgraph Users["End Users"]
        U1[Self-hosted Users]
        U2[Docker Users]
        U3[Cloud Deploy Users]
    end

    Primary --> Users
    Secondary --> Primary
```

### Target Users

1. **Developers** - Need WhatsApp integration for their applications
2. **Small Businesses** - Need customer service automation
3. **Startups** - Need cost-effective solutions
4. **Agencies** - Manage multiple WhatsApp accounts

## 1.7 Competitive Analysis

```mermaid
quadrantChart
    title Feature vs Cost Comparison
    x-axis Low Cost --> High Cost
    y-axis Basic Features --> Advanced Features
    quadrant-1 Premium Solutions
    quadrant-2 Our Target
    quadrant-3 Limited Free
    quadrant-4 Expensive Basic

    WAHA Plus: [0.7, 0.8]
    WAHA Core: [0.2, 0.4]
    Whapi.cloud: [0.8, 0.75]
    Green API: [0.6, 0.6]
    OpenWA: [0.1, 0.85]
```

### Feature Comparison

| Feature       | OpenWA | WAHA Core | WAHA Plus | Whapi.cloud |
| ------------- | ------ | --------- | --------- | ----------- |
| Price         | Free   | Free      | $50+/mo   | $30+/mo     |
| Open Source   | ✅     | ❌        | ❌        | ❌          |
| Multi-session | ✅     | Limited   | ✅        | ✅          |
| Dashboard     | ✅     | ❌        | ✅        | ✅          |
| PostgreSQL    | ✅     | ❌        | ✅        | N/A         |
| Webhook UI    | ✅     | ❌        | ✅        | ✅          |
| Self-hosted   | ✅     | ✅        | ✅        | ❌          |
| Source code   | ✅     | ❌        | ❌        | ❌          |

## 1.8 Technology Decisions

### Why These Technologies?

```mermaid
flowchart TB
    subgraph Runtime["Runtime & Framework"]
        N[Node.js 22 LTS] --> |"TypeScript support, async/await"| NE[NestJS]
        NE --> |"Modular, scalable, DI"| API[REST API]
    end

    subgraph Engine["WhatsApp Engine (pluggable)"]
        WW[whatsapp-web.js] --> |"Puppeteer based"| P[More stealth]
        P --> |"Real browser fingerprint"| S[Lower ban risk]
        BA[baileys] --> |"Browser-free / WebSocket"| BL[Lower resource usage]
    end

    subgraph Storage["Data Storage"]
        SQ[SQLite] --> |"Development"| DEV[Easy setup]
        PG[PostgreSQL] --> |"Production"| PROD[Scalable]
    end

    subgraph Frontend["Dashboard"]
        R[React] --> V[Vite]
        V --> C[Bespoke CSS]
        C --> L[Lucide Icons]
        L --> UI[Modern UI]
    end
```

### Technology Stack Summary

| Layer         | Technology                           | Rationale                                                                               |
| ------------- | ------------------------------------ | --------------------------------------------------------------------------------------- |
| Runtime       | Node.js 22 LTS                       | Stable, long-term support                                                               |
| Language      | TypeScript                           | Type safety, better developer experience                                                |
| Framework     | NestJS                               | Enterprise-grade, modular                                                               |
| WA Engine     | whatsapp-web.js (default) or baileys | Pluggable via `ENGINE_TYPE` env var; wwebjs = Puppeteer/stealth, baileys = browser-free |
| Browser       | Puppeteer/Chrome                     | Used by default (whatsapp-web.js) engine; not required for baileys engine               |
| Database      | SQLite (default) / PostgreSQL        | Zero-config default, PostgreSQL for scaling                                             |
| Cache         | Redis                                | Fast, pub/sub support                                                                   |
| Queue         | BullMQ                               | Reliable job processing                                                                 |
| Dashboard     | React + Vite                         | Fast, modern                                                                            |
| Styling       | Bespoke CSS modules/stylesheets      | Lightweight dashboard styling without Tailwind                                          |
| UI Components | Custom React components + Lucide     | Accessible, polished                                                                    |
| Container     | Docker                               | Portable, consistent                                                                    |

## 1.9 Constraints & Assumptions

### Constraints

1. **Technical**
   - WhatsApp Web protocol can change at any time
   - Puppeteer requires significant resources (~300-500MB RAM per session) when using the default whatsapp-web.js engine; the baileys engine is browser-free and has a much lower footprint
   - WhatsApp rate limiting

2. **Legal**
   - Unofficial API, not affiliated with WhatsApp or Meta (see the [disclaimer](../README.md#disclaimer))
   - Users are responsible for compliant usage

3. **Resource**
   - Open-source project, dependent on community contributions
   - Limited maintainer capacity initially

### Assumptions

1. Users have servers with at least 2GB RAM
2. Users are familiar with Docker or Node.js
3. Users understand the risks of using an unofficial API
4. WhatsApp Web will remain available

## 1.10 Document History

| Version | Date       | Author | Changes          |
| ------- | ---------- | ------ | ---------------- |
| 1.0     | 2026-02-02 | Team   | Initial document |

---

<div align="center">

[Documentation Index](./README.md) · [Next: 02 - Requirements Specification →](./02-requirements-specification.md)

</div>

---

### 02 Requirements Specification (`docs/02-requirements-specification.md`)

# 02 - Requirements Specification

## 2.1 Overview

This document defines the functional and non-functional requirements for OpenWA.

## 2.2 Functional Requirements

### 2.2.1 Session Management

```mermaid
flowchart LR
    subgraph Session["Session Lifecycle"]
        C[Create] --> A[Authenticate]
        A --> |QR Scan| R[Ready]
        R --> |Logout| D[Destroy]
        R --> |Disconnect| RC[Reconnect]
        RC --> R
    end
```

| ID        | Requirement                                                | Priority | Phase |
| --------- | ---------------------------------------------------------- | -------- | ----- |
| FR-SM-001 | The system must create new sessions                        | High     | 1     |
| FR-SM-002 | The system must generate a QR code for authentication      | High     | 1     |
| FR-SM-003 | The system must persist session state for reconnection     | High     | 1     |
| FR-SM-004 | The system must delete a session                           | High     | 1     |
| FR-SM-005 | The system must display session status                     | High     | 1     |
| FR-SM-006 | The system must support multiple sessions                  | High     | 2     |
| FR-SM-007 | The system must auto-reconnect when the connection is lost | Medium   | 2     |
| FR-SM-008 | The system must support session naming                     | Low      | 2     |

### 2.2.2 Messaging

```mermaid
flowchart TB
    subgraph Outbound["Outbound Messages"]
        T[Text]
        I[Image]
        V[Video]
        A[Audio]
        D[Document]
        L[Location]
        CT[Contact]
        S[Sticker]
    end

    subgraph Inbound["Inbound Messages"]
        W[Webhook] --> P[Process]
        P --> ACK[Acknowledgment]
    end

    Outbound --> API[REST API]
    API --> WA[WhatsApp]
    WA --> Inbound
```

| ID         | Requirement                                                      | Priority | Phase |
| ---------- | ---------------------------------------------------------------- | -------- | ----- |
| FR-MSG-001 | The system must send text messages                               | High     | 1     |
| FR-MSG-002 | The system must send images with captions                        | High     | 1     |
| FR-MSG-003 | The system must send video messages                              | High     | 1     |
| FR-MSG-004 | The system must send audio/voice notes                           | High     | 1     |
| FR-MSG-005 | The system must send documents/files                             | High     | 1     |
| FR-MSG-006 | The system must send locations                                   | Medium   | 2     |
| FR-MSG-007 | The system must send contact cards                               | Medium   | 2     |
| FR-MSG-008 | The system must send stickers                                    | Medium   | 2     |
| FR-MSG-009 | The system must reply to a message                               | Medium   | 2     |
| FR-MSG-010 | The system must forward messages                                 | Low      | 3     |
| FR-MSG-011 | The system must delete messages                                  | Low      | 3     |
| FR-MSG-012 | The system must react to messages                                | Low      | 3     |
| FR-MSG-013 | The system must send bulk messages (batch)                       | Medium   | 2     |
| FR-MSG-014 | The system must support template variables in bulk messages      | Medium   | 2     |
| FR-MSG-015 | The system must track status per message in a batch              | Medium   | 2     |
| FR-MSG-016 | The system must cancel a running batch                           | Medium   | 2     |
| FR-MSG-017 | The system must apply delays between messages to reduce ban risk | High     | 2     |
| FR-MSG-018 | The system must validate media size before sending               | High     | 1     |
| FR-MSG-019 | The system must support downloading media from a URL             | High     | 1     |

### 2.2.3 Webhook & Events

```mermaid
sequenceDiagram
    participant WA as WhatsApp
    participant OW as OpenWA
    participant WH as Webhook URL

    WA->>OW: New Message Event
    OW->>OW: Process & Store
    OW->>WH: POST /webhook
    WH-->>OW: 200 OK

    alt Webhook Failed
        OW->>OW: Queue for Retry
        OW->>WH: Retry (up to retryCount attempts in total, default 3)
    end
```

| ID        | Requirement                                                                      | Priority | Phase |
| --------- | -------------------------------------------------------------------------------- | -------- | ----- |
| FR-WH-001 | The system must deliver events to webhook URLs                                   | High     | 1     |
| FR-WH-002 | The system must support multiple webhook URLs                                    | Medium   | 2     |
| FR-WH-003 | The system must retry failed webhooks (per-webhook `retryCount`, 0-5, default 3) | Medium   | 2     |
| FR-WH-004 | The system must filter event types per webhook                                   | Medium   | 2     |
| FR-WH-005 | The system must log webhook delivery status                                      | Medium   | 2     |
| FR-WH-006 | The system must support webhook signature verification                           | Medium   | 2     |
| FR-WH-007 | The system must provide an idempotency key per event                             | High     | 2     |
| FR-WH-008 | The system must provide a delivery ID to track retries                           | Medium   | 2     |
| FR-WH-009 | The system must support exponential backoff for retries                          | Medium   | 2     |
| FR-WH-010 | The system must enable/disable webhooks without deletion                         | Low      | 2     |

### 2.2.4 Real-time Updates (WebSocket)

```mermaid
flowchart LR
    subgraph WebSocket["WebSocket Features"]
        direction TB
        CONN[Connection Management]
        AUTH[Authentication]
        SUB[Event Subscription]
        PING[Keep-alive Ping/Pong]
    end

    subgraph Events["Real-time Events"]
        MSG[Message Events]
        SESS[Session Events]
        QR[QR Code Updates]
    end

    WebSocket --> Events
```

| ID        | Requirement                                                        | Priority | Phase |
| --------- | ------------------------------------------------------------------ | -------- | ----- |
| FR-WS-001 | The system must provide a WebSocket endpoint for real-time updates | Medium   | 2     |
| FR-WS-002 | The system must support API key authentication for WebSocket       | Medium   | 2     |
| FR-WS-003 | The system must allow subscribing to a specific session            | Medium   | 2     |
| FR-WS-004 | The system must allow subscribing to specific event types          | Medium   | 2     |
| FR-WS-005 | The system must implement ping/pong keep-alive                     | Medium   | 2     |
| FR-WS-006 | The system must auto-reconnect with exponential backoff            | Low      | 3     |
| FR-WS-007 | The system must stream QR code updates in real time                | High     | 2     |

### 2.2.5 Contact Management

| ID        | Requirement                                          | Priority | Phase |
| --------- | ---------------------------------------------------- | -------- | ----- |
| FR-CT-001 | The system must retrieve all contacts                | High     | 1     |
| FR-CT-002 | The system must retrieve a contact by ID             | High     | 1     |
| FR-CT-003 | The system must check if a number exists on WhatsApp | High     | 1     |
| FR-CT-004 | The system must retrieve a contact profile picture   | Medium   | 2     |
| FR-CT-005 | The system must block/unblock a contact              | Low      | 3     |

### 2.2.6 Group Management

```mermaid
flowchart TB
    subgraph GroupOps["Group Operations"]
        direction TB
        CR[Create Group]
        UP[Update Info]
        AD[Add Participants]
        RM[Remove Participants]
        PR[Promote to Admin]
        DM[Demote Admin]
        LV[Leave Group]
    end

    subgraph GroupInfo["Group Info"]
        GI[Get Info]
        GP[Get Participants]
        GL[Get Invite Link]
    end
```

| ID        | Requirement                                 | Priority | Phase |
| --------- | ------------------------------------------- | -------- | ----- |
| FR-GR-001 | The system must retrieve all groups         | Medium   | 2     |
| FR-GR-002 | The system must retrieve group info         | Medium   | 2     |
| FR-GR-003 | The system must retrieve group participants | Medium   | 2     |
| FR-GR-004 | The system must create a group              | Medium   | 3     |
| FR-GR-005 | The system must update group info           | Low      | 3     |
| FR-GR-006 | The system must add participants            | Low      | 3     |
| FR-GR-007 | The system must remove participants         | Low      | 3     |
| FR-GR-008 | The system must promote/demote admins       | Low      | 3     |
| FR-GR-009 | The system must get/revoke invite links     | Low      | 3     |
| FR-GR-010 | The system must leave a group               | Low      | 3     |

### 2.2.7 Dashboard (Web UI)

```mermaid
flowchart LR
    subgraph Dashboard["Dashboard Features"]
        direction TB
        SS[Session Status]
        QR[QR Display]
        WM[Webhook Manager]
        LG[Logs Viewer]
        ST[Statistics]
    end

    Dashboard --> API[REST API]
    API --> BE[Backend]
```

| ID        | Requirement                                           | Priority | Phase |
| --------- | ----------------------------------------------------- | -------- | ----- |
| FR-UI-001 | The dashboard must display the status of all sessions | High     | 2     |
| FR-UI-002 | The dashboard must display QR codes                   | High     | 2     |
| FR-UI-003 | The dashboard must manage webhooks                    | Medium   | 2     |
| FR-UI-004 | The dashboard must view logs                          | Medium   | 2     |
| FR-UI-005 | The dashboard must test sending messages              | Medium   | 2     |
| FR-UI-006 | The dashboard must display statistics                 | Low      | 3     |
| FR-UI-007 | The dashboard must support dark mode                  | Low      | 3     |

### 2.2.8 Authentication & Authorization

```mermaid
flowchart TB
    subgraph Auth["Authentication Flow"]
        REQ[Request] --> KEY[API Key Check]
        KEY -->|Valid| IP[IP Whitelist Check]
        KEY -->|Invalid| R401[401 Unauthorized]
        IP -->|Allowed| PERM[Permission Check]
        IP -->|Blocked| R403[403 Forbidden - IP]
        PERM -->|Granted| PROC[Process Request]
        PERM -->|Denied| R403B[403 Forbidden - Permission]
    end
```

| ID        | Requirement                                                           | Priority | Phase |
| --------- | --------------------------------------------------------------------- | -------- | ----- |
| FR-AU-001 | The system must support API key authentication                        | High     | 2     |
| FR-AU-002 | The system must generate/revoke API keys                              | High     | 2     |
| FR-AU-003 | The system must set permissions per API key                           | Medium   | 3     |
| FR-AU-004 | The system must log API access                                        | Medium   | 2     |
| FR-AU-005 | The system must support IP whitelisting per API key                   | Medium   | 2     |
| FR-AU-006 | The system must support CIDR notation for IP ranges                   | Low      | 2     |
| FR-AU-007 | The system must set expiration dates for API keys                     | Medium   | 2     |
| FR-AU-008 | The system must hash API keys before storage (SHA-256)                | High     | 2     |
| FR-AU-009 | The system must track last used timestamps per API key                | Low      | 2     |
| FR-AU-010 | The system must support a master API key from environment             | High     | 2     |
| FR-AU-011 | The system must support the `X-Request-ID` header for request tracing | Medium   | 2     |

### 2.2.9 Bulk Operations

```mermaid
flowchart LR
    subgraph Batch["Batch Processing"]
        direction TB
        CREATE[Create Batch]
        QUEUE[Queue Messages]
        PROCESS[Process with Delay]
        TRACK[Track Progress]
        CANCEL[Cancel Option]
    end

    CREATE --> QUEUE --> PROCESS --> TRACK
    TRACK --> CANCEL
```

| ID        | Requirement                                                                                                                                 | Priority | Phase |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----- |
| FR-BK-001 | The system must accept batches up to 100 messages                                                                                           | Medium   | 2     |
| FR-BK-002 | The system must apply delays between messages (configurable, min 1s)                                                                        | High     | 2     |
| FR-BK-003 | The system must randomize delays for natural behavior                                                                                       | Medium   | 2     |
| FR-BK-004 | The system must track batch progress (sent/failed/pending)                                                                                  | Medium   | 2     |
| FR-BK-005 | The system must cancel a running batch                                                                                                      | Medium   | 2     |
| FR-BK-006 | The system must support template variables in batches                                                                                       | Low      | 3     |
| FR-BK-007 | The system must return a batch ID for status tracking                                                                                       | High     | 2     |
| FR-BK-008 | The system must prune finished batches (completed, cancelled, failed) older than the opt-in `MESSAGE_RETENTION_DAYS` (default 0 keeps them) | Low      | 3     |

## 2.3 Non-Functional Requirements

### 2.3.1 Performance

```mermaid
flowchart LR
    subgraph Performance["Performance Targets"]
        RT[Response Time < 500ms]
        TP[Throughput 100 req/s]
        MS[Memory < 500MB/session]
        ST[Startup < 30s]
    end
```

| ID         | Requirement          | Target           | Priority |
| ---------- | -------------------- | ---------------- | -------- |
| NFR-PF-001 | API response time    | < 500ms (p95)    | High     |
| NFR-PF-002 | Message send latency | < 2s             | High     |
| NFR-PF-003 | QR code generation   | < 3s             | High     |
| NFR-PF-004 | Memory per session   | < 500MB          | High     |
| NFR-PF-005 | Concurrent sessions  | 10+ per instance | Medium   |
| NFR-PF-006 | Webhook delivery     | < 5s             | Medium   |
| NFR-PF-007 | Startup time         | < 30s            | Medium   |

### 2.3.2 Reliability & Availability

| ID         | Requirement               | Target               | Priority |
| ---------- | ------------------------- | -------------------- | -------- |
| NFR-RL-001 | Uptime                    | 99.5%                | High     |
| NFR-RL-002 | Auto-recovery after crash | < 60s                | High     |
| NFR-RL-003 | Session persistence       | Survive restart      | High     |
| NFR-RL-004 | Graceful shutdown         | Complete pending ops | Medium   |
| NFR-RL-005 | Data durability           | No message loss      | High     |

### 2.3.3 Scalability

```mermaid
flowchart TB
    subgraph Vertical["Vertical Scaling"]
        V1[More RAM]
        V2[More CPU]
        V3[More Sessions]
    end

    subgraph Horizontal["Horizontal Scaling"]
        H1[Multiple Instances]
        H2[Load Balancer]
        H3[Shared Storage]
    end

    Vertical --> Single[Single Instance]
    Horizontal --> Cluster[Cluster Mode]
```

| ID         | Requirement                | Target                | Priority |
| ---------- | -------------------------- | --------------------- | -------- |
| NFR-SC-001 | Vertical scaling           | 20+ sessions/instance | Medium   |
| NFR-SC-002 | Horizontal scaling support | Multiple instances    | Low      |
| NFR-SC-003 | Stateless API design       | Session-independent   | High     |

### 2.3.4 Security

| ID         | Requirement        | Description             | Priority |
| ---------- | ------------------ | ----------------------- | -------- |
| NFR-SE-001 | API Authentication | API key required        | High     |
| NFR-SE-002 | Data encryption    | TLS for all connections | High     |
| NFR-SE-003 | Secret management  | Encrypted storage       | High     |
| NFR-SE-004 | Input validation   | Sanitize all inputs     | High     |
| NFR-SE-005 | Rate limiting      | Prevent abuse           | Medium   |
| NFR-SE-006 | Audit logging      | Track all operations    | Medium   |
| NFR-SE-007 | CORS configuration | Configurable origins    | Medium   |

### 2.3.5 Maintainability

| ID         | Requirement          | Description         | Priority |
| ---------- | -------------------- | ------------------- | -------- |
| NFR-MT-001 | Code coverage        | > 80% unit tests    | Medium   |
| NFR-MT-002 | Documentation        | API docs up-to-date | High     |
| NFR-MT-003 | Logging              | Structured logging  | High     |
| NFR-MT-004 | Modular architecture | Loosely coupled     | High     |
| NFR-MT-005 | Configuration        | Environment-based   | High     |

### 2.3.6 Usability

| ID         | Requirement       | Description             | Priority |
| ---------- | ----------------- | ----------------------- | -------- |
| NFR-US-001 | Setup time        | < 5 minutes with Docker | High     |
| NFR-US-002 | API documentation | Swagger/OpenAPI         | High     |
| NFR-US-003 | Error messages    | Clear & actionable      | High     |
| NFR-US-004 | Examples          | Working code examples   | Medium   |
| NFR-US-005 | Dashboard UX      | Intuitive interface     | Medium   |

### 2.3.7 Compatibility

| ID         | Requirement      | Description                | Priority |
| ---------- | ---------------- | -------------------------- | -------- |
| NFR-CP-001 | Node.js version  | 22 LTS                     | High     |
| NFR-CP-002 | Database support | SQLite, PostgreSQL         | High     |
| NFR-CP-003 | OS support       | Linux, macOS, Windows      | High     |
| NFR-CP-004 | Docker support   | Official image             | High     |
| NFR-CP-005 | ARM64 support    | Apple Silicon, ARM servers | Medium   |

## 2.4 Use Cases

### UC-001: Send Text Message

```mermaid
sequenceDiagram
    actor User
    participant API as OpenWA API
    participant Engine as WA Engine
    participant WA as WhatsApp

    User->>API: POST /api/sessions/:sessionId/messages/send-text
    API->>API: Validate request
    API->>Engine: sendMessage(chatId, text)
    Engine->>WA: Send via WhatsApp Web
    WA-->>Engine: Message sent
    Engine-->>API: Success response
    API-->>User: 201 Created {messageId}
```

**Actors**: External application, Developer  
**Precondition**: Session is active and connected  
**Main Flow**:

1. User sends a POST request with `chatId` and `text`
2. The system validates the request
3. The system sends the message via the WhatsApp engine
4. The system returns the `messageId`

**Alternative Flow**:

- Session not active → Return 400 error
- Invalid phone number → Return 400 error
- WhatsApp error → Return 500 error

### UC-002: Receive Message via Webhook

```mermaid
sequenceDiagram
    participant WA as WhatsApp
    participant Engine as WA Engine
    participant API as OpenWA
    participant WH as User Webhook

    WA->>Engine: New message received
    Engine->>API: Message event
    API->>API: Store message
    API->>WH: POST webhook payload
    WH-->>API: 200 OK
    API->>API: Mark delivered
```

**Actors**: WhatsApp, External webhook endpoint  
**Precondition**: Webhook URL configured  
**Main Flow**:

1. WhatsApp sends a message to the session
2. The engine receives and forwards to the API
3. The API stores the message and sends it to the webhook
4. The webhook endpoint acknowledges

**Alternative Flow**:

- Webhook failed → Deliver at most `retryCount` times in **total**, not `retryCount` retries on top of the first try (0-5, default 3 — so the default is one delivery plus two retries, and `retryCount: 1` retries nothing)
- All retries failed → Log and mark failed

### UC-003: Create New Session

```mermaid
stateDiagram-v2
    [*] --> Created: POST /api/sessions
    Created --> Initializing: POST /api/sessions/:sessionId/start
    Initializing --> QrReady: QR issued (refreshes until scanned or stopped)
    QrReady --> Authenticating: QR scanned or pairing code entered
    QrReady --> Disconnected: POST /api/sessions/:sessionId/stop
    Authenticating --> Ready: Auth success
    Authenticating --> Failed: Auth failed
    Ready --> [*]
    Disconnected --> [*]
    Failed --> [*]
```

## 2.5 Requirements Traceability Matrix

| Requirement ID | Use Case | Test Case  | Status         |
| -------------- | -------- | ---------- | -------------- |
| FR-SM-001      | UC-003   | TC-SM-001  | ✅ Implemented |
| FR-SM-002      | UC-003   | TC-SM-002  | ✅ Implemented |
| FR-MSG-001     | UC-001   | TC-MSG-001 | ✅ Implemented |
| FR-WH-001      | UC-002   | TC-WH-001  | ✅ Implemented |

## 2.6 Acceptance Criteria

### Phase 1 MVP Acceptance Criteria

```
✓ A single session can be created and authenticated
✓ QR code can be scanned and session connected
✓ Text messages can be sent
✓ Image messages can be sent
✓ Incoming messages are forwarded to webhooks
✓ API documentation is available via Swagger
✓ Docker image can be built and run
✓ Basic health check endpoint works
```

### Phase 2 Acceptance Criteria

```
✓ Multiple sessions can run concurrently
✓ Dashboard can display all sessions
✓ Webhooks can be managed via UI
✓ PostgreSQL can be used as storage
✓ API key authentication works
✓ Rate limiting is enabled
```

---

<div align="center">

[← 01 - Project Overview](./01-project-overview.md) · [Documentation Index](./README.md) · [Next: 03 - System Architecture →](./03-system-architecture.md)

</div>

---

### 03 System Architecture (`docs/03-system-architecture.md`)

# 03 - System Architecture

## 3.1 Architecture Overview

### High-Level Architecture

```mermaid
flowchart TB
    subgraph Clients["Clients"]
        C1[External Apps]
        C2[Dashboard]
        C3[n8n/Automation]
    end

    subgraph OpenWA["OpenWA Platform"]
        subgraph API["API Layer"]
            REST[REST API<br/>NestJS]
            WS[WebSocket<br/>Real-time]
            SWAGGER[Swagger<br/>Documentation]
        end

        subgraph Core["Core Services"]
            SM[Session<br/>Manager]
            MM[Message<br/>Manager]
            WH[Webhook<br/>Manager]
            QM[Queue<br/>Manager]
        end

        subgraph Engine["WhatsApp Engine (pluggable)"]
            WW[whatsapp-web.js<br/>Puppeteer/Chromium]
            BY[Baileys<br/>WebSocket/No browser]
        end

        subgraph Storage["Storage Layer"]
            DB[(Database<br/>PostgreSQL/SQLite)]
            REDIS[(Redis<br/>Cache/Queue)]
            FS[File Storage<br/>Media Files]
        end
    end

    subgraph External["External"]
        WA[WhatsApp<br/>Servers]
        WEBHOOK[Webhook<br/>Endpoints]
    end

    Clients --> API
    API --> Core
    Core --> Engine
    Core --> Storage
    Engine --> WA
    Core --> WEBHOOK
```

### Component Interaction

```mermaid
sequenceDiagram
    participant Client
    participant API as REST API
    participant SM as Session Manager
    participant Engine as WA Engine
    participant DB as Database
    participant WA as WhatsApp

    Client->>API: POST /api/sessions
    API->>SM: create()
    SM->>DB: Save session (status created)
    API-->>Client: Session
    Client->>API: POST /api/sessions/:sessionId/start
    API->>SM: start()
    SM->>Engine: Initialize
    Engine->>WA: Connect
    WA-->>Engine: QR Code
    Engine-->>SM: QR Ready (status qr_ready)
    API-->>Client: Session
    Client->>API: GET /api/sessions/:sessionId/qr (or the session.qr event)
    API-->>Client: QR Code response
```

## 3.2 Pluggable Architecture Philosophy

OpenWA is designed with a **Pluggable Architecture** that allows infrastructure components to be swapped without changing application code. This enables flexible deployments ranging from minimal single-session bots to larger single-node, multi-session installs.

> **Note — single-instance:** the live WhatsApp engine layer is stateful and held in-process
> (an in-memory `Map` in `EngineRegistry`). OpenWA currently runs as **one API instance per
> session-data volume**; horizontal scaling across multiple API replicas is a future design
> (not implemented). See [13 - Horizontal Scaling](13-horizontal-scaling.md).

### Design Philosophy

```mermaid
flowchart TB
    subgraph Philosophy["Core Design Principles"]
        P1[Program to Interfaces]
        P2[Dependency Injection]
        P3[Configuration-driven]
        P4[Zero Code Changes]
    end

    subgraph Benefits["Benefits"]
        B1[Scale Up/Down Freely]
        B2[Test in Isolation]
        B3[Swap Implementations]
        B4[Environment-specific Config]
    end

    P1 --> B3
    P2 --> B2
    P3 --> B4
    P4 --> B1
```

**Key Principles:**

| Principle                 | Description                                                                                                   | Example                                                 |
| ------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| **Program to Interfaces** | Core code depends on the `IWhatsAppEngine` abstraction, never on a concrete library                           | `IWhatsAppEngine` instead of `whatsapp-web.js` `Client` |
| **Dependency Injection**  | Services are wired via NestJS DI (constructor injection of `EngineFactory`, `StorageService`, `CacheService`) | `constructor(private engineFactory: EngineFactory)`     |
| **Configuration-driven**  | Backend selection via environment variables                                                                   | `STORAGE_TYPE=s3`, `ENGINE_TYPE=baileys`                |
| **Zero Code Changes**     | Switch backends without modifying application code                                                            | Change `.env`, restart                                  |

### Adapter Categories

The WhatsApp engine is the one true plug-in interface (`IWhatsAppEngine`, with concrete
adapters resolved through the plugin loader). The other "pluggable" backends are not behind a
formal `I*Adapter` interface — they are single services that branch internally on a config value:
`StorageService` (`storageType` = `local` | `s3`), `CacheService` (Redis or fail-open no-op), and
the TypeORM `data` connection (`sqlite` | `postgres`).

```mermaid
flowchart LR
    subgraph Core["Application Core"]
        APP[Business Logic]
    end

    subgraph Boundaries["Swappable Boundaries"]
        IE[IWhatsAppEngine<br/>interface + plugin loader]
        SS[StorageService<br/>storageType branch]
        CS[CacheService<br/>Redis / no-op]
        DC[TypeORM 'data' conn<br/>sqlite / postgres]
    end

    subgraph Implementations["Backends"]
        subgraph Engine
            E1[whatsapp-web.js]
            E2[Baileys]
        end
        subgraph Database
            D1[SQLite]
            D2[PostgreSQL]
        end
        subgraph Storage
            S1[Local FS]
            S2[S3 / MinIO]
        end
        subgraph Cache
            C1[Redis]
            C2[Disabled - no-op]
        end
    end

    APP --> Boundaries
    IE -.-> Engine
    DC -.-> Database
    SS -.-> Storage
    CS -.-> Cache
```

### WhatsApp Identity Contract (engine-neutral ids)

WhatsApp addresses the same entity through several id dialects, and each engine speaks a different one:
whatsapp-web.js uses `<phone>@c.us`, while Baileys speaks the raw protocol forms `<phone>@s.whatsapp.net`
and `<lid>@lid` (a privacy id whose number is **not** a phone number). To keep application code, the
REST/webhook payloads, and plugins free of that, the **engine boundary is an anti-corruption layer**:
every WhatsApp id an engine emits in a neutral field (`from` / `to` / `chatId` / `author`, contact and
chat `id`) is reduced to one small **neutral dialect**:

| Neutral form                                            | Meaning                                                                             |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `<phone>@c.us`                                          | a user, by phone (the raw `@s.whatsapp.net` form folds into this)                   |
| `<id>@g.us`                                             | a group                                                                             |
| `<lid>@lid`                                             | a user known **only** by privacy id - phone genuinely unknown (a first-class state) |
| `status@broadcast`, `<id>@newsletter`, `<id>@broadcast` | special channels                                                                    |

Never `@s.whatsapp.net`, never a `:device` suffix. **Resolution rule:** prefer `@c.us` (resolve a lid
to its phone when the mapping is known), and fall back to `@lid` only when it can't be resolved - an
unresolved lid is never faked into a phone number. WhatsApp's Meta-hosted dialects fold in here
too: `<n>@hosted` is the same account as `<n>@c.us` and normalizes to it, and `<lid>@hosted.lid`
normalizes like any other lid. Baileys makes the same fold itself on every inbound message.

The shared implementation lives in `src/engine/identity/wa-id.ts` (`parseWaId` / `toNeutralJid`); the
contract is documented on the `IWhatsAppEngine` interface.

> **Rollout status:** the contract is applied per-engine. On Baileys it covers **inbound event
> payloads** (message / revoked / reaction, group, call and presence events), the ids returned by the
> contact, chat and blocklist listings and by the number check, and **outbound ids**: sends, message
> actions and contact / group-participant writes fold a neutral id back to the engine dialect with
> `toEngineJid` (`baileys-session-store.ts`).

### Engine Lifecycle State Machine

A WhatsApp engine moves through the `EngineStatus` enum
(`engine/interfaces/whatsapp-engine.interface.ts`). The adapter reports the current value via
`getStatus()` and pushes transitions to the host through the `onStateChanged` callback supplied to
`initialize()`:

```mermaid
stateDiagram-v2
    [*] --> Disconnected: Create instance
    Disconnected --> Initializing: initialize(callbacks)
    Initializing --> QrReady: QR emitted
    QrReady --> Authenticating: QR scanned
    Authenticating --> Ready: Auth success
    Initializing --> Failed: Terminal error (onError)
    Authenticating --> Failed: Credentials rejected
    Ready --> Disconnected: disconnect() / dropped link
    Ready --> Failed: Fatal error
    Disconnected --> [*]
    Failed --> [*]
```

```typescript
// engine/interfaces/whatsapp-engine.interface.ts
export enum EngineStatus {
  DISCONNECTED = 'disconnected',
  INITIALIZING = 'initializing',
  QR_READY = 'qr_ready',
  AUTHENTICATING = 'authenticating',
  READY = 'ready',
  ACTION_REQUIRED = 'action_required',
  FAILED = 'failed',
}
```

There is no generic `IAdapterLifecycle`/`AdapterState` abstraction — only the engine carries an
explicit status enum. Storage, cache, and the database connection have no separate lifecycle type;
they follow the standard NestJS provider lifecycle (`OnModuleInit` / `OnModuleDestroy`).

### Dependency Injection & Module Wiring

OpenWA does **not** use a dynamic `AdaptersModule` or string DI tokens. `AppModule`
(`src/app.module.ts`) imports concrete feature modules directly and configures two **named TypeORM
connections**:

- **`main`** — always SQLite (`./data/main.sqlite`); owns the auth (`api_keys`) and audit
  (`audit_logs`) entities. Fixed boot config, not pluggable.
- **`data`** — the pluggable user-data connection: `sqlite` (default) or `postgres`, selected by
  `DATABASE_TYPE`. Owns the session/webhook/message/template/engine entities, plus the
  integration-fabric (`plugin_instances`, `ingress_events`, `conversation_mappings`,
  `integration_delivery_failures`), status-store (`status_updates`) and automation
  (`automation_rules`) entities.

The engine is provided by `EngineModule` as the `EngineFactory` **class** (a normal injectable, not a
string token). Storage and cache are provided as the `StorageService` and `CacheService` classes by
their respective modules.

```typescript
// src/app.module.ts (shape)
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, load: [configuration], validate: validateEnv }),

    // Auth + audit — always SQLite
    TypeOrmModule.forRootAsync({ name: 'main' /* ... ./data/main.sqlite ... */ }),

    // Pluggable user data — sqlite | postgres via DATABASE_TYPE
    TypeOrmModule.forRootAsync({ name: 'data' /* ... */ }),

    CacheModule, // provides CacheService
    StorageModule, // provides StorageService
    EngineModule, // provides EngineFactory
    SessionModule,
    MessageModule,
    WebhookModule /* ...other feature modules... */,
  ],
})
export class AppModule {}
```

### Using the Backends in Services

Services receive the backends by **constructor injection of the concrete class** — there is no
`@Inject('…_ADAPTER')` token:

```typescript
@Injectable()
export class SomeService {
  constructor(
    private readonly storage: StorageService, // branches local vs s3 internally
    private readonly cache: CacheService, // Redis when enabled, else a no-op
  ) {}

  async saveMedia(filePath: string, data: Buffer) {
    await this.storage.putFile(filePath, data); // path-safety guarded
    const cacheUp = await this.cache.isAvailable(); // false when Redis is disabled or unreachable
  }
}
```

### Runtime Configuration Flow

```mermaid
sequenceDiagram
    participant Env as .env File
    participant Config as ConfigService
    participant Svc as StorageService
    participant App as Application

    Note over Env: STORAGE_TYPE=s3
    Env->>Config: Load + validateEnv
    Config->>Svc: storage.type = 's3'
    Svc->>Svc: construct S3Client (forcePathStyle only when S3_ENDPOINT is set)
    App->>Svc: putFile / getFile (unaware of backend)
```

### Backend Selection Matrix

| Environment             | Database   | Storage  | Cache    | Engine          | Use Case                  |
| ----------------------- | ---------- | -------- | -------- | --------------- | ------------------------- |
| **Development**         | SQLite     | Local    | Disabled | whatsapp-web.js | Fast iteration, testing   |
| **Testing**             | SQLite     | Local    | Disabled | whatsapp-web.js | CI/CD, unit tests         |
| **Staging**             | PostgreSQL | Local    | Redis    | whatsapp-web.js | Pre-production validation |
| **Production (Small)**  | SQLite     | Local    | Disabled | whatsapp-web.js | 1-3 sessions, VPS         |
| **Production (Medium)** | PostgreSQL | Local    | Redis    | whatsapp-web.js | 5-10 sessions             |
| **Production (Large)**  | PostgreSQL | S3/MinIO | Redis    | whatsapp-web.js | 10+ sessions, HA          |

### Hot-Swap Considerations

> **Note:** Adapter hot-swap (changing adapter without restart) is **not supported** in v1.0. Changing adapter requires application restart.

Future considerations for hot-swap:

- Graceful connection draining
- State migration between adapters
- Zero-downtime switching

```mermaid
flowchart TD
    A[Config Change Detected] --> B{Hot-swap Supported?}
    B -->|v1.0: No| C[Log Warning]
    C --> D[Require Restart]

    B -->|Future: Yes| E[Drain Connections]
    E --> F[Initialize New Adapter]
    F --> G[Migrate State]
    G --> H[Switch Traffic]
    H --> I[Shutdown Old Adapter]
```

## 3.3 Layered Architecture

### Layered Architecture Pattern

```mermaid
flowchart TB
    subgraph Presentation["Presentation Layer"]
        direction LR
        REST[REST Controllers]
        WS[WebSocket Gateways]
        SWAGGER[OpenAPI Docs]
    end

    subgraph Application["Application Layer"]
        direction LR
        SESS[Session Service]
        MSG[Message Service]
        WH[Webhook Service]
        AUTH[Auth Service]
    end

    subgraph Domain["Domain Layer"]
        direction LR
        ENT[Entities]
        REPO[Repository Interfaces]
        EVT[Domain Events]
    end

    subgraph Infrastructure["Infrastructure Layer"]
        direction LR
        DB[Database]
        CACHE[Redis]
        ENGINE[WA Engine]
        HTTP[HTTP Client]
    end

    Presentation --> Application
    Application --> Domain
    Application --> Infrastructure
    Domain --> Infrastructure
```

## 3.4 Module Structure

### NestJS Module Organization

Trimmed to the load-bearing directories — `src/modules/` holds 31 feature modules. Only `*.module.ts`
is common to all of them; the rest of the shape varies. Most pair a `*.controller.ts` with a
`*.service.ts`, but `events/` is a WebSocket gateway, `mcp/` an MCP server and `queue/` pure BullMQ
wiring (none of the three has either); `docker/`, `status-store/`, `chat-media/` and `takeover/` are
service-only; `health/` and `settings/` are controller-only (`infra/` gained `infra-data.service.ts`); and 9 modules own an `entities/` directory:

```
/* Detailed source-code truncated for AI context efficiency. */
```

## 3.5 Core Components Design

### 3.5.1 Session Manager

The diagram below is conceptual: `SessionManager` is the role played by `SessionService`
(`src/modules/session/session.service.ts`), which injects the TypeORM `Repository<Session>` directly —
there is no `SessionRepository` class in the codebase. The live-engine map itself lives in
`EngineRegistry` (`src/engine/engine-registry.service.ts`), the narrow port that capability services
inject when they only need the running engine for a session; `SessionEngineLifecycle` is its only writer.

```mermaid
classDiagram
    class SessionManager {
        -engines: EngineRegistry
        -sessionRepository: Repository~Session~
        -engineFactory: EngineFactory
        +createSession(config): Session
        +getSession(id): Session
        +deleteSession(id): void
        +getAllSessions(): Session[]
        +restoreSessions(): void
    }

    class Session {
        +id: string
        +name: string
        +status: SessionStatus
        +engine: WhatsAppEngine
        +config: SessionConfig
        +createdAt: Date
        +start(): void
        +stop(): void
        +getQR(): string
    }

    class SessionStatus {
        <<enumeration>>
        CREATED
        INITIALIZING
        QR_READY
        AUTHENTICATING
        READY
        DISCONNECTED
        ACTION_REQUIRED
        FAILED
    }

    class WhatsAppEngine {
        <<interface>>
        +initialize(callbacks): Promise~void~
        +sendTextMessage(chatId, text): Promise~MessageResult~
        +getStatus(): EngineStatus
        +getContacts(): Promise~Contact[]~
        +disconnect(): Promise~void~
    }

    SessionManager --> Session
    Session --> SessionStatus
    Session --> WhatsAppEngine
```

### 3.5.2 Message Flow

```mermaid
flowchart TB
    subgraph Outbound["Outbound Message Flow"]
        A1[API Request] --> V1[Validate]
        V1 --> E1[Engine Send]
        E1 --> R1[Response]
    end

    subgraph Inbound["Inbound Message Flow"]
        E2[Engine Event] --> P2[Process]
        P2 --> S2[Store]
        S2 --> W2[Webhook Dispatch]
        W2 --> D2[Deliver]
    end
```

### 3.5.3 Webhook System

```mermaid
classDiagram
    class WebhookManager {
        -webhooks: Webhook[]
        -httpClient: HttpService
        -queue: Queue
        +registerWebhook(config): Webhook
        +removeWebhook(id): void
        +dispatch(event): void
        -deliverWithRetry(webhook, payload): void
    }

    class Webhook {
        +id: string
        +url: string
        +events: EventType[]
        +secret: string
        +active: boolean
        +retryCount: number
        +headers: Record
    }

    class WebhookPayload {
        +event: EventType
        +timestamp: string
        +sessionId: string
        +idempotencyKey: string
        +deliveryId: string
        +data: object
    }

    class EventType {
        <<enumeration>>
        message.received
        message.sent
        message.ack
        session.status
        session.qr
    }

    WebhookManager --> Webhook
    WebhookManager --> WebhookPayload
    Webhook --> EventType
```

`timestamp` is an ISO 8601 string. The body carries no signature: when the webhook has a secret, the
HMAC travels in the `X-OpenWA-Signature: sha256=<hex>` header. `EventType` shows a few of the dotted
event names; `WEBHOOK_EVENTS` in `src/modules/webhook/dto/webhook.dto.ts` is the full list.

## 3.6 Data Flow Diagrams

### 3.6.1 Send Message Flow

```mermaid
flowchart LR
    subgraph Request["1. Request"]
        A[Client] -->|POST /messages| B[Controller]
    end

    subgraph Validation["2. Validation"]
        B --> C{Valid?}
        C -->|No| D[400 Error]
        C -->|Yes| E[Service]
    end

    subgraph Processing["3. Processing"]
        E --> F[Get Session]
        F --> G{Engine loaded?}
        G -->|No| H[400 Error]
        G -->|Yes| S[Store PENDING row]
        S --> K[Engine send]
    end

    subgraph Execution["4. Execution"]
        K -->|Not ready| H2[409 Error\nrow marked FAILED, message:failed]
        K -->|Accepted| L[WhatsApp]
    end

    subgraph Response["5. Response"]
        L --> M[Success]
        M --> N[Update row to SENT]
        N --> O[Response]
    end
```

### 3.6.2 Webhook Delivery Flow

```mermaid
flowchart TB
    A[Event Triggered] --> B[Create Payload]
    B --> Q{QUEUE_ENABLED?}
    Q -->|No, default| D1[Direct in-process delivery]
    Q -->|Yes| D2[BullMQ delivery job]
    D1 --> E[Sign & POST]
    D2 --> E
    E --> F{Deliver}
    F -->|Success| G[Mark Delivered]
    F -->|Failed| H{Attempts < webhook.retryCount?}
    H -->|Yes| I[Delay & Retry]
    I --> E
    H -->|No| J[Mark Failed]
    J --> K[Log Error]
```

## 3.7 Technology Architecture

### 3.7.1 Runtime Environment

```mermaid
flowchart TB
    subgraph Container["Docker Container"]
        subgraph Node["Node.js Runtime"]
            NEST[NestJS Application]
            WW[whatsapp-web.js<br/>or Baileys]
        end

        subgraph Browser["Headless Browser (wwebjs only)"]
            CHROME[Chromium]
        end

        Node -.->|ENGINE_TYPE=whatsapp-web.js| Browser
    end

    subgraph External["External Services"]
        PG[(PostgreSQL)]
        RD[(Redis)]
    end

    Container --> External
```

### 3.7.2 Deployment Architecture

```mermaid
flowchart TB
    subgraph Production["Production Environment"]
        API[OpenWA API<br/>single instance]
        VOL[(Session-data volume<br/>auth dirs)]

        API --- VOL
        API --> DB[(PostgreSQL)]
        API --> REDIS[(Redis)]
    end

    subgraph Storage["External Storage"]
        S3[S3/MinIO<br/>Live media store]
    end

    API --> S3
```

> Multi-replica deployment behind a load balancer is a future design, not the shipped topology — the
> live engines are held in-process, so one API instance owns a session-data volume. See the
> single-instance note in §3.2 and [13 - Horizontal Scaling](13-horizontal-scaling.md).

## 3.8 API Architecture

### RESTful API Design

```mermaid
flowchart LR
    subgraph Endpoints["API Endpoints"]
        direction TB
        S["/api/sessions"]
        M["/api/sessions/:sessionId/messages"]
        W["/api/sessions/:sessionId/webhooks"]
        C["/api/sessions/:sessionId/contacts"]
        G["/api/sessions/:sessionId/groups"]
        H["/api/health"]
    end

    subgraph Methods["HTTP Methods"]
        GET
        POST
        PUT
        DELETE
    end

    subgraph Format["Response Format"]
        JSON[JSON Response]
        ERR[Error Format]
        PAGE[Pagination]
    end
```

### API Response Structure

Responses are the **raw handler payload** — there is no `{success, data, meta}` envelope.
A controller that returns an object sends exactly that object; a list endpoint returns a bare array.
Errors use the NestJS default shape.

```typescript
// Success Response — the resource itself
{
  "id": "abc",
  "name": "my-session",
  "status": "ready"
}

// List Response — a bare array
[
  { "id": "abc", "name": "my-session", "status": "ready" },
  { "id": "def", "name": "other-session", "status": "disconnected" }
]

// Error Response — NestJS default shape
{
  "statusCode": 404,
  "message": "Session with id 'xxx' not found",
  "error": "Not Found"
}
```

## 3.9 Security Architecture

```mermaid
flowchart TB
    subgraph External["External Request"]
        R[Request]
    end

    subgraph Security["Security Layers"]
        R --> HTTPS[HTTPS/TLS]
        HTTPS --> CORS[CORS Check]
        CORS --> RATE[Rate Limiter]
        RATE --> AUTH[API Key Auth]
        AUTH --> VAL[Input Validation]
        VAL --> APP[Application]
    end

    subgraph Internal["Internal Security"]
        APP --> ENC[Data Encryption]
        ENC --> LOG[Audit Logging]
    end
```

## 3.10 Error Handling Architecture

Handlers throw NestJS's own HTTP exceptions; NestJS's built-in `BaseExceptionFilter` renders them.
There is no custom filter and no custom exception base class — see
[08 - Development Guidelines](./08-development-guidelines.md) for the domain errors in
`src/common/errors/` and the status each maps to.

```mermaid
flowchart TB
    E[Error Occurs] --> T{Error Type}

    T -->|Validation fails| V[BadRequestException 400]
    T -->|Missing resource| N[NotFoundException 404]
    T -->|Bad/absent key| A[UnauthorizedException 401]
    T -->|Role or scope refused| FB[ForbiddenException 403]
    T -->|Engine cannot do this| NI[EngineNotSupportedError → 501]
    T -->|Engine not ready| C[EngineNotReadyError → 409]
    T -->|Engine transport down| SU[EngineTransportError → 503]
    T -->|Unhandled| S[InternalServerErrorException 500]

    V --> F[NestJS BaseExceptionFilter]
    N --> F
    A --> F
    FB --> F
    NI --> F
    C --> F
    SU --> F
    S --> F

    F --> R["{ statusCode, message, error }"]
    S --> L[Log with stack]
```

## 3.11 Scalability Considerations

> **Partially implemented; still deploy `replicas: 1`.** Live engines are held in an in-process `Map`
> in `EngineRegistry`. Each session carries an owner lease (`nodeId`, `nodeUrl`, `leaseExpiresAt`)
> claimed by `SessionOwnershipService`, a takeover sweep adopts sessions whose lease lapsed, and when
> `NODE_URL` is set `SessionProxyInterceptor` forwards session-scoped requests to the owner node.
> OpenWA still runs as one API instance per session-data volume; the two sketches below are retained
> for planning. See the single-instance note in §3.2 and [13 - Horizontal Scaling](13-horizontal-scaling.md).

### Horizontal Scaling Strategy (sketch)

```mermaid
flowchart TB
    subgraph Scaling["Scaling Strategy"]
        direction TB

        subgraph Stateless["Stateless Components"]
            API[API Servers]
            WORKER[Queue Workers]
        end

        subgraph Stateful["Stateful Components"]
            SESSION[Session Instances]
        end

        subgraph Shared["Shared State"]
            DB[(Database)]
            REDIS[(Redis)]
            S3[(Object Storage)]
        end
    end

    Stateless --> Shared
    Stateful --> Shared
```

### Session Affinity (sketch)

```mermaid
flowchart LR
    subgraph Router["Request Router"]
        R[Request] --> H{Has Session ID?}
        H -->|Yes| A[Route to Affinity]
        H -->|No| B[Round Robin]
    end

    A --> I1[Instance 1<br/>Session A, B]
    A --> I2[Instance 2<br/>Session C, D]
    B --> I1
    B --> I2
```

---

## 3.12 Engine Abstraction Layer

> [!IMPORTANT]
> Engine abstraction is critical to mitigate **R001: WhatsApp Protocol Changes** in Risk Management. OpenWA ships two production-ready engines selectable via `ENGINE_TYPE`: `whatsapp-web.js` (default, Chromium/Puppeteer-based) and `baileys` (browser-free, WebSocket/Noise protocol). With the abstraction layer, adding further engines requires no changes to application code.

### Strategy Pattern for Engine

```mermaid
classDiagram
    class IWhatsAppEngine {
        <<interface>>
        +initialize(callbacks): Promise~void~
        +disconnect(): Promise~void~
        +logout(): Promise~void~
        +destroy(): Promise~void~
        +forceDestroy(): Promise~void~
        +getStatus(): EngineStatus
        +getQRCode(): string | null
        +requestPairingCode(phone): Promise~string~
        +sendTextMessage(chatId, text): Promise~MessageResult~
        +sendImageMessage(chatId, media): Promise~MessageResult~
    }

    class WhatsAppWebJsAdapter {
        -client: Client
        +initialize(callbacks): Promise~void~
        +sendTextMessage(): Promise~MessageResult~
    }

    class BaileysAdapter {
        -socket: WASocket
        +initialize(callbacks): Promise~void~
        +sendTextMessage(): Promise~MessageResult~
    }

    class EngineFactory {
        +create(options: EngineCreateOptions): IWhatsAppEngine
    }

    IWhatsAppEngine <|.. WhatsAppWebJsAdapter
    IWhatsAppEngine <|.. BaileysAdapter
    EngineFactory --> IWhatsAppEngine
```

### Engine Interface Definition

Events are **not** delivered through an `on`/`off`/`once` emitter. Instead, the host passes a single
`EngineEventCallbacks` object to `initialize()`; the adapter invokes the registered callbacks for the
lifetime of the engine. Status is an `EngineStatus` **enum** (not a string union), `getQRCode()` is
**synchronous** (`string | null`), and there is no `connect()` / `isReady()` / `getAuthState()` — the
adapter connects inside `initialize()`.

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Engine Factory

The factory resolves the engine through the **plugin loader**, not a hard-coded `switch`. The
configured engine (`engine.type`, default `'whatsapp-web.js'`) is read once in the constructor; the
built-in `whatsapp-web.js` and `baileys` plugins are registered and the configured one is enabled in
`onModuleInit()`. `create()` takes an **options object** (engine-neutral per-call config —
`sessionId` / `dbSessionId` / `proxyUrl` / `proxyType`), not a `type` argument; when it calls the
plugin's `createEngine()` it adds the `sessionDataPath` / `authDir` bases from config, since the factory
hardens and purges the credential dirs under them. The two ids carry the same value: `sessionId` is the
session **UUID** (`Session.id`), which is the on-disk auth-directory key since 0.23.5, and `dbSessionId`
is that UUID under the name FK-bound stores such as `baileys_stored_messages` read. An out-of-tree
engine plugin that keys its own storage by `sessionId` has to re-key it on upgrade:
`SessionAuthDirMigration` renames the two built-in shapes only. There is no `EngineType` union and no
`switch`. If the configured engine's plugin is not registered, `create()` throws
`Engine '<type>' is not registered`; there is no direct-adapter fallback, so the session fails loudly
at start instead of running some other engine. (A typo in `ENGINE_TYPE` is rejected at boot by
`validateEnv`, which whitelists `whatsapp-web.js` | `baileys`.)

```
/* Detailed source-code truncated for AI context efficiency. */
```

### WhatsApp-Web.js Adapter

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Baileys Adapter (Alternative Engine)

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Engine Selection Configuration

```bash
# .env
ENGINE_TYPE=whatsapp-web.js  # Options: whatsapp-web.js (default), baileys

# Switch to the browser-free engine
ENGINE_TYPE=baileys
```

### Migration Strategy

```mermaid
flowchart TB
    subgraph Current["Available Engines"]
        A[whatsapp-web.js\nPuppeteer-based\ndefault]
        A2[Baileys\nWebSocket-based\nENGINE_TYPE=baileys]
    end

    subgraph Risk["Risk Detection"]
        B{Protocol\nBreaking?}
    end

    subgraph Migration["Migration Path"]
        C[Update engine library]
        D[Switch ENGINE_TYPE to the other engine\nre-link each session by QR or pairing code]
        E[Track upstream fix\nOperators use fallback channel]
    end

    subgraph Resolution["Resolution"]
        F[Service Restored]
    end

    A2 --> B
    A --> B
    B -->|Minor| C --> F
    B -->|Major one engine| D --> F
    B -->|Major Both| E --> C
```

### Engine Comparison

| Feature            | whatsapp-web.js       | Baileys             |
| ------------------ | --------------------- | ------------------- |
| **Protocol**       | Web (Puppeteer)       | Native WebSocket    |
| **Resource Usage** | High (~500MB/session) | Low (~50MB/session) |
| **Stability**      | Good                  | Good                |
| **Community**      | Large                 | Large               |
| **Multi-device**   | ✅                    | ✅                  |
| **QR Code**        | ✅                    | ✅                  |
| **Phone Link**     | ✅                    | ✅                  |
| **Maintenance**    | Active                | Active              |

### Benefits of Abstraction

1. **Risk Mitigation** - Swap engines without changing application code
2. **Testing** - The single `IWhatsAppEngine` boundary makes the engine trivial to stub/mock in unit tests
3. **Flexibility** - Run different engines per deployment via `ENGINE_TYPE`
4. **Future-proof** - New engines register as plugins; no changes to application code
5. **Comparison** - Evaluate engine resource/behavior trade-offs per environment

---

## 3.13 Pluggable Adapters

OpenWA uses the adapter pattern for infrastructure components that can be swapped per deployment needs. This allows users with limited resources to run OpenWA without heavyweight external dependencies.

### Adapter Overview

```mermaid
flowchart TB
    subgraph Core["OpenWA Core"]
        APP[Application Logic]
    end

    subgraph Adapters["Pluggable Backends"]
        subgraph Engine["WhatsApp Engine"]
            E1[whatsapp-web.js]
            E2[Baileys]
        end

        subgraph Database["Database"]
            D1[SQLite]
            D2[PostgreSQL]
        end

        subgraph Storage["Media Storage"]
            S1[Local Filesystem]
            S2[S3 / MinIO]
        end

        subgraph Cache["Cache"]
            C1[Redis]
            C2[Disabled - no-op]
        end
    end

    APP --> Engine
    APP --> Database
    APP --> Storage
    APP --> Cache
```

### Adapter Options

| Component           | Options (`ENV`)                          | Default         | Notes                                                        |
| ------------------- | ---------------------------------------- | --------------- | ------------------------------------------------------------ |
| **WhatsApp Engine** | whatsapp-web.js, Baileys (`ENGINE_TYPE`) | whatsapp-web.js | Baileys is browser-free                                      |
| **Database**        | SQLite, PostgreSQL (`DATABASE_TYPE`)     | SQLite          | PostgreSQL for large-scale production                        |
| **Media Storage**   | local, s3 (`STORAGE_TYPE`)               | local           | MinIO is the `s3` backend (`S3_ENDPOINT` enables path-style) |
| **Cache**           | Redis or disabled (`REDIS_ENABLED`)      | Disabled        | When disabled/unreachable, cache fails open (no-op)          |

### 3.13.1 Storage Service

Media storage is a **single service** (`src/common/storage/storage.service.ts`) that branches
internally on `storageType` — there is no `I*Adapter` interface, separate adapter classes, or a
`StorageFactory`. The two backends are `local` (the default; files under `./data/media`) and `s3`.
It is the live media store: the status store writes status media through `putFile` (under
`statuses/`) and sweeps orphans back out with `deleteFile`, and the storage export/import endpoints
move its contents between backends. Of the media, `scripts/backup.sh` archives only the local media
directory, never the S3 bucket. Incoming and outgoing message media is returned inline to REST/webhook
consumers and is **not** written through `StorageService` unless `CHAT_MEDIA_ARCHIVE_ENABLED=true`, which
archives a copy under `chat-media/<sessionId>/` (media this account sent also needs
`CHAT_MEDIA_ARCHIVE_OUTBOUND=true`).
That copy is in addition to the inline one unless `MESSAGE_INLINE_MEDIA=archive`, which replaces the
row's inline base64 with an `{ omitted: true, archived: true }` marker once the file reads back intact.
On S3 every key sits under the `S3_KEY_PREFIX` root (default `media/`).
**MinIO is not a separate type** — it is the `s3` backend. The S3 client is created from credentials
alone, so plain AWS S3 works with no endpoint (the SDK derives one from the region); `S3_ENDPOINT` is
for S3-compatible stores (MinIO, R2, …), and setting it is also what enables `forcePathStyle: true`.
The public method set is `putFile` / `getFile` / `deleteFile` / `iterateFiles` /
`createExportStream` (export) / `importFromStream` (import), plus `getFileCount`,
`getCurrentStorageType` and `refreshS3Availability` (re-probes the bucket; the infra status endpoint
calls it to report S3 reachability).

```
/* Detailed source-code truncated for AI context efficiency. */
```

### 3.13.2 Database Adapter

OpenWA supports SQLite for lightweight deployments and PostgreSQL for high-volume production.

#### Database Comparison

| Feature                | SQLite             | PostgreSQL      |
| ---------------------- | ------------------ | --------------- |
| **Setup**              | Zero config        | Requires server |
| **Concurrent writes**  | Limited (1 writer) | Excellent       |
| **Horizontal scaling** | ❌                 | ✅              |
| **Table partitioning** | ❌                 | ✅              |
| **Memory footprint**   | ~10MB              | ~100MB+         |
| **Backup**             | Copy file          | pg_dump         |
| **Best for**           | 1-5 sessions       | 5+ sessions     |

#### TypeORM Configuration

Database wiring lives inline in `AppModule` (`src/app.module.ts`) as two named
`TypeOrmModule.forRootAsync` connections — there is no standalone `getDatabaseConfig` helper. The
`data` connection is the one shown below; the `main` connection is always SQLite (auth + audit). The
`data` connection's type comes from `DATABASE_TYPE` (`sqlite` default, or `postgres`):

```typescript
// shape of the 'data' connection useFactory in src/app.module.ts
const dbType = configService.get<'sqlite' | 'postgres'>('dataDatabase.type', 'sqlite');
const baseConfig = {
  entities: [/* session, webhook, message, template, engine, integration, status-store, automation globs */],
  migrations: [__dirname + '/database/migrations/*{.ts,.js}'],
  logging: configService.get<boolean>('dataDatabase.logging', false),
};

if (dbType === 'postgres') {
  return {
    ...baseConfig,
    name: 'data',
    type: 'postgres',
    host: configService.get('dataDatabase.host'),
    port: configService.get('dataDatabase.port'),
    username: configService.get('dataDatabase.username'),
    password: configService.get('dataDatabase.password'),
    database: configService.get('dataDatabase.name', 'openwa'),
    synchronize: configService.get('dataDatabase.synchronize', false), // migrations in prod
    migrationsRun: true,
    extra: { max: configService.get('dataDatabase.poolSize', 10) },
  };
}

// SQLite (default): migration-managed unless DATABASE_SYNCHRONIZE=true
const synchronize = configService.get<boolean>('dataDatabase.synchronize', false);
return {
  ...baseConfig,
  name: 'data',
  type: 'better-sqlite3' as const, // DATABASE_TYPE=sqlite -> this driver
  database: configService.get('dataDatabase.database', './data/openwa.sqlite'),
  synchronize,
  migrationsRun: !synchronize,
};
```

#### SQLite Considerations

> **Note:** OpenWA does not currently apply SQLite-specific concurrency hardening. There is **no**
> `journal_mode = WAL` PRAGMA, no `SqliteWriteQueueService`, and no application-level write
> serialization in the source. SQLite is used with TypeORM's defaults apart from a fixed 30 s busy
> timeout (`SQLITE_BUSY_TIMEOUT_MS`), so its standard single-writer behavior applies. For high
> write-concurrency or multi-session deployments, use PostgreSQL (`DATABASE_TYPE=postgres`).
> `MAX_CONCURRENT_SESSIONS` (default `0`, unlimited) caps concurrent sessions on any database.
> Cross-dialect schema differences are handled at migration time (see below), not by a runtime
> optimizations layer.

#### Migration Strategy

There is **no** shared cross-dialect migration base class. Each migration is a plain
`MigrationInterface` that branches inline on the connection's dialect, and the branch always tests for
`'postgres'` (never for a SQLite name — the SQLite driver reports `better-sqlite3`, not `sqlite`).
A Postgres-only migration simply returns early everywhere else:

```typescript
// src/database/migrations/1779235200000-AddUuidDefaultsForPostgres.ts (shape)
export class AddUuidDefaultsForPostgres1779235200000 implements MigrationInterface {
  name = 'AddUuidDefaultsForPostgres1779235200000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    // No-op on SQLite: TypeORM generates the UUID in the driver layer there, so no DB default
    // is needed. Only Postgres expects the column to supply it.
    if (queryRunner.dataSource.options.type !== 'postgres') return;
    // ... ALTER TABLE ... ALTER COLUMN "id" SET DEFAULT gen_random_uuid()::varchar
  }
}
```

Migrations that must run on both dialects with different SQL take the same check as a branch, e.g.
`const isPostgres = queryRunner.connection.options.type === 'postgres';`.

### 3.13.3 Cache Service

There is **no** cache-manager / `CacheModuleOptions` / `redisStore` setup and **no** in-memory cache.
`CacheService` (`src/common/cache/cache.service.ts`) talks to **ioredis directly** and is gated by
`REDIS_ENABLED` (falling back to the `cache.enabled` config flag). It stores no values: its only
consumer is the infra status probe, which calls `isAvailable()` to learn whether Redis answers a ping
right now. When caching is disabled, or Redis is unreachable, `isAvailable()` returns `false` instead
of throwing, so the app keeps serving from its source of truth. In other words, "no cache configured"
means **no cache** (recompute), not an in-process LRU. Cache is never the source of truth, and no
request path reads from it today.

```typescript
// src/common/cache/cache.service.ts
@Injectable()
export class CacheService implements OnModuleDestroy {
  private redis: Redis | null = null;
  private readonly enabled: boolean;

  constructor(private readonly configService: ConfigService) {
    // REDIS_ENABLED is the primary switch; cache.enabled is the legacy fallback.
    this.enabled = process.env.REDIS_ENABLED === 'true' || configService.get<boolean>('cache.enabled', false);
    // Lazy connect: the first isAvailable() call dials Redis, then ioredis owns reconnection —
    // it retries forever with capped backoff (times => Math.min(times * 500, 5000)).
  }

  async isAvailable(): Promise<boolean> {
    if (!this.enabled) return false; // disabled -> always "no cache"
    this.ensureClient(); // create on first use; ioredis handles (re)connecting
    return this.ping(); // reflects live state: false during an outage, true once back
  }
}
```

### 3.13.4 Deployment Profiles

OpenWA provides several deployment profiles for different needs:

```mermaid
flowchart LR
    subgraph Minimal["🪶 Minimal Profile"]
        M1[SQLite]
        M2[Local Storage]
        M3[No Cache]
        M4[Single Session]
    end

    subgraph Standard["⚡ Standard Profile"]
        S1[PostgreSQL]
        S2[Local Storage]
        S3[Redis]
        S4[Multi Session]
    end

    subgraph Enterprise["🏢 Enterprise Profile"]
        E1[PostgreSQL Cluster]
        E2[S3/MinIO]
        E3[Redis Cluster]
        E4[Vertical Headroom]
    end
```

| Profile        | Database   | Storage  | Cache | Sessions | RAM   | Use Case              |
| -------------- | ---------- | -------- | ----- | -------- | ----- | --------------------- |
| **Minimal**    | SQLite     | Local    | None  | 1-3      | 512MB | Personal bot, testing |
| **Standard**   | PostgreSQL | Local    | Redis | 5-10     | 2GB   | Small business        |
| **Enterprise** | PostgreSQL | S3/MinIO | Redis | 10+      | 4GB+  | Agency, high volume   |

> Session counts are guidance only by default. Set `MAX_CONCURRENT_SESSIONS` to a positive integer
> to cap concurrently running or initializing engines, plus sessions waiting to relaunch after a
> failed reconnect; the default `0` keeps the historical unlimited behavior.

### Configuration Examples

Every key below is managed in Dashboard > Infrastructure, so the blocks show them commented out: an
uncommented key in `.env` pins its value over the one the dashboard saves (see the header of
`.env.example`). Uncomment only what you intend to manage by hand.

#### Minimal Profile (.env)

```bash
# Database
# DATABASE_TYPE=sqlite
# DATABASE_NAME=./data/openwa.sqlite

# Storage
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=./data/media

# Cache: omit / leave Redis disabled -> the cache layer no-ops (no in-memory cache)
# REDIS_ENABLED=false
```

#### Standard Profile (.env)

```bash
# Database (Postgres uses discrete host/port/credentials, not a single URL)
# DATABASE_TYPE=postgres
# DATABASE_HOST=localhost
# DATABASE_PORT=5432
# DATABASE_NAME=openwa
# DATABASE_USERNAME=openwa
# DATABASE_PASSWORD=<set-a-strong-password>

# Storage
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=./data/media

# Cache
# REDIS_ENABLED=true
# REDIS_HOST=localhost
# REDIS_PORT=6379
```

#### Enterprise Profile (.env)

```bash
# Database
# DATABASE_TYPE=postgres
# DATABASE_HOST=db-cluster
# DATABASE_PORT=5432
# DATABASE_NAME=openwa
# DATABASE_USERNAME=openwa
# DATABASE_PASSWORD=<set-a-strong-password>
# DATABASE_POOL_SIZE=50

# Storage (S3 or any S3-compatible endpoint; MinIO uses the same vars)
# STORAGE_TYPE=s3
# S3_BUCKET=openwa-media
# S3_REGION=ap-southeast-1
# S3_ACCESS_KEY_ID=<access-key-id>
# S3_SECRET_ACCESS_KEY=<secret-access-key>
# AWS S3 needs NO endpoint (one is derived from the region) — leave S3_ENDPOINT unset for it. An
# endpoint is only for S3-compatible stores (MinIO, R2, …), where setting it also enables path-style:
# S3_ENDPOINT=http://minio:9000

# Cache
# REDIS_ENABLED=true
# REDIS_HOST=redis-cluster
# REDIS_PORT=6379
```

> OpenWA runs as a single API instance per session-data volume; there is no cluster-mode flag.
> "Enterprise" here describes vertical headroom (RAM, Postgres, S3, Redis), not multi-replica
> horizontal scaling — see the single-instance note in §3.2.

### Choosing a Profile

OpenWA does not auto-detect a profile at runtime; pick one by available resources and expected load:

| Available RAM | Suggested profile | Backends                                          |
| ------------- | ----------------- | ------------------------------------------------- |
| < ~1 GB       | Minimal           | SQLite + Local Storage, Redis disabled (no cache) |
| ~1–4 GB       | Standard          | PostgreSQL + Local Storage + Redis                |
| > ~4 GB       | Enterprise        | PostgreSQL + S3/MinIO + Redis                     |

All profiles still run as a single API instance per session-data volume (see §3.2). Enterprise here
means more vertical headroom and external backends, not multi-replica clustering.

---

<div align="center">

[← 02 - Requirements Specification](./02-requirements-specification.md) · [Documentation Index](./README.md) · [Next: 04 - Security Design →](./04-security-design.md)

</div>

---

### 04 Security Design (`docs/04-security-design.md`)

# 04 - Security Design

## 4.1 Security Overview

```mermaid
flowchart TB
    subgraph External["External Threats"]
        A1[Unauthorized Access]
        A2[Data Breach]
        A3[DDoS Attack]
        A4[Injection Attack]
    end

    subgraph Defense["Defense Layers"]
        D1[Authentication]
        D2[Encryption]
        D3[Rate Limiting]
        D4[Input Validation]
        D5[Audit Logging]
    end

    A1 --> D1
    A2 --> D2
    A3 --> D3
    A4 --> D4

    D1 --> APP[Application]
    D2 --> APP
    D3 --> APP
    D4 --> APP
    APP --> D5
```

## 4.2 Authentication

### API Key Authentication Flow

```mermaid
sequenceDiagram
    participant C as Client
    participant G as Auth Guard
    participant S as Service
    participant DB as Database

    C->>G: Request + X-API-Key
    G->>G: Hash API Key
    G->>DB: Find by hash
    alt Key Valid
        DB-->>G: API Key record
        G->>G: Check permissions
        G->>G: Check expiration
        G->>S: Forward request
        S-->>C: Response
    else Key Invalid
        G-->>C: 401 Unauthorized
    end
```

### API Key Format

```
Format: owa_k1_<64 hex chars>   (32 random bytes, hex-encoded — 71 characters in total)
Example: owa_k1_3f9c1d0a7b2e4c5680a1f2d3e4b5c6a7980b1c2d3e4f50617283940a1b2c3d4e

Storage: SHA-256 hash only (never store plain key); `keyPrefix` keeps the first 12 characters
```

Every key minted through the API-keys endpoints uses that format. The bootstrap seed key is the only
exception: an explicit `API_MASTER_KEY` is taken verbatim (surrounding whitespace is stripped), and `ALLOW_DEV_API_KEY=true` opts into the
fixed `dev-admin-key`; with neither set, the seed key is generated in the format above.

### Permission Model

API keys carry **no permission strings**. Authorization is a role hierarchy on the key itself, plus
three scoping dimensions enforced by `ApiKeyGuard`.

| Role       | Rank | Meaning                                                              |
| ---------- | ---- | -------------------------------------------------------------------- |
| `admin`    | 3    | Satisfies every `@RequireRole` level, including API-key management   |
| `operator` | 2    | Satisfies `operator` and `viewer` routes — the default for a new key |
| `viewer`   | 1    | Satisfies `viewer` routes only                                       |

A route declares its minimum level with `@RequireRole(...)`; a key passes when its role ranks at or
above that level (`AuthService.hasPermission`). A key below it is rejected with `403 Forbidden`.

| Scope     | Field             | Effect                                                                                                                                                                                                              |
| --------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Source IP | `allowedIps`      | Empty/absent = unrestricted; non-empty = fail-closed IP whitelist (see §4.3)                                                                                                                                        |
| Sessions  | `allowedSessions` | Empty/absent = every session; non-empty = a session named in the route path outside the list is rejected with `403`, and a `sessionId` filter in the query or body is narrowed to the list (an empty result, `200`) |
| Chats     | `allowedChats`    | Empty/absent = every chat; non-empty = default-deny: only chat-scoped routes, MCP tools and events, only for a listed chat, else `403` (an event outside the list is never delivered); Bull Board refuses it        |

The key-lifecycle routes (`/api/auth/api-keys`) are additionally fenced with `@RequireUnscopedKey()`:
a session-scoped key is refused there whatever its role, so it cannot mint or widen credentials
beyond its own confinement. The same fence covers the other routes that act on the whole deployment:
every `/api/infra` route, the plugin routes (except the per-session config override), `GET /api/settings`,
`GET /api/stats/overview` and `/messages`, `POST /api/sessions` and the proxy setter. `SECURITY.md` lists them.

A session's egress proxy (`proxyUrl` on `POST /api/sessions`, `PATCH /api/sessions/:sessionId/proxy`)
is set only by an unscoped `admin` key; any other key gets `403`. The proxy is trusted egress chosen by
the administrator, so its host is deliberately not checked against internal addresses (a loopback or
private-network proxy sidecar works). The destinations of caller-supplied URLs fetched through it are
still vetted by the SSRF guard. Reading the proxy (`GET /api/sessions/:sessionId/proxy`) returns only
the masked host, type and a `hasCredentials` flag.

### Recovering a lost admin key

Keys are stored as one-way hashes, so a lost key cannot be read back from the database: it is either
found where it was saved, or replaced. Try these in order. If every key stopped working at once after
`API_KEY_PEPPER` was set or changed, follow the pepper recovery in §4.4 instead.

1. **The bootstrap key file or `API_MASTER_KEY`.** Whenever `api_keys` is empty (first boot, or
   after step 4 or the §4.4 pepper reset), the gateway seeds an ADMIN key, set to `API_MASTER_KEY`
   when that is set, and keeps it in `data/.api-key` (or the `BOOTSTRAP_KEY_FILE` path) until that
   key is no longer active (revoked, expired or deleted). While the file holds a live key, the
   startup banner ends with `(full key in <path>)`. Read it with `cat data/.api-key`, with
   `docker compose exec openwa-api cat /app/data/.api-key` on the compose deployment, or with
   `kubectl exec openwa-0 -- cat /app/data/.api-key` on the Helm chart (release `openwa`). The file has no
   trailing newline, so the shell prompt follows the key on the same line: copy only the key. The key
   manages keys only while it still meets step 2's conditions. Once it is demoted from `admin`,
   given any `allowedSessions` or `allowedChats`, or given `allowedIps` that exclude your address,
   the file and the banner line remain but key management is refused; go on to step 2 or 3.

2. **Another ADMIN key.** Any active, unexpired `admin` key without `allowedSessions` or
   `allowedChats`, used from an address its `allowedIps` (when set) admits, can manage keys: mint a
   replacement with `POST /api/auth/api-keys` (`{"name": "Admin", "role": "admin"}`; the raw key is
   returned once, in `apiKey`), then revoke the lost one with `POST /api/auth/api-keys/:id/revoke`,
   or do both from the dashboard's API Keys page.

3. **Replace the key in the main database.** With no usable ADMIN key left, give the lost key's row
   a new key; every other key keeps working. Stop the instance and copy the main database aside. Then
   generate a key and its hash with the gateway's own hash function and configuration (process env,
   `.env` and `data/.env.generated`, so the same `API_KEY_PEPPER`). Run it from the gateway's install
   directory, the one holding `dist/` and `data/`, so it reads that install's configuration. When a
   service manager (systemd, pm2) sets `API_KEY_PEPPER` only in its unit, export the same value in
   the shell first, or the hash will not match:

   ```bash
   GEN="require('./dist/config/load-env'); const key = 'owa_k1_' + require('crypto').randomBytes(32).toString('hex'); console.log('key:', key); console.log('prefix:', key.slice(0, 12)); console.log('hash:', require('./dist/modules/auth/api-key-hash').hashApiKey(key, process.env.API_KEY_PEPPER))"
   node -e "$GEN"                                               # source install, after npm run build
   docker compose run --rm --no-deps openwa-api node -e "$GEN"  # compose deployment
   ```

   Find the row and write the new hash and prefix into it. The main database is
   `MAIN_DATABASE_NAME`, `data/main.sqlite` by default; on the compose deployment run `sqlite3` as
   `docker compose run --rm --no-deps openwa-api sqlite3 /app/data/main.sqlite "..."`:

   ```bash
   sqlite3 data/main.sqlite "SELECT id, name, keyPrefix, isActive, expiresAt FROM api_keys WHERE role = 'admin'"
   sqlite3 data/main.sqlite "UPDATE api_keys SET keyHash = '<hash>', keyPrefix = '<prefix>', isActive = 1, expiresAt = NULL, allowedIps = NULL, allowedSessions = NULL, allowedChats = NULL WHERE id = '<id>'"
   ```

   On the Helm chart (release `openwa`), run the generator in the running pod before stopping it,
   since only that pod has the release's Secret and so its `API_KEY_PEPPER`:
   `kubectl exec openwa-0 -- node -e "$GEN"`. Then scale the StatefulSet to 0, start the
   `openwa-restore` helper pod on the release's data PVC as
   [11 - Runbook: Restore from Backup](./11-operational-runbooks.md#runbook-restore-from-backup)
   does, copy the database aside and run both `sqlite3` commands there with
   `kubectl exec openwa-restore --` against `/app/data/main.sqlite`, then delete the pod and scale
   back to 1.

   Start the instance and use the new key. The update also clears the row's revoked state, expiry,
   and IP, session and chat restrictions, so it can manage keys again; narrow it afterwards through
   the API if it carried any. The old key no longer authenticates.

4. **Reset the key table.** When no row is worth keeping, empty `api_keys` as the pepper recovery in
   §4.4 describes: the next boot seeds a new ADMIN key (from `API_MASTER_KEY` when set) and prints
   it once. Every other key must then be re-issued.

Keys live in each node's own main database ([13 - Horizontal Scaling](./13-horizontal-scaling.md)),
so on a multi-node deployment the recovery applies only to the node it ran on.

## 4.3 IP Whitelisting

IP whitelisting adds an extra security layer by restricting API key access to specific IP addresses.

### IP Whitelist Flow

```mermaid
flowchart TB
    REQ[Incoming Request] --> AUTH[API Key Valid?]
    AUTH -->|No| R401[401 Unauthorized]
    AUTH -->|Yes| WL{IP Whitelist Enabled?}
    WL -->|No| ALLOW[Allow Request]
    WL -->|Yes| CHECK{IP in Whitelist?}
    CHECK -->|No| RIP[403 Forbidden]
    CHECK -->|Yes| ALLOW
    ALLOW --> PROCESS[Process Request]
```

### Managing the whitelist

There is **no `/whitelist` sub-resource**. A key's allowed source IPs are the `allowedIps` field on the API key itself, set when you create or update the key via the API-keys endpoints (see §6.4.9 in the [API Specification](./06-api-specification.md)):

```http
POST /api/auth/api-keys
PUT  /api/auth/api-keys/:id
```

```json
{
  "name": "production-server",
  "allowedIps": ["203.0.113.50", "10.0.0.0/24"]
}
```

`allowedIps` accepts exact IPs and CIDR ranges. An empty or absent list means the key is **not** IP-restricted; a non-empty list fails closed (a request whose client IP can't be determined, or isn't in the list, is rejected). To change the whitelist, `PUT` the key with the new `allowedIps` array.

### Implementation

There is no dedicated whitelist guard, service or table — no `IpWhitelistGuard`, no whitelist
sub-resource, no per-entry `active` flag. Enforcement lives in two places:

- **Client IP resolution** — `ApiKeyGuard` calls `resolveClientIp(request, TRUSTED_PROXIES)`
  (`src/common/utils/ip.ts`). `X-Forwarded-For` is client-controllable, so it is honored **only**
  when the request actually arrives from a configured trusted proxy; with no trusted proxies set the
  header is ignored entirely and the direct socket address is used. That is what prevents a caller
  from spoofing its way past the whitelist with a forged header (see §4.6 for `TRUSTED_PROXIES`).
- **Matching** — `AuthService.validateApiKey` compares that IP against the key's `allowedIps` with
  the shared `ipMatches` helper, which accepts an exact address or CIDR notation and returns `false`
  on a malformed entry rather than coercing it into range. With a whitelist configured, an
  undeterminable client IP throws `ForbiddenException` (`403`) straight away, with no log line;
  an IP outside the list writes a `logger.warn` with `action: 'ip_rejected'` first, then throws the
  same `403`. The key itself is valid, so the refusal is a `403` like the role and chat refusals, and
  a client does not read it as a key to discard.

### Best Practices

| Practice              | Description                                               |
| --------------------- | --------------------------------------------------------- |
| **Use CIDR notation** | For IP ranges, use CIDR instead of multiple entries       |
| **Trusted Proxies**   | Configure trusted proxies for accurate client IP          |
| **Regular Review**    | Review whitelist entries regularly                        |
| **Audit Logging**     | Log all blocked attempts for monitoring                   |
| **Fallback Plan**     | Prepare a process to update the whitelist when IPs change |

### IPv6 Support

`allowedIps` accepts IPv4 addresses and IPv4 CIDR ranges only; the API rejects an IPv6 entry. An
IPv6 address or range already stored on a key created before v0.4.3, when the API started rejecting
them, is matched as written. An IPv4-mapped client address (`::ffff:203.0.113.50`) is normalized to its bare IPv4 address before
matching. `TRUSTED_PROXIES` accepts IPv4 and IPv6 addresses and CIDR ranges (for example `fd00::/8`);
an entry that is neither is ignored, with a warning at boot. A trusted proxy that appends a port to
an `X-Forwarded-For` hop (`203.0.113.7:51000`, `[2001:db8::1]:443`) resolves to the bare address.

## 4.4 Data Encryption

### In Transit

OpenWA serves plain HTTP on its port; terminate **TLS at your reverse proxy / load balancer** (nginx, Traefik, Caddy) and expose the gateway only over HTTPS in production. The API key is bearer-equivalent and is sent on every request, so it must never traverse plaintext `http://` outside local development.

### At Rest

> **There is currently no application-level encryption at rest.** API keys are stored **hashed** (one-way) in the database, but the first-boot key file and other sensitive values are stored as plaintext in the database / on disk and are protected by filesystem and database permissions, not by encryption. Encryption at rest for these fields is a roadmap item, not a shipped feature — do not assume it.

| Data                                                                | At rest                                                                                                       | How it is protected                                                                                                                                                                                                                                                                                                                   |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API keys                                                            | **Hashed** — SHA-256 with an optional `API_KEY_PEPPER` HMAC; never reversible                                 | A database leak alone cannot recover the keys; with a pepper set, hashes can't be precomputed offline. See §4.2.                                                                                                                                                                                                                      |
| Session auth state (WhatsApp credentials)                           | Plaintext on disk (the engine's auth store under the data volume)                                             | Filesystem permissions on the data volume — keep it private.                                                                                                                                                                                                                                                                          |
| Webhook secrets and custom headers                                  | Plaintext: `webhooks.secret` (`varchar`) and custom `webhooks.headers` (JSON, may carry receiver credentials) | Database access control; never returned by the webhook read DTOs (write-only) and omitted from `GET /api/infra/export-data` webhook rows.                                                                                                                                                                                             |
| Proxy credentials                                                   | Plaintext — `sessions.proxyUrl` may embed `user:pass`                                                         | Database access control; never returned by the session read DTOs — only masked `proxyHost`, `proxyType` (derived from the URL scheme), and `hasCredentials` on `GET/PATCH /proxy`. The userinfo is also stripped from `GET /api/infra/export-data` session rows; scheme and host survive so a restore cannot silently connect direct. |
| Bootstrap admin key file (`data/.api-key`, or `BOOTSTRAP_KEY_FILE`) | Plaintext raw ADMIN key, written `0600` on first boot                                                         | Owner-only file permissions; removed when the key it holds is revoked or deleted. Delete it once the key is stored elsewhere.                                                                                                                                                                                                         |
| Plugin instance secrets                                             | Plaintext: `plugin_instances.secret`, `verifyToken`, `config`                                                 | Database access control; `secret`, `verifyToken` and the config fields a plugin marks secret are masked on API reads, but `GET /api/infra/export-data` includes all three verbatim, so treat an export file as a secret.                                                                                                              |
| Generated config (`data/.env.generated`)                            | Plaintext file, written `0600`                                                                                | Owner-only file permissions.                                                                                                                                                                                                                                                                                                          |
| Message content                                                     | Plaintext in the `messages` table                                                                             | Database access control.                                                                                                                                                                                                                                                                                                              |

**Hardening you can apply today:** set `API_KEY_PEPPER`; restrict the data volume and database to the app's user; and encrypt at the infrastructure layer (LUKS / cloud-provider encrypted volumes / an encrypted managed Postgres) rather than relying on application-level field encryption, which is not implemented.

**Setting or changing `API_KEY_PEPPER` on an existing install invalidates every key, the admin key included.** No stored hash matches any more, minting a key needs a valid ADMIN key, and a key is seeded only into an empty `api_keys` table, so nothing can be re-issued through the API. Set the pepper before first boot. On a running install: stop the instance, set the pepper, empty the table in the main database (the `MAIN_DATABASE_NAME` path, `data/main.sqlite` by default; on a source install `sqlite3 data/main.sqlite "DELETE FROM api_keys"`, on the compose deployment `docker compose run --rm --no-deps openwa-api sqlite3 /app/data/main.sqlite "DELETE FROM api_keys"`), start the instance, take the new ADMIN key from the startup banner or `data/.api-key` (or set `API_MASTER_KEY` beforehand to choose it), then re-issue the other keys. On the Helm chart, empty the table while the StatefulSet is at 0 from the `openwa-restore` helper pod of [11 - Runbook: Restore from Backup](./11-operational-runbooks.md#runbook-restore-from-backup) (`kubectl exec openwa-restore -- sqlite3 /app/data/main.sqlite "DELETE FROM api_keys"`), delete the pod, and only then set the pepper in the release's Secret (`secretEnv.API_KEY_PEPPER`, or the `existingSecret`) and scale back to 1; a `helm upgrade` that sets the pepper also scales back to `replicaCount`. Read the new key with `kubectl exec openwa-0 -- cat /app/data/.api-key`.

## 4.5 Input Validation

### Validation Rules

```mermaid
flowchart TB
    INPUT[User Input] --> V1{Type Check}
    V1 -->|Pass| V2{Length Check}
    V1 -->|Fail| ERR[400 Error]
    V2 -->|Pass| V3{Format Check}
    V2 -->|Fail| ERR
    V3 -->|Pass| V4{Sanitize}
    V3 -->|Fail| ERR
    V4 --> SAFE[Safe Input]
```

### Validation Examples

| Field                        | Rules                                                                                                                                                                      |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chatId`                     | Non-empty string of at most 255 characters — **no format pattern**; the engine resolves the id, so `@c.us`, `@g.us`, `@lid`, `@newsletter` and `status@broadcast` all pass |
| `phoneNumber` (pairing code) | Pattern: `^[0-9]{6,15}$` — digits only, international format                                                                                                               |
| `url`                        | Valid URL (`require_tld: false`, so single-label hosts like `http://localhost:3000` pass); HTTPS is a recommendation, not enforced                                         |
| `text`                       | Max 4096 chars (`send-text`)                                                                                                                                               |
| `sessionName`                | Alphanumeric + hyphen, 3-50 chars                                                                                                                                          |

### DTO Validation

```typescript
// src/modules/message/dto/send-message.dto.ts
export class SendTextMessageDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(CHAT_ID_MAX_LENGTH) // 255
  chatId: string;

  @IsString()
  @IsNotEmpty()
  @MaxLength(MESSAGE_TEXT_MAX_LENGTH) // 4096, shared with the agent-tool input schemas
  text: string;
}

// src/modules/webhook/dto/webhook.dto.ts
export class CreateWebhookDto {
  // require_tld:false allows hostnames without a dot (e.g. http://localhost:3000); the SSRF
  // guard still decides whether the host may actually be delivered to. The scheme is required
  // and must be http(s); 2048 is the column width.
  @IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] })
  @MaxCodePoints(2048)
  url: string;

  @IsOptional()
  @IsArray()
  @ArrayMinSize(1)
  // The full WEBHOOK_EVENTS catalog (message.*, status.received, session.*, presence.update,
  // event.response, group.*, call.*) plus '*' for subscribe-all.
  @IsIn([...WEBHOOK_EVENTS, '*'], { each: true })
  events?: string[];
}
```

## 4.6 Rate Limiting

### Rate Limit Configuration

```mermaid
flowchart LR
    REQ[Request] --> RL{Rate Limiter}
    RL -->|Under Limit| APP[Application]
    RL -->|Over Limit| ERR[429 Too Many Requests]

    subgraph Limits["Windows (per route, per client IP)"]
        T1[short: 10 / 1s]
        T2[medium: 100 / 60s]
        T3[long: 1000 / 1h]
    end
```

### Windows

Each window is counted **per route handler per client IP** (the IP resolved through `TRUSTED_PROXIES`) by a global `ThrottlerGuard`, so a client gets the full budget on every endpoint; for an aggregate per-client cap, add a limiter at your reverse proxy. There is **no per-endpoint limit table**: these three windows apply to every non-exempt REST controller route, and exceeding any one returns `429 Too Many Requests`:

| Window   | Default limit | Window length | Env overrides                                       |
| -------- | ------------- | ------------- | --------------------------------------------------- |
| `short`  | 10 requests   | 1 s           | `RATE_LIMIT_SHORT_TTL` / `RATE_LIMIT_SHORT_LIMIT`   |
| `medium` | 100 requests  | 60 s          | `RATE_LIMIT_MEDIUM_TTL` / `RATE_LIMIT_MEDIUM_LIMIT` |
| `long`   | 1000 requests | 3600 s        | `RATE_LIMIT_LONG_TTL` / `RATE_LIMIT_LONG_LIMIT`     |

TTL values are in milliseconds. The `/api/metrics` and `/api/health*` routes are exempt (`@SkipThrottle`), and the ingress route has its own windows (below); `/api/health` still looks up at most 30 failed key presentations per client per minute, and past that answers without `version` and without a key lookup. The MCP endpoint (`/mcp`) and the Bull Board UI (`/api/admin/queues`) are raw Express mounts the guard never sees: RATE_LIMIT_* does not apply to them, and each is bounded by its own pre-auth per-IP throttle (`MCP_IP_RATE_LIMIT_MAX` / `MCP_IP_RATE_LIMIT_WINDOW_MS`). To enforce tighter per-route limits, lower the global windows or add a limiter at your reverse proxy.

An IPv6 client is keyed on its /64, so rotating addresses inside one allocation does not mint fresh buckets. The same key is used by every other per-client limit: the MCP and Bull Board pre-auth throttles, the WebSocket limits below, the per-client share of the in-flight body budget for requests without an API key, the health route's key-lookup budget and auth-failure audit bound, and the REST, queue-dashboard and MCP auth-failure audit bound. `allowedIps` matching and audit rows still see the full address.

### Response on limit

Exceeding a window returns `429 Too Many Requests`. Because the windows are **named** throttlers (`short` / `medium` / `long`), `@nestjs/throttler` suffixes every rate-limit header with the throttler name, so there are no unsuffixed `X-RateLimit-*` headers. `Retry-After` is the exception: on a `429` the guard emits the plain spelling too, because no HTTP client reads a suffixed one.

- On success, each response carries one header triple per window: `X-RateLimit-Limit-short` / `-Remaining-short` / `-Reset-short`, plus the `-medium` and `-long` equivalents.
- On `429`, the exceeded window sets `Retry-After-short`, `Retry-After-medium`, or `Retry-After-long` (seconds until the block expires), and the guard mirrors that value into a plain `Retry-After`. Read the plain one; the suffixed name is what tells an operator which window shed the request.

The ingress route (`ALL /api/ingress/:pluginId/:instanceId/*path`) is exempt from the global per-IP tiers (their 100/min medium window sits below the per-instance limit, so a provider fanning every tenant's webhooks through one egress IP was shed before the per-instance bound could fire). It carries its own two windows instead, both on `INGRESS_INSTANCE_TTL` (default 60000 ms):

- `instance`, keyed on `(pluginId, instanceId)`, env `INGRESS_INSTANCE_LIMIT`, default 120. Sheds one noisy tenant on this bucket without spending its neighbours'. Charged only once a delivery passes signature verification, so unknown-instance, failed-challenge and oversized requests never touch it. A route declaring signature scheme `none` (allowed only with `ALLOW_UNSIGNED_INGRESS=true`) passes every request, so there any caller can spend this bucket.
- `ingress-ip`, keyed on the client (proxy-aware, see `TRUSTED_PROXIES`), env `INGRESS_IP_LIMIT`, default 1200. It is checked before anything else and is the only bound on traffic that fails verification. Every ingress request counts against it, including one the `instance` window then sheds, so a tenant that pushes a shared provider IP past it sheds its neighbours on that IP too. It is sized 10x the per-instance default; raise it for a high-volume provider that delivers many tenants from one egress IP.

Every response the per-IP window admits carries `X-RateLimit-*-ingress-ip`, an acknowledged delivery also carries `X-RateLimit-*-instance`, and a `429` carries the `Retry-After-*` of whichever window shed the request, mirrored into a plain `Retry-After`.

The API exposes the rate-limit headers via CORS (`exposedHeaders`) so browser clients can read them, the plain `Retry-After` included. The simplest backpressure signal remains the `429` status itself, with `Retry-After` as the retry delay.

### In-flight request bodies

The windows above run at the routing layer, after a request body has been buffered, so they cannot bound memory held by slow uploads. A pre-routing middleware does: it caps the request-body bytes in flight across all connections at `INFLIGHT_BODY_BUDGET_BYTES` (default 4 x `BODY_SIZE_LIMIT`) and answers a request that would cross it with `503` and `Retry-After`, before reading its body. A request whose declared `Content-Length` could not fit even on an idle server (above the whole budget, its caller's share or the unkeyed pool) gets `413` instead, without `Retry-After`, since retrying it cannot succeed.

- Requests without an active API key (public ingress webhooks, unauthenticated callers) share a pool of a quarter of the budget, never less than twice `BODY_SIZE_LIMIT` (so half of the default budget), and are charged at most `BODY_SIZE_LIMIT` each. The rest is kept for requests that carry an active key. With `INFLIGHT_BODY_BUDGET_BYTES` below three times `BODY_SIZE_LIMIT` that reserve is smaller than one full-size body, and at twice `BODY_SIZE_LIMIT` or less there is none.
- A request with an active key may hold at most half of the budget per key, wherever it connects from. An unkeyed client (IPv6 on its /64) may hold at most half of the unkeyed pool, or one `BODY_SIZE_LIMIT` if that is larger, so one full-size body from one address leaves room for other unkeyed senders once `INFLIGHT_BODY_BUDGET_BYTES` is at least twice `BODY_SIZE_LIMIT`; below that the room left is the budget minus one `BODY_SIZE_LIMIT`, and at a budget equal to `BODY_SIZE_LIMIT` a single full-size unkeyed body refuses every other body-carrying request, ingress deliveries included, with `503` until it finishes. Behind a reverse proxy without `TRUSTED_PROXIES`, every unkeyed request, ingress included, keys on the proxy address and draws on that one share, so at the defaults a single full-size unkeyed upload refuses ingress deliveries with `503` until it finishes.
- Keys are recognised from an in-memory index of active keys, refreshed at once when this node changes a key and every 30 seconds for a direct write to this node's main database. Keys are per node, so a change made on another node never reaches it. The index only picks the tier; the API-key guard still authorises every request.
- A request holds its declared `Content-Length` until it finishes. After a 15-second grace it must keep up the pace that lands the whole body within `REQUEST_TIMEOUT_MS` (by default 25 MiB in 300 seconds, about 85 KiB/s); a request that falls behind is dropped and its reservation released. A chunked body holds a 1 MiB placeholder, or the bytes that have arrived once they pass it, and must deliver the placeholder at the same pace (about 3.4 KiB/s by default). If that growth crosses the budget, its client's share, or (without a key) the unkeyed pool, that request is aborted.
- A body that sends nothing for 15 seconds is dropped and its reservation released.
- Requests without a body are never refused for lack of budget.

### WebSocket (`/events`) limits

Socket.IO frames never pass through the Nest enhancer pipeline, so the HTTP windows above do **not** apply to the WebSocket surface. `EventsGateway` enforces its own in-process limits instead (all keyed in-memory per process; a blank env value falls back to the default, and one that is not a positive integer fails the boot):

| Limit                                                               | Keyed on                                                        | Default                                | Env overrides                                                       |
| ------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------- |
| Client frames (subscribe/unsubscribe/ping) — token bucket           | API key (IP, IPv6 on its /64, before auth completes)            | 60 frames/s sustained, 120-frame burst | `WS_RATE_LIMIT_FRAME_PER_SECOND` / `WS_RATE_LIMIT_FRAME_BURST`      |
| New handshakes — sliding window, enforced **before** key validation | client IP (resolved through `TRUSTED_PROXIES`, IPv6 on its /64) | 10 per 60 s                            | `WS_RATE_LIMIT_HANDSHAKE_MAX` / `WS_RATE_LIMIT_HANDSHAKE_WINDOW_MS` |
| Simultaneous sockets                                                | API key                                                         | 16                                     | `WS_MAX_SOCKETS_PER_KEY`                                            |

The frame budget is sized ~6x above legitimate traffic: the dashboard emits ~8 subscribe frames at page mount and only occasional ping/unsubscribe frames afterwards (server→client event fan-out is not limited). The handshake window stops an unauthenticated connection flood from forcing a DB `validateApiKey` per attempt; Socket.IO's exponential-backoff reconnect (~6 attempts/min per tab) stays under it. The socket cap covers multi-tab dashboards and SDK clients sharing one key. A rejected handshake or excess socket is answered with a `RATE_LIMITED` error frame and a clean disconnect; an over-budget frame gets a `RATE_LIMITED` error frame and is not dispatched. Violations are audited as `rate_limit_exceeded`, sampled to at most one row per subject+kind per minute (suppressed occurrences are counted into the next row) so the audit trail itself cannot become the flood.

## 4.7 CORS Configuration

### CORS Settings

```
/* Detailed source-code truncated for AI context efficiency. */
```

## 4.8 Webhook Security

### Webhook Signature

```mermaid
sequenceDiagram
    participant OW as OpenWA
    participant WH as Webhook Endpoint

    OW->>OW: Create payload
    OW->>OW: Sign with HMAC-SHA256
    OW->>WH: POST + X-OpenWA-Signature
    WH->>WH: Verify signature
    WH->>WH: Process if valid
    WH-->>OW: 200 OK
```

### Signature Verification

```typescript
// OpenWA: Generate signature
function signPayload(payload: object, secret: string): string {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(JSON.stringify(payload));
  return 'sha256=' + hmac.digest('hex');
}

// Client: Verify signature
function verifySignature(payload: string, signature: string | undefined, secret: string): boolean {
  if (typeof signature !== 'string') return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);

  // timingSafeEqual throws on a length mismatch, so a short or forged header must return false first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Fan-out and Payload Bounds

A single inbound event is delivered to **every** matching webhook of the session, and media arrives
from unauthenticated WhatsApp senders — so the copy amplification is bounded at four points:

| Bound                         | Knob                                      | Default                            | Behavior                                                                                                                                                                             |
| ----------------------------- | ----------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Webhooks per session          | `WEBHOOK_MAX_PER_SESSION`                 | 16 (`0` = unlimited)               | Creating a NEW webhook at/over the cap is rejected with `400`; existing webhooks are grandfathered                                                                                   |
| Autoreply rules per session   | `AUTOMATION_MAX_PER_SESSION`              | 32 (`0` = unlimited)               | Creating a NEW rule at/over the cap is rejected with `400`; existing rules are grandfathered. Every inbound message is evaluated against every rule of its session                   |
| Inline media in payloads      | `WEBHOOK_MEDIA_INLINE_MAX_BYTES`          | 1 MiB (`0` = never inline)         | Larger media is replaced once, before per-webhook cloning and WebSocket broadcast, with `media: { mimetype, filename?, omitted: true, sizeBytes }`                                   |
| Serialized body size          | `WEBHOOK_MAX_PAYLOAD_BYTES`               | 1 MiB                              | Over-budget bodies shed any remaining inline media (marker form) and are re-checked; still over budget → recorded as undelivered, never sent/queued                                  |
| Failed-job retention in Redis | queue `removeOnComplete` / `removeOnFail` | 1h/1000 completed, 24h/5000 failed | Finished jobs auto-evict; payloads were already media-shed before enqueue, so retained jobs stay small. The durable record of a lost delivery is the `webhook_delivery_failures` row |

Each webhook still receives its own copy of the event data — a `webhook:before` hook may mutate
`payload.data` in place and must not bleed into sibling deliveries — but the copy is taken after
media shedding, so it is small. The HMAC signature is computed over the exact shed bytes sent.

The two media bounds compound. `WEBHOOK_MAX_PAYLOAD_BYTES` is measured on the serialized JSON body
and applies after `WEBHOOK_MEDIA_INLINE_MAX_BYTES`; base64 inflates media by a third, so at the
defaults media above roughly 768 KiB reaches webhooks as the omitted marker. Raise both together,
with the payload limit at least 4/3 of the inline limit plus room for the envelope. The WebSocket
gateway applies only the inline limit. Media the engine downloaded stays retrievable from
`GET /api/sessions/:sessionId/messages/:chatId/:messageId/media` (`404` when nothing was stored).

A `webhook_delivery_failures` row holds no event data by default. With
`WEBHOOK_FAILURE_PAYLOAD_RETENTION_HOURS` > 0 a terminal row also keeps the pre-hook event data
(already media-shed, so at most about `WEBHOOK_MEDIA_INLINE_MAX_BYTES` of media) so an ADMIN can
replay it with `POST /api/webhooks/delivery-failures/redrive`. That is message content at rest in
the data database: it is cleared once the window passes (checked at startup and then hourly; setting
the knob back to `0` clears every stored copy on the next start), it is never returned by the list
route, and `GET /api/infra/export-data` drops it from backups. With the queue enabled each job also
carries the copy in Redis for the queue's retention window.

## 4.9 Security Headers

### Helmet Configuration

The shipped configuration lives in `src/configure-app.ts` (the HTTP stack `main.ts` and the e2e suites both install), and that file is the source of truth:

```typescript
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      // The dashboard bundles its fonts; Bull Board's Google Fonts link is blocked (system-font fallback).
      styleSrc: ["'self'", "'unsafe-inline'"],
      // Per-response nonce; the served dashboard document carries the same value.
      scriptSrc: ["'self'", (_req, res) => `'nonce-${res.locals.cspNonce}'`],
      // blob: for the outgoing attachment preview, data: for chat media rendered inline.
      imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
      mediaSrc: ["'self'", 'data:', 'blob:', 'https:'],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      // On in production, unless CSP_UPGRADE_INSECURE_REQUESTS opts an HTTP-only
      // private-network deployment out (#611).
      upgradeInsecureRequests: isUpgradeInsecureRequestsEnabled(...) ? [] : null,
    },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  noSniff: true,
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  crossOriginResourcePolicy: { policy: 'cross-origin' }, // API usage
}));
```

### Security Headers Checklist

| Header                      | Value                                          | Purpose                                                                       |
| --------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------- |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` | Force HTTPS                                                                   |
| `X-Content-Type-Options`    | `nosniff`                                      | Prevent MIME sniffing                                                         |
| `X-Frame-Options`           | `SAMEORIGIN`                                   | Prevent clickjacking (helmet's default; not overridden)                       |
| `X-XSS-Protection`          | `0`                                            | Helmet 8 deliberately disables the legacy auditor — the CSP is the protection |
| `Referrer-Policy`           | `strict-origin-when-cross-origin`              | Control referrer                                                              |

## 4.10 Audit Logging

### What Gets Logged

> **Reality check:** persisted audit coverage currently includes API-key create/update/revoke/delete
> (updates carry before/after authorization state), rejected authentication, session lifecycle
> (including disconnects caused by WhatsApp unlinking the device, account restrictions and their
> lifting, and refused rebinds), integration-instance creation, update, secret rotation, deletion,
> redrive, and scope-binding bridge failures, plugin install, update, uninstall, enable, disable, configuration and session
> activation, webhook delivery-failure redrive batches, send-pacing refusals (sampled) and breaker trips, the
> infra operations (config save, restart request, data export/import, storage export/import),
> WebSocket rate-limit violations (sampled — see §4.6), and Bull Board queue mutations. Enum
> members for API-key use, session connected, message sends, and webhook lifecycle are explicitly
> registered as intentionally unemitted; application logs cover those operational events until dedicated
> audit callsites are added. There is no global audit interceptor.

```mermaid
flowchart TB
    subgraph Events["Logged Events"]
        AUTH[Authentication attempts]
        SESS[Session operations]
        MSG[Message sends]
        WH[Webhook changes]
        ERR[Security errors]
    end

    Events --> LOG[Audit Log]
    LOG --> STORE[(Storage)]
    LOG --> ALERT[Alerts]
```

### Log Format

```json
{
  "id": "uuid",
  "action": "session_started",
  "severity": "info",
  "apiKeyId": "uuid",
  "sessionId": "sess_123",
  "ipAddress": "192.168.1.1",
  "method": "POST",
  "path": "/api/sessions/sess_123/start",
  "statusCode": 201,
  "userAgent": "MyApp/1.0",
  "metadata": {},
  "createdAt": "2026-02-02T10:00:00.000Z"
}
```

`action` is an `AuditAction` enum value (snake_case); `severity` is `info` / `warn` / `error`. There is no `requestId` or `responseTime` **column**, but the active request id is merged into the `metadata` object of every row written inside a request scope, so an entry still correlates with the application log lines for that request. There is no global request-logging interceptor — entries are written explicitly by the code paths that emit them.

### Security Alerts

> **Not implemented.** There is no alerting or automatic temp-block subsystem; the table below is a design target, not shipped behavior. The signals do get recorded: rejected authentication and WebSocket rate-limit violations write persisted audit rows, and an IP-restricted key used from a disallowed IP also emits a `logger.warn`; but nothing acts on them. A REST, queue-dashboard or MCP rejection that names no stored key (missing or unknown) is capped at 10 rows per client IP per minute; a rejection of a stored key (revoked, expired, IP or session refused, insufficient role) is recorded every time there. The public `/api/health` route bounds every auth-failure row it writes, stored key or not, at 10 per client IP per minute. Rate-limit violations are sampled. Forward the audit log / application log to your SIEM to build these alerts.

| Event                | Severity | Intended action (roadmap) |
| -------------------- | -------- | ------------------------- |
| Multiple failed auth | High     | Alert + temp block        |
| Rate limit exceeded  | Medium   | Log + block               |
| Invalid signature    | Medium   | Log                       |
| Unusual activity     | Low      | Log                       |

## 4.11 Security Checklist

### Development

- [ ] Input validation on all endpoints
- [ ] SQL injection prevention (parameterized queries)
- [ ] XSS prevention (output encoding)
- [ ] CSRF protection (if using cookies)
- [ ] Secure dependencies (npm audit)
- [ ] No secrets in code

### Deployment

- [ ] HTTPS only (TLS 1.2+)
- [ ] Security headers configured
- [ ] Rate limiting enabled
- [ ] CORS properly configured
- [ ] Firewall rules set
- [ ] Regular security updates
- [ ] Release image (releases after 0.23.7; earlier images carry no attestation) verified as built by the release workflow at its tag: `gh attestation verify oci://ghcr.io/rmyndharis/openwa:<version> --repo rmyndharis/OpenWA --signer-workflow rmyndharis/OpenWA/.github/workflows/release.yml --source-ref refs/tags/v<version>` (the same digest, and so the same check, applies to `docker.io/rmyndharis/openwa`)

### Operations

- [ ] Audit logging enabled
- [ ] Log monitoring setup
- [ ] Backup encryption
- [ ] Incident response plan
- [ ] Regular security audits

---

## 4.12 Secrets Management

### Secrets Inventory

| Secret                                                                       | Storage                                                                                                                                                           | Rotation guidance                                                  |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Database credentials                                                         | Environment variable                                                                                                                                              | 90 days                                                            |
| Redis password                                                               | Environment variable                                                                                                                                              | 90 days                                                            |
| API master key (`API_MASTER_KEY`)                                            | Environment variable (first-boot seed only)                                                                                                                       | Not via the env var; see the note below                            |
| API key pepper (`API_KEY_PEPPER`)                                            | Environment variable                                                                                                                                              | Rotating it invalidates all existing key hashes (recovery in §4.4) |
| Webhook secrets and custom headers (`webhooks.secret`, `headers`)            | Database: **plaintext** (custom headers may carry receiver credentials); not in the webhook read DTOs, and omitted from `GET /api/infra/export-data` webhook rows | Per webhook                                                        |
| Bootstrap admin key file (`data/.api-key`, or `BOOTSTRAP_KEY_FILE`)          | File system: raw ADMIN key in **plaintext**, `0600`; removed when that key is revoked or deleted                                                                  | Delete it once the key is stored elsewhere                         |
| Plugin instance secrets (`plugin_instances.secret`, `verifyToken`, `config`) | Database: **plaintext**; `secret`, `verifyToken` and secret-marked config are masked on API reads, but `GET /api/infra/export-data` includes them verbatim        | Per instance (regenerate-secret)                                   |
| Session auth state                                                           | File system (data volume) — **not encrypted**                                                                                                                     | Never (tied to the WA session)                                     |

> `API_MASTER_KEY` only seeds the first ADMIN key, and is read only while the key table is empty. Changing it later has no effect: the new value never authenticates and the seeded key stays valid. A value that is set must still be at least 32 characters and not a known default on every production boot, or the boot refuses to start; unset it after rotating. Rotate by minting a new ADMIN key with `POST /api/auth/api-keys` and revoking the seeded `Default Admin Key`.

> There is no application `ENCRYPTION_KEY` — OpenWA does not encrypt data at rest (see §4.4). The rotation cadences above are operational recommendations, not enforced by the app.

### Environment Variables Security

```bash
# ❌ BAD: Secrets hard-coded in a committed file (docker-compose.yml, Dockerfile, source)
DATABASE_PASSWORD=password123

# ✅ GOOD: Use .env file (not committed)
DATABASE_PASSWORD=${DATABASE_PASSWORD}

# ✅ BETTER: Use Docker secrets or vault
docker secret create db_password ./secret.txt
```

### Docker Secrets

> **Caveat:** the `*_FILE` convention shown below requires a secret-file reader in the app (see "Reading Secrets" below), which is **not currently implemented** — OpenWA reads secrets straight from environment variables. Until that helper exists, pass secrets as plain env vars (e.g. an `.env` file with restricted permissions) rather than `_FILE` paths.

```yaml
# Illustrative overlay — not the docker-compose.yml shipped in this repo
services:
  app:
    image: openwa:latest
    secrets:
      - db_password
      - api_master_key
    environment:
      - DATABASE_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    external: true
  api_master_key:
    external: true
```

### Reading Secrets in Application

> **Not implemented as shown.** OpenWA does **not** read `<NAME>_FILE` Docker-secret files — there is no `getSecret()` helper today. Secrets come straight from `process.env`, layered at boot as `process.env` → `.env` → `data/.env.generated` (`override:false`, so a real environment value wins). The function below is a suggested pattern to add if you want Docker-secret `_FILE` support; as-is, `DATABASE_PASSWORD_FILE` is not consulted.

```typescript
// config/secrets.ts
import { readFileSync, existsSync } from 'fs';

export function getSecret(name: string): string {
  // Try file-based secret first (Docker secrets)
  const filePath = process.env[`${name}_FILE`];
  if (filePath && existsSync(filePath)) {
    return readFileSync(filePath, 'utf8').trim();
  }

  // Fall back to environment variable
  const envValue = process.env[name];
  if (!envValue) {
    throw new Error(`Secret ${name} not configured`);
  }

  return envValue;
}

// Usage
const dbPassword = getSecret('DATABASE_PASSWORD');
const masterKey = getSecret('API_MASTER_KEY');
```

### Key Rotation Procedure

> **Not applicable today.** OpenWA stores no encrypted-at-rest data (see §4.4), so there is no data-encryption key to rotate and no `rotateEncryptionKey()` in the codebase. The flow below is illustrative for if/when field-level encryption is added. To rotate the key seeded from `API_MASTER_KEY`, mint a new ADMIN key through the API-key endpoints (§4.2) and revoke the seeded one; editing the env var has no effect after first boot. Rotating `API_KEY_PEPPER` invalidates every existing key hash; see §4.4 for the recovery.

```mermaid
flowchart TB
    A[Generate New Key] --> B[Update Secret Store]
    B --> C[Deploy with Both Keys]
    C --> D[Re-encrypt Data with New Key]
    D --> E[Verify All Data Accessible]
    E --> F[Remove Old Key]
    F --> G[Deploy with New Key Only]
```

```typescript
// Key rotation for encrypted data
async function rotateEncryptionKey(oldKey: string, newKey: string): Promise<void> {
  // 1. Get all encrypted records
  const sessions = await sessionRepo.find();

  for (const session of sessions) {
    // 2. Decrypt with old key
    const authState = decrypt(session.authState, oldKey);

    // 3. Re-encrypt with new key
    session.authState = encrypt(authState, newKey);

    await sessionRepo.save(session);
  }

  logger.log('Key rotation completed', {
    recordsUpdated: sessions.length,
  });
}
```

## 4.13 Dependency Security

### npm Audit Workflow

```bash
# Check for vulnerabilities
npm audit

# Auto-fix non-breaking vulnerabilities
npm audit fix

# View detailed report
npm audit --json > audit-report.json
```

### GitHub Dependabot Configuration

```yaml
# .github/dependabot.yml, root npm ecosystem only (the file also covers /dashboard,
# /sdk/javascript, /sdk/python, /sdk/java, /sdk/php, /sdk/go, github-actions, docker and docker-compose). The comment above each
# ignore is omitted here.
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
      day: monday
    open-pull-requests-limit: 5
    groups:
      minor-and-patch:
        update-types:
          - minor
          - patch
      major:
        update-types:
          - major
    labels:
      - dependencies
    ignore:
      - dependency-name: 'typescript'
        versions: ['>=7.0.0']
      - dependency-name: 'better-sqlite3'
        versions: ['>=14.0.0']
      - dependency-name: 'puppeteer'
        versions: ['>24.38.0']
      - dependency-name: 'audio-decode'
        versions: ['>=3.0.0']
      - dependency-name: '@types/node'
        versions: ['>=23.0.0']
      - dependency-name: '@nestjs/*'
        versions: ['>=12.0.0']
      - dependency-name: 'tar-stream'
        versions: ['>=3.2.1']
```

Majors arrive as their own grouped PR, separate from the minor/patch group. Five ignores freeze a
major line (TypeScript 7, better-sqlite3 14, audio-decode 3, @types/node 23 and later, NestJS 12)
and two freeze the line in use (puppeteer above the 24.38.0 pin it shares with whatsapp-web.js,
tar-stream from 3.2.1). Each ignore's reason and lift condition is the comment above it in
`.github/dependabot.yml`.

### Security Scanning in CI

> **Aspirational template, not in the repo.** There is no Snyk and no CodeQL workflow today. The actual dependency check is a dedicated `audit` job ("Security audit") in `ci.yml`, on push/PR. It is deliberately its own job rather than a step inside Lint: an advisory published against an unrelated dependency would otherwise abort the job before ESLint, the type-check and the drift gates ever ran. It runs `npm run check:audit` over the root tree and `npm audit --audit-level=high` over `dashboard/` and `sdk/javascript/`. All three fence `high` rather than `critical`: none of the trees carries a HIGH advisory today (the root reaches that through the `overrides` in `package.json`), so the threshold fences regressions. `check:audit` applies that threshold per advisory instead of all-or-nothing: an advisory with no patched version can be excused by id in `scripts/check-audit.mjs`, with its reason and its removal condition recorded beside it, rather than dropping the whole job to `critical`; an allowlist entry whose advisory has since gone fails the job too, so an exception cannot outlive its cause. The dashboard and the SDK keep the plain form: they have nothing to excuse and stay stricter than the root. Between releases, `.github/workflows/security-scan.yml` (Scheduled Security Scan) repeats that job every Wednesday at 03:00 UTC and on demand, together with the release Trivy scan against the published `latest` image on amd64 and arm64, and a `base-image-drift` job that fails when the Dockerfile's two `node:22-slim` FROM digests disagree, or when the pin differs from what the tag serves today and either the tag has served that image for 7 or more days, or for 3 or more days with the pin last changed 28 or more days ago. The workflow below is a recommended setup to add if you want Snyk and SAST; its scheduled `npm audit` is already covered by `security-scan.yml`.

```yaml
# .github/workflows/security.yml
name: Security Scan

on:
  push:
    branches: [main]
  schedule:
    - cron: '0 0 * * 1' # Weekly on Monday

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Install dependencies
        run: npm ci

      - name: Run npm audit
        run: npm audit --audit-level=high

      - name: Run Snyk security scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

      - name: SAST with CodeQL
        uses: github/codeql-action/analyze@v2
```

### Allowed/Blocked Packages

Security pins for transitive dependencies live in the `overrides` field of `package.json` (for example `"multer": "2.4.0"` and `"ip-address": "^10.7.1"`), and the `audit` CI job gates regressions through `npm run check:audit` (see above). No install hook fetches an unpinned package: `postinstall` runs `npm ci` against the dashboard lockfile and the repository's own patch scripts.

### Vulnerability Response Matrix

| Severity | Response Time | Action                     |
| -------- | ------------- | -------------------------- |
| Critical | 24 hours      | Immediate patch or disable |
| High     | 72 hours      | Patch in next release      |
| Medium   | 2 weeks       | Plan for next sprint       |
| Low      | 1 month       | Backlog item               |

## 4.14 Incident Response

### Incident Severity Levels

| Level         | Description               | Example                | Response Time |
| ------------- | ------------------------- | ---------------------- | ------------- |
| P1 - Critical | Service down, data breach | Auth bypass, data leak | 15 minutes    |
| P2 - High     | Major feature broken      | Session creation fails | 1 hour        |
| P3 - Medium   | Partial degradation       | Slow webhook delivery  | 4 hours       |
| P4 - Low      | Minor issue               | UI glitch              | 24 hours      |

### Incident Response Flow

```mermaid
flowchart TB
    DETECT[Detect Incident] --> ASSESS[Assess Severity]
    ASSESS --> CONTAIN[Contain Threat]
    CONTAIN --> NOTIFY[Notify Stakeholders]
    NOTIFY --> INVESTIGATE[Investigate Root Cause]
    INVESTIGATE --> REMEDIATE[Remediate]
    REMEDIATE --> RECOVER[Recover Service]
    RECOVER --> POSTMORTEM[Post-mortem]
    POSTMORTEM --> IMPROVE[Implement Improvements]
```

### Security Incident Checklist

```markdown
## Immediate Actions (First 15 Minutes)

- [ ] Confirm incident is real (not false positive)
- [ ] Assess severity level
- [ ] Create incident channel/thread
- [ ] Assign incident commander

## Containment (First Hour)

- [ ] Identify affected systems
- [ ] Isolate compromised components
- [ ] Preserve evidence (logs, snapshots)
- [ ] Block attacker if identified

## Investigation

- [ ] Timeline of events
- [ ] Entry point identification
- [ ] Scope of compromise
- [ ] Data accessed/exfiltrated

## Recovery

- [ ] Patch vulnerability
- [ ] Reset compromised credentials
- [ ] Restore from clean backup if needed
- [ ] Verify system integrity

## Post-Incident

- [ ] Document lessons learned
- [ ] Update security controls
- [ ] Notify affected users if required
- [ ] Schedule blameless post-mortem
```

### Emergency Contacts

A template for an operator to fill in and keep outside the repository — OpenWA ships no
`config/incident-response.yml` and reads no such file. The only contact the project itself
publishes is the vulnerability-reporting channel in [SECURITY.md](../SECURITY.md); the on-call,
channel and status-page entries below are placeholders with no upstream default.

```yaml
# config/incident-response.yml — operator-supplied, not shipped
contacts:
  primary_oncall:
    name: 'On-Call Engineer'
    phone: '+62xxx'
    slack: '@oncall'

  security_lead:
    name: 'Security Lead'
    email: 'yudhi@rmyndharis.com' # see SECURITY.md — GitHub Security Advisories preferred

  escalation:
    - level: 1
      wait: 15m
      contact: primary_oncall
    - level: 2
      wait: 30m
      contact: security_lead

communication:
  internal_channel: '' # e.g. a chat channel you own
  status_page: '' # e.g. a status page you own; the project publishes none
```

### Runbooks

```markdown
## Runbook: Suspected Data Breach

### Detection Signals

- Unusual API access patterns
- Large data exports
- Authentication from new locations
- Failed auth attempts spike

### Immediate Steps

1. Rotate all API keys for affected accounts
2. Enable IP whitelisting if not already
3. Check audit logs for scope
4. Snapshot affected database

### Evidence Collection

- Capture the audit log (the `audit_logs` table / audit query API) and the application logs (`docker compose logs openwa-api`) — there is no `logs:export` script
- Database query logs
- Network traffic captures
- System metrics at incident time
```

### Post-Mortem Template

```markdown
# Incident Post-Mortem: [Title]

**Date:** YYYY-MM-DD
**Severity:** P1/P2/P3
**Duration:** X hours
**Author:** [Name]

## Summary

Brief description of what happened.

## Impact

- Users affected: X
- Data compromised: None/Partial/Full
- Revenue impact: $X

## Timeline

| Time (UTC) | Event                 |
| ---------- | --------------------- |
| 10:00      | Alert triggered       |
| 10:05      | Incident confirmed    |
| 10:15      | Containment started   |
| 11:00      | Root cause identified |
| 12:00      | Service restored      |

## Root Cause

Technical explanation of what went wrong.

## What Went Well

- Detection was quick
- Communication was clear

## What Went Wrong

- Missing monitoring for X
- Delayed response due to Y

## Action Items

| Item                 | Owner     | Due Date   | Status |
| -------------------- | --------- | ---------- | ------ |
| Add monitoring for X | @eng      | 2026-02-15 | Open   |
| Update runbook       | @security | 2026-02-10 | Open   |

## Lessons Learned

Key takeaways for preventing future incidents.
```

---

<div align="center">

[← 03 - System Architecture](./03-system-architecture.md) · [Documentation Index](./README.md) · [Next: 05 - Database Design →](./05-database-design.md)

</div>

---

### 05 Database Design (`docs/05-database-design.md`)

# 05 - Database Design

## 5.1 Overview

OpenWA uses a database to store:

- Session configuration & state
- Webhook configurations
- Message history (with `STORE_EPHEMERAL_MESSAGES=false`, messages received in a disappearing-messages chat and sends made from the linked phone there are not stored; sends made through the API's message send routes still are; a product send is not)
- API keys & authentication
- Audit logs

### Database Support

OpenWA supports two database backends that can be selected at deployment time:

| Database       | Use Case                                    | Sessions | Horizontal Scaling |
| -------------- | ------------------------------------------- | -------- | ------------------ |
| **SQLite**     | Development, personal bot, low-resource VPS | 1-5      | ❌                 |
| **PostgreSQL** | Production, multi-session, high volume      | 5+       | ✅                 |

The PostgreSQL row covers the data database only. The main database (`api_keys`, `audit_logs`) is
always a SQLite file on each node, and running more than one replica is not supported yet; see
[13 - Horizontal Scaling Guide](./13-horizontal-scaling.md).

> [!NOTE]
> **SQLite as a Production Option**
>
> SQLite can be used in production with limitations:
>
> - Maximum ~5 concurrent sessions (due to single-writer limitation)
> - Single-file storage — back up with [`scripts/backup.sh`](../scripts/backup.sh) (§5.8), which copies `main.sqlite` and the data store (consistently while the app runs, when `sqlite3` is installed) and also captures the engine auth state; a bare copy of `./data/*.sqlite` restores every session unpaired
> - No horizontal scaling support
> - Ideal for: personal bots, small businesses with 1-3 WhatsApp numbers
>
> For configuration, see [03 - System Architecture: Pluggable Adapters](./03-system-architecture.md#313-pluggable-adapters)

### Dual-Database Architecture

OpenWA v0.2+ implements a **dual-database architecture** that separates boot configuration from user data:

```
/* Detailed source-code truncated for AI context efficiency. */
```

| Component   | Database             | Location                       | Purpose                                    |
| ----------- | -------------------- | ------------------------------ | ------------------------------------------ |
| **Main DB** | SQLite (always)      | `./data/main.sqlite` (default) | Boot-critical config, API keys, audit logs |
| **Data DB** | SQLite or PostgreSQL | Configurable                   | User data, sessions, messages, webhooks    |

The main DB is unconditionally SQLite, but its _path_ is not fixed: `MAIN_DATABASE_NAME` overrides the
`./data/main.sqlite` default, and is honoured by both the runtime connection factory
(`src/config/configuration.ts`) and the CLI DataSource (`src/database/data-source-main.ts`). Each
process opens its own main DB file and nothing replicates it, so in a multi-node deployment API keys
and audit logs are per node: a key created, revoked or narrowed on one node is unchanged on the
others.

> [!IMPORTANT]
> **Why Dual-Database?**
>
> The Main DB is always SQLite to ensure the application can bootstrap without external dependencies:
>
> - API keys needed for authentication before any external DB connection
> - Audit logs must persist even if Data DB fails
> - Enables switching Data DB type without losing authentication

#### Built-in PostgreSQL Orchestration

When using PostgreSQL Built-in mode (`DATABASE_TYPE=postgres` with `POSTGRES_BUILTIN=true`), `main.ts` starts the managed `postgres` container (creating it if it does not exist) **before** `NestFactory.create`. It has to run that early: the `data` connection is opened while Nest instantiates providers, before any `onModuleInit` hook, so a stopped `openwa-postgres` would otherwise fail every boot. The step is bounded at 15 seconds and never fails boot on its own. `DockerService.onModuleInit()` later runs the same orchestration for `redis` / `minio` when their own `REDIS_BUILTIN` / `MINIO_BUILTIN` flags are set.

The container can still be coming up when the `data` connection first dials it, so that connection is configured with `retryAttempts: 10` and `retryDelay: 3000` (`src/app.module.ts`), giving the database roughly 30 seconds to become reachable.

> [!NOTE]
> If the Docker API is unreachable, the start is skipped with a warning: no container is started, and the `data` connection then fails its retries against whatever `DATABASE_HOST` points at. Start the container by hand (`docker start openwa-postgres`) and restart OpenWA.

#### PostgreSQL Schema Selection

When using PostgreSQL, OpenWA can place its tables and migration ledger in a dedicated schema via the `POSTGRES_SCHEMA` environment variable:

| Setting           | Default  | Description                                                                                                   |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `POSTGRES_SCHEMA` | `public` | PostgreSQL schema for OpenWA tables and TypeORM migration ledger (lower-case letters, digits and underscores) |

**Use Cases:**

- **Managed PostgreSQL:** Use your cloud provider's project schema (e.g., a schema provisioned by the provider)
- **Multi-tenant databases:** Isolate OpenWA from other applications sharing the same database
- **Clean separation:** Keep OpenWA's tables organized separately from other schemas

**Configuration:**

```bash
# .env or dashboard Infrastructure page
POSTGRES_SCHEMA=openwa  # Use a dedicated schema
POSTGRES_SCHEMA=public   # Default behavior (historical)
```

**Requirements:**

- The schema must already exist before migration time
- The compose `postgres` service creates the schema via `scripts/postgres-init-schema.sh`, but only when its volume is first initialized; on an existing volume, run `CREATE SCHEMA <name>;` once
- The dashboard built-in database (`POSTGRES_BUILTIN=true`) always uses `public`
- External/managed PostgreSQL: run `CREATE SCHEMA <name>;` once before first startup
- SQLite ignores this setting

**Validation:**

- Schema name is validated at boot as a lower-case Postgres identifier (a lower-case letter or underscore, then lower-case letters, digits or underscores, max 63 chars)
- Reserved `pg_` prefix is rejected to prevent conflicts with system schemas
- Invalid values cause fast boot failure rather than migration-time errors

> [!NOTE]
> TypeORM's `schema` option alone does not set the session `search_path`. OpenWA additionally sets `search_path=<schema>,public` via PostgreSQL's startup `options` parameter so raw, unqualified migration DDL resolves to the configured schema. The migration ledger and all tables land in the specified schema while keeping `public` accessible for `pg_catalog` and helpers.

#### Data Migration API

OpenWA provides endpoints for migrating data between database types:

| Endpoint                 | Method | Description                          |
| ------------------------ | ------ | ------------------------------------ |
| `/api/infra/export-data` | GET    | Export all Data DB tables as JSON    |
| `/api/infra/import-data` | POST   | Import JSON data (replaces existing) |

**Migration Workflow:**

```bash
# 1. Export from current database
curl -s 'http://localhost:2785/api/infra/export-data' \
  -H 'X-API-Key: YOUR_KEY' > backup.json

# 2. Change database configuration (SQLite → PostgreSQL or vice versa)

# 3. Restart application with new config

# 4. Import to new database
curl -X POST 'http://localhost:2785/api/infra/import-data' \
  -H 'X-API-Key: YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d @backup.json
```

> [!IMPORTANT]
> The export leaves out webhook `secret` and custom `headers` and the `user:pass` of a session `proxyUrl`. Set them again after the import with `PUT /api/sessions/:sessionId/webhooks/:id` and `PATCH /api/sessions/:sessionId/proxy`, or webhooks deliver unsigned and an authenticated proxy fails the session's next start. The import is one request bounded by `BODY_SIZE_LIMIT` (`413` above it). Plugin instance secrets travel in plaintext, so treat the file as a secret. See [14 - Migration Guide: API-Based Migration](./14-migration-guide.md#api-based-migration-recommended-for-v02) for the full procedure.

#### Cross-Database Date Portability

To ensure date/time values work across both SQLite and PostgreSQL, OpenWA uses a `DateTransformer` together with `dateColumnType()`. On SQLite the column is `text` and dates are stored as ISO 8601 strings; on PostgreSQL the column is a native `timestamp` and the `Date` passes through to the driver:

```typescript
// src/common/transformers/date.transformer.ts
export const DateTransformer: ValueTransformer = {
  from: (value: string | Date | null): Date | null => {
    if (!value) return null;
    if (value instanceof Date) return value;
    return new Date(value);
  },
  to: (value: Date | null): string | Date | null => {
    if (!value) return null;
    if (value instanceof Date) {
      return process.env.DATABASE_TYPE === 'postgres' ? value : value.toISOString();
    }
    return value;
  },
};

// Usage in entities (Data DB only); dateColumnType() is 'timestamp' on PostgreSQL, 'text' on SQLite
@Column({ type: dateColumnType(), nullable: true, transformer: DateTransformer })
connectedAt!: Date | null;
```

> [!NOTE]
> Main DB entities (api_keys, audit_logs) use native SQLite `datetime` type since they always remain in SQLite.

#### Timestamps on PostgreSQL are UTC

Every timestamp column on the PostgreSQL data connection is `timestamp without time zone`, which stores no zone: the value means whatever the writer intended. OpenWA pins that meaning to **UTC**, on the connection rather than on the deployment (`src/database/postgres-utc.ts`):

- a JS `Date` parameter is bound as UTC (`parseInputDatesAsUTC`), so an app-written column such as `sessions.connectedAt` holds UTC wall-clock time whatever zone the host runs in;
- a naive timestamp is parsed back as UTC, through a parser registered for the scalar `timestamp` OID only (the `timestamp[]` OID keeps the driver's array parser; the schema has no such column);
- every pooled connection issues `SET TIME ZONE 'UTC'` on connect, through the pool's own connect hook so that a socket failure or a refused statement fails the acquire instead of the process. That is what makes the server-side `DEFAULT now()` behind each `@CreateDateColumn`/`@UpdateDateColumn` write UTC too. Boot reads the effective `TimeZone` back, at two instants six months apart so a zone that merely reads +00 in winter is caught, and fails if it is not UTC; a pin that a pooler or a server-side default overrode cannot pass unnoticed.

Three `@CreateDateColumn`/`@UpdateDateColumn` columns are filled by the app rather than by that default: `lid_mappings.updatedAt`, `chat_states.updatedAt` and `baileys_stored_messages.createdAt` reach the database through an `upsert` that passes the value, so the default behind them never fires and they follow the binding rule above instead of the session zone.

`messages.createdAt` is a fourth, and split within itself: a live message takes the server default, while a row written by the Baileys history backfill carries the message's own time, stamped in `message-history-projector.ts` so the chat panel orders history correctly. Both conventions are UTC since 0.23.6, and the two cannot be told apart row by row, which is why the 0.23.6 upgrade notes exclude that column from any blanket conversion. A restore writes `createdAt`/`updatedAt` explicitly wherever the archive carries them, so a restored table follows the archive rather than either rule above; the 0.23.6 upgrade notes cover that case separately.

Comparisons therefore mean the same thing on both dialects: a retention `LessThan(cutoff)`, a lease deadline written by another node, and a backup restored from any host all line up. SQLite is unaffected; it already stores ISO text in UTC.

## 5.2 Entity Relationship Diagram

```
/* Detailed source-code truncated for AI context efficiency. */
```

Only `webhooks`, `templates`, `automation_rules` and `baileys_stored_messages` declare an `ON DELETE CASCADE` foreign key to `sessions.id`. `messages`, `message_batches`, `chat_states`, `status_updates`, `webhook_outbox_events`, `webhook_delivery_failures`, `integration_delivery_failures` and `send_idempotency_keys` reference it through a plain `sessionId` (`session_id` on `message_batches`) with no foreign key. Deleting a session through the API removes their rows explicitly; a raw `DELETE FROM sessions` leaves them behind.

## 5.3 Table Specifications

> [!IMPORTANT]
> **Column names are camelCase — with one exception.** There is no snake_case naming strategy on
> either connection, so the columns really are `"sessionId"`, `"createdAt"`, `"waMessageId"` and so
> on, and hand-written SQL must quote them: unquoted, PostgreSQL folds them to lowercase and the
> statement fails at runtime.
>
> The exception is **`message_batches`**, whose columns genuinely are `batch_id` / `session_id` /
> `created_at` — it was created that way and never renamed. So no single rule covers the whole
> schema; the DDL below states each table's real names, and `SELECT * FROM <table> LIMIT 0` settles
> any doubt. The **types** in these blocks stay illustrative (they are dialect-portable and defined
> by the TypeORM entities), but every identifier is literal.

### 5.3.1 sessions

Stores WhatsApp session configuration and state.

```sql
CREATE TABLE sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL UNIQUE,
    status VARCHAR(50) NOT NULL DEFAULT 'created',
    phone VARCHAR(20),
    "pushName" VARCHAR(100),
    config JSONB NOT NULL DEFAULT '{}',
    "proxyUrl" VARCHAR(255),
    "proxyType" VARCHAR(10),
    "connectedAt" TIMESTAMP,
    "lastActiveAt" TIMESTAMP,
    -- Session ownership / multi-node routing: which process runs the engine, since when, where it
    -- answers HTTP for peers, and how long its claim survives unrenewed. All NULL on a single node.
    "nodeId" VARCHAR(190),
    "claimedAt" TIMESTAMP,
    "nodeUrl" VARCHAR(2048),
    "leaseExpiresAt" TIMESTAMP,
    -- 'stopped' after POST /stop or /force-kill, NULL again after an explicit POST /start. Boot
    -- auto-start and the takeover sweep skip a stopped row. Server-owned: not in any session
    -- response, only in the /api/infra/export-data backup.
    "desiredState" VARCHAR(20),
    "createdAt" TIMESTAMP NOT NULL DEFAULT NOW(),
    "updatedAt" TIMESTAMP NOT NULL DEFAULT NOW()
);
```

> [!NOTE]
> The **types** above are illustrative — the schema is defined by the TypeORM entity (`src/modules/session/entities/session.entity.ts`) and column types are dialect-portable (`jsonColumnType()` → `simple-json`, dates via `DateTransformer`). The **column names are literal**: see the naming note at the top of §5.3 above before writing SQL against any of these tables. The `sessions` entity declares only the index implied by the `UNIQUE` constraint on `name`; there are no separate `status`/`phone`/`createdAt` indexes.

> [!NOTE]
> Auth state is **not** stored in this table. Both engines persist credentials on the **filesystem**: `whatsapp-web.js` through LocalAuth, Baileys in a multi-file auth dir with the layout of its `useMultiFileAuthState`, written atomically. An unparseable Baileys `creds.json` is moved aside with its key files to `corrupt-<ms>-<suffix>/` and the session relinks by QR. The `baileys_stored_messages` table holds only Baileys' serialized message store (the library ships none), not credentials.

**Session Status Values:**

```mermaid
stateDiagram-v2
    [*] --> created: POST /sessions
    created --> initializing: start()
    initializing --> qr_ready: QR generated
    qr_ready --> authenticating: QR scanned
    authenticating --> ready: Auth success
    authenticating --> failed: Auth failed
    ready --> disconnected: Connection lost
    ready --> action_required: Needs an operator
    action_required --> disconnected: stop() / logout()
    disconnected --> initializing: reconnect()
    ready --> [*]: DELETE
    failed --> [*]: DELETE
```

| Status            | Description                                                       |
| ----------------- | ----------------------------------------------------------------- |
| `created`         | Session created, not started                                      |
| `initializing`    | Starting browser & WhatsApp                                       |
| `qr_ready`        | QR code ready for scanning                                        |
| `authenticating`  | QR scanned, authenticating                                        |
| `ready`           | Connected and ready                                               |
| `disconnected`    | Disconnected, can reconnect                                       |
| `action_required` | Engine running, but it needs an operator before it can work again |
| `failed`          | Failed, needs recreation                                          |

`action_required` differs from `failed` in that the engine is still loaded and still holds its
WhatsApp connection — nothing is broken, but something outside the gateway has to change before the
session is usable. The reason is carried on `lastError`, and every engine-backed action (`stop`,
`logout`) still applies; sending is refused until the session returns to `ready`. Reaching it always
takes deliberate evidence, never a single failed probe, precisely because it stops sends. A restart
clears it, and so does a gateway restart.

**Config Schema:**

`config` is an opaque JSON blob; the keys the session service actually reads are:

```json
{
  "maxReconnectAttempts": 5,
  "reconnectBaseDelay": 5000,
  "autoRejectCalls": false
}
```

| Key                    | Default   | Effect                                                                                                                                                                                                 |
| ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `maxReconnectAttempts` | unlimited | Reconnect attempt cap, clamped to 0-20 (`0` disables reconnect entirely). Bounds the gateway's own reconnect: every reconnect on whatsapp-web.js, and on Baileys only the one after a logged-out close |
| `reconnectBaseDelay`   | `5000` ms | Base delay of the reconnect backoff, clamped to 1000-300000 ms. Same engine scope as `maxReconnectAttempts`                                                                                            |
| `autoRejectCalls`      | `false`   | Auto-reject an incoming call as soon as it rings (Baileys only)                                                                                                                                        |

The gateway's reconnect attempt count restarts only once the session has stayed READY for 5 minutes,
so a session that keeps dropping sooner than that spends a finite `maxReconnectAttempts` and ends
FAILED. The Baileys in-engine retry has no attempt cap and never ends FAILED on a transient drop.

Set them at creation with `POST /api/sessions`, or on an existing session with
`PATCH /api/sessions/{sessionId}/config` — no restart, and no re-scan of the QR. The patch merges, so a key
it does not mention keeps its stored value, and an explicit `null` clears a key back to the default
above (the only way back to unlimited reconnect attempts once a cap is set).

When each takes effect differs, because each is read at a different moment: `autoRejectCalls` is
re-read from this row on every incoming call, so a patch applies to the next call. The two reconnect
keys are read once per `start()` into the in-memory reconnect state, so a patch applies on the next
start and leaves a reconnect sequence already in flight alone.

`GET /api/sessions/{sessionId}/config` reports the effective values. It reports only these three keys and
never the raw column — `config` is stripped from `SessionResponseDto` alongside the
credential-bearing `proxyUrl`, and echoing an opaque blob back would defeat that.

> [!NOTE]
> Proxy settings are **not** read from `config` — they live in the dedicated `proxyUrl` / `proxyType` columns shown in the DDL above. Puppeteer options are global engine configuration from the environment (`engine.puppeteer.*`), not per-session. Anything else placed in `config` is stored but ignored.

---

### 5.3.2 webhooks

Stores webhook endpoint configurations.

```sql
CREATE TABLE webhooks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    "sessionId" UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    url VARCHAR(2048) NOT NULL,
    events JSONB NOT NULL DEFAULT '["message.received"]',
    secret VARCHAR(255),
    headers JSONB DEFAULT '{}',
    filters JSONB,                       -- optional smart pre-filter; null = fire on every subscribed event
    active BOOLEAN NOT NULL DEFAULT true,
    "retryCount" INTEGER NOT NULL DEFAULT 3,
    "lastTriggeredAt" TIMESTAMP WITH TIME ZONE,
    "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
```

**Events Schema (allowed values):**

```json
[
  "message.received",
  "message.sent",
  "message.ack",
  "message.receipt",
  "message.failed",
  "message.revoked",
  "message.reaction",
  "message.edited",
  "status.received",
  "session.status",
  "session.qr",
  "session.authenticated",
  "session.disconnected",
  "session.reconnect_loop",
  "session.restriction",
  "presence.update",
  "event.response",
  "group.join",
  "group.leave",
  "group.update",
  "group.join_request",
  "call.received",
  "call.accepted",
  "call.rejected",
  "call.missed"
]
```

---

### 5.3.2a automation_rules

Per-session single-message autoreply rules. `conditions` reuses the webhook filter shape verbatim
(null/empty matches every inbound message except channel, broadcast-list and status messages, which
need a `kind` condition); the reply goes through the ordinary send path. On Baileys a message the
account received through a contact's broadcast list is filed under the sender's own chat (`kind`
`individual`), as WhatsApp lists it, so a rule without a `kind` condition matches it.

```sql
CREATE TABLE automation_rules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    "sessionId" VARCHAR NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    enabled BOOLEAN NOT NULL DEFAULT true,
    conditions JSONB,                    -- webhook filter shape; null = match all except channel/broadcast/status (need a kind condition)
    "replyText" TEXT NOT NULL,
    "cooldownSeconds" INTEGER NOT NULL DEFAULT 60,  -- per-(rule, chat) quiet period; 0 disables
    "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
```

---

### 5.3.3 messages

Stores message history. With `STORE_EPHEMERAL_MESSAGES=false`, a message received in a disappearing-messages chat is neither stored nor dispatched, a history sync skips such messages, and the echo of a send made from the linked phone there is dispatched but not stored. A send made through the API's message send routes into such a chat (single or bulk) is still stored, because those routes write their own row. A product send (`POST .../messages/send-product`) writes none and is dropped like a phone-side send (see [06 - API Specification](./06-api-specification.md)). This is a **plain (non-partitioned)** table — the same schema on SQLite and PostgreSQL.

```
/* Detailed source-code truncated for AI context efficiency. */
```

> [!NOTE]
> There is **no** PostgreSQL RANGE partitioning, `create_messages_partition()` function, or `pg_cron` schedule in OpenWA. `messages` is a single plain table on both backends. The `timestamp` column uses a `bigint→number` value transformer so the REST/SDK/MCP contract returns a JS number on both SQLite and PostgreSQL.

> [!NOTE]
> Message rows carry no separate `media`/`ack`/`from_me`/`is_group` columns. Media and other engine-specific details are stored in the `metadata` JSON column; delivery state is the `status` enum and `direction` distinguishes inbound vs. outbound.

---

### 5.3.4 (removed) contacts

> [!NOTE]
> **There is no `contacts` table.** Contacts are read live from the engine on demand (e.g. `GET /sessions/:id/contacts`) and are not persisted to the database.

---

### 5.3.5 api_keys

Stores API keys for authentication. Lives on the **main** (always-SQLite) connection.

```sql
CREATE TABLE api_keys (
    id VARCHAR PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    "keyHash" VARCHAR(64) NOT NULL,                -- UNIQUE index
    "keyPrefix" VARCHAR(12) NOT NULL,              -- shown in the UI; the full key is never stored
    role VARCHAR(20) NOT NULL DEFAULT 'operator',  -- admin | operator | viewer
    "allowedIps" TEXT,                             -- simple-array (comma-joined), null = any IP
    "allowedSessions" TEXT,                        -- simple-array, null = all sessions
    "allowedChats" TEXT,                           -- simple-array, null = all chats
    "isActive" BOOLEAN NOT NULL DEFAULT 1,
    "expiresAt" DATETIME,
    "lastUsedAt" DATETIME,
    "usageCount" INTEGER NOT NULL DEFAULT 0,
    "createdAt" DATETIME NOT NULL DEFAULT (datetime('now')),
    "updatedAt" DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE UNIQUE INDEX "IDX_api_keys_keyHash" ON api_keys("keyHash");
```

> [!NOTE]
> Access control is **role-based** (`admin` / `operator` / `viewer`), optionally scoped by `allowedIps`, `allowedSessions` and `allowedChats`. There is no granular `permissions` string array — see [04 - Security Design](./04-security-design.md) for what each role can do.

---

### 5.3.6 audit_logs

Consolidated audit trail for API-key, session, send-pacing, rate-limit, queue-dashboard, integration-instance and infrastructure events. This is the **only** audit table — there are no separate `session_logs`, `webhook_logs`, or `api_key_logs` tables. Lives on the **main** (always-SQLite) connection.

```sql
CREATE TABLE audit_logs (
    id VARCHAR PRIMARY KEY,
    action VARCHAR(50) NOT NULL,                   -- e.g. session_created, api_key_auth_failed, infra_config_saved
    severity VARCHAR(10) NOT NULL DEFAULT 'info',  -- info | warn | error
    "apiKeyId" VARCHAR(36),
    "apiKeyName" VARCHAR(100),
    "sessionId" VARCHAR(36),
    "sessionName" VARCHAR(100),
    "ipAddress" VARCHAR(45),
    "userAgent" VARCHAR(500),
    method VARCHAR(10),
    path VARCHAR(500),
    "statusCode" INTEGER,
    metadata TEXT,                                 -- simple-json
    "errorMessage" TEXT,
    "createdAt" DATETIME NOT NULL DEFAULT (datetime('now'))
);

-- Indexes (created by migrations-main/)
CREATE INDEX "IDX_audit_logs_action" ON audit_logs(action);
CREATE INDEX "IDX_audit_logs_apiKeyId" ON audit_logs("apiKeyId");
CREATE INDEX "IDX_audit_logs_sessionId" ON audit_logs("sessionId");
CREATE INDEX "IDX_audit_logs_createdAt" ON audit_logs("createdAt");
```

These are the index names on a default install. With `MAIN_DATABASE_SYNCHRONIZE=true`, the
synchronize pass after the chain replaces them with the TypeORM-derived hash names the entities'
`@Index()` declarations generate (`IDX_df3b25181df0b4b59bd93f16e1` on `api_keys` and
`IDX_cee5459245f652b75eb2759b4c`, `IDX_741fa976d1e04e695f3aa23cb8`, `IDX_dd2b6e43c767b6b5b2bb227ace`,
`IDX_c69efb19bf127c97e6740ad530` on `audit_logs`).

**Audit actions** are an enum (`AuditAction`) spanning API-key lifecycle (`api_key_created`,
`api_key_updated`, `api_key_used`, `api_key_revoked`, `api_key_deleted`, `api_key_auth_failed`, capped
at 10 rows per client IP per minute for a REST, queue-dashboard or MCP rejection that names no stored
key, where a rejection of a stored key is recorded every time, and at 10 per client IP per minute for
any rejection on `/api/health`), session
lifecycle (`session_created`, `session_started`, `session_stopped`, `session_force_killed`,
`session_logged_out`, `session_deleted`, `session_qr_generated`, `session_connected`,
`session_disconnected`, `session_config_updated`), WhatsApp-imposed account restrictions (`session_restricted`,
`session_restriction_lifted`), a refused relink of a session to a different WhatsApp number
(`session_rebind_rejected`, written as a WARN row), messages
(`message_sent`, `message_failed`), send-pacing enforcement (`send_pacing_blocked`, sampled to at
most one row per session per minute; `send_breaker_tripped`, never sampled), webhooks (`webhook_created`, `webhook_deleted`,
`webhook_triggered`, `webhook_failed`), the delivery-failure redrive (`webhook_deliveries_redriven`, one row per
batch), rate-limit enforcement (`rate_limit_exceeded`, sampled to at
most one row per subject+kind per minute), the queue dashboard (`queue_board_mutated`), integration
plugin instances (`integration_instance_created`, `integration_instance_updated`,
`integration_instance_secret_regenerated`, `integration_instance_deleted`,
`integration_instance_redriven`), plugin management (`plugin_installed`, `plugin_updated`, `plugin_uninstalled`,
`plugin_enabled`, `plugin_disabled`, `plugin_config_updated`, `plugin_sessions_updated`; metadata carries the plugin
id, version, the host of the requested download URL and config field names, never config values), and ADMIN-only infrastructure operations (`infra_config_saved`,
`infra_restart_requested`, `infra_data_exported`, `infra_data_imported`, `infra_storage_exported`,
`infra_storage_imported`).

Eight of these are reserved and never emitted, so no row ever carries them: `api_key_used`,
`session_connected`, `message_sent`, `message_failed`, `webhook_created`, `webhook_deleted`,
`webhook_triggered` and `webhook_failed`. `src/modules/audit/intentionally-unemitted-actions.ts` is the
authoritative list, with the reason for each.

> [!NOTE]
> Audit-log retention is automatic: see [§5.7 Data Retention](#57-data-retention). Other event types (session logs, API access logs) are surfaced via structured application logging, not dedicated database tables. The one exception is a webhook delivery that exhausts every retry — that lands in the `webhook_delivery_failures` table (§5.3.8), not just the log stream.

### 5.3.7 message_batches

Tracks bulk/batch message jobs. A single table holds the job state plus its messages, options, progress, and per-message results as JSON columns (there are **no** separate `batch_jobs` / `batch_job_messages` tables).

```sql
CREATE TABLE message_batches (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    batch_id VARCHAR NOT NULL,
    session_id VARCHAR NOT NULL,
    status VARCHAR NOT NULL DEFAULT 'pending',   -- pending | processing | completed | cancelled | failed
    messages JSONB NOT NULL,                     -- [{ chatId, type, content, variables? }]
    options JSONB,                               -- { delayBetweenMessages, randomizeDelay, stopOnError }
    progress JSONB,                              -- { total, sent, failed, pending, cancelled }
    results JSONB,                               -- [{ chatId, status, messageId?, error?, sentAt? }]
    current_index INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    started_at TIMESTAMP WITH TIME ZONE,
    completed_at TIMESTAMP WITH TIME ZONE,
    CONSTRAINT "UQ_message_batches_session_id_batch_id" UNIQUE (session_id, batch_id)
);
```

> [!NOTE]
> Batch-id uniqueness is **scoped to the session**, not global — one session cannot deny a batch id to another, so the same `batch_id` may exist under different sessions.

**Batch Status Values:**

| Status       | Description                    |
| ------------ | ------------------------------ |
| `pending`    | Job created, not yet processed |
| `processing` | Sending messages in progress   |
| `completed`  | All messages processed         |
| `cancelled`  | Job cancelled by user          |
| `failed`     | Job failed (fatal error)       |

---

### 5.3.8 Other data-connection tables

The data connection also owns:

- **`templates`** — reusable message templates (`src/modules/template/entities/template.entity.ts`), with a unique constraint on `(sessionId, name)` — one template name per session.
- **`status_updates`** — inbound status/story broadcasts with a 24-hour TTL (`src/modules/status-store/entities/status-update.entity.ts`); unique on `(sessionId, waStatusId)`. Attached media is stored via `StorageService`, not in the row.
- **`webhook_delivery_failures`**: durable record of a webhook delivery that exhausted its retries, or was not sent (attempts 0) (`src/modules/webhook/entities/webhook-delivery-failure.entity.ts`), surfaced via the ADMIN `GET /webhooks/delivery-failures`. A shed or shutdown-refused delivery is replayed by the outbox until its replay budget runs out; an oversize payload or a preflight failure on first dispatch is retired after its failure record is stored. A later successful delivery removes the row. While `WEBHOOK_FAILURE_PAYLOAD_RETENTION_HOURS` > 0 (default 0), a terminal row (attempts > 0) also keeps the pre-hook event data in `payload` (never selected by the listing, which reports `replayable` instead) so ADMIN `POST /webhooks/delivery-failures/redrive` can replay it with the stored idempotency key; the payload is cleared once that window passes and the row stays.
- **`webhook_outbox_events`**: the outbound delivery record (`src/modules/webhook/entities/webhook-outbox-event.entity.ts`). A successfully written `pending` row holds pre-hook event data before the attempt. Enqueue changes it to `queued` without dropping the payload. For an ordinary dispatch, delivery success, subscription invalidation or a successfully recorded terminal failure retires the copy; failed terminal recording leaves it available for reconciliation. A sweep replay sent directly (the queue off, or its enqueue failed) that fails keeps the copy pending until the replay budget is spent, and a delivered redrive of the event also retires it. The reconciler checks the stored job before replaying queued work and reuses its idempotency key. Settled rows drop their payload and are pruned on age (§5.7). Message persistence and outbox insertion remain separate transactions.
- **`send_idempotency_keys`**: one claimed `Idempotency-Key` per `(sessionId, idempotencyKey)` (unique) from the opt-in header on the message send routes (`src/modules/message/entities/send-idempotency-key.entity.ts`). The insert that wins the unique key runs the send; the row then holds `state` (`pending`, `completed` with the `responseBody` replayed to retries, or `failed` when the outcome is unknown), the `route` and a SHA-256 `requestHash` of route and body. Rows expire 24 hours after the claim (`expiresAt`) and are pruned hourly (§5.7). Not exported by `GET /infra/export-data`; a replace-all import keeps the claims of the sessions it restores and removes the others.
- **`plugin_instances`** — one configured instance of an adapter plugin, keyed `${pluginId}:${instanceId}` (`src/modules/integration/entities/plugin-instance.entity.ts`); holds the host-minted ingress HMAC secret, masked on API reads.
- **`ingress_events`** — persist-before-ack durable row and inbound dedup oracle, unique on `(pluginId, instanceId, providerDeliveryId)` (`src/modules/integration/entities/ingress-event.entity.ts`). The full payload is retired to `NULL` once dispatch is settled, leaving a slim dedup marker.
- **`conversation_mappings`** — bidirectional WA-chat ↔ provider-conversation mapping plus handover state (`src/modules/integration/entities/conversation-mapping.entity.ts`).
- **`integration_delivery_failures`**: DLQ-of-record for failed inbound (ingress) deliveries, the rows the instance redrive replays; only the ingress path writes it, and only as `inbound`. A data import restores each row's `direction` as stored, and redrive replays only `inbound` rows (`src/modules/integration/entities/integration-delivery-failure.entity.ts`).
- **`baileys_stored_messages`** — Baileys engine message store — the serialized WAMessage proto (`src/engine/adapters/baileys-stored-message.entity.ts`); present only when the Baileys engine is used. (Credentials live on the filesystem, not here.)
- **`lid_mappings`** — LID↔phone-number identity mappings (`src/engine/identity/lid-mapping.entity.ts`).
- **`chat_states`** (engine): per-session mute, archive and pin state of each Baileys chat (`src/engine/adapters/baileys-chat-state.entity.ts`), keyed `(sessionId, chatId)`. `chatId` is the person's phone JID (`<phone>@s.whatsapp.net`) whenever their lid resolves, so a state WhatsApp syncs under the lid and one set through the phone number land on the same row; it stays `<lid>@lid` while the lid is unresolved, and a row found under the lid later is folded onto the phone JID on the chat's next change (reads and the fold take each field from the newest row that observed it: `observed` lists the fields a row has seen, and null, on a row not written since the column was added, means the fields it holds a set value for; the next write that can read the row records the list). WhatsApp does not re-deliver that state, so it is kept in backups. A chat deleted on the phone or through the API loses its row, so a later message starts it clean.

Additionally, the `AddMessagesFts` migration creates the full-text-search structures over `messages` (a FTS5 virtual table on SQLite, a generated `body_ts` `tsvector` column plus GIN index on PostgreSQL) that back the `/search` endpoint.

> [!NOTE]
> **Tables that do _not_ exist.** Earlier drafts referenced `contacts`, `session_logs`, `webhook_logs`, `api_key_logs`, `webhook_idempotency`, and `ip_whitelist`. None of these are implemented. Contacts are read live from the engine; auditing is the single `audit_logs` table; webhook idempotency is not a persisted table; and per-key IP restrictions are stored inline on `api_keys."allowedIps"` (a `simple-array`), not in a separate `ip_whitelist` table.

---

## 5.4 Index Strategy

### Query Pattern Analysis

These indexes are the ones declared on the entities (see §5.3); the rows below map them to the hot query paths.

| Query Pattern                    | Index Used                                                            | Frequency |
| -------------------------------- | --------------------------------------------------------------------- | --------- |
| Get session by ID                | `sessions.id` (PK)                                                    | Very High |
| Get session by name              | `sessions.name` (UNIQUE)                                              | High      |
| List messages by session (paged) | `("sessionId", "createdAt")` composite                                | Very High |
| List a chat's messages (paged)   | `IDX_messages_sessionId_chatId_createdAt`                             | Very High |
| Send-pacing chat history probes  | `IDX_messages_sessionId_chatId_createdAt`                             | Very High |
| List messages by message time    | `IDX_messages_session_timestamp_id` and its chat / direction variants | High      |
| Search / stats by chat           | `"chatId"`                                                            | Medium    |
| Ack/dedup a message              | `UQ_messages_sessionId_waMessageId` (UNIQUE)                          | Very High |
| Message stats over a date range  | `IDX_messages_createdAt`                                              | Medium    |
| Find a session's webhooks        | `IDX_webhooks_sessionId`                                              | Very High |
| Authenticate API key             | UNIQUE on `api_keys("keyHash")`, main DB                              | Very High |
| Filter audit logs                | `audit_logs` indexes on `action` / `apiKeyId` / `sessionId`           | Medium    |

### Composite & Unique Indexes (as implemented)

```
/* Detailed source-code truncated for AI context efficiency. */
```

> [!NOTE]
> The partial/filtered indexes shown in earlier drafts (e.g. `WHERE status = 'ready'`, `WHERE active = true`) are not part of the current schema. Add them only if a real query pattern justifies the maintenance cost.

### Index Maintenance

```sql
-- Check index usage
SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;

-- Find unused indexes
SELECT
    schemaname || '.' || relname AS table,
    indexrelname AS index,
    pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
    idx_scan as index_scans
FROM pg_stat_user_indexes ui
JOIN pg_index i ON ui.indexrelid = i.indexrelid
WHERE NOT indisunique
AND idx_scan < 50
ORDER BY pg_relation_size(i.indexrelid) DESC;

-- Reindex to reclaim space (run during maintenance window)
REINDEX TABLE messages;
```

## 5.5 Data Flow

### Message Storage Flow

```mermaid
flowchart TB
    subgraph Inbound["Inbound Message"]
        E[Engine Event] --> G{Disappearing chat and STORE_EPHEMERAL_MESSAGES=false?}
        G -->|Yes| D[Drop: no row, no webhook]
        G -->|No| P[Hooks / Process]
        P --> DB[(Insert, UNIQUE sessionId + waMessageId)]
        DB -->|Duplicate| X[Stop]
        DB -->|Stored, or a non-duplicate DB error| W[Webhook + WebSocket]
    end

    subgraph Outbound["Outbound Message (single send)"]
        A[API Request] --> V[Validate]
        V --> DBP[(Persist PENDING)]
        DBP --> EN[Engine Send]
        EN --> DBO[(Update SENT or FAILED)]
        DBO --> R[Response]
    end
```

### Session State Flow

```mermaid
flowchart LR
    subgraph Memory["In-Memory State"]
        WA[WhatsApp Client]
        QR[QR Code]
        CONN[Connection Status]
    end

    subgraph Persistent["Database State (sessions row)"]
        CONFIG[Session Config]
        META[status / phone / pushName]
        TS[connectedAt / lastActiveAt]
    end

    subgraph FS["Engine Auth (not in sessions table)"]
        FSAUTH[whatsapp-web.js: filesystem LocalAuth]
        BAUTH[Baileys: filesystem multi-file auth dir]
    end

    Memory -->|Sync| Persistent
    Persistent -->|Restore| Memory
```

## 5.6 Migration Strategy

OpenWA runs **two separate TypeORM connections**, each with its own migrations directory and CLI DataSource:

| Connection | DataSource            | Migrations dir                  | Owns                                                                                                                                                                                                                                                                                                                                                      |
| ---------- | --------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **main**   | `data-source-main.ts` | `src/database/migrations-main/` | `api_keys`, `audit_logs`, always SQLite (`./data/main.sqlite` by default)                                                                                                                                                                                                                                                                                 |
| **data**   | `data-source.ts`      | `src/database/migrations/`      | `sessions`, `webhooks`, `messages`, `message_batches`, `templates`, `status_updates`, `automation_rules`, `webhook_delivery_failures`, `webhook_outbox_events`, `send_idempotency_keys`, the integration tables (`plugin_instances`, `ingress_events`, `conversation_mappings`, `integration_delivery_failures`), engine tables, SQLite **or** PostgreSQL |

Migrations are hand-authored and idempotent (`IF NOT EXISTS`) so they are safe to adopt on a database originally created by `synchronize`. The two connections differ in how schema is managed:

- **data** — `synchronize` defaults **off**, so this connection is migration-managed by default. On PostgreSQL `migrationsRun` is hardcoded on, and `DATABASE_SYNCHRONIZE=true` is rejected outright at boot validation (it would drop the migration-created `body_ts` tsvector column that `/search` depends on). On SQLite there is no such rejection and `migrationsRun` is the inverse of `synchronize` — so an opted-in `DATABASE_SYNCHRONIZE=true` switches the data connection to entity-synchronized schema and turns its migration chain **off**; only the deduplication of terminal `webhook_delivery_failures` rows still runs, ahead of the schema synchronization at every boot.
- **main**: migration-managed by default, like data: `migrations-main/` creates and upgrades `api_keys` / `audit_logs` at boot. The chain is idempotent, so a `main.sqlite` an earlier release built with synchronize is adopted in place on the first boot (rows kept, missing columns added, the ledger written). `MAIN_DATABASE_SYNCHRONIZE=true` additionally synchronizes after the chain, for development; it cannot add an entity column that has no migration yet, because the missing-column check below refuses the boot first, so write the migration in `migrations-main/`. Boot logs a warning when it is set under `NODE_ENV=production`. Before any provider reads the file, boot refuses to start (in both modes) when the ledger records a migration this release does not ship (a newer release upgraded the file) or when an entity column is still missing after the chain ran (an older release rebuilt the table, so its values are gone, or the column has no migration yet); the error names the migration or column. For a newer ledger or a rebuilt table, the remedy is restoring `main.sqlite` from the backup taken before the downgrade or upgrade; for a column with no migration yet, it is adding one in `migrations-main/`. For a table rebuilt without `api_keys.allowedChats` and no backup, stop the instance, delete that migration's ledger row from the file the error names (the `MAIN_DATABASE_NAME` path, `data/main.sqlite` by default) and start it. On a source install that is `sqlite3 data/main.sqlite "DELETE FROM migrations WHERE name = 'AddApiKeyAllowedChats1786600000000'"`. On the compose deployment the file lives in the `openwa-data` volume, not on the host, and a stopped container cannot be exec'd into, so run the CLI in a one-off container of the service: `docker compose stop openwa-api && docker compose run --rm --no-deps openwa-api sqlite3 /app/data/main.sqlite "DELETE FROM migrations WHERE name = 'AddApiKeyAllowedChats1786600000000'"` (with `MAIN_DATABASE_NAME` set, use that path under `/app`). On the Helm chart, scale the StatefulSet to 0 and start the `openwa-restore` helper pod on the release's data PVC as [11 - Runbook: Restore from Backup](./11-operational-runbooks.md#runbook-restore-from-backup) does, run `kubectl exec openwa-restore -- sqlite3 /app/data/main.sqlite "DELETE FROM migrations WHERE name = 'AddApiKeyAllowedChats1786600000000'"`, then delete the pod and scale back to 1. Then the chain re-creates `api_keys.allowedChats` empty, which means every chat, so re-apply each key's `allowedChats` before exposing the API. A file whose ledger does not record the lost column's migration (0.23.6 and 0.23.7 wrote the ledger only with `MAIN_DATABASE_SYNCHRONIZE=false`) is not detected: the column comes back empty, which means every chat, so each key's `allowedChats` must be re-applied. Never delete or rename a `migrations-main/` file: its ledger row would read as a newer release's.

### Migration Files

```
/* Detailed source-code truncated for AI context efficiency. */
```

> [!NOTE]
> Run with `npm run migration:run` (data connection) and `npm run migration:run:main` (main connection). The `AddUuidDefaultsForPostgres` migration is dialect-guarded — it is a no-op on SQLite (TypeORM generates UUIDs in the driver layer) and only adds `DEFAULT gen_random_uuid()::varchar` on PostgreSQL.

### Sample Migration (TypeORM)

```typescript
import { MigrationInterface, QueryRunner } from 'typeorm';

// Real migration: enforces inbound dedup on the data connection.
export class AddMessagesWaMessageIdUnique1781300000000 implements MigrationInterface {
  name = 'AddMessagesWaMessageIdUnique1781300000000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    if (!(await queryRunner.hasTable('messages'))) return;
    // ... losslessly de-duplicate existing rows (keep earliest per sessionId+waMessageId) ...
    await queryRunner.query(`DROP INDEX IF EXISTS "IDX_messages_sessionId_waMessageId"`);
    await queryRunner.query(
      `CREATE UNIQUE INDEX IF NOT EXISTS "UQ_messages_sessionId_waMessageId" ` +
        `ON "messages" ("sessionId", "waMessageId")`,
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX IF EXISTS "UQ_messages_sessionId_waMessageId"`);
  }
}
```

## 5.7 Data Retention

### Retention Policies

Seven tables have an automated _time-based_ retention job, across six services: **`audit_logs`**, **`status_updates`**, **`webhook_delivery_failures`**, **`webhook_outbox_events`** (settled rows only), **`ingress_events`**, **`integration_delivery_failures`** and **`send_idempotency_keys`**. Separately, **`baileys_stored_messages`** is capped per session rather than by age: each write keeps the newest `BAILEYS_MESSAGE_STORE_LIMIT` rows (default 5000) for that session and deletes the rest. Everything else is kept indefinitely (api keys, sessions, webhooks, pending and processing batches, templates, conversation mappings, plugin instances, lid mappings, chat states, automation rules) and is removed only by user action (e.g. deleting a session) or operational backup/restore. Two more, `messages` and finished `message_batches`, have an opt-in time-based job (`MESSAGE_RETENTION_DAYS`, off by default); until it is set they are kept indefinitely too.

| Data Type                     | Default Retention | Configurable                                                    |
| ----------------------------- | ----------------- | --------------------------------------------------------------- |
| Sessions / Webhooks           | Indefinite        | No                                                              |
| Messages / finished batches   | Indefinite        | Yes, `MESSAGE_RETENTION_DAYS` (≤ 0 keeps them, max 36500)       |
| Status updates                | 24 hours          | No (fixed, matches WhatsApp's own story expiry)                 |
| Audit logs                    | 90 days           | Yes — `AUDIT_RETENTION_DAYS` (≤ 0 disables)                     |
| Webhook delivery failures     | 90 days           | Yes — `WEBHOOK_FAILURE_RETENTION_DAYS` (≤ 0 disables)           |
| Webhook outbox (settled rows) | 7 days            | Yes, `WEBHOOK_OUTBOX_RETENTION_DAYS` (≤ 0 does **not** disable) |
| Ingress events (dedup rows)   | 7 days            | Yes — `INGRESS_DEDUP_RETENTION_DAYS` (≤ 0 does **not** disable) |
| Integration delivery failures | 90 days           | Yes — `INGRESS_RETENTION_DAYS` (≤ 0 disables this prune only)   |
| Send idempotency keys         | 24 hours          | No (fixed: the window a key guards a retry)                     |

### Audit-Log Cleanup Job

`AuditService` prunes old `audit_logs` rows. It is **not** a `@Cron` — it runs once at startup, then on a 24-hour `setInterval` (`src/modules/audit/audit.service.ts`):

```typescript
// src/modules/audit/audit.service.ts (abridged)
onModuleInit(): void {
  const parsed = Number.parseInt(process.env.AUDIT_RETENTION_DAYS ?? '', 10);
  const retentionDays = Number.isInteger(parsed) ? Math.max(0, parsed) : 90;
  if (retentionDays <= 0) return; // AUDIT_RETENTION_DAYS <= 0 disables retention

  const runCleanup = () => this.cleanup(retentionDays).catch(/* best-effort */);
  runCleanup();                                              // prune once at startup
  this.cleanupTimer = setInterval(runCleanup, 24 * 60 * 60 * 1000); // then daily
  this.cleanupTimer.unref?.();
}

async cleanup(olderThanDays = 30): Promise<number> {
  const cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - olderThanDays);
  const result = await this.auditRepository.delete({ createdAt: LessThan(cutoff) });
  return result.affected || 0;
}
```

### Sibling Prune Jobs

The other interval-based prunes are the same shape: one prune at startup, then a 24-hour `setInterval`, `unref`'d, never a `@Cron`:

- **`webhook_delivery_failures`**: `WebhookService.onModuleInit()` (`src/modules/webhook/webhook.service.ts`), window `WEBHOOK_FAILURE_RETENTION_DAYS` (default 90; ≤ 0 disables the prune and logs that it is off). The replay `payload` has its own, shorter window: `WEBHOOK_FAILURE_PAYLOAD_RETENTION_HOURS` (default 0, store none) clears expired payloads at startup and hourly; at 0 one startup pass clears any payload an earlier setting left.
- **`webhook_outbox_events`**: `WebhookOutboxService.onModuleInit()` (`src/modules/webhook/webhook-outbox.service.ts`), window `WEBHOOK_OUTBOX_RETENTION_DAYS` (default 7). Only settled rows are deleted; `pending` and `queued` rows can still be replayed and are never pruned on age. A non-positive value does **not** disable the prune: a settled row carries no payload, so the service warns and falls back to 7 days.
- **`ingress_events`** and **`integration_delivery_failures`** — `IntegrationRetentionService` (`src/modules/integration/integration-retention.service.ts`) prunes both in one timer on two independent windows. `INGRESS_DEDUP_RETENTION_DAYS` (default 7) bounds the dedup rows; a non-positive value does **not** disable it — an unpruned dedup table grows without bound for no functional gain, so the service warns and falls back to the 7-day default. `INGRESS_RETENTION_DAYS` (default 90) bounds the DLQ rows, where long retention can be a deliberate operator choice, so ≤ 0 disables that prune (and only that prune). An aged `pending` event that still carries its payload is first given a dead-letter row and a `failed` mark, and a later run prunes it; it is kept if that write fails. One whose instance or session was deleted, or whose queue job still owns the delivery, is settled and pruned without a dead-letter row.
- **`messages`** and finished **`message_batches`**: `MessageRetentionService` (`src/modules/message/message-retention.service.ts`), window `MESSAGE_RETENTION_DAYS` (default 0, which keeps everything; boot rejects a value above 36500). Messages are deleted by `createdAt` in batches of 500 ids, at most 200 batches per run, so a run deletes at most 100,000 messages; a run that hits that limit logs a warning and is followed by another a minute later until the backlog is cleared. Pending and processing batches are never pruned. Once enabled, send pacing treats a contact silent for longer than the window as a new (cold) contact again, stats totals and search results shrink, archived media files are reclaimed by the chat-media orphan sweep, and a history sync does not write back messages older than the window. Search plugins get no `message:deleted` for pruned rows, so an external index keeps its copies.
- **`send_idempotency_keys`**: `SendIdempotencyService` (`src/modules/message/idempotency/send-idempotency.service.ts`) deletes rows past `expiresAt` (24 hours after the claim) on an hourly `setInterval`, with no startup run: a claim that collides with an expired row deletes it and claims the key itself, so a late prune never blocks a send.

### Status-Update TTL Sweep

`StatusStoreService` (`src/modules/status-store/status-store.service.ts`) stamps every ingested status row with `expiresAt = postedAt + 24h` and runs two recurring sweeps, both started in `onModuleInit` and both `unref`'d so they never hold the process open:

- **TTL purge** — once at startup, then every 15 minutes; deletes rows past their `expiresAt`.
- **Orphaned-media sweep** — hourly by default (`STATUS_ORPHAN_SWEEP_INTERVAL_MS`); deletes status media files no row references, after a grace window (`STATUS_ORPHAN_GRACE_MS`, default 1 hour) so a file mid-ingest is never reaped.

The 24-hour TTL itself is a fixed constant (`STATUS_TTL_MS`) and is not configurable.

## 5.8 Backup Strategy

> [!NOTE]
> This section is **operational guidance**, not a built-in feature. OpenWA ships no scheduler, encryption step, S3 uploader or pruning for backups; wire those up externally (cron, your host's backup tooling, etc.). The JSON export/import endpoints in §5.1 are a portability path, not a backup mechanism.
> The authoritative full-system backup is [`scripts/backup.sh`](../scripts/backup.sh), documented in the [operational runbook](./11-operational-runbooks.md#runbook-database-backup); it also captures engine auth state, including `BAILEYS_AUTH_DIR` for Baileys.

Build any scheduled or off-site pipeline around the archive `scripts/backup.sh` writes, not around a bare `pg_dump` or a copy of the `.sqlite` files. The data store alone omits `main.sqlite` (API keys and the audit log) and the engine auth state, so a restore from it comes back with no API keys and every session unpaired. The flow, and which steps stay with the operator, is in [10.7 Backup & Recovery](./10-devops-infrastructure.md#107-backup--recovery).

---

<div align="center">

[← 04 - Security Design](./04-security-design.md) · [Documentation Index](./README.md) · [Next: 06 - API Specification →](./06-api-specification.md)

</div>

---

### 06 Api Specification (`docs/06-api-specification.md`)

# 06 - API Specification

## 6.1 API Overview

### Base URL

Every REST route is mounted under the global `api` prefix:

```
http://<host>:2785/api
```

For local development that is `http://localhost:2785/api`; behind a reverse proxy substitute your public origin (the `/api` prefix is unchanged).

### Authentication

A global API-key guard protects every route unless it is explicitly marked **public** (`@Public()`). Send the key in the `X-API-Key` header:

```http
X-API-Key: owa_k1_your-api-key-here
```

> **Auth is header-only (never in a URL).** A query-parameter API key is **not** accepted anywhere. REST routes take the key via the `X-API-Key` header or `Authorization: Bearer <key>` (`X-API-Key` wins when both are sent); the WebSocket (Socket.IO) handshake — see §6.5 Real-time API — accepts it only via the handshake `auth.apiKey` field or the `X-API-Key` header. The former `?apiKey=` query fallback was **removed** (it leaked the credential into proxy/access logs). Never put the key in a URL.

Three kinds of route are public and take no API key. The metrics endpoint authenticates with `Authorization: Bearer <METRICS_TOKEN>` instead. The ingress routes (`/api/ingress/:pluginId/:instanceId/*`) verify each delivery with the route's own provider scheme (an HMAC signature or token; see §6.4.17). The health routes (`/api/health`, `/api/health/live`, `/api/health/ready`) and `GET /api/infra/health` take no credential at all.

### Common Headers

```http
X-API-Key: owa_k1_your-api-key      # required on every non-public REST route
Content-Type: application/json       # required on requests with a JSON body
```

`Content-Type: application/json` is only needed when sending a body. There is no required `Accept` or `X-Request-ID` header.

### Roles & Authorization

API keys carry one of three roles, ordered by privilege:

| Role       | Rank | Can do                                                                                                                               |
| ---------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `viewer`   | 1    | Read-only routes (no `@RequireRole`, or routes that only need a valid key)                                                           |
| `operator` | 2    | Everything a viewer can, plus write/action routes guarded by `@RequireRole(OPERATOR)` (send messages, group/contact mutations, etc.) |
| `admin`    | 3    | Everything, plus admin-only routes guarded by `@RequireRole(ADMIN)` (API-key management, settings)                                   |

`@RequireRole(role)` enforces a **minimum** role using the hierarchy `VIEWER < OPERATOR < ADMIN`: a key satisfies the guard if its own rank is ≥ the required rank (so an `admin` key passes an `OPERATOR`-guarded route). A route with no `@RequireRole` accepts any valid key, including `viewer`. A key whose role is below the requirement gets `403 Forbidden`; a missing or invalid key gets `401 Unauthorized`.

A key may additionally be scoped to specific sessions (`allowedSessions`) and/or source IPs (`allowedIps`). The scope/IP check runs in the guard **before** any role check, so a request outside that scope is rejected with `403` even if the role would otherwise allow it. `401` is kept for a missing, unknown, revoked or expired key.

A key may also be restricted to selected chats (`allowedChats`: group `<id>@g.us`, contact `<phone>@c.us` or `<lid>@lid`, or a bare phone number, which is stored as `<phone>@c.us`), at most 2000 entries. An empty or absent list leaves the key unrestricted. A chat-restricted key is **denied by default**: it may call only routes marked as chat-scoped, and every other route (webhooks, automation, status, channels, key management, and any route added later) answers `403 "API key is restricted to selected chats"` after the role check. The chat-scoped routes are the message sends (text, template, image, video, audio, document, location, contact, sticker, poll, reply, click-button, forward, bulk and `send-product`), react, edit, delete, pin, unpin, star and vote-poll, and the history, reactions and media of one chat; the chat actions (read, unread, archive, mute, pin, delete, typing, clearing a chat's messages) and a chat's presence (subscribe and read); one contact's read, profile picture, phone lookup, update, delete, block and unblock routes; one group's detail, settings, subject, description, leave and picture routes; the label routes that address a chat; and the session's own detail (`GET /api/sessions/:sessionId`), which names no chat. On a chat-scoped route, every chat id the request names (a `:chatId` / `:groupId` / `:contactId` path param, `?chatId=`, a body `chatId` / `fromChatId` / `toChatId`, and each `messages[].chatId` of a bulk send) must be inside the allowlist, or the request answers `403 "API key not authorized for this chat"`; a contact entry also matches its mapped `@lid` form, and the reverse. The chat, group, contact and label-chat list routes (`GET /api/sessions/:sessionId/chats`, `/groups`, `/contacts` and `/labels/:labelId/chats`) return only the allowed entries, filtered before paging. `GET /api/search` with the built-in provider narrows its hits to the allowed chats before `limit`/`offset`/`total` apply, and an optional `?chatId=` must name an allowed chat. Plugin providers return `403` for these keys before the worker is invoked; set `SEARCH_PROVIDER=builtin-fts` to enable scoped search. `GET /api/sessions/:sessionId/groups/:groupId` and its `GET`/`PUT .../settings` admit a group inside the allowlist and answer `403` for any other. On `GET /api/sessions/:sessionId/messages` such a key must pass `?chatId=`; omitting it answers `403`. A `quotedMessageId` is refused with `403` everywhere except the reply route, which binds the quote to the chat it sends into. The `/events` WebSocket admits chat-restricted keys and delivers only chat events inside their allowlist. MCP tools enforce the same chat fence, filter list results before paging, and refuse tools without chat-scoped authorization. The Bull Board queue dashboard still refuses chat-restricted keys with `403`.

### API-Key Lifecycle

OpenWA seeds an initial admin key on first run (printed to the startup log and written to `data/.api-key`, or `/app/data/.api-key` in Docker). Use it to mint scoped, lower-privilege keys for integrations. Full key creation, listing, rotation, and revocation are documented under the auth resource in **§6.4.9 (API Keys)**.

## 6.2 Response Format

> **OpenWA returns the raw handler payload directly — there is NO `{ success, data, meta }` envelope.** A resource route returns the resource object as-is; a list route returns a **bare JSON array**. Read fields directly (`response.id`, not `response.data.id`).

### Success Response

A successful request returns the resource (or array) exactly as the handler produced it:

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "my-session",
  "status": "ready"
}
```

List endpoints return a bare array (some paginated list routes instead return a small wrapper such as `{ "messages": [...], "total": 42 }` — the per-endpoint docs state the exact shape):

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

Session `status` wire values are **lowercase**: `created | initializing | qr_ready | authenticating | ready | disconnected | action_required | failed`.

### Error Response

Errors use the NestJS default shape, published in `openapi.json` as the `ErrorResponse` schema. The HTTP status is on the status line and mirrored in `statusCode`:

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

A few refusals add a stable machine-readable `code`, so a client can branch without parsing `message`:

| `code`                          | Status | Meaning                                                                                                                                       |
| ------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `SESSION_NAME_TEARDOWN_PENDING` | `409`  | The previous session's credential teardown under that name is still in flight; retry once it settles                                          |
| `SESSION_STOP_INCOMPLETE`       | `502`  | The engine did not confirm the stop; retryable                                                                                                |
| `SESSION_LOGOUT_INCOMPLETE`     | `502`  | The engine did not confirm the logout; retryable                                                                                              |
| `SESSION_FORCE_KILL_INCOMPLETE` | `502`  | The engine did not confirm the force-kill; its process may still be running                                                                   |
| `SEND_PACING_LIMITED`           | `429`  | Send pacing refused the send; the body also carries `retryAfterSeconds`                                                                       |
| `ENGINE_PAGE_ERROR`             | `500`  | whatsapp-web.js: WhatsApp Web rejected the operation in the page; the body also carries `pageError` and, when the page could read it, `build` |
| `IMPORT_ALREADY_RUNNING`        | `409`  | Another data import is in progress                                                                                                            |
| `IMPORT_WOULD_ORPHAN_ENGINES`   | `409`  | The backup lacks sessions running, starting or waiting to relaunch here; retry with `stopOrphans=true` or `force=true`                        |
| `IMPORT_NESTED_TRANSACTION`     | `409`  | Another database transaction is open on the connection; retry with no other data operation in flight                                          |
| `EXPORT_IN_PROGRESS`            | `409`  | A data export is in progress; retry the import after it finishes                                                                              |
| `IDEMPOTENCY_KEY_INVALID`       | `400`  | The `Idempotency-Key` header is not 1-255 visible ASCII characters                                                                            |
| `IDEMPOTENCY_KEY_REUSED`        | `422`  | The key was already used for another route or body                                                                                            |
| `IDEMPOTENCY_KEY_IN_PROGRESS`   | `409`  | The first request with this key is still running; retry later with the same key                                                               |
| `IDEMPOTENCY_OUTCOME_UNKNOWN`   | `409`  | A failure may have sent the message; check the chat before sending again with a new key                                                       |
| `POLL_OPTION_NOT_FOUND`         | `400`  | None of the option texts match the poll; the body also carries `validOptions`, the texts it offers                                            |

Validation failures (`statusCode: 400`) return `message` as an **array** of field-level strings, with `error: "Bad Request"`, when field detail is enabled: by default outside production, or anywhere with `VALIDATION_ERROR_DETAIL=true`. Under `NODE_ENV=production` (the Docker image, compose and Helm default) detail is off unless that variable is set, and the body is only `{ "statusCode": 400, "message": "Bad Request" }`. Clients should accept `message` as either a string or an array of strings. A global `ValidationPipe` runs with `whitelist` + `forbidNonWhitelisted`, so any request-body field not declared on the DTO is rejected with `400`. A request whose path or query string contains `%00`, or whose body holds a NUL character (U+0000) in any string, is refused with `400` before the route runs. The body check does not cover three routes: `POST /api/infra/import-data` accepts a backup holding one and drops it from the stored text listed below and from template names, bodies, headers and footers and automation-rule names and replies, so a SQLite backup with a NUL there restores on PostgreSQL, the ingress route (`/api/ingress/:pluginId/:instanceId/:path`) reads its body raw and hands it to its plugin as delivered, and `POST /mcp` runs outside the Nest pipe chain, so a NUL in its tool input gets an in-band `BadRequestException` tool error instead of a `400`. Message text, captions, chat and contact names, media types and the account's own profile name received from WhatsApp, and dead-letter errors, are stored without NUL characters.

### General Error Codes

| HTTP Status | Meaning               | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ----------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`       | Bad Request           | DTO validation failed, unknown body field, a NUL character in the URL or (outside the import, ingress and MCP routes) the body, or a business precondition not met (e.g. session not active, media over cap)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `401`       | Unauthorized          | Missing/invalid/expired/revoked `X-API-Key` (or `METRICS_TOKEN` for metrics)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `403`       | Forbidden             | A valid key refused by its `allowedIps` (a blocked or undeterminable source IP) or used outside its `allowedSessions` scope, a key whose **role** is below the route's `@RequireRole` requirement, or a key with `allowedChats` that calls a route that is not chat-scoped, names a chat outside its allowlist, or sends a `quotedMessageId` outside the reply route (see Roles & Authorization); or WhatsApp itself refused an otherwise well-formed operation (for example a group write without admin rights), in which case the key is fine and each route's Errors line names the refusal; or, on an ingress `GET` verification challenge, the `verifyToken` did not match                                                                                                                                                                                                                                           |
| `404`       | Not Found             | The addressed resource (session, message, webhook, batch, …) does not exist                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `409`       | Conflict              | A uniqueness constraint was violated (e.g. duplicate name); a credential teardown for the same session name is still in flight on `start`/`delete` (retryable; body carries `code: 'SESSION_NAME_TEARDOWN_PENDING'`); or, on routes that reach a WhatsApp engine, the engine exists but is not ready yet (retryable; each route section carries the exact wording); or, on a multi-node deployment, another node currently owns the session's live engine (retryable)                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `413`       | Payload Too Large     | The request body exceeds `BODY_SIZE_LIMIT`, or base64 or downloaded media exceeds the media byte cap (see §6.3), or the declared `Content-Length` is larger than the in-flight body budget, or the caller's share of it, could ever admit (not retryable, no `Retry-After`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `415`       | Unsupported Media     | The request has a body carrying a `Content-Encoding` other than `identity`; compressed request bodies are not accepted, as the aggregate body cap counts wire bytes. On the ingress route, also a body in a content type no parser reads (only `application/json` and `application/x-www-form-urlencoded` are read); that answer is the plain-text body `unsupported ingress content type`, not the JSON error shape                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `429`       | Too Many Requests     | A rate limit was exceeded: the per-client-IP tiers, the ingress per-instance limit, the concurrent bulk batch cap, or send pacing (body carries `code: 'SEND_PACING_LIMITED'` and `retryAfterSeconds`); honor `Retry-After` when present                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `500`       | Internal Server Error | Send failed at the WhatsApp engine or an unexpected server error; when WhatsApp Web itself threw during a whatsapp-web.js send or status post, the body carries `code: 'ENGINE_PAGE_ERROR'`, `pageError` (`name`, `message`) and, when the page could read it, the WhatsApp Web `build`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `501`       | Not Implemented       | The operation is not supported by the active engine (see the capability matrix, docs/29)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `502`       | Bad Gateway           | An engine transport failure (e.g. a dead Baileys socket; retryable), an upstream component returned something unusable (not retryable; each route section carries the exact wording), or, on a multi-node deployment, forwarding a session-scoped request to its owner node failed after the request may already have been sent, so a non-idempotent call must not be replayed blindly (see docs/13)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `503`       | Service Unavailable   | A dependency or the session is not ready (boot draining or shutdown teardown, a datastore down, the engine reconnecting, a media `url` fetch through the session's egress proxy failing before any response, whether the proxy or the target is at fault), or WhatsApp did not answer an engine operation within its deadline; retryable, except that a write which timed out waiting for WhatsApp may still have been applied, so a non-idempotent call must not be replayed blindly (treat a `503` on a write as outcome-unknown unless the route section says repeating it is safe). On a multi-node deployment a forward that fails before reaching the owner node answers `503` and was not carried out (see docs/13); a `503` from the owner itself is relayed unchanged and keeps that route's meaning. Also answered, with `Retry-After`, when too many request bodies are in flight at once                      |
| `504`       | Gateway Timeout       | An upstream the gateway waits on did not answer in time: on a multi-node deployment, the owner node did not answer a forwarded session-scoped request within `SESSION_PROXY_TIMEOUT_MS`; on `POST /sessions/{sessionId}/start` (see §6.4.1), the engine did not finish starting within its timeout, either because initialization never finished at all (WhatsApp Web, the network or the session `proxyUrl` unreachable in a way that hangs the connection, or a browser stalled mid-startup, for example under a container memory or resource limit) or, on whatsapp-web.js, because its auth poll expired after the page loaded (typically an unreachable `proxyUrl`; on Baileys an unreachable proxy does not fail the start), and the message names which. On the forwarding path the request may still have been carried out on the owner node, so a non-idempotent call must not be replayed blindly (see docs/13) |

### Timestamp Conventions

OpenWA uses **two** timestamp representations — be careful which a field is:

- **Message timestamps are epoch numbers (Unix seconds), not ISO strings.** This applies to the `timestamp` field on messages returned by send responses, history, and persisted message records (the persisted column is stored as a bigint and surfaced as a `number`).
- **Entity audit fields use ISO-8601 UTC strings** (example: `2026-02-02T10:00:00.000Z`). This applies to `createdAt` / `updatedAt` on persisted entities, `expiresAt`, batch `startedAt` / `completedAt`, and similar metadata fields.

## 6.3 Media Specifications

### Media DTO (flat shape)

All media send routes (`send-image`, `send-video`, `send-audio`, `send-document`, `send-sticker`) share one **flat** request DTO — `SendMediaMessageDto`. There is **no** nested `{ image: { url } }` wrapper; the media source fields sit at the top level of the body:

| Field      | Type     | Required    | Constraints                                         | Description                                                                                                                                                                               |
| ---------- | -------- | ----------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chatId`   | string   | yes         | non-empty, max 255 chars                            | Recipient — `<phone>@c.us` or `<groupId>@g.us`                                                                                                                                            |
| `url`      | string   | conditional | valid http/https URL; required when `base64` absent | Remote media URL. Fetched server-side through an SSRF guard; a blocked/internal URL, a non-2xx answer, a timeout or a failed connection yields `400`, a download over the media cap `413` |
| `base64`   | string   | conditional | required when `url` absent                          | Raw base64 media data. Decoded size is checked against the media cap                                                                                                                      |
| `mimetype` | string   | conditional | required when `base64` is used                      | MIME type, e.g. `image/jpeg`, `video/mp4`, `application/pdf`                                                                                                                              |
| `filename` | string   | no          | max 255 chars                                       | Optional file name (also used as the persisted body fallback for documents)                                                                                                               |
| `caption`  | string   | no          | max 1024 chars                                      | Optional caption (not persisted for audio)                                                                                                                                                |
| `mentions` | string[] | no          | array of WIDs, max 1024 of 64 characters            | WIDs to @mention in the caption (e.g. `["62811@c.us"]`). See **Mentions** below                                                                                                           |

Provide **exactly one** of `url` or `base64`. Omitting both, or supplying `base64` without `mimetype`, returns `400`.

A document sent without a `filename` is delivered under the default name `file` — on the whatsapp-web.js engine, a URL-based send first derives the URL basename before that fallback applies.

```json
{
  "chatId": "6281234567890@c.us",
  "url": "https://example.com/image.jpg",
  "caption": "Check out this image!"
}
```

```json
{
  "chatId": "6281234567890@c.us",
  "base64": "/9j/4AAQSkZJRg...",
  "mimetype": "image/jpeg",
  "filename": "photo.jpg"
}
```

### Size Limit

There is a **single shared media byte cap**, not a per-type table. A base64 (or downloaded) media blob whose **decoded** size exceeds the cap is rejected with `413 Payload Too Large`. The cap is `MEDIA_DOWNLOAD_MAX_BYTES`, default **50 MiB (52,428,800 bytes)**; the same value bounds outbound base64 sends, remote-URL downloads, and inbound media. It takes a raw byte count, not a unit string, and the gateway refuses to boot on a non-positive or non-numeric value rather than falling back silently (`50mb` would parse to 50 bytes).

A whole request body is separately capped by `BODY_SIZE_LIMIT` (`25mb` by default), and base64 is about a third larger than the bytes it encodes, so with default settings a base64 send above roughly 18 MiB decoded is rejected with `413` by the body parser before the media cap applies. Raise `BODY_SIZE_LIMIT` to send larger base64 media, or send it by `url`.

### Text Limit

`send-text` enforces a maximum body length of **4096 characters** (`text` is `@MaxLength(4096)`). Media captions are limited to **1024 characters**.

### Mentions

`send-text`, the media send routes, `send-template`, `send-bulk` (per item), `reply` and `edit` all accept an optional `mentions` array of WIDs (`<phone>@c.us`) to tag participants — most useful in groups. On `edit` the tags are re-applied rather than preserved: an edit replaces the message content, so a rewritten body that still reads `@62811` loses the tag unless the list is sent again. Two things are required for WhatsApp to render a tag and notify the participant:

1. The `mentions` array lists the WID(s), e.g. `["62811@c.us"]`.
2. The `text`/`caption` contains the matching `@<number>` token, e.g. `Hello @62811`.

The contract is engine-neutral: pass neutral `@c.us` WIDs and the active engine (whatsapp-web.js or Baileys) de-normalizes them internally. Whether a mention surfaces a notification is ultimately client-side — outside a shared group some clients may not render it.

### Send response: `201` means accepted, not delivered

Single-recipient send routes under `/messages` return **HTTP 201** with `{ "messageId", "timestamp" }` as soon as the gateway hands the message to the WhatsApp client. This confirms the send was _accepted_ — it does **not** confirm the recipient received it. Two routes differ: `POST send-bulk` returns **202** with a batch envelope (`{ batchId, status, totalMessages, … }`), and the `status/send-*` routes return **201** with `{ statusId, timestamp, expiresAt }` — a `statusId`, not a `messageId`, and an ISO timestamp rather than epoch seconds.

Two consequences worth knowing:

1. **A number reported as unregistered is refused before sending.** Baileys checks a phone destination when it has no LID mapping: a negative answer returns `400`, and an unanswered lookup returns `503`. Known LIDs skip that query. whatsapp-web.js also returns `400` when it cannot resolve the recipient, which can include its first-contact limitation. Neither result guarantees that a registered recipient will receive the message.
2. **There is no synchronous delivery confirmation on either engine** (whatsapp-web.js or Baileys), so the `201` cannot be made to mean "delivered."

**Before sending to a new number**, you can confirm it is a registered WhatsApp account with `GET /api/sessions/:sessionId/contacts/check/:number` (returns `{ exists, whatsappId }`; needs an `OPERATOR` key, like the send itself; see the Contacts reference).

**For real delivery state**, track the stored message's `status`, which advances asynchronously as WhatsApp sends acks: `sent → delivered → read`, or `failed` when WhatsApp reports an error for the message — which also dispatches a `message.failed` webhook. See the message shape under the Messages reference.

A message resting at `sent` is **not** diagnostic on its own. It means no ack has advanced it yet, and a registered recipient whose device has not come online since the send stays at `sent` indefinitely too — that is the designed behavior, not a fault. Use `contacts/check` to tell an unregistered number apart from a registered one that simply has not received yet.

## 6.4 REST API Reference

Every path below is prefixed with `/api`. Unless marked **public**, send `X-API-Key: <key>`; `OPERATOR`/`ADMIN` annotations require a key of at least that role. Responses are the raw payload (no envelope); list endpoints return a bare array.

### 6.4.1 Sessions

Base path `/api/sessions`. All routes that return a session return data shaped by `SessionResponseDto.fromEntity` (via `transformSession`), which **strips** `config` and the raw credential-bearing `proxyUrl` / `proxyType` columns and renames the entity field `lastActiveAt` to `lastActive`. Masked proxy details are available from `GET/PATCH /api/sessions/:sessionId/proxy` only. Session `status` wire values are lowercase: `created | initializing | qr_ready | authenticating | ready | disconnected | action_required | failed`.

#### GET /api/sessions

List all sessions, scoped to the API key's `allowedSessions`, ordered `createdAt` DESC.

**Auth:** API key · **Scope:** session-scoped (a key with a non-empty `allowedSessions` sees only those sessions, whatever its role; a key with a null or empty `allowedSessions` lists all)

**Query parameters**

| Name     | Type             | Required | Default | Description                                                                                                                                                                                             |
| -------- | ---------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `limit`  | integer (1-1000) | No       | `1000`  | Max sessions to return; oversized/non-finite values are clamped/fallback to the default window.                                                                                                         |
| `offset` | integer          | No       | `0`     | Sessions to skip for paging; negative/non-finite values resolve to `0`.                                                                                                                                 |
| `name`   | string           | No       |         | Return only the session with exactly this name (case-sensitive). No match, including a name outside a scoped key's `allowedSessions`, returns `200 []`. An empty value or a repeated key returns `400`. |

**Response** `200`

```json
[
  {
    "id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
    "name": "my-bot",
    "status": "ready",
    "phone": "6281234567890",
    "pushName": "My Bot",
    "connectedAt": "2026-06-25T08:14:02.000Z",
    "lastActive": "2026-06-25T09:01:55.000Z",
    "createdAt": "2026-06-20T11:30:00.000Z",
    "updatedAt": "2026-06-25T09:01:55.000Z",
    "lastError": null,
    "restriction": null,
    "engineLoaded": true
  }
]
```

`lastError` can be non-null only when `status` is `failed` or `action_required`, or `initializing` from the fifth consecutive reconnect attempt of a session whose engine retries a dropped connection on its own (Baileys), until the session is ready or a QR arrives (a QR window that runs out unscanned is not an attempt), or `initializing` while a reconnect waits to retry after the engine failed to relaunch, carrying that failure's reason until the next attempt starts; any other status clears it. It is held in memory per process, so it reads `null` after a restart and on a node not running the session. `config` and the raw `proxyUrl` / `proxyType` columns are not present (stripped by `fromEntity`).

`restriction` reports a limit **WhatsApp itself** has placed on the account, as opposed to `lastError`, which describes a fault on the gateway's side of the link. It is `null` when there is none, and otherwise `{ kind, code, expiresAt }`:

| `kind`              | Meaning                                                                                                                                                                                   | Engine          |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `reachout_timelock` | The account stays connected and existing chats keep working; WhatsApp blocks only the **start of new conversations**. `expiresAt` carries the end of enforcement when WhatsApp states it. | Baileys         |
| `tos_block`         | WhatsApp Web refuses the link on Terms-of-Service grounds (`TOS_BLOCK`, or `SMB_TOS_BLOCK` for a business account).                                                                       | whatsapp-web.js |
| `proxy_block`       | WhatsApp Web refuses the egress address the session connects from (`PROXYBLOCK`) — about the route, not the account.                                                                      | whatsapp-web.js |

`code` is the engine's own token for the cause, passed through verbatim (`TOS_BLOCK`, `BIZ_QUALITY`, `WEB_COMPANION_ONLY`, …), so a value newer than your gateway build still reaches you rather than being flattened. Because `tos_block`/`proxy_block` prevent the session from linking at all, neither can appear alongside a `ready` status; a `reachout_timelock` can, and usually does. Like `engineLoaded`, the field is derived from live engine state, never persisted, and re-established on the next connect. Changes are also delivered as the `session.restriction` webhook.

`engineLoaded` reports whether a node of the gateway holds a live engine for the session at the moment of the response: the process that answered, or, in a multi-node deployment, the node with a live claim on the session. It is the precondition the lifecycle routes enforce, and **`status` is not a substitute for it**: `disconnected` covers both a session whose engine is still registered while an automatic reconnect backs off — where `POST /start` answers `400` — and one stopped through `POST /stop`, which has no engine and does need a start. When `engineLoaded` is `true`, `stop`, `logout` and `force-kill` can act; when it is `false`, `start` is the applicable route. For a session another node holds, that assumes request routing (`NODE_URL` set on every node, see [13 - Horizontal Scaling](./13-horizontal-scaling.md)): the lifecycle routes are then forwarded to the owner and act there. Without routing only the owner can act on the session; on any other node `start` and `stop` answer `409` and `logout` and `force-kill` answer `400`. The field is derived per request from live state (this process's engines and the other nodes' session claims), so it is never persisted and never appears in historical/exported data.

**Errors:** `400` empty or repeated `name` · `401` missing/invalid `X-API-Key`

#### GET /api/sessions/:sessionId

Get a single session by ID.

**Auth:** API key · **Scope:** session-scoped (key's `allowedSessions` enforced against `:sessionId`)

**Path parameters**

| Name        | Type   | Description           |
| ----------- | ------ | --------------------- |
| `sessionId` | string | WhatsApp session UUID |

**Response** `200`

```json
{
  "id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
  "name": "my-bot",
  "status": "ready",
  "phone": "6281234567890",
  "pushName": "My Bot",
  "connectedAt": "2026-06-25T08:14:02.000Z",
  "lastActive": "2026-06-25T09:01:55.000Z",
  "createdAt": "2026-06-20T11:30:00.000Z",
  "updatedAt": "2026-06-25T09:01:55.000Z",
  "lastError": null,
  "restriction": null,
  "engineLoaded": true
}
```

**Errors:** `401` missing/invalid key · `403` key not scoped to this session · `404` session not found

#### GET /api/sessions/:sessionId/config

Get the effective tunable configuration for a session. Only the three recognised keys are reported,
resolved through the same clamps the engine applies — the opaque stored `config` column is never
echoed back (it is stripped from `SessionResponseDto` alongside the raw `proxyUrl` column, and anything else placed
in it is stored but ignored).

**Auth:** API key · **Scope:** session-scoped (key's `allowedSessions` enforced against `:sessionId`)

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Response** `200`

```json
{ "autoRejectCalls": false, "maxReconnectAttempts": null, "reconnectBaseDelay": 5000 }
```

`maxReconnectAttempts: null` means unlimited (the default); `reconnectBaseDelay` is milliseconds.
Both bound the gateway's own reconnect, whose attempt count restarts only once the session has
stayed READY for 5 minutes, so a session that keeps dropping sooner than that spends its cap. On
Baileys that covers only the reconnect after a logged-out close: that engine retries a transient drop
internally, with a fixed 1s to 60s backoff and no attempt cap. See §5 (Database Design) for what each
key does and the moment it is read.

**Errors:** `401` missing/invalid key · `403` key not scoped to this session · `404` session not found

#### PATCH /api/sessions/:sessionId/config

Merge a patch into the session's tunable configuration. No restart is required or performed:
omitted keys keep their stored value, and an explicit `null` clears a key back to its default (the
only way back to unlimited reconnect attempts once a cap is set). `autoRejectCalls` is re-read on
every incoming call, so it applies immediately; the two reconnect settings are read once per start
and therefore apply on the next start, leaving a reconnect sequence already in flight alone.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `UpdateSessionConfigDto` (any subset; each key also accepts `null`)

| Field                  | Type    | Constraints               | Description                                                                                                                                                                                      |
| ---------------------- | ------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `autoRejectCalls`      | boolean | none                      | Auto-reject every incoming call as soon as it rings (Baileys only)                                                                                                                               |
| `maxReconnectAttempts` | number  | integer, 0–20             | Reconnect attempt cap (`0` disables reconnect; `null` = unlimited). Bounds the gateway's own reconnect: every reconnect on whatsapp-web.js, and on Baileys only the one after a logged-out close |
| `reconnectBaseDelay`   | number  | integer, 1000–300000 (ms) | Base delay of the reconnect backoff, on the same engine scope                                                                                                                                    |

```json
{ "maxReconnectAttempts": 5 }
```

**Response** `200` — the resulting `SessionConfigResponseDto` (same shape as the GET above).

**Errors:** `400` a supplied value is outside its accepted range · `401` missing/invalid key · `403` key not scoped to this session, or key lacks OPERATOR role · `404` session not found · `409` the config kept changing under concurrent requests; retry

#### GET /api/sessions/:sessionId/proxy

Read a session's masked proxy configuration. Credentials embedded in the stored `proxyUrl` are **never** returned — only the parsed `proxyHost:port`, a `proxyType` derived from the URL scheme, and a `hasCredentials` flag.

**Auth:** API key · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Response** `200` — `SessionProxyResponseDto`

```json
{
  "enabled": true,
  "proxyType": "http",
  "proxyHost": "proxy.example.com:8080",
  "hasCredentials": true
}
```

When no proxy is configured, `enabled` is `false` and the other fields are `null`/`false`.

**Errors:** `401` missing/invalid key · `403` key not scoped to this session · `404` session not found

#### PATCH /api/sessions/:sessionId/proxy

Update per-session proxy settings. No restart is required or performed — changes apply on the **next** `POST /start`. Send `proxyUrl: null` to clear the proxy.

**Auth:** API key (ADMIN) that is not restricted to specific sessions. The proxy carries all of the session's egress, including the gateway's fetches of URLs a caller supplies, and its host is trusted egress that is not checked against internal addresses, so setting or clearing it is an administrator's decision. A key below ADMIN, or a session-scoped key, is rejected with `403` (`@RequireRole(ADMIN)`, `@RequireUnscopedKey`). Reading it (`GET` above) stays available to any key and never returns credentials.

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `UpdateSessionProxyDto` (any subset; each key also accepts `null`)

| Field      | Type   | Constraints                                                                                                                                              | Description                                                                                                                                                                                                                                                                                                                                          |
| ---------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `proxyUrl` | string | `@IsOptional`; `@IsString`; max 255; `@IsUrl` (protocols `http`/`https`/`socks4`/`socks5`, `require_protocol`, `require_tld:false`, `allow_underscores`) | Per-session proxy egress; credentialed `http://user:pass@host` and single-label hosts allowed. Send `null` to clear. ⚠ **Must be a real, reachable proxy** when set — an unreachable value blocks the WhatsApp WebSocket on start: on whatsapp-web.js the start times out (~30s, 504); on Baileys the start succeeds and the session keeps retrying. |

```json
{ "proxyUrl": "http://user:pass@proxy.example.com:8080" }
```

**Response** `200` — the resulting `SessionProxyResponseDto` (same shape as the GET above).

**Errors:** `400` validation (bad `proxyUrl`) · `401` missing/invalid key · `403` key not scoped to this session, key lacks ADMIN role, or the key is restricted to specific sessions · `404` session not found

#### GET /api/sessions/:sessionId/qr

Get the QR code (PNG data URL) for session authentication.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Response** `200` — `QRCodeResponseDto`

```json
{
  "qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...",
  "status": "qr_ready"
}
```

`status` is the session's current lowercase status.

**Errors:** `400` session not started / QR not ready yet / already authenticated · `401` · `403` · `404` not found

#### GET /api/sessions/:sessionId/groups

Get all groups the session is a member of (paginated).

**Auth:** API key · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Query parameters**

| Name     | Type             | Required | Default | Description                         |
| -------- | ---------------- | -------- | ------- | ----------------------------------- |
| `limit`  | integer (1–1000) | No       | `1000`  | Max groups to return                |
| `offset` | integer          | No       | `0`     | Number of groups to skip for paging |

**Response** `200`

```json
[{ "id": "1234567890-123@g.us", "name": "Project Team", "linkedParentJID": null }]
```

Bare array mapped from the engine's group list then paginated. `linkedParentJID` is present for community-linked groups.

**Errors:** `400` session not started (engine not in memory) · `401` · `403` · `404` session not found · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### GET /api/sessions/:sessionId/chats

Get active chats for a session, most-recent first (paginated).

**Auth:** API key · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Query parameters**

| Name     | Type             | Required | Default | Description              |
| -------- | ---------------- | -------- | ------- | ------------------------ |
| `limit`  | integer (1–1000) | No       | `1000`  | Max chats to return      |
| `offset` | integer          | No       | `0`     | Chats to skip for paging |

**Response** `200` — `ChatSummary[]`

```json
[
  {
    "id": "6281234567890@c.us",
    "name": "Alice",
    "isGroup": false,
    "kind": "individual",
    "unreadCount": 2,
    "timestamp": 1719306115,
    "lastMessage": "See you tomorrow",
    "lastMessageType": "text",
    "archived": false,
    "pinned": false,
    "muted": false
  }
]
```

`lastMessageType` uses the same engine-neutral types as messages, such as `text`, `image`, `voice`, and `document`. It identifies media even when `lastMessage` has no caption. The field is omitted when the engine has no last message available; on Baileys it becomes available after history sync or a new message.

`archived`, `pinned` and `muted` are the read side of the `chats/archive`, `chats/pin` and
`chats/mute` endpoints. `muted` is the verdict; `muteExpiration`, present only when `muted` is true,
is the instant the mute ends in epoch milliseconds (`0` = indefinite), the same unit as
`chats/mute`'s `muteUntil`, so a value read here can be written straight back.

Sorted by `timestamp` DESC (most recent first) then paginated. `timestamp` is an epoch number (seconds). `kind` is the user-facing chat discriminator — one of `individual|group|channel|status|broadcast|unknown`; `isGroup` is retained for back-compat (true only for `kind: "group"`).

On the Baileys engine the list is rebuilt after a restart: groups and chats with a persisted archive, pin or mute state come back at once, and any other 1:1 chat when its next message arrives. See `docs/29-engine-capability-matrix.md` §29.7 (`getChats` after a restart).

**Errors:** `400` session not started · `401` · `403` · `404` session not found · `409` session not connected (also answered for a few seconds while WhatsApp Web reloads its page and the engine re-injects) · `503` page connection died mid-read, or the command timed out

#### GET /api/sessions/stats/overview

Get session statistics for multi-session monitoring.

**Auth:** API key · **Scope:** session-scoped (aggregate counts limited to the key's `allowedSessions`)

**Response** `200`

```json
{
  "total": 4,
  "active": 2,
  "ready": 2,
  "disconnected": 1,
  "byStatus": { "ready": 2, "disconnected": 1, "created": 1 },
  "memoryUsage": { "heapUsed": 142, "heapTotal": 210, "rss": 318 }
}
```

`byStatus` is keyed by lowercase status values. `memoryUsage` values are megabytes (`Math.round(bytes / 1024 / 1024)`). `active` = count of running engines on the node that answered; in a multi-node deployment it is that node's count, not the cluster's (see docs/13). A scoped key sees only its `allowedSessions` stats.

**Errors:** `401` missing/invalid `X-API-Key`

#### POST /api/sessions

Create a new WhatsApp session.

**Auth:** API key (OPERATOR) that is not restricted to specific sessions. Creating a session is a deployment-level act: the new session is outside the caller's `allowedSessions` by construction, so a session-scoped key is rejected with `403` (`@RequireUnscopedKey`). An unscoped OPERATOR/ADMIN key may create a session; setting `proxyUrl` requires an ADMIN key.

**Request body** — `CreateSessionDto`

| Field       | Type                                      | Required | Constraints                                                                                                                                                                   | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ----------- | ----------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`      | string                                    | Yes      | `@IsString`; length 3–50; `@Matches(/^[a-zA-Z0-9-]+$/)` (letters, numbers, hyphens only)                                                                                      | Unique session name; duplicate → `409`                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `config`    | object                                    | No       | `@IsOptional`; `@IsObject`; `autoRejectCalls`, `maxReconnectAttempts` and `reconnectBaseDelay` validated as on `PATCH /config` (400 naming the key); other keys not validated | Session settings. Only `autoRejectCalls` (boolean, Baileys only), `maxReconnectAttempts` (integer 0-20, default unlimited) and `reconnectBaseDelay` (ms, integer 1000-300000, default 5000) are read; other keys are stored but ignored. Change them later with `PATCH /api/sessions/:sessionId/config`. Defaults to `{}`; never returned in responses                                                                                                                     |
| `proxyUrl`  | string                                    | No       | `@IsOptional`; `@IsString`; max 255; `@IsUrl` (protocols `http`/`https`/`socks4`/`socks5`, `require_protocol`, `require_tld:false`, `allow_underscores`)                      | Per-session proxy egress; credentialed `http://user:pass@host` and single-label hosts allowed; trusted egress, not SSRF-blocked; setting it requires an ADMIN key (`403` otherwise). ⚠ **Must be a real, reachable proxy** — an unreachable value silently blocks the WhatsApp WebSocket (no QR; on whatsapp-web.js the start answers `504`, on Baileys it succeeds and the session keeps retrying); leave unset unless you need it. See "Per-session egress proxy" below. |
| `proxyType` | `http` \| `https` \| `socks4` \| `socks5` | No       | `@IsOptional`; `@IsIn([...])`                                                                                                                                                 | Deprecated and ignored: the `proxyUrl` scheme selects the proxy protocol                                                                                                                                                                                                                                                                                                                                                                                                   |

```json
{
  "name": "my-bot",
  "config": { "autoRejectCalls": false, "maxReconnectAttempts": 5, "reconnectBaseDelay": 5000 }
}
```

Minimal: `{ "name": "my-bot" }`.

**Optional — per-session egress proxy.** Route a session's traffic through a proxy only if your
network cannot reach WhatsApp directly. Set `proxyUrl` on the same request:

```json
{
  "name": "my-bot",
  "proxyUrl": "http://user:pass@your-real-proxy.host:8080"
}
```

> ⚠ `proxyUrl` **must point at a real, reachable proxy server.** A placeholder or unreachable value
> (e.g. `http://proxy.example.com:8080`) launches the engine pinned to a dead proxy, so the WhatsApp
> WebSocket never connects, **no QR code is ever delivered**, and `POST /api/sessions/:sessionId/start`
> returns `504 Gateway Timeout` after ~30s on whatsapp-web.js; on Baileys it succeeds and the session
> keeps retrying the connection. Leave `proxyUrl` unset unless you genuinely need a proxy.

On the Baileys engine the proxy carries everything the engine fetches over HTTP, on all four schemes: inbound
media, the WhatsApp Web version lookup, the history-sync and app-state payloads of the initial sync, and a
product card's `imageUrl`. SOCKS4 has no authentication step, so credentials in a `socks4://` URL are not a
login: the user name travels as the connect request's user id and the password is dropped, which a warning
says at session start. Use `socks5`, `http` or `https` for a proxy that needs a password.

A URL **you** supply is fetched through the session proxy too, on both engines: the media URL of a send, the
`url` of `POST /api/sessions/:sessionId/media/convert/voice|video`, and the link preview of a text send. The
session named in the request decides which proxy that is: the one its running engine started with, or, when
the session is not running, the one stored on its row. Set `SESSION_PROXY_URL_FETCH=false` to fetch those URLs
from the gateway's own address instead, for a proxy that only routes to WhatsApp. The SSRF guard applies
either way: the scheme and the destination are checked before any socket is opened, and the checked addresses
are what a SOCKS proxy is asked to connect to, in resolver order, so a proxy that routes only one address
family still reaches a dual-stack host (a `socks4` proxy is always given an IPv4 one, the only family that
protocol carries). Behind an HTTP or HTTPS proxy the destination is named to the proxy (in the `CONNECT` line
for an https URL, in the request line for an http one) and resolved by the proxy, so the connection there
cannot be pinned to the address that was vetted: a name that passes the check and then re-resolves to an
internal address reaches it, if the proxy can. Use a SOCKS proxy, or `SESSION_PROXY_URL_FETCH=false`, to keep
that DNS-rebinding protection.

Only an ADMIN key may set or clear a session proxy (`proxyUrl` here, or `PATCH /api/sessions/:sessionId/proxy`).
The proxy host itself is trusted egress chosen by the administrator: it is not checked against internal
addresses, so a loopback or private-network proxy works. The destinations of URLs you supply are checked
whichever proxy they go through.

**Response** `201`

```json
{
  "id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
  "name": "my-bot",
  "status": "created",
  "phone": null,
  "pushName": null,
  "connectedAt": null,
  "lastActive": null,
  "createdAt": "2026-06-25T09:00:00.000Z",
  "updatedAt": "2026-06-25T09:00:00.000Z",
  "lastError": null,
  "restriction": null,
  "engineLoaded": false
}
```

Like every other session route, this returns the `SessionResponseDto` shape (via `fromEntity`), so `config`, `proxyUrl` and `proxyType` are stripped and `lastActiveAt` appears as `lastActive`. Newly created `status` is `created`. Masked proxy details come only from `GET /api/sessions/{sessionId}/proxy`.

**Errors:** `400` validation (bad `name`/`proxyUrl`/`proxyType`, or an extra non-whitelisted field) · `401` · `403` key lacks OPERATOR role, `proxyUrl` set by a key below ADMIN, or the key is restricted to specific sessions · `409` session name already exists

#### POST /api/sessions/:sessionId/start

Start a session and initialize the WhatsApp connection.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

No request body.

**Response** `200`

```json
{
  "id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
  "name": "my-bot",
  "status": "initializing",
  "phone": null,
  "pushName": null,
  "connectedAt": null,
  "lastActive": null,
  "createdAt": "2026-06-20T11:30:00.000Z",
  "updatedAt": "2026-06-25T09:05:00.000Z",
  "lastError": null,
  "restriction": null,
  "engineLoaded": true
}
```

Returned via `transformSession`. Status typically transitions to `initializing` / `qr_ready`.

A session stopped with `POST /stop` or `POST /force-kill` is marked to run again only once the start gets past the `400` and `409` refusals below; a refused start leaves the stop in place.

**Errors:** `400` session already started / already starting, or this node already holds `MAX_CONCURRENT_SESSIONS` slots, counting running and starting engines and sessions waiting to relaunch after a failed reconnect (`Maximum concurrent sessions reached (N)`, answered only after the `404` and the `409` for a session running on another node; the session is not started, so retry once a slot frees or on a node with room) · `401` · `403` · `404` not found · `409` credential teardown for the same session name still in flight (retryable; body carries `code: 'SESSION_NAME_TEARDOWN_PENDING'`; no destructive side effect runs before the refusal — a retry after cleanup settles proceeds), or another node currently holds this session's engine (refused before any engine is launched), or, with no `code`, a `POST /stop`, a `POST /force-kill` that found a running engine to kill, or a data import (`stopOrphans`) of this session that began or finished while the start was waiting, or a delete of it still running (the start yields and launches nothing; after a stop or force-kill a new `POST /start` clears it and starts the session, after a delete or an import that removed the session the retry answers `404`) · `503` the gateway is shutting down, or the engine's browser or socket connection died during launch and the one automatic retry failed too; no engine is left running, so the start can be retried · `504` the engine did not finish starting within its timeout (WhatsApp Web or the network unreachable, a stalled browser or resource limit, or, on whatsapp-web.js, an unreachable `proxyUrl`); the engine is torn down, so the start can be retried once the cause is addressed

#### POST /api/sessions/:sessionId/stop

Stop a session and disconnect WhatsApp.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

No request body.

**Response** `200`

```json
{
  "id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
  "name": "my-bot",
  "status": "disconnected",
  "phone": "6281234567890",
  "pushName": "My Bot",
  "connectedAt": "2026-06-25T08:14:02.000Z",
  "lastActive": "2026-06-25T09:01:55.000Z",
  "createdAt": "2026-06-20T11:30:00.000Z",
  "updatedAt": "2026-06-25T09:10:00.000Z",
  "lastError": null,
  "restriction": null,
  "engineLoaded": false
}
```

Returned via `transformSession`; status typically becomes `disconnected`.

A stopped session stays down: boot auto-start (`AUTO_START_SESSIONS`) and the multi-node takeover sweep skip it until an explicit `POST /start`. The stop is recorded before the teardown, so a `502` stop keeps it too; a `409` refusal records nothing.

**Errors:** `401` · `403` · `404` not found · `409` another node currently holds this session's live engine (multi-node deployments) · `502` `SESSION_STOP_INCOMPLETE` — session stopped locally but the engine teardown did not complete (retryable; the graceful disconnect and the force-destroy escalation both failed, status `disconnected`, no success audit)

#### POST /api/sessions/:sessionId/logout

Attempt an engine-native unlink of this companion device, then tear the session down locally.

`stop` disconnects while keeping the stored credentials, and `delete` additionally purges the
on-disk auth directories and the session row, but neither tells WhatsApp anything: the device stays
listed under the account holder's **Linked Devices** on the phone until they remove it by hand.
`logout` attempts the engine-native unlink operation itself.

A `200` means the engine-native unlink operation **and** the required local credential cleanup both
completed — for Baileys, a valid companion identity, an acknowledged `remove-companion-device` IQ
response, and removal of the on-disk auth dir; for whatsapp-web.js, the native `Client.logout()`
promise (including `LocalAuth.logout()`) settled. `200` is **not** an independent observation that
the handset UI no longer shows the linked device — only the linked device itself can observe that,
and callers must not claim otherwise. Because a completed unlink wipes the stored credentials, a
later `start` always requires a fresh QR scan or pairing code.

The session must be running — the unlink is a network round-trip that needs a live engine, so a
stopped session is rejected with `400` (the row is left untouched) rather than reported as a success
that never reached WhatsApp.

If the engine-backed logout attempt does not complete, the session is still torn down locally
(map reconciled, status `disconnected`) but the route returns `502` with a stable
`code: 'SESSION_LOGOUT_INCOMPLETE'`: no send / no acknowledgement / timeout or transport error / or
a local-cleanup failure. `phone` is cleared on this path and **no** `session_logged_out` audit row
is written (that audit is only written on the `200` path). Start the session again and retry the
logout. Do not assume the retry reconnects automatically or lands in a guaranteed QR state — whether
the old credentials remain usable depends on where the failure happened, and the route does not
report which.

With `AUTO_START_SESSIONS=true`, auto-start selects sessions whose `phone` is non-null. Both a `200`
and a `502` logout clear `phone`, and a WhatsApp-initiated unlink reported by a running engine clears
it the same way, so none of those are auto-started on boot — an incomplete-logout (`502`)
session must be started explicitly and the logout retried by hand. A session stopped through
`POST /stop` or `POST /force-kill` is not auto-started either, until an explicit `POST /start`.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

No request body.

**Response** `200`

```json
{
  "id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
  "name": "my-bot",
  "status": "disconnected",
  "phone": null,
  "pushName": "My Bot",
  "connectedAt": "2026-06-25T08:14:02.000Z",
  "lastActive": "2026-06-25T09:01:55.000Z",
  "createdAt": "2026-06-20T11:30:00.000Z",
  "updatedAt": "2026-06-25T09:11:00.000Z",
  "lastError": null,
  "restriction": null,
  "engineLoaded": false
}
```

Returned via `transformSession`; status becomes `disconnected` and `phone` is cleared (so the boot
auto-start does not resurrect the session). Recorded in the audit log as `session_logged_out`,
distinguishing an intentional unlink from a plain stop.

**Errors:** `400` session is not started (no engine to send through; the row is left untouched) · `401` · `403` · `404` not found · `502` `SESSION_LOGOUT_INCOMPLETE` — session stopped locally but the logout operation did not complete (retryable; `phone` cleared, no success audit)

#### POST /api/sessions/:sessionId/force-kill

Force-kill a stuck session (SIGKILL the wedged engine, then tear it down).

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

No request body.

**Response** `200`

```json
{
  "id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
  "name": "my-bot",
  "status": "disconnected",
  "phone": "6281234567890",
  "pushName": "My Bot",
  "connectedAt": "2026-06-25T08:14:02.000Z",
  "lastActive": "2026-06-25T09:01:55.000Z",
  "createdAt": "2026-06-20T11:30:00.000Z",
  "updatedAt": "2026-06-25T09:12:00.000Z",
  "lastError": null,
  "restriction": null,
  "engineLoaded": false
}
```

Returned via `transformSession`.

Like `stop`, a force-killed session stays down across restarts and takeover until an explicit `POST /start`; a `400` refusal records nothing.

**Errors:** `400` session is not started (no live engine to kill) · `401` · `403` · `404` not found · `502` `SESSION_FORCE_KILL_INCOMPLETE`: session stopped locally but the engine force-kill did not complete (status `disconnected`, no success audit; a retry answers `400` because the engine is no longer registered, so restart the node to reap a leaked process)

#### POST /api/sessions/:sessionId/pairing-code

Request an 8-char pairing code to link via phone number (alternative to QR).

> ⚠️ **On the whatsapp-web.js engine, only request a code for a number you are prepared to re-link.** A request for a number that already has a linked session has been observed to end with WhatsApp revoking that device: the linked session logs a LOGOUT, its credentials are deleted, and it falls back to `qr_ready` with no phone. Nothing here refuses such a request: the guards check the session's state, never the number. The request runs inside the shared WhatsApp Web page and resets its linking mode before asking for a code, so the blast radius is the account rather than the session. Baileys was not affected in the same tests. Link by QR when a session of that number must stay up.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `RequestPairingCodeDto`

| Field         | Type   | Required | Constraints                                                                                       | Description                                                      |
| ------------- | ------ | -------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `phoneNumber` | string | Yes      | `@IsString`; `@IsNotEmpty`; `@Matches(/^[0-9]{6,15}$/)` (digits only, 6–15, no `+`/spaces/dashes) | International format: country code + number, e.g. `628123456789` |

```json
{ "phoneNumber": "628123456789" }
```

**Response** `201` — `PairingCodeResponseDto`

```json
{ "pairingCode": "ABCD1234", "status": "qr_ready" }
```

`status` is the lowercase session status.

**Errors:** `400` validation, or session not started, or already authenticated · `401` · `403` · `404` not found · `409` session not waiting to be linked yet; wait for `status` to read `qr_ready` and retry (after a code was accepted, wait for `ready` instead). On Baileys a session that already reads `qr_ready` can still answer `409` while its socket is closing, for up to the WebSocket close timeout (30 s); that is retryable and the status follows shortly. · `503` whatsapp-web.js only: the gateway stopped waiting after its attempt budget, usually because WhatsApp Web kept reloading its QR page; retryable, and the last attempt's reason rides in the message. An attempt that timed out may still have reached WhatsApp, and a retry starts the linking flow again, so the unlink warning above applies to the retry too.

#### POST /api/sessions/:sessionId/presence/subscribe

Ask WhatsApp to start reporting who is online or typing in a chat.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped · **Engines:** Baileys only

There is no synchronous answer: presence cannot be _fetched_ from either engine, only received.
Updates arrive as the `presence.update` webhook and socket event; the latest is readable at
`GET /api/sessions/:sessionId/presence/:chatId`.

Two properties to design around:

- **The subscription belongs to the connection.** It does not survive a restart or an automatic
  reconnect, and must be re-issued. The gateway does not silently replay subscriptions, because a
  replay would report a presence the account never actually asked for.
- **Subscribe per chat, not to everything.** WhatsApp emits an update on every transition — each time
  someone starts and stops typing — so a broad subscription is a firehose. Only genuine state
  _changes_ are dispatched onward, which bounds the event volume but not the socket traffic.

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `SubscribePresenceDto`

| Field    | Type   | Required | Constraints                                                                                                    | Description                                                                           |
| -------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `chatId` | string | Yes      | `@IsString`; `@IsNotEmpty`; `@MaxLength(255)`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace) | Engine-native JID, e.g. `1234567890@c.us` (wwebjs) or `1234@s.whatsapp.net` (Baileys) |

**Response** `200`

```json
{ "success": true }
```

**Errors:** `400` validation, or session not started · `401` · `403` · `404` session not found · `501` the active engine cannot observe presence (whatsapp-web.js exposes only `sendPresenceAvailable`/`sendPresenceUnavailable`, which publish the account's _own_ presence, and emits no presence event) · `409` conflict or engine not ready (retryable) · `503` Baileys recipient lookup failed or timed out before the operation was sent

#### GET /api/sessions/:sessionId/presence/:chatId

The last presence reported for a chat.

**Auth:** API key (VIEWER) · **Scope:** session-scoped

**Response** `200`

```json
{
  "chatId": "1234567890@c.us",
  "participants": [{ "id": "1234567890@c.us", "state": "composing", "lastSeen": 1786000000 }],
  "observedAt": "2026-08-03T12:00:00.000Z"
}
```

`state` is one of `available` / `unavailable` / `composing` / `recording` / `paused` — the middle two
mean actively typing or recording _in this chat_, `paused` means they stopped without sending.
`lastSeen` is epoch **seconds** and is absent whenever the contact's privacy settings hide last-seen,
which is the default for most accounts and is not an error. `groupOnlineCount` appears for groups
when WhatsApp reports it. A 1:1 chat still returns a `participants` array, holding the one contact.

`observedAt` is when **this gateway** received the report, not a WhatsApp timestamp. Presence is
short-lived, so an old `observedAt` means the state is stale rather than steady.

The body is `null` when nothing has been reported — the chat was never subscribed, or nothing has
changed since. That is a normal state rather than a missing resource, so it is `200` with a null
body, not a `404`. Presence is held in memory and never persisted: answering "typing" from before a
restart would be worse than answering nothing.

**Errors:** `401` · `403` · `404` session not found

#### PUT /api/sessions/:sessionId/presence

Set the account's OWN global presence: appear online or offline. WhatsApp routes notifications away
from the phone while a linked device announces itself online, so a headless bot that never goes
offline suppresses the phone's own alerts — `available: false` hands them back. Supported on both
engines.

A successful call is remembered for the life of the running engine and re-applied once each time
that connection opens, including a Baileys transient reconnect. Baileys announces itself on connect
(`available` unless `BAILEYS_MARK_ONLINE_ON_CONNECT=false`), which would otherwise replace the
caller's choice. The preference is dropped whenever the gateway replaces the engine: stop, restart,
reconnect recovery, a watchdog recycle, or a takeover by another node. Re-issue it after
`session.status` reports `ready` again. On Baileys, typing, recording and outbound sends do not
change global presence.

Not best-effort — a failure surfaces instead of leaving the account silently online. On Baileys a
`409` is also returned when the account push name has not synced yet: `sendPresenceUpdate` returns
without sending until `creds.me.name` is set, and the endpoint refuses that case instead of
reporting success.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Request body** — `SetOwnPresenceDto`

| Field     | Type    | Required | Description                                      |
| --------- | ------- | -------- | ------------------------------------------------ |
| available | boolean | Yes      | `true` = appear online, `false` = appear offline |

```json
{ "available": false }
```

**Response** `200`

```json
{ "success": true }
```

**Errors:** `400` session not started / validation · `401` · `403` key lacks OPERATOR role · `404` session not found · `409` engine not ready · `503` the whatsapp-web.js page died or did not answer within the protocol timeout (retryable)

#### POST /api/sessions/:sessionId/chats/read

Mark a chat as read/seen.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `MarkChatReadDto`

| Field        | Type     | Required | Constraints                                                                                                    | Description                                                                                                          |
| ------------ | -------- | -------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `chatId`     | string   | Yes      | `@IsString`; `@IsNotEmpty`; `@MaxLength(255)`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace) | Engine-native JID, e.g. `1234567890@c.us` (wwebjs) or `1234@s.whatsapp.net` (Baileys)                                |
| `messageIds` | string[] | No       | `@IsArray`; `@ArrayNotEmpty`; `@ArrayMaxSize(100)`; each a non-empty token with no whitespace                  | Messages to acknowledge. Omit the field to acknowledge only the newest received message; an empty array is rejected. |

Baileys acknowledges individual messages rather than chats, and the receipt enumerates ids instead of carrying a read-up-to watermark. Without `messageIds` only the newest received message the engine still holds in memory gets a receipt, so a burst leaves its earlier messages unread and a session restarted since the message arrived has nothing to acknowledge at all. Each supplied id is resolved through the message store, which is what carries the `participant` a group receipt needs. Ignored by whatsapp-web.js, whose own `sendSeen` is chat-level.

```json
{ "chatId": "1234567890@c.us", "messageIds": ["3EB0C767D26B8A3F1A2B", "3EB0C767D26B8A3F1A2C"] }
```

**Response** `200`

```json
{ "success": true }
```

Returns HTTP `200`, matching the OpenAPI contract.

> **`success: false` is a real outcome on the Baileys engine.** The read receipt is sent against the
> newest message the chat received, so a chat the session has received no message in (one holding
> only the account's own sends included) is reported as declined rather than marked read. The
> whatsapp-web.js engine reads the chat from the page and needs no local history, but it still
> answers `success: false` for a chat the page cannot find or when the page declines the read.

**Errors:** `400` validation, or session not started · `401` · `403` · `404` session not found · `409` the session is not connected (engine exists but is not `ready`) · `503` WhatsApp did not answer within the request budget, or the engine’s browser page died — the change may or may not have been applied

#### POST /api/sessions/:sessionId/chats/unread

Mark a chat as unread.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `MarkChatUnreadDto`

| Field    | Type   | Required | Constraints                                                                    | Description                               |
| -------- | ------ | -------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
| `chatId` | string | Yes      | `@IsString`; `@IsNotEmpty`; `@MaxLength(255)`; `@Matches(/^[^\s@]+@[^\s@]+$/)` | Engine-native JID, e.g. `1234567890@c.us` |

This route takes `chatId` alone. It previously shared `MarkChatReadDto`, which is why the two are described separately now that the read body carries `messageIds`.

```json
{ "chatId": "1234567890@c.us" }
```

**Response** `200`

```json
{ "success": true }
```

Returns HTTP `200`, matching the OpenAPI contract.

**Errors:** `400` validation, or session not started · `401` · `403` · `404` session not found · `409` the session is not connected (engine exists but is not `ready`) · `503` WhatsApp did not answer within the request budget, or the engine’s browser page died — the change may or may not have been applied

#### DELETE /api/sessions/:sessionId/chats/:chatId/messages

Delete every message in a chat, keeping the chat itself in the list.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description                                                                           |
| ----------- | ------ | ------------------------------------------------------------------------------------- |
| `sessionId` | string | Session UUID                                                                          |
| `chatId`    | string | Engine-native JID, e.g. `1234567890-123@g.us`. URL-encode it if your client does not. |

**Response** `200`

```json
{ "success": true }
```

On success the gateway also removes its stored copies of the chat's messages (rows, inline and archived media, search entries) and emits `message:deleted` for each. Changes made on the phone are not mirrored.

> **`success: false` is a real outcome**, as with `chats/archive`: an unknown chat on
> whatsapp-web.js, or on Baileys a chat with no known history — the clear is an app-state
> modification keyed to the chat's last message.

**Errors:** `400` session not ready · `401` missing/invalid API key · `404` session not found · `409` the session is not connected (engine exists but is not `ready`) · `503` WhatsApp did not answer within the request budget, or the engine’s browser page died — the change may or may not have been applied

#### POST /api/sessions/:sessionId/chats/archive

Archive or unarchive a chat.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `ArchiveChatDto`

| Field     | Type    | Required | Constraints                                                                                                                                              | Description                                   |
| --------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `chatId`  | string  | Yes      | `@IsString`; `@IsNotEmpty`; `@MaxLength(255)`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace)                                           | Engine-native JID, e.g. `1234567890-123@g.us` |
| `archive` | boolean | Yes      | `@IsBoolean` (strict: the exact strings `"true"`/`"false"` map to booleans; any other string such as `"0"` or `"no"` is rejected, not coerced to `true`) | `true` to archive, `false` to unarchive       |

```json
{ "chatId": "1234567890-123@g.us", "archive": true }
```

**Response** `200`

```json
{ "success": true }
```

> **`success: false` is a real outcome here, not an error.** On the Baileys engine the archive is an
> app-state modification keyed to the chat's **last message**, so a chat with no known history
> cannot be archived at all — the same limitation `chats/delete` and `chats/unread` already carry on
> that engine. Rather than fail with a 500, the endpoint reports `success: false`.

> **No `chat.archived` webhook fires for your own archive.** Baileys emits no event for a change the
> account itself made (remote-device archives arrive later via chat-update diffing), and
> whatsapp-web.js's `chat_archived` event is not wired. Treat the HTTP response as the outcome.

**Errors:** `400` session not ready · `401` missing/invalid API key · `404` session not found · `409` the session is not connected (engine exists but is not `ready`) · `503` WhatsApp did not answer within the request budget, or the engine’s browser page died — the change may or may not have been applied

#### POST /api/sessions/:sessionId/chats/mute

Mute a chat's notifications until a given moment, or unmute it.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `MuteChatDto`

| Field       | Type           | Required | Constraints                                                                                                    | Description                                                     |
| ----------- | -------------- | -------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `chatId`    | string         | Yes      | `@IsString`; `@IsNotEmpty`; `@MaxLength(255)`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace) | Engine-native JID, e.g. `1234567890-123@g.us`                   |
| `muteUntil` | number \| null | Yes      | `@IsInt`; `@Min(1)` when not `null`                                                                            | Epoch **milliseconds** the mute expires at, or `null` to unmute |

```json
{ "chatId": "1234567890-123@g.us", "muteUntil": 1800000000000 }
```

```json
{ "chatId": "1234567890-123@g.us", "muteUntil": null }
```

**Response** `200`

```json
{ "success": true }
```

> **`muteUntil` is required, and `null` is not the same as omitting it.** The two plausible readings
> of a missing field — unmute, or mute forever — are opposites, so the endpoint rejects the omission
> with a `400` instead of guessing. Send `null` to unmute. To mute indefinitely, send a far-future
> timestamp: neither engine exposes a portable "forever" value (whatsapp-web.js uses `-1` internally,
> Baileys has none), so the API keeps one well-defined shape.

> **Milliseconds, not seconds.** This was measured against a live WhatsApp account rather than read
> off the protocol: the app-state field is the unsuffixed `MuteAction.muteEndTimestamp` while the same
> proto spells other millisecond fields `…Ms`, which reads as seconds and is wrong. A seconds-scale
> value is an instant in 1970, so the mute expires the moment it is set — and the request still
> answers `200`, because nothing in the chain rejects a timestamp in the past.

> **Unlike `chats/archive`, there is no declined outcome.** The mute change is not keyed to the chat's
> last message on either engine, so a chat with no known history mutes like any other. The response is
> always `{ "success": true }` or an error.

**Errors:** `400` session not ready, invalid `chatId`/`muteUntil`, or a `chatId` the whatsapp-web.js engine cannot resolve (the Baileys engine writes the mute without resolving the chat first and answers `success: true` for a chat that does not exist) · `401` missing/invalid API key · `404` session not found · `409` the session is not connected (engine exists but is not `ready`) · `503` WhatsApp did not answer within the request budget, or the engine’s browser page died — the change may or may not have been applied

#### POST /api/sessions/:sessionId/chats/pin

Pin a chat to the top of the chat list, or unpin it. Chat-level — distinct from
`messages/pin`, which pins a message inside a chat.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `PinChatDto`

| Field    | Type    | Required | Constraints                                                                                                                                              | Description                                   |
| -------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `chatId` | string  | Yes      | `@IsString`; `@IsNotEmpty`; `@MaxLength(255)`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace)                                           | Engine-native JID, e.g. `1234567890-123@g.us` |
| `pin`    | boolean | Yes      | `@IsBoolean` (strict: the exact strings `"true"`/`"false"` map to booleans; any other string such as `"0"` or `"no"` is rejected, not coerced to `true`) | `true` to pin, `false` to unpin               |

```json
{ "chatId": "1234567890-123@g.us", "pin": true }
```

**Response** `200`

```json
{ "success": true }
```

> **`success: false` means WhatsApp refused the pin, and only a pin can be refused.** WhatsApp allows
> at most **three** pinned chats. On the whatsapp-web.js engine a fourth pin returns `success: false`
> without changing anything. Unpinning always succeeds.

> **The Baileys engine cannot see that cap.** It writes an app-state patch and WhatsApp reports
> nothing back, so it answers `success: true` for every accepted request — including a fourth pin
> that WhatsApp may then decline on its own. Treat `true` on that engine as "the request was sent",
> not "the chat is pinned". Unlike `chats/archive` the patch is not keyed to the chat's last message,
> so a chat with no known history pins normally.

**Errors:** `400` session not ready, or a `chatId` the whatsapp-web.js engine cannot resolve (the Baileys engine answers `success: true` for a chat that does not exist) · `401` missing/invalid API key · `404` session not found · `409` the session is not connected (engine exists but is not `ready`) · `503` WhatsApp did not answer within the request budget, or the engine’s browser page died — the change may or may not have been applied

#### POST /api/sessions/:sessionId/chats/delete

Delete a chat from the chat list (e.g. a group you have left).

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `DeleteChatDto`

| Field    | Type   | Required | Constraints                                                                                                    | Description                                   |
| -------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `chatId` | string | Yes      | `@IsString`; `@IsNotEmpty`; `@MaxLength(255)`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace) | Engine-native JID, e.g. `1234567890-123@g.us` |

```json
{ "chatId": "1234567890-123@g.us" }
```

**Response** `200`

```json
{ "success": true }
```

Returns HTTP `200`, matching the OpenAPI contract.

On success the gateway also removes its stored copies of the chat's messages (rows, inline and archived media, search entries) and emits `message:deleted` for each. Changes made on the phone are not mirrored.

**Errors:** `400` validation, or session not started · `401` · `403` · `404` session not found · `409` the session is not connected (engine exists but is not `ready`) · `503` WhatsApp did not answer within the request budget, or the engine’s browser page died — the change may or may not have been applied

#### POST /api/sessions/:sessionId/chats/typing

Send a typing/recording presence indicator to a chat (or clear it with `paused`).

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Request body** — `SendChatStateDto`

| Field    | Type                                | Required | Constraints                                                                                         | Description                                                 |
| -------- | ----------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `chatId` | string                              | Yes      | `@IsString`; `@IsNotEmpty`; `@MaxLength(255)` (no JID regex; engine-neutral, the adapter validates) | Engine-native chat id, e.g. `1234567890@c.us`               |
| `state`  | `typing` \| `recording` \| `paused` | Yes      | `@IsIn(['typing','recording','paused'])`                                                            | `typing`/`recording` show the indicator; `paused` clears it |

```json
{ "chatId": "1234567890@c.us", "state": "typing" }
```

**Response** `200`

```json
{ "success": true }
```

Always returns `{ "success": true }` (the service returns void; the controller hardcodes `true`).

**Errors:** `400` validation, or session not started · `401` · `403` · `404` session not found · `409` conflict or engine not ready (retryable)

#### DELETE /api/sessions/:sessionId

Delete a session.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Path parameters**

| Name        | Type   | Description  |
| ----------- | ------ | ------------ |
| `sessionId` | string | Session UUID |

**Response** `204` — empty body (`@HttpCode(204)`, returns void). A `findOne` lookup runs first, so a missing id yields `404`.

The delete removes, in one transaction, the session's messages, message batches, chat states, received statuses, webhooks, templates, stored Baileys messages, automation rules, webhook outbox rows, webhook delivery-failure records, integration dead-letter rows and send idempotency claims, then purges its on-disk auth data. It keeps the audit log, the LID mappings, and the integration conversation mappings (so a re-paired session keeps its provider conversations); ingress dedup rows age out on their own after `INGRESS_DEDUP_RETENTION_DAYS` (default 7).

**Errors:** `401` missing/invalid key · `403` key not scoped to this session, or key role below OPERATOR · `404` session not found · `409` credential teardown for the same session name still in flight (retryable; body carries `code: 'SESSION_NAME_TEARDOWN_PENDING'`; on a `409` the row is **not** deleted and no hook/auth-purge runs — retry after cleanup settles), or, on a multi-node deployment, another node currently holds this session's live engine (no `code`; nothing is deleted; send the delete to the owning node)

### 6.4.2 Messages

All routes are mounted under `/api/sessions/:sessionId/messages`. Reads (`GET` history, batch status, reactions) accept any valid API key (including VIEWER). All write/send routes require **API key (OPERATOR)** or higher. Single-recipient send routes return `MessageResponseDto { messageId, timestamp }` (`timestamp` is an epoch **number** in seconds; there is no `status` field); `POST send-bulk` instead returns `202` with `BulkMessageResponseDto`. The global ValidationPipe runs `whitelist` + `forbidNonWhitelisted`, so any body field not listed below is rejected with `400`.

#### Idempotent sends

The single-recipient send routes (`send-text`, `send-template`, `send-image`, `send-video`, `send-audio`, `send-document`, `send-location`, `send-contact`, `send-sticker`, `send-poll`, `reply`, `forward`) accept an optional `Idempotency-Key` request header, so a client can retry after a timeout or a dropped connection without sending the message twice. Without the header nothing changes.

The key is 1-255 visible ASCII characters (a UUID works), unique per session, and is held for **24 hours** from the first request. Within that window:

| Retry with the same key                                                                         | Answer                                                                                                     |
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| first request succeeded, same route and body                                                    | the first response again, with `Idempotent-Replayed: true`; nothing is sent                                |
| first request still running                                                                     | `409` `IDEMPOTENCY_KEY_IN_PROGRESS`; retry after it finishes                                               |
| first request failed with an uncertain delivery outcome                                         | `409` `IDEMPOTENCY_OUTCOME_UNKNOWN`: the message may have gone out; check the chat, then use a **new** key |
| first request was refused with `4xx` or `501` before calling the engine                         | the key was freed, so the retry runs as a new request                                                      |
| transport explicitly reported that nothing was sent, or WhatsApp rate-limited the request first | the key was freed, so the retry runs as a new request                                                      |
| different route or body (key order in the JSON does not matter)                                 | `422` `IDEMPOTENCY_KEY_REUSED`                                                                             |

A malformed key, or the header sent twice, gets `400` `IDEMPOTENCY_KEY_INVALID`. A request still pending ten minutes after it was claimed (its node most likely stopped) also reads as `IDEMPOTENCY_OUTCOME_UNKNOWN`. HTTP status alone does not prove that nothing was sent: an engine can report `409` after attempting delivery, so engine-stage failures retain the key unless transport explicitly proves that nothing was sent. Keys are stored in the data database (`send_idempotency_keys`); concurrent processes using that database share the claim, and the session-owner proxy forwards the header. This does not change the deployment support limits in [Horizontal scaling](./13-horizontal-scaling.md). `send-bulk` does not take the header: retrying its request can create another batch. A call that outlives the 24-hour key window is outside the retry guarantee.

#### GET /api/sessions/:sessionId/messages

Get persisted message history for a session from the local DB (paginated, filterable). Reads the DB only — does not hit WhatsApp.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Query parameters**

| Name        | Type    | Required | Default   | Description                                                                                                                                                                                                                             |
| ----------- | ------- | -------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| chatId      | string  | No       | —         | Filter by chat ID. Matched across `@c.us` / `@s.whatsapp.net` dialects via the lid-mapping table. Required for a key restricted to selected chats.                                                                                      |
| from        | string  | No       | —         | Filter by sender; matches `from` or a group message's `author`. A phone also matches any lid that resolves to it.                                                                                                                       |
| limit       | integer | No       | 50        | Clamped to `[1,100]`; a non-finite value falls back to 50.                                                                                                                                                                              |
| offset      | integer | No       | 0         | Clamped to `>=0`; a non-finite value falls back to 0.                                                                                                                                                                                   |
| after       | string  | No       | —         | Keyset cursor: the `id` of the last message of the previous page. Anchors the window to a row rather than a count, so a message arriving mid-walk cannot shift it. Takes precedence over `offset`. Unknown in this session gives `400`. |
| inlineMedia | boolean | No       | true      | Set `false` (or `0`) to omit every inline media payload, leaving each row's `{ omitted, sizeBytes }` marker and the media endpoint. The budget below is per response, so a paged walk pulls it afresh on every page.                    |
| messageId   | string  | No       | (none)    | Exact WhatsApp message reference within the session and optional chat filter.                                                                                                                                                           |
| since       | number  | No       | (none)    | Inclusive lower message-time bound, Unix epoch milliseconds.                                                                                                                                                                            |
| until       | number  | No       | (none)    | Exclusive upper message-time bound, Unix epoch milliseconds.                                                                                                                                                                            |
| direction   | string  | No       | (none)    | `incoming` or `outgoing`.                                                                                                                                                                                                               |
| type        | string  | No       | (none)    | Exact stored type token, such as `text`, `image` or `voice`; no search text required.                                                                                                                                                   |
| orderBy     | string  | No       | createdAt | Newest first by ingestion time (`createdAt`) or message time (`timestamp`).                                                                                                                                                             |

Time bounds accept decimal or scientific notation from 0 to 9007199254740991 milliseconds. Blank, malformed, non-finite, unsafe or reversed bounds return `400`. They compare against stored whole-second message times, with `since` inclusive and `until` exclusive. `orderBy=timestamp` uses the message UUID as its tie-breaker and excludes unknown message times. Without these options, ingestion-time ordering and existing stored rows are preserved.

With a time bound or `orderBy=timestamp`, `unknownTimestampTotal` counts rows matching all non-time filters whose message time is unknown. Those rows are excluded from `messages` and `total`. This describes stored history; exhausting a page does not prove complete remote WhatsApp history.

Keep the same filters and order when continuing with `after`. When using the new filters, the anchor must belong to the selected session, chat, sender, type, direction, message reference and time window. An unknown, deleted or mismatched anchor returns `400`. Concurrent older arrivals may appear on subsequent pages; pagination does not freeze the archive.

Example (stored messages for 2026-09-20 in Europe/Vienna):

```http
GET /api/sessions/:sessionId/messages?since=1789855200000&until=1789941600000&orderBy=timestamp&inlineMedia=false&limit=100
```

**Response** `200`

```json
{
  "messages": [
    {
      "id": "9f1c2e7a-2b3d-4c5e-8a91-0d1e2f3a4b5c",
      "sessionId": "3f6b2a1c-8d4e-4f7a-9b2c-5e1d0a7c6b48",
      "waMessageId": "true_628123456789@c.us_3EB0ABCD",
      "chatId": "628123456789@c.us",
      "from": "6281234567890",
      "to": "628123456789@c.us",
      "chatName": null,
      "author": null,
      "body": "Hello from OpenWA!",
      "type": "text",
      "direction": "outgoing",
      "timestamp": 1719312000,
      "metadata": null,
      "mediaPath": null,
      "mediaMimetype": null,
      "status": "sent",
      "createdAt": "2026-06-25T09:20:00.000Z"
    }
  ],
  "total": 1
}
```

Each `Message`: `{ id (uuid), sessionId, waMessageId (string|null), chatId, from, to, chatName (string|null; the sender's push name, or their saved contact name when no push name was reported), author (string|null; the real sender of a group, status or broadcast-list message, where from holds the group, status@broadcast or list id; on Baileys a list message the account received is filed under the sender, so from is the sender too), body (string|null), type, direction ('incoming'|'outgoing'), timestamp (number|null), metadata (object|null), mediaPath (string|null; storage key of archived media), mediaMimetype (string|null), status ('pending'|'sent'|'delivered'|'read'|'failed'), createdAt (ISO date) }`. By default, ordered by `createdAt` DESC, then by a dialect-dependent second key. The tiebreaker matters: `createdAt` is not unique (SQLite stores whole seconds, a PostgreSQL bulk write ties every row it inserts, and a history backfill carries WhatsApp's own second-resolution timestamp), and without a total order two pages of one walk can repeat a row and omit another. On SQLite the second key is `rowid`, the stored insertion sequence, so messages sharing a second come back in the order they arrived. PostgreSQL has no equivalent (`ctid` moves on every ack update), so it keeps `id`, a random uuid: the walk is equally correct there, but a same-second group is not in arrival order. Note that `offset` still addresses a position by count, so a list taking concurrent writes can shift under a walk: a message arriving mid-walk pushes every older row down one, and the next page re-serves a row the previous one already returned. Pass `after` instead to walk a live chat safely; it anchors on the last row you received, which an arriving message cannot move. The response is the raw service object (no envelope). Unlike the live `IncomingMessage` shape below, this persisted `Message` does **not** carry `kind` — re-derive the chat kind from `chatId` (see `ChatKind` / `chatKind()`) if needed. When present, `metadata` may include `media`, `quotedMessage`, `call`, `reactions`, `poll { name, options, allowMultipleAnswers }` for a poll creation message, and, for Baileys inbound business prompts, `buttons: [{ id, text }, …]` so a client (including the dashboard Chats thread) can re-render the choices after reload and tap them via [`POST .../click-button`](#post-apisessionssessionidmessagesclick-button).

For messages whose edits the gateway observes, `metadata.edited` is `true`. Chats displays a localized edited indicator for these messages, including after reloading. Existing messages are not retroactively marked, and a deleted message keeps its deleted placeholder instead. The `message.edited` event payload is unchanged.

> **Inline media is carried up to a budget, then omitted.** `MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES` (8 MiB of encoded base64 by default) bounds how much inline media one response may hold across its rows. A row is not a bounded object, `limit` is clamped to `[1,100]` but each row can carry its base64 in `metadata.media.data`, so a page of media rows could otherwise reach hundreds of megabytes and fail the read outright. The budget is spent newest-first, matching the selected descending order, so a page that cannot carry everything keeps the most recent media. Past it a payload is replaced with `{ mimetype, filename?, omitted: true, sizeBytes }`, the same marker the engine emits for inbound media over `MEDIA_DOWNLOAD_MAX_BYTES`, and the bytes remain available from [`GET /messages/:chatId/:messageId/media`](#get-apisessionssessionidmessageschatidmessageidmedia). Two rules bound the edges: the newest payload is always inlined even when it alone exceeds the budget (otherwise a single large photo would be permanently unreadable through this route), and a budget of `0` means "never inline" and grants no such allowance. The knob is validated at boot, `8MiB` would parse to 8 bytes, and is forwarded by both compose files. The MCP `MessageList` tool shares this path and the same budget. A row stored under `MESSAGE_INLINE_MEDIA=archive` arrives with the marker already in place, plus `archived: true`, its bytes live only in the chat-media archive, behind the same media route (see that route for the mode).

**Errors:** `400` invalid filters or an unknown or mismatched cursor · `401` missing/invalid API key · `403` a key restricted to selected chats sent no `chatId`, or one outside its allowlist

A blank `after` is treated as absent, the way a blank `limit` or `offset` already is, so a client
templating a cursor it has not got yet keeps the unfiltered first page rather than a `400`.

#### GET /api/sessions/:sessionId/messages/:chatId/history

Fetch chat history live from WhatsApp for a chat, bypassing the local DB.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                                         |
| --------- | ------ | --------------------------------------------------- |
| sessionId | string | Session ID                                          |
| chatId    | string | Chat ID, e.g. `628123456789@c.us` or `groupId@g.us` |

**Query parameters**

| Name         | Type    | Required | Default | Description                                                                                                       |
| ------------ | ------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| limit        | integer | No       | 50      | Clamped to `[1,100]`; when `deep=true` the ceiling rises to 2000. Non-finite falls back to 50.                    |
| includeMedia | boolean | No       | false   | Truthy only for `true` or `1`. Downloads base64 media (slower). Forced OFF when `deep=true`.                      |
| deep         | boolean | No       | false   | Truthy only for `true` or `1`. Raises the limit ceiling 100→2000 (whatsapp-web.js only) and forces metadata-only. |

**Response** `200`

Returns a bare array of engine-neutral `IncomingMessage` objects: the chat's most recent `limit` messages, oldest first
(ascending `timestamp`).

```json
[
  {
    "id": "true_628123456789@c.us_3EB0ABCD",
    "from": "628123456789@c.us",
    "to": "628987654321@c.us",
    "chatId": "628123456789@c.us",
    "body": "Hi there",
    "type": "text",
    "timestamp": 1719312000,
    "fromMe": false,
    "isGroup": false,
    "kind": "individual",
    "author": "628123456789@c.us"
  }
]
```

Each item may also include `isStatusBroadcast`, `mentionedIds`, `isLidSender`, `ephemeralDuration` (the chat's disappearing-messages timer in seconds, present only when one is set), `contact`, `media { mimetype, filename?, data?, omitted?, sizeBytes? }`, `quotedMessage { id, body }`, `call { video, missed }` (for `call` messages), `location { latitude, longitude, description?, address?, url? }`, `order { orderId, token? }` (for `order` messages), `product { productId, title?, description?, businessOwnerJid? }` (for `product` messages) and `poll { name, options, allowMultipleAnswers }` (for poll creation messages, when the engine provides the choices). `senderPhone` is **not** among them: this endpoint reads live from the engine and performs no privacy-id resolution, which runs only on the inbound `message.received` path (§6.6). To map an `@lid` sender seen here, call `GET /api/sessions/:sessionId/contacts/:contactId/phone`. `type` is one of `text|image|video|audio|voice|document|sticker|location|contact|poll|call|revoked|order|product|masked|unknown`. An `order` is a cart the customer placed from the business catalog and a `product` is a single catalog product shared into the chat. Only whatsapp-web.js serves this route (Baileys answers `501`), so every `order` and `product` block here comes from that engine. The message carries no line items and this API exposes no order lookup, so `orderId` plus the single-order `token` are a correlation handle you redeem elsewhere. The catalog routes answer `501` on whatsapp-web.js, so a `productId` read here does not resolve through them. whatsapp-web.js exposes no catalog field and cannot tell a share of a whole catalog from a product: a `product` message without a product id arrives with no `product` block. On Baileys the blocks arrive on `message.received`, where `GET /api/sessions/:sessionId/catalog/products/:productId` resolves a `productId` only when the product came from the session's own catalog (`businessOwnerJid` says whose it is), and a share of a whole catalog is typed `unknown`, both live and in the rows history sync stores. A Baileys `message.received` may also carry `button { id, text? }` when the sender tapped a WhatsApp Business button, template quick-reply, list row, or native-flow control (this live-history route never sees it, since Baileys answers `501` here). A `masked` message is one WhatsApp deliberately withholds from linked/companion devices — e.g. a high-security business OTP — so its `body` is empty by design (the content is only available on the primary phone) rather than a parsing failure; this occurs on the Baileys engine. `kind` is the user-facing chat discriminator of `chatId` — one of `individual|group|channel|status|broadcast|unknown`; it supersedes `isStatusBroadcast` (equivalent to `kind === 'status'`), which is retained for back-compat.

**Errors:** `400` session not active · `401` missing/invalid API key · `500` engine error · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine · `503` the whatsapp-web.js page died mid-read (retryable)

#### GET /api/sessions/:sessionId/messages/:chatId/:messageId/reactions

Get reactions for a specific message, grouped by emoji with the senders.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                     |
| --------- | ------ | ------------------------------- |
| sessionId | string | Session ID                      |
| chatId    | string | Chat ID containing the message  |
| messageId | string | Message ID to get reactions for |

**Response** `200`

Returns a bare array of `MessageReaction`:

```json
[
  {
    "emoji": "👍",
    "senders": [{ "senderId": "628123456789@c.us", "emoji": "👍", "timestamp": 1719312050 }]
  }
]
```

**Errors:** `400` session not active · `401` missing/invalid API key · `404` message not found in the chat (whatsapp-web.js) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine · `503` the whatsapp-web.js page died mid-read (retryable)

#### POST /api/sessions/:sessionId/messages/vote-poll

Cast a vote on a poll.

Poll creation messages include `poll { name, options, allowMultipleAnswers }` when the engine provides the choices. Live `message.received` and `message.sent` events and whatsapp-web.js history carry it at the top level. Stored-message responses carry the same object in `metadata.poll`, including polls sent through `send-poll`. The dashboard displays these option texts. Use the exact strings in `options` when voting; their order and whitespace are preserved.

Legacy stored polls may have no choices because they were captured before this field was stored. Vote counts are not included.

**Auth:** API key (OPERATOR) · **Engines:** whatsapp-web.js only — Baileys returns `501`

**Body**

| Field         | Type     | Required | Description                                                                                                                       |
| ------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| chatId        | string   | yes      | Chat containing the poll (max 255 characters)                                                                                     |
| pollMessageId | string   | yes      | The poll creation message                                                                                                         |
| options       | string[] | yes      | The option **texts** to select, exactly as they appear on the poll (max 12). Replaces the current selection; `[]` clears the vote |

**Response** `200` — `{ "success": true }`

> **Options are texts, not ids.** whatsapp-web.js matches poll options by name, and no engine
> surfaces a stable per-option id through this API, so the text is the only handle available. A poll
> with two identically-worded options will therefore select **both**.
>
> A text that matches no option is ignored. When **none** of the texts match, the route answers `400`
> with `code: "POLL_OPTION_NOT_FOUND"` and the poll's exact texts in `validOptions`, and nothing is
> sent, so the current vote stays. (Before, whatsapp-web.js sent an empty selection, which cleared the
> vote, and the route answered `200`.) Send `[]` to clear the vote on purpose.

> **Only recent polls can be voted on.** The poll must be within the 100-message window the engine
> fetches for the chat — the same limit that applies to react/delete/edit/pin. An older poll comes
> back `404`.

> **Baileys returns `501`.** The library exposes no vote-send helper at all — only `decryptPollVote`
> for _receiving_ votes. Sending one requires hand-building a `PollUpdateMessage` with HMAC-SHA256
> vote encryption keyed by the poll creation's `messageSecret`, which is not wired here.

**Errors:** `400` session not active, the target message is not a poll, or none of the option texts match the poll (`POLL_OPTION_NOT_FOUND`) · `401` missing/invalid API key · `403` key lacks OPERATOR role · `404` poll not found in recent history · `501` Baileys engine · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-request; repeating it is safe (retryable)

#### POST /api/sessions/:sessionId/messages/pin

Pin a message in its chat for a bounded window.

**Auth:** API key (OPERATOR)

**Body**

| Field           | Type   | Required | Description                                                                                                                                   |
| --------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| chatId          | string | yes      | Chat containing the message (max 255 characters)                                                                                              |
| messageId       | string | yes      | Message to pin                                                                                                                                |
| durationSeconds | number | no       | `86400` (24h), `604800` (7d) or `2592000` (30d). Defaults to `86400`. Any other value is rejected with `400` — WhatsApp recognises no others. |

**Response** `200` — `{ "success": true }`

**Errors:** `400` session not active, or `durationSeconds` outside the three accepted values · `401` missing/invalid API key · `403` the whatsapp-web.js engine refused the pin (in a group only admins may pin; the Baileys engine has no acceptance signal and answers `200`) · `404` message not found in the chat · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-request; a retry is safe but restarts the pin duration; on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe)

> On whatsapp-web.js the message must be within the 100-message fetch window for the chat, the same
> limit that applies to react/delete/edit. On Baileys it must be in the adapter's message store.

#### POST /api/sessions/:sessionId/messages/star

Star (bookmark) a message, or remove its star. Starring is private to the account — the other party
never sees it — and unlike pinning it has no group-admin restriction and never expires.

**Auth:** API key (OPERATOR)

**Body**

| Field     | Type    | Required | Description                                      |
| --------- | ------- | -------- | ------------------------------------------------ |
| chatId    | string  | yes      | Chat containing the message (max 255 characters) |
| messageId | string  | yes      | Message to star or unstar                        |
| star      | boolean | yes      | `true` to star, `false` to remove the star       |

**Response** `200` — `{ "success": true }`

> **Best-effort on whatsapp-web.js.** Its `star()`/`unstar()` resolve with no value and silently do
> nothing when WhatsApp declines the message, so a `200` means the instruction was delivered, not
> that the star is definitely set. There is no signal at the engine boundary to distinguish the two.
> Baileys applies the change through an app-state modification and is exact.

**Errors:** `400` session not active · `401` missing/invalid API key · `404` message not found in the chat · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### POST /api/sessions/:sessionId/messages/unpin

Remove a message's pin. Takes no duration.

**Auth:** API key (OPERATOR)

**Body**

| Field     | Type   | Required | Description                                      |
| --------- | ------ | -------- | ------------------------------------------------ |
| chatId    | string | yes      | Chat containing the message (max 255 characters) |
| messageId | string | yes      | Message to unpin                                 |

**Response** `200` — `{ "success": true }`

**Errors:** `400` session not active · `401` missing/invalid API key · `403` the whatsapp-web.js engine refused the unpin (in a group only admins may unpin; the Baileys engine has no acceptance signal and answers `200`) · `404` message not found in the chat · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-request; repeating it is safe (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe)

#### GET /api/sessions/:sessionId/messages/:chatId/:messageId/media

Download a message's **stored** media bytes.

The route serves the chat-media **archive** first, and falls back to the **inline base64 copy** on
the message row when no archived file is servable. Archiving is **opt-in and off by default**
(`CHAT_MEDIA_ARCHIVE_ENABLED`); when enabled, each inbound message's media is written to whatever
backs `StorageService` (local disk or S3) in addition to the inline copy. Media **sent by this
account** is archived too when `CHAT_MEDIA_ARCHIVE_OUTBOUND=true` is set alongside it — off by
default, since it doubles storage again for the outbound half.

Media sent by this account is downloadable here regardless of that flag, because its payload is
stored inline: base64 API sends persist it on the send path, and media composed on a linked phone is
downloaded by the engine for the own-send echo. The flag buys a durable copy with its own lifecycle
(S3 portability, TTL retention), not retrievability. A **URL-based send is the exception**: the
gateway fetches those bytes at send time and stores none, so on Baileys there is nothing to serve or
archive. On whatsapp-web.js the own-send echo downloads the media of every message the account
sends, a URL-based one included, and when the echo row is written first its bytes are kept, so the
route can answer `200` or `404` for the same kind of send.

The inline fallback also keeps an inbound message's media downloadable after
`CHAT_MEDIA_ARCHIVE_TTL_DAYS` retention purges the archived file, since retention leaves the inline
copy in place.

**Archive-only storage (`MESSAGE_INLINE_MEDIA=archive`).** By default an archived file is a second
copy: the row keeps its inline base64 too. Setting `MESSAGE_INLINE_MEDIA=archive` (it needs
`CHAT_MEDIA_ARCHIVE_ENABLED=true`; the boot fails otherwise) stores each archived file once: after
the file is written, read back byte for byte and the row points at it, the row's
`metadata.media` is replaced with `{ mimetype, filename?, omitted: true, sizeBytes, archived: true }`,
and this route becomes the only way to the bytes. Storage or verification failures leave the inline
copy intact. Concurrent row updates are preserved, and a revoked message stays revoked.
What it does not cover: media over `CHAT_MEDIA_ARCHIVE_MAX_BYTES` is
not archived and stays inline; a row without a WhatsApp message ID keeps its inline bytes;
outbound media is archived (and replaced) only with
`CHAT_MEDIA_ARCHIVE_OUTBOUND=true`; rows stored before the switch keep their inline copy. Two
consequences to weigh before enabling it: with a non-zero `CHAT_MEDIA_ARCHIVE_TTL_DAYS`, retention
now removes the only copy, after which this route answers `404`; and a database export no longer
carries those bytes, so back up the media store alongside the database. Webhook payloads carry the
in-memory engine message rather than the row, so they are unaffected. The dashboard previews
archived images, video and audio inline by fetching them from this route. Each preview is one request
for the whole file, up to `CHAT_MEDIA_ARCHIVE_MAX_BYTES`, started as its bubble nears the viewport and
paced under the per-IP rate limit; once the default 100 requests a minute are spent, a refused preview
stays a download button. The latest 100 inline or archived previews share a memory cap; older media
keeps its download button.

A revoked message (deleted for everyone, or through `POST /messages/delete`) has no media: its row is
cleared on revoke, and this route answers `404` for it.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                                 |
| --------- | ------ | ------------------------------------------- |
| sessionId | string | Session ID                                  |
| chatId    | string | Chat ID containing the message              |
| messageId | string | WhatsApp message ID whose media to download |

**Response** `200` — the raw media bytes as the response body, served as an **attachment**
(`Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`). `Content-Type` is the essence of the
stored mimetype (the part before any `;`, trimmed and lowercased; parameters are dropped) when that is in a
conservative inert set (common image/video/audio types) and
`application/octet-stream` otherwise — a document, or an `image/svg+xml`, is never served as active
content on the API origin.

**Errors:** `401` missing/invalid API key · `403` key not scoped to this session · `404`
`No media stored for this message` — the message carries no media, `MEDIA_DOWNLOAD_ENABLED=false`
or the media was above `MEDIA_DOWNLOAD_MAX_BYTES` when it was stored (the row holds a size-only
marker), it was a URL-based API send (the gateway stores none of those bytes, though on
whatsapp-web.js the own-send echo may), or the message is not in this gateway's history (e.g. history
backfill, which is media-free)

Note: this is a three-path-segment route, so it never collides with the two-segment
`GET /messages/:chatId/history` regardless of declaration order.

#### GET /api/sessions/:sessionId/messages/batch/:batchId

Get the processing status and progress of a bulk batch.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| batchId   | string | Batch ID    |

**Response** `200`

```json
{
  "batchId": "batch_a1b2c3d4",
  "status": "processing",
  "progress": { "total": 2, "sent": 1, "failed": 0, "pending": 1, "cancelled": 0 },
  "results": [
    {
      "chatId": "628111111111@c.us",
      "status": "sent",
      "messageId": "true_628111111111@c.us_3EB0ABCD",
      "sentAt": "2026-06-25T09:21:00.000Z"
    }
  ],
  "startedAt": "2026-06-25T09:20:55.000Z",
  "completedAt": null
}
```

`status` is one of `pending|processing|completed|cancelled|failed`; `results` holds one entry per recipient attempted as of the last saved checkpoint, with `status` `sent` or `failed`. While the batch is `processing`, the row is saved after every recipient, so `results` and `progress` trail the send loop by at most the recipient in flight, and a cancel from another node stops the run after at most one more recipient; a recipient not yet attempted, or dropped by a cancel, appears only in the `progress` counters. A failed result carries a sanitized `error { code, message }` (internals are not leaked).

**Errors:** `401` missing/invalid API key · `404` batch not found for this session

### Send pacing (opt-in, `429 SEND_PACING_LIMITED`)

Every outbound message send — the `messages/send-*` routes, `messages/edit`, `messages/forward`,
bulk batches, status posts and the catalog send (`messages/send-product`) — passes an optional pacing governor before
it reaches WhatsApp. Actions on existing messages (react, vote, pin, star) are not sends and are
not paced. It is **off by default**: unless `SEND_PACING_ENABLED=true`, nothing is refused and no
extra work is done.

When enabled, three rules can refuse a send:

| Rule              | What it means                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Warm-up daily cap | The session has used its allowance for the current **UTC** day. The allowance grows with the session's age (`SEND_PACING_WARMUP_SCHEDULE`), because a brand-new WhatsApp account that immediately sends at volume is the pattern that gets numbers banned. The count comes from the messages table, so it survives restarts — and so it only sees sends that write a row there. Status posts and message edits are checked against the cap but never counted into it (a status post writes no row, and an edit only updates one), and neither is a bulk item the engine refuses, because bulk persists its row only after the send succeeds. A Baileys product send writes no row itself: it is counted once its own-send echo writes the outgoing row, shortly after the send returns, and a product send into a disappearing-messages chat is not counted at all when `STORE_EPHEMERAL_MESSAGES=false`. A session using any of them can exceed its stated allowance. Clearing or deleting a chat removes its stored rows, so that chat's sends today stop counting toward either cap, and the next send to it counts as a new conversation (the cold-reachout cap may refuse it). A bulk item that succeeds is counted, as is a failed single send, whose PENDING row is kept as FAILED. |
| Cold-reachout cap | The session has used its allowance of **new conversations** for the UTC day (`SEND_PACING_COLD_DAILY_CAP`). A send is a cold reachout when the account has no history with that chat in **either** direction — replying to someone who wrote to you first is never counted, and never refused by this rule. Status posts address no chat and are exempt.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| Failure breaker   | Consecutive send failures reached `SEND_PACING_BREAKER_THRESHOLD`, which usually means WhatsApp has already started refusing this account. The streak has no time decay — only a successful send resets it, so failures spread across a long quiet period still accumulate toward the threshold. Sends resume after `SEND_PACING_BREAKER_COOLDOWN_MS`, or immediately after any send succeeds.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |

A refusal is `429` with a body carrying **`code: "SEND_PACING_LIMITED"`** and `retryAfterSeconds`:

```json
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Daily allowance of 5 new conversation(s) reached for a session 0 day(s) old",
  "code": "SEND_PACING_LIMITED",
  "retryAfterSeconds": 34521
}
```

The `code` is what distinguishes it from the **global rate limiter's** own `429`, which carries no
`code`. The difference matters to a client: the throttler's 429 clears in seconds, while a cap reached
by stored sends lasts until the next UTC day. Do not retry a `SEND_PACING_LIMITED` response before
`retryAfterSeconds`, and read it rather than assume a day-long wait.

A chat send (not an edit) also counts toward both caps before its row lands, so parallel sends cannot
together pass them. A single send, which writes its row before WhatsApp is asked, is held for 10 seconds
from its admission. A bulk item or product send is held while it is in flight (at most 5 minutes) and
for 10 seconds after. A held send also stays held for as long as any send admitted after it is still
held; for the cold-reachout cap, only a later cold send keeps an earlier one held. A send that fails
before WhatsApp may have taken it hands its place back at once. A daily or
cold-reachout refusal can therefore come while the stored count is still under the cap; when only such
held sends push it over, including for the group participant adds below, its `retryAfterSeconds` is 10
or less instead of the time to the next UTC day; while a held send is still in flight or holding, a
retry at that hint can be refused again. The hold is best-effort at two edges: a message sent from the
linked phone while API sends are in flight, and a send still in flight when the UTC day changes, can
each let one extra API send past a cap.

Inside a bulk batch a refusal fails just that item (honouring `stopOnError`), not the batch — the
allowance may free up, and a batch killed outright could not be resumed.

**Group participant adds draw on the same cold budget.** `POST .../groups/:groupId/participants` and
`POST .../groups` put the account in front of people who did not ask for it, in bulk, in a single
call — the most ban-associated action available — so each participant the account has no history
with costs one cold reachout. A repeated id costs one; participants already known cost nothing. The
whole request is refused rather than partially applied, so a `429` can never be confused with the
per-participant failures those endpoints report normally. A request naming more new contacts than
the whole day's allowance cannot fit that day however long it waits, so it is refused with `400` and
no `retryAfterSeconds`; split it into batches no larger than the allowance. No message is sent, so
neither call consumes the overall daily send allowance.

Two consequences worth knowing: a paced-out send fires **no** `message:sending` plugin hook (see
`docs/19-plugin-architecture.md`), and refusals are counted in the `openwa_send_pacing_refusals_total`
Prometheus counter, labelled by rule.

#### POST /api/sessions/:sessionId/messages/send-text

Send a plain text message.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendTextMessageDto`

| Field             | Type     | Required | Constraints                              | Description                                                                                  |
| ----------------- | -------- | -------- | ---------------------------------------- | -------------------------------------------------------------------------------------------- |
| chatId            | string   | Yes      | non-empty, max 255                       | `phone@c.us` or `groupId@g.us`                                                               |
| text              | string   | Yes      | non-empty, max 4096                      | Message text                                                                                 |
| mentions          | string[] | No       | array of WIDs, max 1024 of 64 characters | WIDs to @mention (e.g. `["62811@c.us"]`). See **Mentions** below                             |
| linkPreview       | boolean  | No       | —                                        | `false` suppresses it; on Baileys `true` is required to get one. See **Link previews** below |
| customLinkPreview | object   | No       | `{ url, title, description? }`           | Attach a preview you supply. **Baileys only.** See **Link previews** below                   |
| quotedMessageId   | string   | No       | non-empty                                | Quote an earlier message, making this a reply. See [Quoted sends](#quoted-sends) below       |

```json
{ "chatId": "628123456789@c.us", "text": "Hello from OpenWA!" }
```

```json
{ "chatId": "628123456789@c.us", "text": "see https://example.com", "linkPreview": false }
```

**Link previews.** Sending `false` stops the preview on both engines. The two differ on what
happens otherwise: whatsapp-web.js lets WhatsApp Web build one in-page (free, so it is the default),
while on Baileys the gateway must fetch the page itself — a blocking outbound request per URL before
the message can go out — so there a preview is **opt-in**:

|                      | whatsapp-web.js                                                       | Baileys                                            |
| -------------------- | --------------------------------------------------------------------- | -------------------------------------------------- |
| `linkPreview: false` | no preview                                                            | no preview                                         |
| unset                | asks WhatsApp Web to build a preview in-page                          | no preview                                         |
| `linkPreview: true`  | asks WhatsApp Web to build a preview in-page                          | the gateway fetches the page itself and builds one |
| `customLinkPreview`  | `501` — the library takes a boolean only, with no way to pass a title | preview attached verbatim, nothing fetched         |

**How Baileys previews are generated.** Not with the library's own generator. That one delegates to
`link-preview-js`, which carries an unfixed SSRF advisory
([GHSA-4gp8-rjrq-ch6q](https://github.com/advisories/GHSA-4gp8-rjrq-ch6q), CWE-918 — "IPv6 and
internal loopback attacks", no patched release). Since the URL comes from message text, an attacker
influences what this server fetches, so the gateway supplies its own generator instead: it fetches
through the same SSRF guard used elsewhere, which validates the destination **and pins the
connection to the vetted address**, closing the DNS-rebinding window a validate-then-delegate
approach would leave open. The pin holds on a direct fetch and through a SOCKS session proxy; behind
an HTTP or HTTPS session proxy the proxy resolves the name, so that window stays open there (see
"Per-session egress proxy" above). The fetch is always guarded, independent of `WEBHOOK_SSRF_PROTECT`; a deployment
that intentionally allows an internal host lists it in `SSRF_ALLOWED_HOSTS`. A refused, slow or broken site
yields no preview — never a failed send.

`customLinkPreview` fetches **nothing at all**, so it works for URLs this server cannot reach, and it
cannot be combined with `linkPreview: false` — that asks for the opposite, and the request is
rejected with `400` rather than guessing which half was meant.

> whatsapp-web.js's own documentation notes the flag "has no effect on multi-device accounts". Its
> code does act on the flag, but that caveat is upstream's and is repeated here rather than
> contradicted — if a preview still appears on a multi-device account despite `false`, that is why.

```json
{ "chatId": "120363000000000000@g.us", "text": "Hello @62811", "mentions": ["62811@c.us"] }
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

`messageId` is the WhatsApp message id from the engine. By default (`SIMULATE_TYPING`; set it to `false` to disable) a typing indicator and a humanising pause run before the send: 500 ms plus 45 ms per character, capped at `SIMULATE_TYPING_MAX_MS` (default 5000 ms), with +/-15% jitter.

**Errors:** `400` unknown body field, validation failure, or session not active / blocked by a plugin hook · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends)) · `503` Baileys recipient lookup failed or timed out before the operation was sent

##### Quoted sends

Nine `send-*` routes accept an optional `quotedMessageId`: `send-text` above, and `send-image`,
`send-video`, `send-audio`, `send-document`, `send-sticker`, `send-location`, `send-contact` and
`send-poll` below. Supplying it turns that send into a reply, so a reply can carry media, a location,
a contact card or a poll — not only text. `POST .../messages/reply` remains the text shorthand, and
like the send routes it accepts `mentions`.

`send-template`, `send-bulk` and `send-product` do NOT accept the field, and reject it
as an unknown property.

```json
{
  "chatId": "628123456789@c.us",
  "contactName": "Alice",
  "contactNumber": "628999888777",
  "quotedMessageId": "true_628123456789@c.us_3EB0ABCD"
}
```

**The id is engine-specific and is deliberately not harmonised.**

|                      | whatsapp-web.js                                  | Baileys                           |
| -------------------- | ------------------------------------------------ | --------------------------------- |
| id to supply         | the serialized message id (`true_<chat>_<hash>`) | the raw message key id            |
| where it is resolved | in the WhatsApp Web page                         | the gateway's local message store |
| message not found    | `404` — the send is refused                      | `404` — the send is refused       |

An id the engine cannot resolve **fails the send** rather than delivering the message unquoted. On
whatsapp-web.js that is a deliberate choice: the library's default is to send anyway and report
success, which would hand back `201` for a message that is not a reply.

One upstream gap remains on whatsapp-web.js and cannot be switched off from here: if the quoted
message resolves but the page decides it is not replyable, the message is sent without the quote and
the call still succeeds.

On whatsapp-web.js a quoted send to a channel, `status@broadcast` or a broadcast list answers `501`
and nothing is sent: the library drops a reply to those recipients without sending it, so the
gateway refuses it before the call. The Baileys engine does not refuse it.

On Baileys a message deleted for everyone is not found (`404`): the store drops its content, so the
deleted text or media is never quoted back into the chat. An edited message is quoted with its edited
text.

Quoting a message from a **different chat** is not validated on these `send-*` routes on either
engine; the id is passed through as given. `POST /messages/reply` is stricter: both engines refuse a
quoted id that does not belong to the target chat, with `404`.

#### POST /api/sessions/:sessionId/messages/send-template

Render a stored text template (header/body/footer joined by blank lines, `{{vars}}` substituted) and send it as text.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendTemplateMessageDto`

| Field        | Type                    | Required    | Constraints                                       | Description                                                 |
| ------------ | ----------------------- | ----------- | ------------------------------------------------- | ----------------------------------------------------------- |
| chatId       | string                  | Yes         | non-empty, max 255                                | Target chat                                                 |
| templateId   | string                  | Conditional | non-empty; required when `templateName` is absent | Stored template id                                          |
| templateName | string                  | Conditional | non-empty; required when `templateId` is absent   | Stored template name                                        |
| vars         | Record\<string,string\> | No          | object                                            | Substituted into `{{placeholder}}` tokens; defaults to `{}` |
| mentions     | string[]                | No          | array of WIDs, max 1024 of 64 characters          | WIDs to @mention in the rendered body                       |
| linkPreview  | boolean                 | No          | —                                                 | Same engine split as `send-text`; see **Link previews**     |

```json
{
  "chatId": "628123456789@c.us",
  "templateName": "order-confirmation",
  "vars": { "customer": "Alice", "orderId": "1234" }
}
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

Delegates to the send-text path after rendering.

**Errors:** `400` unknown body field, validation failure, neither `templateId` nor `templateName` given, or session not active · `401` missing/invalid API key · `403` key role below OPERATOR · `404` session or template not found · `500` engine error · `409` conflict or engine not ready (retryable) · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends)) · `503` Baileys recipient lookup failed or timed out before the operation was sent

#### POST /api/sessions/:sessionId/messages/send-image

Send an image (by URL or base64) with an optional caption.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendMediaMessageDto`

| Field           | Type     | Required    | Constraints                              | Description                                                                              |
| --------------- | -------- | ----------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
| chatId          | string   | Yes         | non-empty, max 255                       | Target chat                                                                              |
| url             | string   | Conditional | URL; required when `base64` is absent    | http/https media URL (SSRF-guarded; a blocked internal or unreachable URL maps to `400`) |
| base64          | string   | Conditional | string; required when `url` is absent    | Base64 media data (capped to the media byte limit)                                       |
| mimetype        | string   | Conditional | string; required when using `base64`     | MIME type of the media                                                                   |
| filename        | string   | No          | max 255                                  | File name                                                                                |
| caption         | string   | No          | max 1024                                 | Caption text                                                                             |
| mentions        | string[] | No          | array of WIDs, max 1024 of 64 characters | WIDs to @mention in the caption. See **Mentions** above                                  |
| quotedMessageId | string   | No          | non-empty                                | Quote an earlier message, making this a reply — see [Quoted sends](#quoted-sends)        |

```json
{ "chatId": "628123456789@c.us", "url": "https://example.com/image.jpg", "caption": "Check out this image!" }
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

**Errors:** `400` neither `url` nor `base64`, base64 without `mimetype`, SSRF-blocked URL, a `url` that answers non-2xx, times out or cannot be reached, session not active, or unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `413` base64 or downloaded media over the media cap (see §6.3) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe) · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends))

#### POST /api/sessions/:sessionId/messages/send-video

Send a video (by URL or base64) with an optional caption. Uses the same `SendMediaMessageDto` (and the same validation rules and errors) as `send-image`.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendMediaMessageDto` (fields `chatId`, `url`, `base64`, `mimetype`, `filename`, `caption`, `mentions`, `quotedMessageId` — see `send-image`)

```json
{ "chatId": "628123456789@c.us", "url": "https://example.com/clip.mp4", "caption": "video" }
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

**Errors:** `400` media validation failure / a `url` that answers non-2xx, times out or cannot be reached / session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `413` base64 or downloaded media over the media cap (see §6.3) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe) · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends))

#### POST /api/sessions/:sessionId/messages/send-audio

Send an audio message (by URL or base64). Uses `SendAudioMessageDto`. A `caption` is accepted by the DTO but not persisted for audio. Set `ptt: true` to send a real WhatsApp **voice note** (microphone bubble + waveform) instead of a plain audio file. `ptt` is a JSON boolean, exclusive to this endpoint and to `audio` items of `send-bulk`, and — because voice notes require `audio/ogg; codecs=opus` — the server defaults the mimetype to that when you set `ptt` without one; for reliable playback (especially on the Baileys engine, which does not transcode) supply OGG/Opus bytes. A `ptt` voice note is stored as message `type: "voice"`. On the Baileys engine its waveform is read through ffmpeg (`FFMPEG_PATH`, also with media conversion disabled); a note of more than about 35 minutes, or one whose waveform cannot be read, goes out with flat bars. The duration is read in a separate process and is `0` when it cannot be read.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendAudioMessageDto` (all `SendMediaMessageDto` fields — `chatId`, `url`, `base64`, `mimetype`, `filename`, `caption`, `mentions`, `quotedMessageId` — plus optional `ptt` boolean)

```json
{ "chatId": "628123456789@c.us", "url": "https://example.com/voice.ogg", "mimetype": "audio/ogg", "ptt": true }
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

**Errors:** `400` media validation failure / a `url` that answers non-2xx, times out or cannot be reached / session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `413` base64 or downloaded media over the media cap (see §6.3) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe) · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends))

#### POST /api/sessions/:sessionId/messages/send-document

Send a document/file (by URL or base64). Uses `SendMediaMessageDto`; `filename` is used as the persisted body fallback.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendMediaMessageDto` (fields `chatId`, `url`, `base64`, `mimetype`, `filename`, `caption`, `mentions`, `quotedMessageId` — see `send-image`)

```json
{
  "chatId": "628123456789@c.us",
  "url": "https://example.com/report.pdf",
  "filename": "report.pdf",
  "mimetype": "application/pdf"
}
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

**Engine differences:** Baileys always sends a document as a document, while whatsapp-web.js deliberately keeps normal mimetype classification for `status@broadcast` and broadcast lists; the library returns `null` for document-mode sends to those recipients, so forcing the flag there would turn a working send into a failure. For URL-based sends without an explicit `filename`, whatsapp-web.js derives the URL basename, percent-decoded; Baileys falls back to the literal `file`.

**Errors:** `400` media validation failure / a `url` that answers non-2xx, times out or cannot be reached / session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `413` base64 or downloaded media over the media cap (see §6.3) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe) · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends))

#### POST /api/sessions/:sessionId/messages/send-location

Send a location pin.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendLocationDto`

| Field           | Type   | Required | Constraints        | Description                                                                       |
| --------------- | ------ | -------- | ------------------ | --------------------------------------------------------------------------------- |
| chatId          | string | Yes      | non-empty, max 255 | Target chat                                                                       |
| latitude        | number | Yes      | valid latitude     | Latitude (out-of-range → `400`)                                                   |
| longitude       | number | Yes      | valid longitude    | Longitude (out-of-range → `400`)                                                  |
| description     | string | No       | string, max 1024   | Pin description                                                                   |
| address         | string | No       | string, max 1024   | Pin address                                                                       |
| quotedMessageId | string | No       | non-empty          | Quote an earlier message, making this a reply — see [Quoted sends](#quoted-sends) |

```json
{
  "chatId": "628123456789@c.us",
  "latitude": -6.2088,
  "longitude": 106.8456,
  "description": "Jakarta",
  "address": "Central Jakarta"
}
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

**Errors:** `400` invalid coords / session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` whatsapp-web.js cannot send a location to a channel (`<id>@newsletter`) or a status or broadcast list (`@broadcast`); nothing is sent · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends)) · `503` Baileys recipient lookup failed or timed out before the operation was sent

#### POST /api/sessions/:sessionId/messages/send-contact

Send a contact card (vCard).

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendContactDto`

| Field           | Type   | Required | Constraints        | Description                                                                       |
| --------------- | ------ | -------- | ------------------ | --------------------------------------------------------------------------------- |
| chatId          | string | Yes      | non-empty, max 255 | Target chat                                                                       |
| contactName     | string | Yes      | non-empty, max 255 | Display name for the contact card                                                 |
| contactNumber   | string | Yes      | non-empty, max 30  | Contact phone number                                                              |
| quotedMessageId | string | No       | non-empty          | Quote an earlier message, making this a reply — see [Quoted sends](#quoted-sends) |

```json
{ "chatId": "628123456789@c.us", "contactName": "John Doe", "contactNumber": "628987654321" }
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

**Errors:** `400` validation failure / session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` whatsapp-web.js cannot send a contact card to a channel (`<id>@newsletter`) or a status or broadcast list (`@broadcast`); nothing is sent · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends)) · `503` Baileys recipient lookup failed or timed out before the operation was sent

#### POST /api/sessions/:sessionId/messages/send-sticker

Send a sticker (by URL or base64; typically webp). Reuses `SendMediaMessageDto`. On Baileys a WebP is sent unchanged and any other raster `image/*` is converted to a 512x512 WebP first, keeping up to 500 animation frames; `image/svg+xml` is refused with `400`.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendMediaMessageDto` (fields `chatId`, `url`, `base64`, `mimetype`, `filename`, `caption`, `mentions`, `quotedMessageId` — see `send-image`)

```json
{ "chatId": "628123456789@c.us", "url": "https://example.com/sticker.webp", "mimetype": "image/webp" }
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1719312000 }
```

**Errors:** `400` media validation failure / (Baileys) media that is neither WebP nor an image that converts to one, or an animation with more than 500 frames / a `url` that answers non-2xx, times out or cannot be reached / session not active / unknown body field; on Baileys, also a phone number WhatsApp reports as unregistered · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `413` base64 or downloaded media over the media cap (see §6.3) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine, or (whatsapp-web.js) a sticker to a channel, `status@broadcast` or a broadcast list · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe) · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends))

#### POST /api/sessions/:sessionId/messages/send-poll

Send a native WhatsApp poll.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendPollDto`

| Field                | Type     | Required | Constraints                               | Description                                                  |
| -------------------- | -------- | -------- | ----------------------------------------- | ------------------------------------------------------------ |
| chatId               | string   | Yes      | non-empty, max 255                        | Target chat                                                  |
| name                 | string   | Yes      | max 255                                   | Poll question / title                                        |
| options              | string[] | Yes      | 2–12 items, each non-empty, max 100 chars | Options to vote on                                           |
| allowMultipleAnswers | boolean  | No       | —                                         | Allow picking several options (default single choice)        |
| quotedMessageId      | string   | No       | non-empty                                 | Quote an earlier message — see [Quoted sends](#quoted-sends) |

```json
{
  "chatId": "1203630000@g.us",
  "name": "Where should we meet?",
  "options": ["Park", "Beach", "Downtown"],
  "allowMultipleAnswers": false
}
```

**Response** `201`

```json
{ "messageId": "true_1203630000@g.us_3EB0ABCD", "timestamp": 1719312000 }
```

**Errors:** `400` validation failure (option count/length) / session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the `quotedMessageId` could not be resolved (see Quoted sends) · `500` engine error · `409` conflict or engine not ready (retryable) · `501` whatsapp-web.js cannot send a poll to a status or broadcast list (`@broadcast`), nor one with `quotedMessageId` to a channel (`<id>@newsletter`); nothing is sent · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends)) · `503` Baileys recipient lookup failed or timed out before the operation was sent

#### POST /api/sessions/:sessionId/messages/reply

Reply to a message, quoting a prior message.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `ReplyMessageDto`

| Field           | Type     | Required | Constraints                              | Description                              |
| --------------- | -------- | -------- | ---------------------------------------- | ---------------------------------------- |
| chatId          | string   | Yes      | non-empty, max 255                       | Target chat                              |
| quotedMessageId | string   | Yes      | non-empty                                | WhatsApp id of the message being quoted  |
| text            | string   | Yes      | non-empty, max 4096                      | Reply text                               |
| mentions        | string[] | No       | array of WIDs, max 1024 of 64 characters | WIDs to @mention. See **Mentions** above |

```json
{ "chatId": "628123456789@c.us", "quotedMessageId": "true_628123456789@c.us_3EB0ABCD", "text": "Replying to you" }
```

**Response** `201`

```json
{ "messageId": "true_628123456789@c.us_3EB0EFGH", "timestamp": 1719312100 }
```

The quoted body is best-effort resolved from the DB for the reply preview.

**Errors:** `400` validation failure / session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the quoted message is not found in this chat · `500` engine error · `409` conflict or engine not ready (retryable) · `501` whatsapp-web.js cannot send a reply to a channel (`<id>@newsletter`) or a status or broadcast list (`@broadcast`); nothing is sent · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends)) · `503` Baileys recipient lookup failed or timed out before the operation was sent

#### POST /api/sessions/:sessionId/messages/click-button

Tap a choice on a WhatsApp Business button / list prompt by sending the structured reply proto WhatsApp expects.

**Auth:** API key (OPERATOR) · **Engines:** Baileys only; whatsapp-web.js returns `501`

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body**: `ClickButtonDto`

| Field     | Type   | Required | Constraints         | Description                                                                  |
| --------- | ------ | -------- | ------------------- | ---------------------------------------------------------------------------- |
| chatId    | string | Yes      | non-empty, max 255  | Chat that holds the prompt                                                   |
| messageId | string | Yes      | non-empty           | WhatsApp id of the inbound prompt (`buttons` on `message.received`)          |
| buttonId  | string | Yes      | non-empty, max 256  | Stable id of the choice (`buttons[].id`)                                     |
| text      | string | No       | non-empty, max 4096 | Visible label; resolved from the stored prompt when omitted, else `buttonId` |

```json
{ "chatId": "5519987517809@c.us", "messageId": "0587502EE4F5C80E5C", "buttonId": "yes", "text": "Sim" }
```

**Response** `201`

```json
{ "messageId": "3EB0CLICK1", "timestamp": 1719312100 }
```

> **Baileys only.** whatsapp-web.js has no interactive-reply send path and answers `501`.

> **Not a native UI tap.** Classic prompts go out through Baileys' `buttonReply` /
> `listReply` helpers quoted to the stored prompt. Native-flow `interactiveMessage` replies are
> unverified against a live business prompt and must not be treated as fully supported. WhatsApp may
> reject or treat any of these differently from a phone tap. The prompt must already be in the
> Baileys message store (received while the session was live); persisted `metadata.buttons` can still
> render after the store evicts the prompt, and a tap then 404s. URL/call CTA buttons cannot be
> clicked this way, only quick-reply style choices and list rows.

**Errors:** `400` validation failure, session not active, the message is not a clickable prompt, or `buttonId` is not among its choices · `401` · `403` · `404` prompt not in the engine store / wrong chat · `501` whatsapp-web.js · `409` conflict or engine not ready (retryable) · `500` engine error · `503` Baileys recipient lookup failed or timed out before the operation was sent

#### POST /api/sessions/:sessionId/messages/forward

Forward a message from one chat to another.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `ForwardMessageDto`

| Field      | Type   | Required | Constraints        | Description                           |
| ---------- | ------ | -------- | ------------------ | ------------------------------------- |
| fromChatId | string | Yes      | non-empty, max 255 | Source chat                           |
| toChatId   | string | Yes      | non-empty, max 255 | Destination chat                      |
| messageId  | string | Yes      | non-empty          | WhatsApp id of the message to forward |

```json
{ "fromChatId": "628111111111@c.us", "toChatId": "628222222222@c.us", "messageId": "true_628111111111@c.us_3EB0XYZ" }
```

**Response** `201`

```json
{ "messageId": "true_628222222222@c.us_3EB0NEW", "timestamp": 1719312200 }
```

`messageId` may be an empty string when the engine could not recover the forwarded copy's id.

**Errors:** `400` validation failure / session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` the message to forward is not found in `fromChatId` · `500` engine error · `409` conflict or engine not ready (retryable) · `422` the `Idempotency-Key` was already used for a different request (see [Idempotent sends](#idempotent-sends)) · `503` Baileys recipient lookup failed or timed out before the operation was sent

#### POST /api/sessions/:sessionId/messages/react

Add or remove a reaction to a message (an empty emoji removes the reaction).

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `ReactMessageDto`

| Field     | Type   | Required | Constraints                   | Description                                                                      |
| --------- | ------ | -------- | ----------------------------- | -------------------------------------------------------------------------------- |
| chatId    | string | Yes      | non-empty, max 255            | Target chat                                                                      |
| messageId | string | Yes      | non-empty                     | Message to react to                                                              |
| emoji     | string | Yes      | string (may be empty), max 32 | Reaction emoji; an empty string removes the reaction. The field must be present. |

```json
{ "chatId": "628123456789@c.us", "messageId": "true_628123456789@c.us_3EB0ABCD", "emoji": "👍" }
```

**Response** `200`

The controller hardcodes the result after the engine call. Note the `200` status (via `@HttpCode`), not `201`.

```json
{ "success": true }
```

**Errors:** `400` session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` message not found in this chat · `500` engine error · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-request; repeating it is safe (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe)

#### POST /api/sessions/:sessionId/messages/delete

Delete a message (for everyone by default); also clears the stored record and flags it `revoked`.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `DeleteMessageDto`

| Field       | Type    | Required | Constraints              | Description                                            |
| ----------- | ------- | -------- | ------------------------ | ------------------------------------------------------ |
| chatId      | string  | Yes      | non-empty, max 255       | Target chat                                            |
| messageId   | string  | Yes      | non-empty                | Message to delete                                      |
| forEveryone | boolean | No       | boolean (default `true`) | Delete for everyone; defaults to `true` in the service |

```json
{ "chatId": "628123456789@c.us", "messageId": "true_628123456789@c.us_3EB0ABCD", "forEveryone": true }
```

**Response** `200`

After the engine delete, the stored message's body and `metadata` (inline media, quote, reactions) are cleared, its archived-media pointer is removed, and its `type` is set to `revoked`, the same as when the engine reports a revoke (`message.revoked`). The archived file itself is deleted later by the chat-media orphan sweep, once it has stayed unreferenced for the sweep's grace window. If the stored row under that id belongs to another chat than `chatId`, it is left as it is and the request still answers `200`. Note the `200` status (via `@HttpCode`).

```json
{ "success": true }
```

**Errors:** `400` session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR · `404` message not found in this chat · `500` engine error · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe)

#### POST /api/sessions/:sessionId/messages/edit

Edit the text of a message sent by this account; also updates the stored record's body.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `EditMessageDto`

| Field     | Type     | Required | Constraints                              | Description                                       |
| --------- | -------- | -------- | ---------------------------------------- | ------------------------------------------------- |
| chatId    | string   | Yes      | non-empty, max 255                       | Chat containing the message                       |
| messageId | string   | Yes      | non-empty                                | Message to edit (the send response's `messageId`) |
| body      | string   | Yes      | non-empty, ≤ 4096 chars                  | New text content                                  |
| mentions  | string[] | No       | array of WIDs, max 1024 of 64 characters | Re-applies participant tags to the new body       |

```json
{ "chatId": "628123456789@c.us", "messageId": "true_628123456789@c.us_3EB0ABCD", "body": "Corrected text" }
```

**Response** `200`

Both fields describe the edited message rather than the edit itself, on both engines: `messageId` is the id you passed, and `timestamp` is the time that message was originally sent, since an edit does not move a message in the chat. If the stored row under that id belongs to another chat than `chatId`, or is not a message the account sent, its body is left as it is and the response is the same.

```json
{ "messageId": "true_628123456789@c.us_3EB0ABCD", "timestamp": 1760000000 }
```

**Errors:** `400` session not active / unknown body field · `401` missing/invalid API key · `403` key role below OPERATOR, or the engine refused the edit (both engines refuse a message the account did not send; whatsapp-web.js also refuses one that is not text, where the Baileys engine has no acceptance signal and answers `200`) · `404` message not found · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-request; repeating it is safe (retryable); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe)

#### POST /api/sessions/:sessionId/messages/send-bulk

Send messages to multiple recipients as an async batch — returns immediately and processes in the background.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `SendBulkMessageDto`

| Field    | Type                  | Required | Constraints                      | Description                                                                                                                                                                                                                          |
| -------- | --------------------- | -------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| batchId  | string                | No       | string, max 255                  | Auto-generated `batch_<hex>` if omitted; a duplicate id returns `400`                                                                                                                                                                |
| messages | BulkMessageItemDto[]  | Yes      | array, max 100, nested-validated | The batch items (see below); exact duplicate entries (same `chatId`, `type`, `content` and `variables`) are collapsed before processing, first occurrence wins, order preserved; distinct messages to the same `chatId` are all sent |
| options  | BulkMessageOptionsDto | No       | nested-validated                 | Pacing/error options (see below)                                                                                                                                                                                                     |

Each `BulkMessageItemDto`: `{ chatId: string, type: 'text'|'image'|'video'|'audio'|'document', content: BulkMessageContentDto, variables?: Record<string,string> }`. `content` (all fields optional, nested-validated): `text?: string`, `image?`/`video?`/`audio?`/`document?`: `{ url?, base64?, mimetype?, filename?, ptt? }` (`ptt` applies to `audio` only), `caption?: string`, `mentions?: string[]` (per item; a batch fans out to many chats, and a WID is only taggable in a chat the participant is in).

`BulkMessageOptionsDto`: `{ delayBetweenMessages?: number (1000–60000, default 3000), randomizeDelay?: boolean (default true), stopOnError?: boolean (default false) }`.

Each item must carry what its `type` sends: a non-empty `chatId` of at most 255 characters, a non-empty `content.text` for `text`, and a `url` or `base64` under `content.<type>` for a media type. Without `base64`, the `url` must be an absolute http(s) URL; a `url` holding a placeholder is checked once `variables` fill it in. If any item does not, the request answers `400` and nothing is queued. The check runs again per item after `variables` and the `message:sending` gate, where a failure fails that item only.

Each item's base64 media is checked against the media byte cap (`MEDIA_DOWNLOAD_MAX_BYTES`) twice: at batch creation, and again per item after `variables` and the `message:sending` plugin gate are applied. An item that outgrows the cap only after rendering fails individually (`failed` in `results`, with `message:failed` fired) instead of being sent. `totalMessages` in the response reflects the de-duplicated item count.

The rendered text is bounded the same way, by `TEMPLATE_RENDER_MAX_CHARS` (default 64 KiB) — the limit the single-send template path already applied. `content.text` and `content.caption` are length-validated _before_ substitution, so caller-supplied `variables` could otherwise inflate each item without bound while the request body stayed far below the in-flight body budget. An item whose `text` or `caption` exceeds the limit after substitution fails individually with a message naming it, rather than being truncated silently or sent. Media is not bounded by this cap — `MEDIA_DOWNLOAD_MAX_BYTES` governs it, three orders of magnitude higher, because a 100 KB image is already ~137,000 base64 characters and would fail the character limit on every personalised media send. Sizes are measured before an item is rendered, so an item that would render to something enormous fails without being built. A `url`, `filename` or other string that carries a placeholder may grow the item by no more than the same limit in total, and a `base64` that would render past the media limit fails with the media size message.

```json
{
  "messages": [
    {
      "chatId": "628111111111@c.us",
      "type": "text",
      "content": { "text": "Hi {{name}}" },
      "variables": { "name": "Alice" }
    },
    {
      "chatId": "628222222222@c.us",
      "type": "image",
      "content": { "image": { "url": "https://example.com/promo.jpg" }, "caption": "Promo" }
    }
  ],
  "options": { "delayBetweenMessages": 3000, "randomizeDelay": true, "stopOnError": false }
}
```

**Response** `202`

`202 Accepted` (via `@HttpCode`). `statusUrl` points at the batch-status route below, with the session id and `batchId` percent-encoded.

```json
{
  "batchId": "batch_a1b2c3d4",
  "status": "pending",
  "totalMessages": 2,
  "estimatedCompletionTime": "2026-06-25T09:21:00.000Z",
  "statusUrl": "/api/sessions/my-session/messages/batch/batch_a1b2c3d4"
}
```

**Errors:** `400` session not active, duplicate `batchId`, a `batchId` of `.` or `..`, or DTO/nested validation failure (unknown nested field rejected) · `401` missing/invalid API key · `403` key role below OPERATOR · `413` base64 media over the media cap (see §6.3) · `429` too many bulk batches already in progress on this node (`BULK_MAX_CONCURRENT_BATCHES`, default 50); retry shortly · `500` engine error · `503` the server is shutting down; nothing was sent, so retry the batch shortly

#### POST /api/sessions/:sessionId/messages/batch/:batchId/cancel

Cancel a running (pending/processing) bulk batch. No request body.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| batchId   | string | Batch ID    |

**Response** `200`

`200` via `@HttpCode`. On cancel, the remaining pending count is moved to `cancelled`.

```json
{
  "batchId": "batch_a1b2c3d4",
  "status": "cancelled",
  "progress": { "total": 2, "sent": 1, "failed": 0, "pending": 0, "cancelled": 1 }
}
```

**Errors:** `400` batch already completed, cancelled or failed · `401` missing/invalid API key · `403` key role below OPERATOR · `404` batch not found

### 6.4.3 Contacts

Contact endpoints are scoped under a session: `/api/sessions/:sessionId/contacts`. All read routes require a valid API key, except the number check (`GET .../check/:number`), which requires an `OPERATOR` key because each call queries WhatsApp about a third party and bulk checks put the linked account's standing at risk; every write (saving or removing an addressbook entry with `PUT`/`DELETE` `/:contactId`, block and unblock) requires an `OPERATOR` key. Every route returns `400 "Session is not started"` when the session has no running engine (missing or stopped); a started session whose engine is not ready yet (initializing, waiting for QR, reconnecting) answers `409` (retryable). Responses are the raw handler payload (no envelope).

The `Contact` object returned by the list and get-by-id routes has this shape:

```json
{
  "id": "6281234567890@c.us",
  "name": "Jane Doe",
  "pushName": "Jane",
  "number": "6281234567890",
  "isMyContact": true,
  "isBlocked": false,
  "profilePicUrl": "https://pps.whatsapp.net/v/..."
}
```

`name`, `pushName`, and `profilePicUrl` are optional and may be absent. `profilePicUrl` is a cached URL that only Baileys can fill (whatsapp-web.js never sets it), and it is often absent there too, so its absence says nothing about whether the contact has a picture; use `GET /api/sessions/:sessionId/contacts/:contactId/profile-picture` for that.

#### GET /api/sessions/:sessionId/contacts

List all contacts for a session, returned as an in-memory paginated window.

On Baileys the list is the saved address book: contacts with a name saved on the phone (`isMyContact: true`), rebuilt from WhatsApp's app-state contact collection on connect, including after a process restart. A peer known only by its pushname is not listed but still resolves through the get-by-id route below with `isMyContact: false`. whatsapp-web.js lists what the WhatsApp Web page holds, which also includes unsaved chat partners (`isMyContact: false`). A contact the page holds under both its LID and its phone is listed once, under its phone id with the phone as `number`. An entry the page cannot read (for example a device-scoped id), or one without a readable id, is left out of the list; the gateway logs a warning with the count, and the response does not signal it.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID. |

**Query parameters**

| Name   | Type    | Required | Default | Description                                                                                           |
| ------ | ------- | -------- | ------- | ----------------------------------------------------------------------------------------------------- |
| limit  | integer | No       | 1000    | Parsed with `parseInt(…,10)`; clamped to `[1, 1000]`. Omitted or non-finite values fall back to 1000. |
| offset | integer | No       | 0       | Parsed with `parseInt(…,10)`; non-finite values fall back to 0, then truncated to `>= 0`.             |

**Response** `200` — bare `Contact[]` array

```json
[
  {
    "id": "6281234567890@c.us",
    "name": "Jane Doe",
    "pushName": "Jane",
    "number": "6281234567890",
    "isMyContact": true,
    "isBlocked": false,
    "profilePicUrl": "https://pps.whatsapp.net/v/..."
  }
]
```

**Errors:** `400` session is not started · `401` missing/invalid API key · `403` key not scoped to this session · `409` conflict or engine not ready (retryable) · `500` engine error, including whatsapp-web.js when entries could not be read and no unblocked contact was, or when no entry had a readable id (a page-wide failure, not an empty address book) · `503` the WhatsApp page died while reading contacts or the command timed out (retry shortly)

#### GET /api/sessions/:sessionId/contacts/blocked

The contacts this account has blocked — the read half of the block/unblock endpoints. A bare array
of neutral contact ids, and ids only: whatsapp-web.js resolves full contact models, but Baileys'
blocklist query answers bare jids, and inventing the other fields on one engine would make the two
engines claim different things about the same account. An id is listed once, even when whatsapp-web.js
holds the contact under both its LID and its phone.

**Auth:** API key

**Response** `200`

```json
["628123456789@c.us", "628987654321@c.us"]
```

**Errors:** `400` session not started · `401` missing/invalid `X-API-Key` · `409` engine not ready · `503` WhatsApp did not answer the blocklist query (on Baileys the gateway bounds the query with its own clock — an unanswered query is a `503`, never an empty list)

#### GET /api/sessions/:sessionId/contacts/check/:number

Check whether a phone number exists on WhatsApp and return its canonical WhatsApp id when it does.

**Auth:** API key (`OPERATOR`)

**Path parameters**

| Name      | Type   | Description                                                             |
| --------- | ------ | ----------------------------------------------------------------------- |
| sessionId | string | Session ID.                                                             |
| number    | string | Phone number to check, e.g. `628123456789` (digits, no `@c.us` suffix). |

**Response** `200`

```json
{
  "number": "628123456789",
  "exists": true,
  "whatsappId": "628123456789@c.us"
}
```

`whatsappId` is the canonical native chat id, or `null` when the number is not on WhatsApp; `exists` is `whatsappId !== null`.

> Route-order caveat: this route is two path segments (`check/:number`), so it never collides with the single-segment `GET /:contactId` — a contact id of literally `check` resolves to `GET /:contactId`.

**Errors:** `400` session is not started · `401` missing/invalid API key · `403` key lacks OPERATOR role, is not scoped to this session, is restricted to selected chats, or its IP allow-list excludes the client · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### GET /api/sessions/:sessionId/contacts/:contactId

Get a single contact by its WhatsApp id.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                                               |
| --------- | ------ | --------------------------------------------------------- |
| sessionId | string | Session ID.                                               |
| contactId | string | Contact id / JID, e.g. `6281234567890@c.us` or an `@lid`. |

**Response** `200` — `Contact`

```json
{
  "id": "6281234567890@c.us",
  "name": "Jane Doe",
  "pushName": "Jane",
  "number": "6281234567890",
  "isMyContact": true,
  "isBlocked": false,
  "profilePicUrl": "https://pps.whatsapp.net/v/..."
}
```

**Errors:** `400` session is not started · `401` missing/invalid API key · `404` `Contact <id> not found` (engine returned null) · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-read or the command timed out, so the lookup reached no answer (distinct from the `404`, which asserts the contact does not exist)

#### GET /api/sessions/:sessionId/contacts/:contactId/profile-picture

Get the profile picture URL for a contact (best-effort).

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                                  |
| --------- | ------ | -------------------------------------------- |
| sessionId | string | Session ID.                                  |
| contactId | string | Contact id / JID, e.g. `6281234567890@c.us`. |

**Response** `200`

```json
{ "url": "https://pps.whatsapp.net/v/..." }
```

`url` is `null` when there is no picture or the contact's privacy hides it — both are answers. A
lookup that could not reach an answer is **not** reported this way; it answers `503`, so a caller can
tell "this contact has no avatar" from "we could not find out".

> The path segment is `profile-picture` (hyphenated), not `profile-pic`.

**Errors:** `400` session is not started · `401` missing/invalid API key · `409` conflict or engine not ready (retryable) · `503` the engine could not complete the lookup

Note the batch route below keeps its best-effort contract: it has no per-id error channel, so a
failed lookup there still appears as `null`.

#### GET /api/sessions/:sessionId/contacts/profile-pictures

Batch-resolve profile picture URLs for many contacts in one request (a chat sidebar would otherwise fire a burst of single fetches and exhaust the per-IP throttle).

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID. |

**Query parameters**

| Name | Type   | Required | Description                                                                                                                                                        |
| ---- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ids  | string | Yes      | Comma-separated contact ids. Blank entries are dropped; only the **first 50** ids are looked up, the rest are ignored (they simply do not appear in the response). |

**Response** `200`

```json
{
  "pictures": {
    "6281234567890@c.us": "https://pps.whatsapp.net/v/...",
    "6289876543210@c.us": null
  }
}
```

`pictures` is keyed by the requested id. A per-id failure — or a lookup that exceeds the 8 s per-id deadline — yields `null` for that id rather than failing the batch. An omitted or empty `ids` returns `{ "pictures": {} }`.

An engine that is not ready is the exception: it describes the session rather than one id, so the whole request answers `409` and no partial result is returned.

> Route-order caveat: `profile-pictures` is declared **before** `GET /:contactId`, so the literal segment wins — a contact whose id is literally `profile-pictures` cannot be fetched through the single-contact route.

**Errors:** `400` session is not started · `401` missing/invalid API key · `403` key not scoped to this session · `409` conflict or engine not ready (retryable)

#### GET /api/sessions/:sessionId/contacts/:contactId/phone

Resolve a contact id (e.g. an `@lid`) to a phone number (MSISDN digits), best-effort.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                                  |
| --------- | ------ | -------------------------------------------- |
| sessionId | string | Session ID.                                  |
| contactId | string | Contact id / JID to resolve, e.g. an `@lid`. |

**Response** `200`

```json
{ "contactId": "12345678901234@lid", "phone": "6281234567890" }
```

`phone` is `null` when the engine cannot map the id (e.g. an `@lid` the account has never seen).

For inbound messages the gateway can attach this automatically instead: `RESOLVE_LID_TO_PHONE=true` adds `senderPhone` to every `message.received` payload (§6.6). It is off by default; this endpoint works either way.

**Errors:** `400` session is not started · `401` missing/invalid API key · `409` conflict or engine not ready (retryable)

#### PUT /api/sessions/:sessionId/contacts/:contactId

Save a contact to the account's addressbook, or edit an existing entry. This is the WhatsApp
contact record — it does not block, delete, or otherwise touch the chat.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                     |
| --------- | ------ | ------------------------------- |
| sessionId | string | Session ID                      |
| contactId | string | Contact ID (e.g. `628xxx@c.us`) |

**Request body** — `UpsertContactDto`

| Field     | Type   | Required | Constraints | Description                                            |
| --------- | ------ | -------- | ----------- | ------------------------------------------------------ |
| firstName | string | Yes      | 1–100 chars | The contact's first name                               |
| lastName  | string | No       | ≤ 100 chars | Omit for a single-name contact — WhatsApp allows those |

**Response** `200` — `{ "success": true, "message": "Contact saved" }`

> The entry is saved to the WhatsApp addressbook only; it is **not** synced through to the device
> addressbook. Both engines are called with their sync-to-device flag off, so behaviour matches
> across engines rather than depending on which one is running.

> **A privacy id (`…@lid`) is refused with `400`.** The addressbook is keyed by phone number, and a
> lid's digits are not one — whatsapp-web.js takes a bare number here, so an unguarded lid would be
> stored as if it were a real phone. Pass a phone-based contact id instead. Any other id that does not
> name a person (a group, newsletter, broadcast or non-numeric id) is refused with `400` as well.

**Errors:** `400` session not active, invalid request, or the contact id does not name a phone-based individual (a `@lid`, group, newsletter, broadcast or non-numeric id) · `401` missing/invalid API key · `403` key lacks OPERATOR role · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### DELETE /api/sessions/:sessionId/contacts/:contactId

Remove a contact from the account's addressbook. Does not block the contact or delete the chat.

**Auth:** API key (OPERATOR)

**Response** `200` — `{ "success": true, "message": "Contact deleted" }`

**Errors:** `400` session not active, or the contact id does not name a phone-based individual (a `@lid`, group, newsletter, broadcast or non-numeric id; same reason as the `PUT` above) · `401` missing/invalid API key · `403` key lacks OPERATOR role · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### POST /api/sessions/:sessionId/contacts/:contactId/block

Block a contact.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                                  |
| --------- | ------ | -------------------------------------------- |
| sessionId | string | Session ID.                                  |
| contactId | string | Contact id / JID, e.g. `6281234567890@c.us`. |

This route takes no request body and binds no DTO. Send none or an empty body `{}`: a body is not validated, and any content it carries is ignored.

**Response** `200`

This route is annotated `@HttpCode(200)`, so it returns `200` rather than the POST default `201`.

```json
{ "success": true, "message": "Contact blocked" }
```

**Errors:** `400` session is not started, or the id does not name an individual (group/newsletter/broadcast/free text are refused on both engines; a phone-based or privacy `@lid` id is accepted, because the blocklist read answers both shapes), or, on Baileys, an id the session cannot map between phone and privacy-id form (a `@lid` with no known phone, a phone-based id with no known `@lid`) · `401` missing/invalid API key · `403` key role below OPERATOR · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### DELETE /api/sessions/:sessionId/contacts/:contactId/block

Unblock a contact.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                                  |
| --------- | ------ | -------------------------------------------- |
| sessionId | string | Session ID.                                  |
| contactId | string | Contact id / JID, e.g. `6281234567890@c.us`. |

No request body.

**Response** `200`

No `@HttpCode` override is present, so this DELETE returns the NestJS default `200` (not `204`).

```json
{ "success": true, "message": "Contact unblocked" }
```

**Errors:** `400` session is not started, or the id does not name an individual (group/newsletter/broadcast/free text are refused on both engines; a phone-based or privacy `@lid` id is accepted, because the blocklist read answers both shapes), or, on Baileys, a phone-based id the session cannot map to its `@lid` · `401` missing/invalid API key · `403` key role below OPERATOR · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

### 6.4.4 Groups

All group routes are nested under a session: base path `/api/sessions/:sessionId/groups`. Reads (`GET`) require a plain API key, except the invite-code read (`GET .../:groupId/invite-code`), which requires an `OPERATOR` role key because the code is a join capability rather than data; writes (create/modify/leave/revoke) require an `OPERATOR` role key. All routes resolve the engine for the session first, so a session that is not started yields `400 Session is not started`.

#### GET /api/sessions/:sessionId/groups

List all groups for a session, with pagination.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Query parameters**

| Name   | Type                              | Required | Default | Description                                                            |
| ------ | --------------------------------- | -------- | ------- | ---------------------------------------------------------------------- |
| limit  | string (parsed base-10 to number) | No       | 1000    | Max groups to return; clamped to [1, 1000]. Omitted/non-finite → 1000. |
| offset | string (parsed base-10 to number) | No       | 0       | Groups to skip. Non-finite → 0; negative truncated to 0.               |

**Response** `200`

Raw array (no envelope). The service calls `engine.getGroups()`, projects each entry down to `id`/`name`/`linkedParentJID`, then paginates to bound the window. Anything else the engine's group object may carry is dropped by that projection — use `GET /groups/:groupId` (participants, settings, owner) when you need more than the identity.

```json
[
  {
    "id": "120363021234567890@g.us",
    "name": "Project Team",
    "linkedParentJID": null
  }
]
```

**Errors:** `400` session is not started · `401` missing/invalid `X-API-Key` · `404` session not found · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### GET /api/sessions/:sessionId/groups/:groupId

Get detailed group info including participants.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                              |
| --------- | ------ | ---------------------------------------- |
| sessionId | string | Session ID                               |
| groupId   | string | Group ID, e.g. `120363021234567890@g.us` |

**Response** `200`

Raw object (no envelope). `engine.getGroupInfo()` returns `GroupInfo | null`; the service throws `404` when it is `null`.

```json
{
  "id": "120363021234567890@g.us",
  "name": "Project Team",
  "description": "Internal coordination group.",
  "owner": "628123456789@c.us",
  "createdAt": 1718900000,
  "isReadOnly": false,
  "isAnnounce": false,
  "linkedParentJID": null,
  "participants": [
    {
      "id": "628123456789@c.us",
      "number": "628123456789",
      "name": "Alice",
      "isAdmin": true,
      "isSuperAdmin": true
    }
  ]
}
```

**Errors:** `400` session is not started · `401` missing/invalid `X-API-Key` · `403` a key restricted to selected chats names a group outside its allowlist · `404` `Group <groupId> not found` · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### GET /api/sessions/:sessionId/groups/:groupId/picture

Get the group's picture URL.

**Auth:** API key

**Response** `200` — `{ "url": "https://…" }`, or `{ "url": null }` when the group has no picture or
it is hidden by privacy settings.

**Errors:** `400` the id does not name a group, or the session is not active · `401` missing/invalid API key · `409` engine not ready · `503` WhatsApp did not answer within the request budget — nothing could be read; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408) or fails on its side (code 500 or above): retry after a pause

#### PUT /api/sessions/:sessionId/groups/:groupId/picture

Set the group's picture. The account must be a group admin.

**Auth:** API key (OPERATOR)

**Request body** — `SetGroupPictureDto` (same shape as the profile-picture body)

| Field    | Type   | Required          | Constraints          | Description                                       |
| -------- | ------ | ----------------- | -------------------- | ------------------------------------------------- |
| url      | string | One of url/base64 | http(s) URL          | Fetched server-side through the SSRF guard        |
| base64   | string | One of url/base64 | —                    | Wins over `url` when both are present             |
| mimetype | string | With `base64`     | must match `image/*` | Defaults to `image/jpeg` when only `url` is given |

**Response** `200` — `{ "success": true, "message": "Group picture updated" }`

**Errors:** `400` the id does not name a group, the session is not active, neither `url` nor `base64` was supplied, the `url` answers non-2xx, times out or cannot be reached, or the image cannot be read (Baileys refuses an SVG) · `401` missing/invalid API key · `403` key lacks OPERATOR role, or the engine refused (admin rights required) · `404` no such group · `409` the session is not connected (engine exists but is not `ready`) · `413` base64 or downloaded media over the media cap (see §6.3) · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable), or WhatsApp did not answer within the request budget — the change may or may not have been applied; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### DELETE /api/sessions/:sessionId/groups/:groupId/picture

Remove the group's picture. The account must be a group admin.

**Auth:** API key (OPERATOR)

**Response** `200` — `{ "success": true, "message": "Group picture removed" }`

**Errors:** `400` the id does not name a group, or the session is not active · `401` missing/invalid API key · `403` key lacks OPERATOR role, or the engine refused (admin rights required) · `404` no such group · `409` the session is not connected (engine exists but is not `ready`) · `503` WhatsApp did not answer within the request budget — the change may or may not have been applied; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### GET /api/sessions/:sessionId/groups/:groupId/invite-code

Get the group invite code and full invite link. The code is a transferable join capability rather than plain read data, so it sits at OPERATOR, like the QR endpoint.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Response** `200`

`inviteCode` comes from `engine.getGroupInviteCode()`; `inviteLink` is `https://chat.whatsapp.com/<inviteCode>`.

```json
{
  "inviteCode": "AbCdEf123456",
  "inviteLink": "https://chat.whatsapp.com/AbCdEf123456"
}
```

**Errors:** `400` session is not started · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or the engine refused (admin rights required) · `404` no such group · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/groups

Create a new group with an initial set of participants.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `CreateGroupDto`

| Field        | Type     | Required | Constraints                                                        | Description                                               |
| ------------ | -------- | -------- | ------------------------------------------------------------------ | --------------------------------------------------------- |
| name         | string   | Yes      | `@IsString`, `@IsNotEmpty`, 1..100 chars                           | Group subject/name                                        |
| participants | string[] | Yes      | `@IsArray`, `@ArrayNotEmpty`, `@IsString({each:true})`, 1..256 ids | Non-empty array of WhatsApp IDs, e.g. `628123456789@c.us` |

```json
{
  "name": "Project Team",
  "participants": ["628123456789@c.us", "628987654321@c.us"]
}
```

**Response** `201`

Returns the created `Group` directly (raw).

```json
{
  "id": "120363021234567890@g.us",
  "name": "Project Team",
  "participantsCount": 3,
  "isAdmin": true,
  "linkedParentJID": null
}
```

**Errors:** `400` validation (missing/empty `name` or `participants`, or any non-DTO field) / session not started, or a participant does not name an individual (phone, `<phone>@c.us` or `<lid>@lid`), or (send pacing on) the request names more new contacts than the whole day's cold-reachout allowance (no `retryAfterSeconds`; split the batch, see Send pacing) · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or WhatsApp refused the create · `409` conflict or engine not ready (retryable) · `429` `SEND_PACING_LIMITED` when send pacing is enabled (cold-reachout cap reached for today, see Send pacing) · `501` not supported on the active engine · `503` WhatsApp rate-limited the create on Baileys (code 429; nothing was created, so a retry is safe; a create WhatsApp timed out, code 408, answers `500` instead, as it may have succeeded: check before retrying)

#### POST /api/sessions/:sessionId/groups/:groupId/participants

Add participants to a group.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — `ParticipantsDto`

| Field        | Type     | Required | Constraints                                                        | Description                     |
| ------------ | -------- | -------- | ------------------------------------------------------------------ | ------------------------------- |
| participants | string[] | Yes      | `@IsArray`, `@ArrayNotEmpty`, `@IsString({each:true})`, 1..256 ids | Non-empty array of WhatsApp IDs |

```json
{ "participants": ["628123456789@c.us"] }
```

**Response** `200`

Status is forced to `200` via `@HttpCode(HttpStatus.OK)` (overriding the POST default). `results` carries the engine's per-participant outcome — a partial refusal does **not** fail the batch, so check `results[].success` rather than the envelope's `success`. If every listed participant is refused, the request fails with `403` instead.

```json
{
  "success": true,
  "message": "Participants added",
  "results": [
    { "id": "628123456789@c.us", "success": true, "status": 200 },
    { "id": "628987654321@c.us", "success": false, "status": 403, "message": "invite-only" }
  ]
}
```

Each entry is a `ParticipantOperationResult`: `id` (the participant the outcome belongs to), `success` (true only when the engine confirmed the change for that participant), and the optional engine-reported `status`/`message` (e.g. `200` ok, `403` invite-only/not-admin, `404` not registered, `409` already a member). Engines that only confirm the batch as a whole report one success entry per requested participant.

**Errors:** `400` validation / session not started, or a participant does not name an individual (phone, `<phone>@c.us` or `<lid>@lid`), or (send pacing on) the request names more new contacts than the whole day's cold-reachout allowance (no `retryAfterSeconds`; split the batch, see Send pacing) · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or WhatsApp refused every listed participant (a partial refusal still returns `200`) · `404` group not found · `409` conflict or engine not ready (retryable) · `429` `SEND_PACING_LIMITED` when send pacing is enabled (cold-reachout cap reached for today, see Send pacing) · `503` session not ready or dependency unavailable (retryable), or WhatsApp did not answer within the request budget, so no per-participant outcome was read; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### DELETE /api/sessions/:sessionId/groups/:groupId/participants

Remove participants from a group. Note: this DELETE carries a JSON request body.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — `ParticipantsDto`

| Field        | Type     | Required | Constraints                                                        | Description                     |
| ------------ | -------- | -------- | ------------------------------------------------------------------ | ------------------------------- |
| participants | string[] | Yes      | `@IsArray`, `@ArrayNotEmpty`, `@IsString({each:true})`, 1..256 ids | Non-empty array of WhatsApp IDs |

```json
{ "participants": ["628123456789@c.us"] }
```

**Response** `200`

No `@HttpCode`, so NestJS uses the DELETE default of `200`. `results` carries the per-participant outcome (see the add route above).

```json
{
  "success": true,
  "message": "Participants removed",
  "results": [{ "id": "628123456789@c.us", "success": true, "status": 200 }]
}
```

**Errors:** `400` validation / session not started, or a participant does not name an individual (phone, `<phone>@c.us` or `<lid>@lid`) · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or WhatsApp refused every listed participant (a partial refusal still returns `200`) · `404` group not found · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable), or WhatsApp did not answer within the request budget, so no per-participant outcome was read; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/groups/:groupId/participants/promote

Promote participants to group admin.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — `ParticipantsDto`

| Field        | Type     | Required | Constraints                                                        | Description                     |
| ------------ | -------- | -------- | ------------------------------------------------------------------ | ------------------------------- |
| participants | string[] | Yes      | `@IsArray`, `@ArrayNotEmpty`, `@IsString({each:true})`, 1..256 ids | Non-empty array of WhatsApp IDs |

```json
{ "participants": ["628123456789@c.us"] }
```

**Response** `200` (forced via `@HttpCode(HttpStatus.OK)`). `results` carries the per-participant outcome (see the add route above).

```json
{
  "success": true,
  "message": "Participants promoted to admin",
  "results": [{ "id": "628123456789@c.us", "success": true, "status": 200 }]
}
```

**Errors:** `400` validation / session not started, or a participant does not name an individual (phone, `<phone>@c.us` or `<lid>@lid`) · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or WhatsApp refused every listed participant (a partial refusal still returns `200`) · `404` group not found · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable), or WhatsApp did not answer within the request budget, so no per-participant outcome was read; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/groups/:groupId/participants/demote

Demote participants from group admin.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — `ParticipantsDto`

| Field        | Type     | Required | Constraints                                                        | Description                     |
| ------------ | -------- | -------- | ------------------------------------------------------------------ | ------------------------------- |
| participants | string[] | Yes      | `@IsArray`, `@ArrayNotEmpty`, `@IsString({each:true})`, 1..256 ids | Non-empty array of WhatsApp IDs |

```json
{ "participants": ["628123456789@c.us"] }
```

**Response** `200` (forced via `@HttpCode(HttpStatus.OK)`). `results` carries the per-participant outcome (see the add route above).

```json
{
  "success": true,
  "message": "Participants demoted from admin",
  "results": [{ "id": "628123456789@c.us", "success": true, "status": 200 }]
}
```

**Errors:** `400` validation / session not started, or a participant does not name an individual (phone, `<phone>@c.us` or `<lid>@lid`) · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or WhatsApp refused every listed participant (a partial refusal still returns `200`) · `404` group not found · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable), or WhatsApp did not answer within the request budget, so no per-participant outcome was read; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### PUT /api/sessions/:sessionId/groups/:groupId/subject

Change the group name/subject.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — `GroupSubjectDto`

| Field   | Type   | Required | Constraints                              | Description            |
| ------- | ------ | -------- | ---------------------------------------- | ---------------------- |
| subject | string | Yes      | `@IsString`, `@IsNotEmpty`, 1..100 chars | New group subject/name |

```json
{ "subject": "New Team Name" }
```

**Response** `200`

No `@HttpCode`; PUT default is `200`.

```json
{ "success": true, "message": "Group subject updated" }
```

**Errors:** `400` validation (empty `subject`) / session not started · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or the engine refused (admin rights required) · `404` no such group · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### PUT /api/sessions/:sessionId/groups/:groupId/description

Change the group description. An empty string clears the description.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — `GroupDescriptionDto`

| Field       | Type   | Required | Constraints                                  | Description                                                                |
| ----------- | ------ | -------- | -------------------------------------------- | -------------------------------------------------------------------------- |
| description | string | Yes      | `@IsString` (no `@IsNotEmpty`), ≤ 1024 chars | Must be present and a string, but `""` is valid and clears the description |

```json
{ "description": "Internal coordination group." }
```

**Response** `200`

No `@HttpCode`; PUT default is `200`.

```json
{ "success": true, "message": "Group description updated" }
```

**Errors:** `400` validation (`description` missing / not a string) / session not started · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or the engine refused (admin rights required) · `404` no such group · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/groups/:groupId/leave

Leave a group.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — none (send an empty body).

**Response** `200` (forced via `@HttpCode(HttpStatus.OK)`)

```json
{ "success": true, "message": "Left the group" }
```

**Errors:** `400` session is not started · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or the engine refused · `404` no such group · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/groups/:groupId/invite-code/revoke

Revoke the current invite code and generate a new one.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — none (send an empty body).

**Response** `200` (forced via `@HttpCode(HttpStatus.OK)`)

`inviteCode` is the **new** code from `engine.revokeGroupInviteCode()`; `inviteLink` is `https://chat.whatsapp.com/<newCode>`.

```json
{
  "inviteCode": "XyZ987654321",
  "inviteLink": "https://chat.whatsapp.com/XyZ987654321",
  "message": "Invite code revoked and new one generated"
}
```

**Errors:** `400` session is not started · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or the engine refused (admin rights required) · `404` no such group · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### GET /api/sessions/:sessionId/groups/join-info

Preview a group from its invite code, **without joining**. Supported on both engines.

**Auth:** API key · **Scope:** session-scoped

Read-only — nothing about the account's membership changes, which is what makes it safe to call on a
code from an untrusted source, and what makes it the natural step before `POST /groups/join`.

**Query parameters**

| Name   | Type   | Required | Description                                        |
| ------ | ------ | -------- | -------------------------------------------------- |
| `code` | string | Yes      | Group invite code — the part after the invite link |

**Response** `200`

```json
{
  "id": "120363012345678901@g.us",
  "name": "Product team",
  "description": "Internal coordination",
  "owner": "628123456789@c.us",
  "createdAt": 1700000000,
  "participantCount": 42
}
```

There is **no participant list** — the account is not a member — only `participantCount`, and only
when WhatsApp discloses one. `id` and `name` are always present; every other field is **omitted**
rather than zeroed when the engine did not report it, because `whatsapp-web.js` returns an untyped
object with no guaranteed shape and a defaulted `createdAt: 0` would read as "created at the epoch".

**Errors:** `400` no code supplied, or session not started · `401` · `404` no such invite — invalid, expired, or revoked · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/groups/join

Join a group via an invite code (the part after `https://chat.whatsapp.com/`).

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Request body** — `JoinGroupDto`

| Field      | Type   | Required | Constraints  | Description       |
| ---------- | ------ | -------- | ------------ | ----------------- |
| inviteCode | string | Yes      | 1..128 chars | Group invite code |

```json
{ "inviteCode": "XyZ987654321" }
```

**Response** `200`

```json
{ "success": true, "groupId": "120363000000000000@g.us" }
```

**Errors:** `400` session is not started / invalid invite code · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### GET /api/sessions/:sessionId/groups/:groupId/settings

Read the group's admin-only settings and disappearing-message timer.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Response** `200`

`announce` = only admins can send messages; `locked` = only admins can edit group info. `ephemeralSeconds` and `memberAddMode` are each present only when the engine reports them.

```json
{ "announce": false, "locked": false, "ephemeralSeconds": 604800, "memberAddMode": "all" }
```

`memberAddMode` is `"all"` (any member may add participants) or `"admins"`. Both engines report and
accept it, but they encode it differently underneath — Baileys as a boolean where `true` means
_everyone_, whatsapp-web.js as WhatsApp's own `all_member_add`/`admin_add` strings (its typings claim
a boolean with the opposite sense). The adapters normalise both to these two values.

**Errors:** `400` session is not started · `401` missing/invalid `X-API-Key` · `403` a key restricted to selected chats names a group outside its allowlist · `404` group not found · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### PUT /api/sessions/:sessionId/groups/:groupId/settings

Update group settings. Each present field maps to one engine call; absent fields stay untouched. The caller must be a group admin for the change to take effect.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — `GroupSettingsDto` (at least one field required)

| Field            | Type    | Required | Constraints       | Description                                                                                            |
| ---------------- | ------- | -------- | ----------------- | ------------------------------------------------------------------------------------------------------ |
| announce         | boolean | No       | boolean           | Only admins can send messages                                                                          |
| locked           | boolean | No       | boolean           | Only admins can edit group info                                                                        |
| ephemeralSeconds | integer | No       | ≥ 0               | Disappearing-message timer in seconds (`0` disables). **Baileys only** — whatsapp-web.js returns `501` |
| memberAddMode    | string  | No       | `all` \| `admins` | Who may add participants. Supported on both engines                                                    |

```json
{ "announce": true, "ephemeralSeconds": 86400 }
```

> **Ordering within a patch is deliberate.** `ephemeralSeconds` is applied first because it is the
> only field with a deterministic per-engine refusal (whatsapp-web.js always `501`s it); applying
> anything else first would leave a half-applied patch behind when that call throws. `memberAddMode`
> is supported on both engines and is therefore applied after it.

**Response** `200`

```json
{ "success": true, "message": "Group settings updated" }
```

**Errors:** `400` session is not started / empty patch / unknown body field · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, the account is not a group admin (`memberAddMode` on whatsapp-web.js), or a key restricted to selected chats names a group outside its allowlist · `404` group not found · `501` `ephemeralSeconds` on the whatsapp-web.js engine (library limitation) · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### GET /api/sessions/:sessionId/groups/:groupId/membership-requests

List the pending join requests of a group with join-approval mode turned on — the queue the
`group.join_request` webhook/socket event announces. Admin-only on both engines: a non-admin read
is refused by WhatsApp, not silently emptied.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Response** `200` — a bare array; fields the engine does not report are omitted rather than defaulted.

```json
[
  {
    "participantId": "628123456789@c.us",
    "addedById": "628987654321@c.us",
    "method": "invite_link",
    "requestedAt": 1754700000
  }
]
```

Each entry is a `GroupMembershipRequest`: `participantId` (the user asking to join), optional
`addedById` (who created the request — differs from the requester on a non-admin add), optional
`method` (`invite_link` | `non_admin_add` | `linked_group_join`), optional `requestedAt` (unix
seconds).

**Errors:** `400` session is not started · `401` missing/invalid `X-API-Key` · `403` the engine refused the read — admin rights required · `404` no such group · `409` engine not ready · `503` WhatsApp did not answer within the request budget; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/groups/:groupId/membership-requests/approve

Approve pending join requests — the named requesters, or **every** pending request when the body
names none. Approving an empty queue is a no-op that returns an empty `results` list. Deliberately
not paced by the cold-reachout governor: unlike `POST .../participants`, the people here asked for
the contact themselves. On whatsapp-web.js the engine pauses 250–500ms between requesters
(upstream anti-abuse pacing), so acting on a large queue is a proportionally long request.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| groupId   | string | Group ID    |

**Request body** — `MembershipRequestActionDto`

| Field        | Type     | Required | Constraints                                                                                                | Description                                                   |
| ------------ | -------- | -------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| participants | string[] | No       | omit to skip (explicit `null` → `400`), `@IsArray`, `@ArrayNotEmpty`, `@IsString({each:true})`, 1..256 ids | Requester WhatsApp IDs. Omit to act on every pending request. |

```json
{ "participants": ["628123456789@c.us"] }
```

**Response** `200`

Status is forced to `200` via `@HttpCode(HttpStatus.OK)` (overriding the POST default). `results`
carries the engine's per-participant outcome — the same `ParticipantOperationResult` contract as
the participant writes: a partial refusal does **not** fail the batch, while a batch that failed
for every **named** requester is a `403`.

```json
{
  "success": true,
  "message": "Membership requests approved",
  "results": [{ "id": "628123456789@c.us", "success": true, "status": 200 }]
}
```

**Errors:** `400` validation / session not started · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or the engine refused (admin rights / every named requester failed) · `404` no such group · `409` engine not ready · `503` WhatsApp did not answer within the request budget; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/groups/:groupId/membership-requests/reject

Reject pending join requests. Same body, response shape, batch-guard contract and error map as
`.../membership-requests/approve`; rejecting an empty queue is likewise a no-op.

**Errors:** `400` validation / session not started · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role, or the engine refused (admin rights / every named requester failed) · `404` no such group · `409` engine not ready · `503` WhatsApp did not answer within the request budget; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

```json
{
  "success": true,
  "message": "Membership requests rejected",
  "results": [{ "id": "628123456789@c.us", "success": true, "status": 200 }]
}
```

### 6.4.5 Message Templates

Reusable message templates scoped to a session, with `{{variable}}` placeholders rendered at send time. All routes are nested under `/api/sessions/:sessionId/templates` and require an **OPERATOR** key. The `sessionId` is stored on the template; only `POST` checks that the session exists, the other handlers do not validate it.

#### GET /api/sessions/:sessionId/templates

List all templates for a session, newest first.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                                   |
| --------- | ------ | --------------------------------------------- |
| sessionId | string | Session ID; filters templates by `sessionId`. |

**Response** `200`

Bare `Template[]` array (no pagination, no envelope). Ordered by `createdAt` DESC. Returns an empty array — not `404` — when the session has no templates.

```json
[
  {
    "id": "f1c2a3b4-5d6e-7f80-9a1b-2c3d4e5f6071",
    "sessionId": "9b1c0e2a-3d4f-5a6b-7c8d-9e0f1a2b3c4d",
    "name": "order-confirmation",
    "body": "Hi {{customer}}, your order {{orderId}} has shipped.",
    "header": "OpenWA Store",
    "footer": "Reply STOP to unsubscribe.",
    "createdAt": "2026-06-25T10:15:00.000Z",
    "updatedAt": "2026-06-25T10:15:00.000Z"
  }
]
```

**Errors:** `401` missing/invalid `X-API-Key` · `403` key below OPERATOR role

#### GET /api/sessions/:sessionId/templates/:id

Get a single template by ID within the session.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                                   |
| --------- | ------ | --------------------------------------------- |
| sessionId | string | Session ID; combined with `id` in the lookup. |
| id        | string | Template UUID.                                |

**Response** `200`

Raw `Template` entity (no envelope).

```json
{
  "id": "f1c2a3b4-5d6e-7f80-9a1b-2c3d4e5f6071",
  "sessionId": "9b1c0e2a-3d4f-5a6b-7c8d-9e0f1a2b3c4d",
  "name": "order-confirmation",
  "body": "Hi {{customer}}, your order {{orderId}} has shipped.",
  "header": "OpenWA Store",
  "footer": "Reply STOP to unsubscribe.",
  "createdAt": "2026-06-25T10:15:00.000Z",
  "updatedAt": "2026-06-25T10:15:00.000Z"
}
```

**Errors:** `401` missing/invalid `X-API-Key` · `403` key below OPERATOR role · `404` no row matches the `id`+`sessionId` pair (`{ "statusCode": 404, "message": "Template with id '<id>' not found", "error": "Not Found" }`)

#### POST /api/sessions/:sessionId/templates

Create a message template for the session (with `{{variable}}` placeholders in the body).

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                                                                                 |
| --------- | ------ | ------------------------------------------------------------------------------------------- |
| sessionId | string | Session ID; stored as `template.sessionId`. Must name an existing session, otherwise `404`. |

**Request body** — `CreateTemplateDto`

| Field  | Type   | Required | Constraints               | Description                                                                                          |
| ------ | ------ | -------- | ------------------------- | ---------------------------------------------------------------------------------------------------- |
| name   | string | yes      | non-empty, max 100 chars  | Unique template name within the session (DB unique index on `[sessionId, name]`). Duplicate → `409`. |
| body   | string | yes      | non-empty, max 4096 chars | Template body containing `{{variable}}` placeholders rendered at send time.                          |
| header | string | no       | max 1024 chars            | Optional header text; coerced to `null` when omitted. Prepended to rendered body.                    |
| footer | string | no       | max 1024 chars            | Optional footer text; coerced to `null` when omitted. Appended to rendered body.                     |

```json
{
  "name": "order-confirmation",
  "body": "Hi {{customer}}, your order {{orderId}} has shipped.",
  "header": "OpenWA Store",
  "footer": "Reply STOP to unsubscribe."
}
```

**Response** `201`

Returns the saved `Template` entity raw (no envelope). The lazy `session` relation is not loaded on a freshly saved entity, so it is absent from the JSON.

```json
{
  "id": "f1c2a3b4-5d6e-7f80-9a1b-2c3d4e5f6071",
  "sessionId": "9b1c0e2a-3d4f-5a6b-7c8d-9e0f1a2b3c4d",
  "name": "order-confirmation",
  "body": "Hi {{customer}}, your order {{orderId}} has shipped.",
  "header": "OpenWA Store",
  "footer": "Reply STOP to unsubscribe.",
  "createdAt": "2026-06-25T10:15:00.000Z",
  "updatedAt": "2026-06-25T10:15:00.000Z"
}
```

**Errors:** `400` validation failure (missing/empty `name`/`body`, over-length, or any extra field rejected by `forbidNonWhitelisted`) · `401` missing/invalid `X-API-Key` · `403` key below OPERATOR role · `404` no session with that id (`{ "statusCode": 404, "message": "Session with id '<id>' not found", "error": "Not Found" }`) · `409` duplicate `name` for the session

#### PUT /api/sessions/:sessionId/templates/:id

Update a template's name/body/header/footer (partial; only provided fields change).

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description              |
| --------- | ------ | ------------------------ |
| sessionId | string | Session ID.              |
| id        | string | Template UUID to update. |

**Request body** — `UpdateTemplateDto`

| Field  | Type   | Required | Constraints                           | Description                                                                                                                                                                                                                                                                                                         |
| ------ | ------ | -------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| name   | string | no       | if present: non-empty, max 100 chars  | Applied only when not `undefined`; explicit `null` → `400`. Duplicate name → `409`.                                                                                                                                                                                                                                 |
| body   | string | no       | if present: non-empty, max 4096 chars | Applied only when not `undefined`; explicit `null` → `400`.                                                                                                                                                                                                                                                         |
| header | string | no       | if present: max 1024 chars            | Applied only when not `undefined`. Explicit `null` is accepted and **clears** the stored header (`200`); omit the key to leave it unchanged. The published schema types this field as a non-nullable `string`, so a generated or typed SDK client sends `""` instead, which the renderer skips exactly like `null`. |
| footer | string | no       | if present: max 1024 chars            | Applied only when not `undefined`. Explicit `null` is accepted and **clears** the stored footer (`200`); omit the key to leave it unchanged. The published schema types this field as a non-nullable `string`, so a generated or typed SDK client sends `""` instead, which the renderer skips exactly like `null`. |

```json
{
  "body": "Hi {{customer}}, your order {{orderId}} is out for delivery.",
  "footer": "Thanks for shopping with us."
}
```

**Response** `200`

Loads via lookup (`404` if missing), patches the provided fields, saves, and returns the updated entity raw. `updatedAt` is refreshed.

```json
{
  "id": "f1c2a3b4-5d6e-7f80-9a1b-2c3d4e5f6071",
  "sessionId": "9b1c0e2a-3d4f-5a6b-7c8d-9e0f1a2b3c4d",
  "name": "order-confirmation",
  "body": "Hi {{customer}}, your order {{orderId}} is out for delivery.",
  "header": "OpenWA Store",
  "footer": "Thanks for shopping with us.",
  "createdAt": "2026-06-25T10:15:00.000Z",
  "updatedAt": "2026-06-25T11:02:00.000Z"
}
```

**Errors:** `400` validation / `forbidNonWhitelisted` · `401` missing/invalid `X-API-Key` · `403` key below OPERATOR role · `404` `id`+`sessionId` not found (raised before any write) · `409` rename collides with another template name in the session

#### DELETE /api/sessions/:sessionId/templates/:id

Delete a template by ID.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description              |
| --------- | ------ | ------------------------ |
| sessionId | string | Session ID.              |
| id        | string | Template UUID to delete. |

**Response** `204`

No content (empty body). The handler looks the template up first, so a missing template yields `404` rather than a silent `204`.

**Errors:** `401` missing/invalid `X-API-Key` · `403` key below OPERATOR role · `404` `id`+`sessionId` not found

### 6.4.6 Catalog & Channels

WhatsApp Business catalog browsing/sending and channel (newsletter) operations. Catalog read routes (`/catalog…`) require any valid API key; the product **send** route (`POST /api/sessions/:sessionId/messages/send-product`) lives under the `/messages` path and requires an **OPERATOR** key. Channel read routes require any valid API key; every channel write (create, delete, mute, admin demote, ownership transfer, subscribe, unsubscribe) requires **OPERATOR**.

#### GET /api/sessions/:sessionId/catalog

Get business catalog info for the session's WhatsApp Business account.

**Auth:** API key

**Path parameters**

| Name        | Type   | Description          |
| ----------- | ------ | -------------------- |
| `sessionId` | string | WhatsApp session id. |

**Response** `200`

```json
{
  "id": "1234567890123456",
  "name": "My Storefront",
  "productCount": 12,
  "url": "https://wa.me/c/6281234567890"
}
```

**Baileys engine only.** whatsapp-web.js has no native Catalog API (the former null-returning stub was removed) and answers `501`; its readiness guard runs first, so a session that exists but is not `READY` (initializing, waiting on a QR, reconnecting) gets `409` instead. Baileys returns the catalog synthesized from its first collection; a business without collections has no catalog to describe and the route answers `200` with an empty body. WhatsApp does not always answer the underlying `w:biz:catalog` query for a business account: when the server stays silent (the socket is healthy and other queries reply) the request spends its budget and answers `503`. That is a WhatsApp-side limitation for the affected account, not a transient a retry clears, so a `503` here can be permanent. A rate limit from WhatsApp also answers `503`, and that one can clear on retry. Any other 4xx-class error WhatsApp answers the query with is a `403` (a WhatsApp 408 is a `503` like the rate limit), except item-not-found on the first query, which reads as an account with no catalog.

**Errors:** `401` missing/invalid API key · `403` WhatsApp refused the catalog query · `404` `Session <sessionId> not found or not connected` · `409` session present but not READY · `501` whatsapp-web.js only (no Catalog API) · `503` the catalog query went unanswered or was rate-limited by WhatsApp, or the session/dependency is not ready (a rate limit or the not-ready case can clear on retry; a silently unanswered catalog query does not)

#### GET /api/sessions/:sessionId/catalog/products

List catalog products with pagination.

**Auth:** API key

**Path parameters**

| Name        | Type   | Description          |
| ----------- | ------ | -------------------- |
| `sessionId` | string | WhatsApp session id. |

**Query parameters**

| Name    | Type    | Required | Default | Description                                                             |
| ------- | ------- | -------- | ------- | ----------------------------------------------------------------------- |
| `page`  | integer | No       | `1`     | Page number. Coerced from string; must be an integer `>= 1` or `400`.   |
| `limit` | integer | No       | `20`    | Page size. Must be an integer `>= 1`. No upper cap declared on the DTO. |

Validated against `ProductQueryDto` via the global ValidationPipe; any unknown query key is rejected with `400` (forbidNonWhitelisted).

**Response** `200`

```json
{
  "products": [
    {
      "id": "PROD_12345",
      "name": "Wireless Earbuds",
      "description": "Noise-cancelling, 24h battery",
      "price": 49990,
      "currency": "IDR",
      "priceFormatted": "IDR 49,990",
      "imageUrl": "https://example.com/img/earbuds.jpg",
      "url": "https://wa.me/p/PROD_12345/6281234567890",
      "isAvailable": true,
      "retailerId": "SKU-EB-01"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}
```

`price` and `priceFormatted` are absent for a product WhatsApp lists without a price, and `currency` is absent for one listed without a currency (a price with no currency is formatted as a bare number).

**Baileys engine only.** whatsapp-web.js answers `501` (its readiness guard runs first, so a session that exists but is not `READY` gets `409` instead). Baileys pages the products with a cursor; query validation still runs first, so a bad `page`/`limit` is a `400`.

**Errors:** `400` invalid `page`/`limit` or unknown query key · `401` missing/invalid API key · `403` WhatsApp refused the catalog query · `404` `Session <sessionId> not found or not connected` · `409` session present but not READY · `501` whatsapp-web.js only (no Catalog API) · `503` the catalog query went unanswered or was rate-limited by WhatsApp, or the session/dependency is not ready (a rate limit or the not-ready case can clear on retry; a silently unanswered catalog query does not)

#### GET /api/sessions/:sessionId/catalog/products/:productId

Get a specific catalog product by id.

**Auth:** API key

**Path parameters**

| Name        | Type   | Description          |
| ----------- | ------ | -------------------- |
| `sessionId` | string | WhatsApp session id. |
| `productId` | string | Catalog product id.  |

**Response** `200`

```json
{
  "id": "PROD_12345",
  "name": "Wireless Earbuds",
  "description": "Noise-cancelling, 24h battery",
  "price": 49990,
  "currency": "IDR",
  "priceFormatted": "IDR 49,990",
  "imageUrl": "https://example.com/img/earbuds.jpg",
  "url": "https://wa.me/p/PROD_12345/6281234567890",
  "isAvailable": true,
  "retailerId": "SKU-EB-01"
}
```

`price`, `priceFormatted` and `currency` are absent under the same conditions as in the list above.

**Baileys engine only.** whatsapp-web.js answers `501` (readiness-guarded as above). Baileys resolves the product from the session catalog; an id no product carries answers `200` with an empty body.

**Errors:** `401` missing/invalid API key · `403` WhatsApp refused the catalog query · `404` `Session <sessionId> not found or not connected` · `409` session present but not READY · `501` whatsapp-web.js only (no Catalog API) · `503` the catalog query went unanswered or was rate-limited by WhatsApp, or the session/dependency is not ready (a rate limit or the not-ready case can clear on retry; a silently unanswered catalog query does not)

#### POST /api/sessions/:sessionId/messages/send-product

Send a product message (catalog product card) to a chat. Note: this route lives under the `/messages` path but belongs to the catalog module.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name        | Type   | Description          |
| ----------- | ------ | -------------------- |
| `sessionId` | string | WhatsApp session id. |

**Request body** — `SendProductDto`

| Field       | Type   | Required | Constraints                             | Description                                                       |
| ----------- | ------ | -------- | --------------------------------------- | ----------------------------------------------------------------- |
| `chatId`    | string | Yes      | `@IsString @IsNotEmpty @MaxLength(255)` | Target chat/recipient id (e.g. `6281234567890@c.us`).             |
| `productId` | string | Yes      | `@IsString @IsNotEmpty @MaxLength(255)` | Catalog product id to send.                                       |
| `body`      | string | No       | `@IsString @MaxLength(4096)`            | Optional message body/caption; `null` is the same as omitting it. |

```json
{
  "chatId": "6281234567890@c.us",
  "productId": "PROD_12345",
  "body": "Check out this item!"
}
```

**Response** `201` (Baileys engine only) — the sent `MessageResult`

**Errors:** `400` missing or empty `chatId`/`productId`, a `productId` over 255 or a `body` over 4096 characters, wrong types, any field not on the DTO, a product with no image (a product card needs one), or a `message:sending` plugin blocked the send or returned an invalid `productId`/`body`; on Baileys, also a phone number WhatsApp reports as unregistered · `401` missing/invalid API key · `403` API-key role below OPERATOR, or WhatsApp refused the catalog query · `404` `Session <sessionId> not found or not connected`, or the product is not in the session catalog · `409` session present but not READY (retryable) · `500` engine error · `501` whatsapp-web.js only (no Catalog API) · `503` the catalog query went unanswered or was rate-limited by WhatsApp, or the session/dependency is not ready (a rate limit or the not-ready case can clear on retry; a silently unanswered catalog query does not); on Baileys, also when the recipient lookup failed or timed out before the operation was sent (nothing reached WhatsApp, so a retry is safe)

On whatsapp-web.js the readiness guard runs before the refusal, so a session that exists but is not
`READY` gets `409` instead of `501`. Baileys resolves the product from the session catalog and sends
the single-product message; an id with no catalog row is a `404` before anything is sent.

#### GET /api/sessions/:sessionId/channels

List all channels/newsletters the session is subscribed to.

**Auth:** API key

**Path parameters**

| Name        | Type   | Description                                                                      |
| ----------- | ------ | -------------------------------------------------------------------------------- |
| `sessionId` | string | WhatsApp session id. The engine must be started or the request fails with `400`. |

**Response** `200`

```json
[
  {
    "id": "120363000000000000@newsletter",
    "name": "OpenWA Updates",
    "description": "Release notes and tips",
    "inviteCode": "ABC123xyz",
    "subscriberCount": 1042,
    "verified": true
  }
]
```

Bare array, no envelope. Only the whatsapp-web.js engine serves this route, and its channel mapping
fills neither `picture` nor `createdAt`, so neither field appears here even though the channel schema
declares them.

**Errors:** `400` `Session is not started` · `401` missing/invalid API key · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine

#### GET /api/sessions/:sessionId/channels/:channelId

Get a single channel/newsletter by its id.

**Auth:** API key

**Path parameters**

| Name        | Type   | Description                                  |
| ----------- | ------ | -------------------------------------------- |
| `sessionId` | string | WhatsApp session id. Engine must be started. |
| `channelId` | string | Channel/newsletter id.                       |

**Response** `200`

```json
{
  "id": "120363000000000000@newsletter",
  "name": "OpenWA Updates",
  "description": "Release notes and tips",
  "inviteCode": "ABC123xyz",
  "subscriberCount": 1042,
  "verified": true,
  "createdAt": 1717200000
}
```

> **`createdAt` is Baileys-only, and the lookup reaches further there.** The whatsapp-web.js engine
> exposes no per-id channel lookup, so the adapter scans the subscribed-channel list: a channel the
> account does not follow answers `404` even though it exists, and `createdAt` is always absent from
> the payload. The Baileys engine resolves any channel by id and fills `createdAt`. Neither engine
> fills `picture`: WhatsApp reports the channel picture as a media path, not a URL.

**Errors:** `400` `Session is not started` · `401` missing/invalid API key · `404` `Channel <channelId> not found` (engine returned null; on whatsapp-web.js this includes a channel the account does not follow) · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### GET /api/sessions/:sessionId/channels/:channelId/messages

Get recent messages from a channel/newsletter.

**Auth:** API key

**Path parameters**

| Name        | Type   | Description                                  |
| ----------- | ------ | -------------------------------------------- |
| `sessionId` | string | WhatsApp session id. Engine must be started. |
| `channelId` | string | Channel/newsletter id.                       |

**Query parameters**

| Name    | Type   | Required | Default | Description                                                                                                                                                                                                                                                                                                                             |
| ------- | ------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `limit` | number | No       | `50`    | Max messages to return. Taken as a raw query string and run through `parseInt(limit, 10)` when present. There is **no** DTO/ValidationPipe on this value; a missing or non-numeric `limit` (e.g. `?limit=abc`) uses `50`, and any other value is clamped to 1..100 (above 100 returns at most 100, `0` or a negative value asks for 1). |

**Response** `200`

```json
[
  {
    "id": "false_120363000000000000@newsletter_3EB0...",
    "body": "v0.7.3 is out!",
    "timestamp": 1719331200,
    "hasMedia": false
  }
]
```

Bare array. `timestamp` is an epoch number (seconds). Neither engine populates `mediaUrl` on channel messages today; use `hasMedia` to tell a media post apart.

**Errors:** `400` `Session is not started` · `401` missing/invalid API key · `404` `Channel <channelId> not found` (on whatsapp-web.js, the id is not among the channels the account follows) · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine

#### POST /api/sessions/:sessionId/channels

Create a channel. Supported on **both** engines.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

The account becomes the channel's owner, which is what makes deleting it possible later — neither
engine can delete a channel it does not own.

**Request body** — `CreateChannelDto`

| Field         | Type   | Required | Constraints  | Description         |
| ------------- | ------ | -------- | ------------ | ------------------- |
| `name`        | string | Yes      | 1–100 chars  | Channel name        |
| `description` | string | No       | ≤ 2048 chars | Channel description |

**Response** `201` — the created `Channel`, including its `inviteCode` (the code, not the full
`https://whatsapp.com/channel/…` link — the code is what `POST /channels/subscribe` takes).

**Errors:** `400` validation, or session not started · `401` · `403` the engine refused (on whatsapp-web.js this includes channel creation being disabled for the account) · `409` conflict or engine not ready (retryable) · `503` WhatsApp rate-limited the create on Baileys (code 429; nothing was created, so a retry is safe; a create WhatsApp timed out, code 408, answers `500` instead, as it may have succeeded: check before retrying)

#### POST /api/sessions/:sessionId/channels/:channelId/delete

Delete a channel this account owns. Supported on **both** engines.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

Irreversible, and every subscriber loses the channel.

> **Why not `DELETE /channels/:channelId`?** That route already exists and means _unsubscribe_.
> Leaving a channel and destroying it are very different acts, and they must not be reachable by the
> same request with one wrong verb — so deletion takes an explicit path, matching the
> `POST .../messages/delete` and `POST .../chats/delete` convention used elsewhere.

**Response** `200` — `{ "success": true }`

**Errors:** `400` session not started · `401` · `403` the engine refused (not found, or this account does not own it) · `404` the id is not a channel (does not end in `@newsletter`) · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/channels/:channelId/mute

Mute or unmute a channel. Supported on **both** engines.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

Silences the channel's notifications for this account. The subscription is untouched — this is not a
soft unsubscribe.

**Request body** — `MuteChannelDto`

| Field  | Type    | Required | Description                   |
| ------ | ------- | -------- | ----------------------------- |
| `mute` | boolean | Yes      | `true` mutes, `false` unmutes |

**Response** `200` — `{ "success": true }`

**Errors:** `400` validation, or session not started · `401` · `403` the engine refused · `404` the id is not a channel (does not end in `@newsletter`); on whatsapp-web.js, also a channel id that is not among the session's subscribed channels or has not synced into the local collection yet · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/channels/:channelId/admins/demote

Demote a channel admin back to a plain subscriber. **Baileys only** — the whatsapp-web.js engine
answers `501`.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

Requires this account to own the channel. There is **no promote counterpart**, and that is an
upstream limit rather than a gap here: neither engine library exposes one, so an admin is promoted
from the WhatsApp app and can then be demoted through this endpoint.

`whatsapp-web.js` declares `Client.demoteChannelAdmin`, but its page body calls a WhatsApp Web
module (`WAWebDemoteNewsletterAdminAction`) that no longer exports the function, so every call
fails. Rather than ship a route that always errors on that engine, it answers `501` there.

**Request body** — `DemoteChannelAdminDto`

| Field    | Type   | Required | Description                                  |
| -------- | ------ | -------- | -------------------------------------------- |
| `userId` | string | Yes      | WhatsApp ID of the admin, e.g. `628xxx@c.us` |

**Response** `200` — `{ "success": true }`

**Errors:** `400` validation, or session not started · `401` · `403` the engine refused (not the owner, or the user is not an admin) · `409` conflict or engine not ready (retryable) · `501` the whatsapp-web.js engine cannot perform this · `503` WhatsApp did not answer within the request budget; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/channels/:channelId/owner/transfer

Hand a channel this account owns to a new owner. **Baileys only** — the whatsapp-web.js engine
answers `501`.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

> **Irreversible.** Once the transfer lands, this session is no longer the owner and cannot take the
> channel back through this API.

The upstream option to also dismiss yourself as an admin in the same call is **not exposed**: the
WhatsApp Web function it depends on no longer exists, and it sits inside a branch that swallows its
own errors, so it would fail silently instead of refusing.

`whatsapp-web.js` declares `Client.transferChannelOwnership` and its page function is present, but on
current WhatsApp Web it rejects every call **locally** — measured at 4-9ms against a 352-531ms
known-server baseline in the same page — against a subscriber list the library has no working path to
repopulate. Rather than ship a route that always fails on that engine, it answers `501` there.

**Request body** — `TransferChannelOwnershipDto`

| Field        | Type   | Required | Description                                      |
| ------------ | ------ | -------- | ------------------------------------------------ |
| `newOwnerId` | string | Yes      | WhatsApp ID of the new owner, e.g. `628xxx@c.us` |

**Response** `200` — `{ "success": true }`

**Errors:** `400` validation, or session not started · `401` · `403` WhatsApp refused the transfer · `409` conflict or engine not ready (retryable) · `501` the whatsapp-web.js engine cannot perform this · `503` WhatsApp did not answer within the request budget, and the transfer may or may not have applied; on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### POST /api/sessions/:sessionId/channels/subscribe

Subscribe to a channel using its invite code.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name        | Type   | Description                                  |
| ----------- | ------ | -------------------------------------------- |
| `sessionId` | string | WhatsApp session id. Engine must be started. |

**Request body** — `SubscribeChannelDto`

| Field        | Type   | Required | Constraints               | Description                                      |
| ------------ | ------ | -------- | ------------------------- | ------------------------------------------------ |
| `inviteCode` | string | Yes      | `@IsString` `@IsNotEmpty` | Channel invite code from the channel share link. |

```json
{ "inviteCode": "ABC123xyz" }
```

**Response** `201`

```json
{
  "id": "120363000000000000@newsletter",
  "name": "OpenWA Updates",
  "description": "Release notes and tips",
  "inviteCode": "ABC123xyz",
  "subscriberCount": 1042,
  "verified": true,
  "createdAt": 1717200000
}
```

**Errors:** `400` `Session is not started`, missing/empty `inviteCode`, or any unknown body field · `401` missing/invalid API key · `403` API-key role below OPERATOR, or WhatsApp refused the follow · `404` no channel for that invite code · `409` conflict or engine not ready (retryable) · `501` not supported on the active engine · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

#### DELETE /api/sessions/:sessionId/channels/:channelId

Unsubscribe from a channel.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name        | Type   | Description                                  |
| ----------- | ------ | -------------------------------------------- |
| `sessionId` | string | WhatsApp session id. Engine must be started. |
| `channelId` | string | Channel id to unsubscribe from.              |

**Response** `200`

```json
{ "success": true }
```

Note: the controller returns a literal `{ success: true }` after the void engine call resolves, as the channel delete, mute, admin demote and ownership transfer routes do. There is no `@HttpCode` override, so it returns `200`, not `204`.

**Errors:** `400` `Session is not started` · `401` missing/invalid API key · `403` API-key role below OPERATOR, or WhatsApp refused the unfollow (for example the account does not follow the channel) · `404` the id is not a channel (does not end in `@newsletter`) · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable); on Baileys, also when WhatsApp rate-limits or times out the request (code 429 or 408): retry after a pause

### 6.4.7 Labels & Status

Labels are a WhatsApp Business feature: every label route lives under a session and reads/writes the chat-label assignments exposed by the engine. Status routes manage the session's status feed (stories) — reading visible statuses and posting/deleting your own. Read routes require a base API key; all writes require `OPERATOR`.

**The two engines split cleanly down the middle here, and neither covers both halves.**

|                                                                                                   | whatsapp-web.js                                  | Baileys                               |
| ------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------- |
| Read labels (`GET /labels`, `/labels/:labelId`, `/labels/chat/:chatId`, `/labels/:labelId/chats`) | ✅                                               | `501` — exposes no label query at all |
| Edit labels (`PUT`/`DELETE /labels/:labelId`)                                                     | `501` — can read and assign, but cannot edit one | ✅                                    |
| Assign to a chat (`POST`/`DELETE /labels/chat/…`)                                                 | ✅                                               | ✅                                    |

So a deployment can read labels or edit them, depending on the engine, but not both. Assignment is
the only part that works everywhere. This is a library split, not a gateway one — see
`docs/29-engine-capability-matrix.md` for the symbols behind each cell.

**Creating a label means choosing its id.** WhatsApp carries a single write keyed on the label id, so
create and update are the same operation and there is no server-assigned id to hand back — which is
why the route is `PUT /labels/:labelId` rather than `POST /labels`. Reusing an existing id **rewrites
that label** instead of failing, because the protocol has no create-only form.

**Reads are store-backed, not engine-direct.** `GET /status` and `GET /status/:id` no longer call the engine — they read from an OpenWA-side store that ingests inbound status/story broadcasts as they arrive (plus a best-effort backfill of currently-active stories on session connect), with a 24h TTL matching WhatsApp's own story expiry. This makes reads **identical on both engines**: `whatsapp-web.js` (which had a native `getBroadcasts()`/`getBroadcastById()` path) and Baileys (which never had one — `fetchStatus` only returns the _about_ text, not stories, so the raw engine methods still throw `501` if called directly, they're just no longer on the read path) now return the same shape from the same source. A status older than 24h, or received before the store existed, will not appear.

#### GET /api/sessions/:sessionId/labels

List all labels defined for the session. Labels exist only on a WhatsApp Business account: on whatsapp-web.js a personal account answers `200` with an empty array, not an error. Baileys has no label read and answers `501` on either account type.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |

**Response** `200`

```json
[
  { "id": "1", "name": "New customer", "hexColor": "#FF9485" },
  { "id": "5", "name": "Paid", "hexColor": "#25D366" }
]
```

Bare array — raw return of `engine.getLabels()`; no envelope.

**Errors:** `400` session is not started (no live engine) · `401` missing/invalid API key · `501` the Baileys engine does not implement label reads (whatsapp-web.js only) · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-read (retryable)

#### GET /api/sessions/:sessionId/labels/:labelId

Get a single label by its ID.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description |
| --------- | ------ | ----------- |
| sessionId | string | Session ID  |
| labelId   | string | Label ID    |

**Response** `200`

```json
{ "id": "5", "name": "Paid", "hexColor": "#25D366" }
```

The engine resolves `Label | null`; a `null` is mapped to `404` in the service, so a `200` always carries a label.

**Errors:** `400` session is not started · `401` missing/invalid API key · `404` `Label <labelId> not found` · `501` the Baileys engine does not implement label reads (whatsapp-web.js only) · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-read (retryable)

#### GET /api/sessions/:sessionId/labels/:labelId/chats

Every chat carrying a label.

**Auth:** API key · **Scope:** session-scoped · **Engines:** whatsapp-web.js only

**Response** `200` — a bare array of `ChatSummary`, the same shape `GET /sessions/:sessionId/chats` returns.

**Errors:** `400` session not started (also when the session does not exist) · `401` · `404` label or chat not found · `501` Baileys, which has no label query · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died or the command timed out, so nothing was read (retryable; says nothing about whether the label exists)

#### PUT /api/sessions/:sessionId/labels/:labelId

Create or update a label.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped · **Engines:** Baileys only

The label id is **yours to choose** and travels in the path. Whether this creates or updates depends
only on whether that id already exists — reusing one rewrites that label rather than failing.
The write replaces the whole label, so send every field it should keep: an omitted name or colour
is not preserved.

**Request body** — `UpsertLabelDto`

| Field   | Type   | Required | Constraints  | Description                                  |
| ------- | ------ | -------- | ------------ | -------------------------------------------- |
| `name`  | string | No       | 1–100 chars  | Not preserved when omitted                   |
| `color` | number | No       | integer 0–19 | WhatsApp's colour **index**, not a hex value |

`color` deliberately does not round-trip with the `hexColor` the read routes return: neither engine
exposes the index-to-hex mapping — whatsapp-web.js passes hex through from the WA Web store and
Baileys only ever speaks in indices — so translating between them here would be guesswork that
silently sets the wrong colour.

```json
{ "name": "VIP customer", "color": 3 }
```

**Response** `200`

```json
{ "success": true }
```

**Errors:** `400` validation, or session not started (also when the session does not exist) · `401` · `403` · `501` whatsapp-web.js, which cannot edit labels · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### DELETE /api/sessions/:sessionId/labels/:labelId

Delete a label. It disappears from every chat it was on.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped · **Engines:** Baileys only

**Response** `200`

```json
{ "success": true }
```

**Errors:** `400` session not started (also when the session does not exist) · `401` · `403` · `501` whatsapp-web.js, which cannot edit labels · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### GET /api/sessions/:sessionId/labels/chat/:chatId

List the labels currently assigned to a specific chat.

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                                             |
| --------- | ------ | ------------------------------------------------------- |
| sessionId | string | Session ID                                              |
| chatId    | string | Chat ID (e.g. `6281234567890@c.us` or a group `…@g.us`) |

**Response** `200`

```json
[{ "id": "5", "name": "Paid", "hexColor": "#25D366" }]
```

Bare array — raw return of `engine.getChatLabels(chatId)`.

**Errors:** `400` session is not started · `401` missing/invalid API key · `501` the Baileys engine does not implement label reads (whatsapp-web.js only) · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-read (retryable)

#### POST /api/sessions/:sessionId/labels/chat/:chatId

Add a label to a chat.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description      |
| --------- | ------ | ---------------- |
| sessionId | string | Session ID       |
| chatId    | string | Chat ID to label |

**Request body** — `AddLabelDto`

| Field   | Type   | Required | Constraints      | Description                 |
| ------- | ------ | -------- | ---------------- | --------------------------- |
| labelId | string | yes      | non-empty string | Label ID to add to the chat |

```json
{ "labelId": "5" }
```

**Response** `200`

```json
{ "success": true }
```

The handler always returns the literal `{ "success": true }`.

**Errors:** `400` validation failure (missing/empty/non-string `labelId`, or any unknown body field — strict whitelist), or session is not started · `401` missing/invalid API key · `403` key lacks `OPERATOR` role · `404` (whatsapp-web.js) the chat does not exist on this session, so nothing was written, or the account has no label with this `labelId` (`Label <labelId> not found`), so the chat's labels are unchanged · `409` conflict or engine not ready (retryable) · `422` labels require a WhatsApp Business account, or the chat type has no labels · `503` session not ready or dependency unavailable (retryable)

#### DELETE /api/sessions/:sessionId/labels/chat/:chatId/:labelId

Remove a label from a chat.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description        |
| --------- | ------ | ------------------ |
| sessionId | string | Session ID         |
| chatId    | string | Chat ID            |
| labelId   | string | Label ID to remove |

**Response** `200`

```json
{ "success": true }
```

The handler always returns `{ "success": true }`. DELETE default status is `200` (no `@HttpCode` override).

**Errors:** `400` session is not started · `401` missing/invalid API key · `403` key lacks `OPERATOR` role · `404` (whatsapp-web.js) the chat does not exist on this session, so nothing was written · `409` conflict or engine not ready (retryable) · `422` labels require a WhatsApp Business account, or the chat type has no labels · `503` session not ready or dependency unavailable (retryable)

#### GET /api/sessions/:sessionId/status

Get all contact status updates (stories) visible to the session, read from the store (24h TTL, both engines — see the note above).

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                 |
| --------- | ------ | --------------------------- |
| sessionId | string | WhatsApp session identifier |

**Response** `200`

```json
{
  "statuses": [
    {
      "id": "false_6281234567890@c.us_3A1F...",
      "contact": { "id": "6281234567890@c.us", "name": "Alice", "pushName": "Alice" },
      "type": "image",
      "caption": "On the road",
      "mediaUrl": "/api/sessions/0a941dac-a965-45e7-b318-74ae8be134f0/status/false_6281234567890@c.us_3A1F.../media",
      "backgroundColor": "#25D366",
      "font": 2,
      "timestamp": "2026-06-25T08:30:00.000Z",
      "expiresAt": "2026-06-26T08:30:00.000Z"
    }
  ]
}
```

The controller wraps the store array in `{ statuses }`, ordered newest-first. `type` is one of `text | image | video | voice` (`voice` was added with the send-voice endpoint below; before that anything that was not an image or a video read back as `text`); `caption`, `backgroundColor`, `font` are optional. `mediaUrl` is present only when the status carried media that the store kept (see the media endpoint below) — it is a same-origin path into this API, not an external WhatsApp CDN link. `timestamp` and `expiresAt` serialize to ISO strings (these are `Date` values, not the epoch-number convention used by message timestamps).

**Errors:** `401` missing/invalid API key · `403` key not scoped to this session

#### GET /api/sessions/:sessionId/status/:id

Get status updates posted by a specific contact, read from the store (24h TTL, both engines).

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                                                       |
| --------- | ------ | ----------------------------------------------------------------- |
| sessionId | string | WhatsApp session identifier                                       |
| id        | string | Contact JID/id whose statuses to read (e.g. `6281234567890@c.us`) |

**Response** `200`

```json
{
  "statuses": [
    {
      "id": "false_6281234567890@c.us_3A1F...",
      "contact": { "id": "6281234567890@c.us", "pushName": "Alice" },
      "type": "text",
      "caption": "Hello!",
      "backgroundColor": "#25D366",
      "font": 0,
      "timestamp": "2026-06-25T08:30:00.000Z",
      "expiresAt": "2026-06-26T08:30:00.000Z"
    }
  ]
}
```

Same `{ statuses }` wrapper and `Status` shape as the list-all route. An unknown `contactId` returns `{ "statuses": [] }`, not a `404`.

**Errors:** `401` missing/invalid API key · `403` key not scoped to this session

#### GET /api/sessions/:sessionId/status/:statusId/media

Stream a stored status's media bytes (the file behind a `mediaUrl` returned above).

**Auth:** API key

**Path parameters**

| Name      | Type   | Description                                          |
| --------- | ------ | ---------------------------------------------------- |
| sessionId | string | WhatsApp session identifier                          |
| statusId  | string | The status `id` (e.g. from a `GET /status` response) |

**Response** `200` — the raw media bytes as the response body, served as an **attachment** (`Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`). `Content-Type` is the essence of the stored mimetype (see below) when that is a single image, video or audio type other than `image/svg+xml`, which is scriptable despite the `image/` prefix, and `application/octet-stream` otherwise, so no stored status media is ever served as active content on the API origin. Streamed via `StreamableFile` from whatever backs `StorageService` (local disk or S3 — the route does not care which).

Only the essence of the stored mimetype is checked and served: the part before any `;`, trimmed and lowercased, since MIME types are case-insensitive. Parameters are dropped, and a value that is not a single well-formed type (a comma-joined list, for example) is served as `application/octet-stream`.

**Errors:** `401` missing/invalid API key · `403` key not scoped to this session · `404` `Status media not found or expired` — the status is text-only, its media was omitted (e.g. over the configured size cap), or the 24h TTL has since purged the row

Note: `:statusId/media` is a two-path-segment route, so it never collides with the single-segment `GET /status/:id` above regardless of declaration order.

#### POST /api/sessions/:sessionId/status/send-text

Post a text status (story) to the session's status feed. The recipients allow-list is honored on Baileys only; whatsapp-web.js broadcasts to the account's status-privacy audience.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                 |
| --------- | ------ | --------------------------- |
| sessionId | string | WhatsApp session identifier |

**Request body** — `SendTextStatusDto`

| Field           | Type     | Required | Constraints                                                          | Description                                                                                                                                                                                                                                                                                                                                        |
| --------------- | -------- | -------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| text            | string   | yes      | not blank (`@Matches(/\S/)`), `@MaxLength(4096)`                     | Status text body                                                                                                                                                                                                                                                                                                                                   |
| recipients      | string[] | no       | 0–256 items, each matching `^\d+@(c\.us\|lid)$`                      | JIDs of the contacts permitted to view the status. **Honored on Baileys only** (passed as `statusJidList`), where it is required: an absent or empty list is refused with `400` `recipients is required to post a status on the Baileys engine`. whatsapp-web.js ignores it and broadcasts to the account's status-privacy audience; omit it there |
| backgroundColor | string   | no       | 6-digit hex color matching `^#[0-9A-Fa-f]{6}$`                       | e.g. `#25D366`; bad value → `backgroundColor must be a hex color (e.g., #25D366)`                                                                                                                                                                                                                                                                  |
| font            | integer  | no       | `@IsIn([0, 1, 2, 6, 7, 8, 9, 10])` — `3`–`5` are rejected with `400` | WhatsApp status font index: `0` (default), `1`, `2`, `6` (bold), `7`, `8`, `9`, `10`. whatsapp-web.js honors only `0`–`7` and clamps anything above back to the default                                                                                                                                                                            |

```json
{ "text": "Hello from OpenWA!", "recipients": ["6281234567890@c.us"], "backgroundColor": "#25D366", "font": 2 }
```

**Response** `201`

```json
{
  "statusId": "false_status@broadcast_3A1F...",
  "timestamp": "2026-06-25T08:30:00.000Z",
  "expiresAt": "2026-06-26T08:30:00.000Z"
}
```

Returns the engine `StatusResult` directly (no wrapper). POST default status is `201`.

**Recipient JIDs:** `@c.us` (regular phone) recipients are reliable. `@lid` (privacy-id) recipients are best-effort and unverified — WhatsApp may not deliver to an unresolved LID, so prefer `@c.us` where the phone number is known.

**Sender-side caveat:** the posting account's own phone may display a "waiting for this status update" notice in its status feed; this is cosmetic — recipients view the status normally.

**Errors:** `400` validation failure (unknown body field, a JID not matching `@c.us`/`@lid`, more than 256 recipients, `text` over 4096 chars, bad `backgroundColor`/`font`), or (Baileys) no `recipients`, or the post was blocked by a plugin · `401` missing/invalid API key · `403` key lacks `OPERATOR` role · `404` session not found / not connected · `409` conflict or engine not ready (retryable)

#### POST /api/sessions/:sessionId/status/send-image

Post an image status (story) from a URL or base64 payload. The recipients allow-list is honored on Baileys only; whatsapp-web.js broadcasts to the account's status-privacy audience.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                 |
| --------- | ------ | --------------------------- |
| sessionId | string | WhatsApp session identifier |

**Request body** — `SendImageStatusDto`

| Field          | Type                        | Required | Constraints                                                                                                                                                       | Description                                                                                                                                                                            |
| -------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| image          | object (`StatusMediaInput`) | yes      | validated nested object; one of `url`/`base64` must be present — an empty `{}` is rejected with `400`                                                             | Media source wrapper                                                                                                                                                                   |
| image.url      | string                      | no       | must be a non-empty string whenever `base64` is absent **or** `url` is present at all — `"url": ""` is rejected with `400` even when a valid `base64` is supplied | Media source URL                                                                                                                                                                       |
| image.base64   | string                      | no       | must be a non-empty string whenever `url` is absent **or** `base64` is present at all — `"base64": ""` is rejected with `400` even when a valid `url` is supplied | Base64-encoded media data                                                                                                                                                              |
| image.mimetype | string                      | no       | —                                                                                                                                                                 | Media MIME type; if omitted the service defaults to `image/jpeg`                                                                                                                       |
| recipients     | string[]                    | no       | 0–256 items, each matching `^\d+@(c\.us\|lid)$`                                                                                                                   | JIDs of the contacts permitted to view the status (`statusJidList`). Required in practice on Baileys (it posts to exactly this allow-list); ignored by whatsapp-web.js — omit it there |
| caption        | string                      | no       | `@MaxLength(1024)`                                                                                                                                                | Optional caption                                                                                                                                                                       |

The service resolves the media as `image.base64 || image.url || ''` — `base64` wins when both are supplied — and applies mimetype `image.mimetype ?? 'image/jpeg'`.

```json
{
  "image": { "url": "https://example.com/photo.jpg", "mimetype": "image/png" },
  "recipients": ["6281234567890@c.us"],
  "caption": "My status"
}
```

**Response** `201`

```json
{
  "statusId": "false_status@broadcast_3A1F...",
  "timestamp": "2026-06-25T08:30:00.000Z",
  "expiresAt": "2026-06-26T08:30:00.000Z"
}
```

Returns the engine `StatusResult` directly. POST default status is `201`.

**Recipient JIDs:** `@c.us` (regular phone) recipients are reliable. `@lid` (privacy-id) recipients are best-effort and unverified — prefer `@c.us` where the phone number is known. **Sender-side caveat:** the posting account's own phone may show a "waiting for this status update" notice; recipients view it normally.

**Errors:** `400` validation failure (unknown body field, an empty media wrapper, a JID not matching `@c.us`/`@lid`, more than 256 recipients, or a caption over 1024 chars), or a `url` that answers non-2xx, times out or cannot be reached, or (Baileys) no `recipients`, or the post was blocked by a plugin · `401` missing/invalid API key · `403` key lacks `OPERATOR` role · `404` session not found / not connected · `409` conflict or engine not ready (retryable) · `413` base64 or downloaded media over the media cap (see §6.3) · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable)

#### POST /api/sessions/:sessionId/status/send-video

Post a video status (story) from a URL or base64 payload. The recipients allow-list is honored on Baileys only; whatsapp-web.js broadcasts to the account's status-privacy audience.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                 |
| --------- | ------ | --------------------------- |
| sessionId | string | WhatsApp session identifier |

**Request body** — `SendVideoStatusDto`

| Field          | Type                        | Required | Constraints                                                                                                                                                       | Description                                                                                                                                                                            |
| -------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| video          | object (`StatusMediaInput`) | yes      | validated nested object; one of `url`/`base64` must be present — an empty `{}` is rejected with `400`                                                             | Media source wrapper                                                                                                                                                                   |
| video.url      | string                      | no       | must be a non-empty string whenever `base64` is absent **or** `url` is present at all — `"url": ""` is rejected with `400` even when a valid `base64` is supplied | Media source URL                                                                                                                                                                       |
| video.base64   | string                      | no       | must be a non-empty string whenever `url` is absent **or** `base64` is present at all — `"base64": ""` is rejected with `400` even when a valid `url` is supplied | Base64-encoded media data                                                                                                                                                              |
| video.mimetype | string                      | no       | —                                                                                                                                                                 | Media MIME type; if omitted the service defaults to `video/mp4`                                                                                                                        |
| recipients     | string[]                    | no       | 0–256 items, each matching `^\d+@(c\.us\|lid)$`                                                                                                                   | JIDs of the contacts permitted to view the status (`statusJidList`). Required in practice on Baileys (it posts to exactly this allow-list); ignored by whatsapp-web.js — omit it there |
| caption        | string                      | no       | `@MaxLength(1024)`                                                                                                                                                | Optional caption                                                                                                                                                                       |

The service resolves the media as `video.base64 || video.url || ''` — `base64` wins when both are supplied — and applies mimetype `video.mimetype ?? 'video/mp4'`.

```json
{
  "video": { "url": "https://example.com/clip.mp4", "mimetype": "video/quicktime" },
  "recipients": ["6281234567890@c.us"],
  "caption": "Watch this"
}
```

**Response** `201`

```json
{
  "statusId": "false_status@broadcast_3A1F...",
  "timestamp": "2026-06-25T08:30:00.000Z",
  "expiresAt": "2026-06-26T08:30:00.000Z"
}
```

Returns the engine `StatusResult` directly. POST default status is `201`.

**Recipient JIDs:** `@c.us` (regular phone) recipients are reliable. `@lid` (privacy-id) recipients are best-effort and unverified — prefer `@c.us` where the phone number is known. **Sender-side caveat:** the posting account's own phone may show a "waiting for this status update" notice; recipients view it normally.

**Errors:** `400` validation failure (unknown body field, an empty media wrapper, a JID not matching `@c.us`/`@lid`, more than 256 recipients, or a caption over 1024 chars), or a `url` that answers non-2xx, times out or cannot be reached, or (Baileys) no `recipients`, or the post was blocked by a plugin · `401` missing/invalid API key · `403` key lacks `OPERATOR` role · `404` session not found / not connected · `409` conflict or engine not ready (retryable) · `413` base64 or downloaded media over the media cap (see §6.3) · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable)

#### POST /api/sessions/:sessionId/status/send-voice

Post an audio status (story) as a **voice note**, from a URL or base64 payload. The recipients allow-list is honored on Baileys only; whatsapp-web.js broadcasts to the account's status-privacy audience.

> **Format matters.** WhatsApp plays a status voice note only when it is Ogg/Opus, and neither engine transcodes — bytes are sent as supplied. Convert first via `POST /api/sessions/:sessionId/media/convert/voice` (§6.4.15) and post the `base64` it returns. Sending another format produces a bubble that will not play.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                 |
| --------- | ------ | --------------------------- |
| sessionId | string | WhatsApp session identifier |

**Request body** — `SendVoiceStatusDto`

| Field           | Type                        | Required | Constraints                                                                                           | Description                                                                                                      |
| --------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| audio           | object (`StatusMediaInput`) | yes      | validated nested object; one of `url`/`base64` must be present — an empty `{}` is rejected with `400` | Media source wrapper                                                                                             |
| audio.url       | string                      | no       | must be a non-empty string whenever `base64` is absent **or** `url` is present at all                 | Media source URL                                                                                                 |
| audio.base64    | string                      | no       | must be a non-empty string whenever `url` is absent **or** `base64` is present at all                 | Base64-encoded media data                                                                                        |
| audio.mimetype  | string                      | no       | —                                                                                                     | Media MIME type; if omitted the service defaults to `audio/ogg; codecs=opus`                                     |
| recipients      | string[]                    | no       | 0–256 items, each matching `^\d+@(c\.us\|lid)$`                                                       | JIDs permitted to view the status (`statusJidList`). Required in practice on Baileys; ignored by whatsapp-web.js |
| backgroundColor | string                      | no       | `^#[0-9A-Fa-f]{6}$`                                                                                   | Background colour rendered behind the voice-note bubble. Baileys only; whatsapp-web.js ignores it                |

There is **no `caption`**: WhatsApp has nowhere to render one on a status voice note.

```json
{ "audio": { "base64": "T2dnUwACAAAA..." }, "recipients": ["6281234567890@c.us"] }
```

**Response** `201` — the engine `StatusResult`, identical in shape to the image/video variants.

**Read-back:** a voice status is listed with `"type": "voice"`. That member was added with this endpoint; before it, anything that was not an image or a video was reported as `text`.

**Errors:** `400` validation failure, neither `url` nor `base64` supplied, or a `url` that answers non-2xx, times out or cannot be reached, or (Baileys) no `recipients`, or the post was blocked by a plugin · `401` missing/invalid API key · `403` key lacks `OPERATOR` role · `404` session not found / not connected · `413` base64 or downloaded media exceeds `MEDIA_DOWNLOAD_MAX_BYTES` · `409` conflict or engine not ready (retryable) · `503` a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable)

#### DELETE /api/sessions/:sessionId/status/:id

Delete one of the session's own posted statuses.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                                                             |
| --------- | ------ | ----------------------------------------------------------------------- |
| sessionId | string | WhatsApp session identifier                                             |
| id        | string | Id of the status to delete (the `statusId` returned by a `send-*` call) |

**Response** `200`

```json
{ "message": "Status deleted successfully" }
```

The service returns `void`; the controller returns a fixed success object. DELETE default status is `200`.

**Errors:** `401` missing/invalid API key · `403` key lacks `OPERATOR` role, or (whatsapp-web.js) the id is not one of the account's own statuses, or (Baileys) this session did not post the status in the last 24 hours, so its recipients are unknown (a status posted from the phone or from another node, or before this session's engine was last created, is refused too: a process restart, a session stop and start, and a reconnect the gateway runs itself, such as after a failed liveness check, each create a new one) · `404` `Session {id} not found or not connected` · `409` conflict or engine not ready (retryable) · `503` the whatsapp-web.js page died mid-request, so the revoke did not complete

Safe to retry: revoking an already-revoked status converges. The status POST routes deliberately do
NOT answer `503` for the same failure, because whatsapp-web.js can throw after the request is on the
wire and a client replaying on `503` would publish the status a second time. They answer an opaque
`500` there, exactly as the message sends do.

### 6.4.8 Webhooks (management)

Webhooks are configured per session and managed under `/api/sessions/:sessionId/webhooks` (handled by `WebhookController`). Three cross-session endpoints live on `WebhooksListController`: `GET /api/webhooks` (list, **OPERATOR**), `GET /api/webhooks/delivery-failures` (dead-letter log, **ADMIN**) and `POST /api/webhooks/delivery-failures/redrive` (replay of that log, **ADMIN**). Every other route requires an API key with **OPERATOR** role or higher.

Two fields — `secret` and `headers` — are **write-only**: they are accepted on create/update but are never returned by any webhook route (the response DTO has no `@Expose` for them, so `fromEntity` drops them). `GET /api/infra/export-data` also omits both from its `webhooks` rows, so a backup no longer carries webhook credentials — a restored webhook comes back unsigned (`secret` null, `headers` `{}`) until you set them again. The `secret` is used to compute the `X-OpenWA-Signature: sha256=<hex>` HMAC-SHA256 header on deliveries.

The `events` array accepts these members plus the `*` wildcard: `message.received`, `message.sent`, `message.ack`, `message.receipt`, `message.failed`, `message.revoked`, `message.reaction`, `message.edited`, `session.status`, `session.qr`, `session.authenticated`, `session.disconnected`, `session.reconnect_loop`, `session.restriction`, `presence.update`, `event.response`, `call.accepted`, `call.rejected`, `call.missed`, `group.join`, `group.leave`, `group.update`, `group.join_request`, `call.received`, `status.received`. All of them are actively dispatched by at least one engine — none is a reserved placeholder. Six are **Baileys only**, because whatsapp-web.js produces no callback behind them: `message.receipt` (whatsapp-web.js has no per-recipient receipt event), `presence.update` (its prerequisite `POST .../presence/subscribe` answers `501` there, so this one announces itself), `event.response` (accepted on subscribe and never fired) and `call.accepted` / `call.rejected` / `call.missed` (whatsapp-web.js has no call-outcome callback, so these three are accepted on subscribe and then simply never fire). `message.receipt` is the one event a `*` subscription does not receive: it fires once per recipient, so it must be named explicitly. `call.received` is dispatched by both engines but is not reliable on whatsapp-web.js: it fired there in a live test on 2026-09-17 and did not in one on 2026-08-10. See the per-event catalog below for engine scope.

#### GET /api/sessions/:sessionId/webhooks

List all webhooks for a session, ordered by `createdAt` descending.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                       |
| --------- | ------ | --------------------------------- |
| sessionId | string | Session ID to filter webhooks by. |

**Response** `200`

```json
[
  {
    "id": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
    "sessionId": "my-session",
    "url": "https://your-server.com/webhook",
    "events": ["message.received", "session.status"],
    "filters": null,
    "active": true,
    "retryCount": 3,
    "lastTriggeredAt": null,
    "createdAt": "2026-06-25T10:00:00.000Z",
    "updatedAt": "2026-06-25T10:00:00.000Z"
  }
]
```

Returns a bare array; empty array if the session has no webhooks. `secret` and `headers` are never included. Not paginated.

**Errors:** `401` missing/invalid API key · `403` insufficient role

#### GET /api/sessions/:sessionId/webhooks/:id

Get a single webhook by ID, scoped to the session.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type          | Description                                                                                                                                               |
| --------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| sessionId | string        | Session ID. The lookup is `WHERE { id, sessionId }`, so a webhook belonging to a different session resolves to `404` (no cross-session existence oracle). |
| id        | string (uuid) | Webhook ID.                                                                                                                                               |

**Response** `200`

```json
{
  "id": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
  "sessionId": "my-session",
  "url": "https://your-server.com/webhook",
  "events": ["message.received"],
  "filters": null,
  "active": true,
  "retryCount": 3,
  "lastTriggeredAt": "2026-06-25T11:30:00.000Z",
  "createdAt": "2026-06-25T10:00:00.000Z",
  "updatedAt": "2026-06-25T10:00:00.000Z"
}
```

**Errors:** `401` missing/invalid API key · `403` insufficient role · `404` webhook not found in this session (message `"Webhook with id '<id>' not found"`)

#### GET /api/webhooks

List webhooks visible to the calling API key, scoped to its allowed sessions.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped — derived from the authenticated key, not from any param/query

**Query parameters**

| Name     | Type             | Required | Default | Description                                                                                     |
| -------- | ---------------- | -------- | ------- | ----------------------------------------------------------------------------------------------- |
| `limit`  | integer (1-1000) | No       | `1000`  | Max webhooks to return; oversized/non-finite values are clamped/fallback to the default window. |
| `offset` | integer          | No       | `0`     | Webhooks to skip for paging; negative/non-finite values resolve to `0`.                         |

**Response** `200`

```json
[
  {
    "id": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
    "sessionId": "my-session",
    "url": "https://your-server.com/webhook",
    "events": ["message.received"],
    "filters": null,
    "active": true,
    "retryCount": 3,
    "lastTriggeredAt": null,
    "createdAt": "2026-06-25T10:00:00.000Z",
    "updatedAt": "2026-06-25T10:00:00.000Z"
  }
]
```

Bare array, ordered by `createdAt` descending, bounded by `limit`/`offset`. If the calling key has a non-empty `allowedSessions` list, results are filtered to `WHERE sessionId IN (allowedSessions)`; a key with null/empty `allowedSessions` (e.g. an unrestricted ADMIN key) sees **all** webhooks. This is the cross-session list; the per-session list lives at `GET /api/sessions/:sessionId/webhooks`.

**Errors:** `401` missing/invalid API key · `403` insufficient role

#### GET /api/webhooks/delivery-failures

List webhook deliveries that exhausted their retries or were not sent (attempts 0), most recent first. A shed or shutdown-refused delivery is replayed by the outbox until its replay budget runs out; an oversize payload or a preflight failure on first dispatch is not replayed. This is the dead-letter trail referenced by §6.6 — a receiver outage longer than the retry window, an over-budget payload, or a blocked (SSRF-guarded) URL lands here instead of vanishing.

**Auth:** API key (ADMIN) · **Scope:** results are confined to the calling key's `allowedSessions`, so a session-restricted ADMIN key cannot read another session's rows via `sessionId`

**Query parameters**

| Name        | Type             | Required | Default | Description                                                                                                          |
| ----------- | ---------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
| `sessionId` | string           | No       | —       | Narrow to one session. A value outside the key's `allowedSessions` returns `[]` (no cross-session existence oracle). |
| `limit`     | integer (1-1000) | No       | `1000`  | Max records to return; oversized/non-finite values are clamped/fallback to the default window.                       |
| `offset`    | integer          | No       | `0`     | Records to skip for paging; negative/non-finite values resolve to `0`.                                               |

**Response** `200`

```json
[
  {
    "id": "8c0b1f2e-3d4a-5b6c-7d8e-9f0a1b2c3d4e",
    "webhookId": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
    "sessionId": "my-session",
    "event": "message.received",
    "url": "https://your-server.com/webhook",
    "idempotencyKey": "msg_my-session_3EB0..._f1e2d3c4-b5a6-7890-1234-567890abcdef",
    "deliveryId": "dlv_0f8c1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
    "attempts": 4,
    "lastStatusCode": 502,
    "lastError": "HTTP 502: Bad Gateway",
    "createdAt": "2026-06-25T11:59:00.000Z",
    "replayable": false
  }
]
```

Bare array of `WebhookDeliveryFailure` rows, ordered by `createdAt` descending. `lastStatusCode` is `null` when the failure was a network/timeout/SSRF error rather than a non-2xx response; `idempotencyKey`/`deliveryId` let you correlate the lost event with your own receiver logs.

`replayable` is `true` when the row still holds the event data and can be replayed with [`POST /api/webhooks/delivery-failures/redrive`](#post-apiwebhooksdelivery-failuresredrive). The event data itself is never returned. Only terminal rows (`attempts` > 0) recorded while `WEBHOOK_FAILURE_PAYLOAD_RETENTION_HOURS` > 0 keep it, and only until that window passes; with the default `0` every row reads `false`.

A delivery shed because the dispatch queue was full, or refused during shutdown, is recorded here with `attempts: 0` while its outbox row stays pending, so the outbox sweep replays it. With the queue disabled, a delivery that shutdown interrupts while it waits out a retry backoff is recorded the same way (`attempts: 0`, `lastError` `ConcurrencyLimiter closed`) if the backoff ends within `WEBHOOK_SHUTDOWN_DRAIN_MS`, although its earlier attempts were sent and their responses are not in the row; one still asleep when the drain ends is only logged (`webhook_delivery_abandoned_shutdown`). Either way its outbox row stays pending, and the replay on the next start replaces or clears the row. Any delivery the receiver accepts with a `2xx`, inline or from a queued job, removes the rows filed under its idempotency key, so a replay that succeeds leaves none. A replay that exhausts its retries files a row carrying the real error and attempt count and only then removes the `attempts: 0` row, so the event stays on record throughout, across a restart or a lost queue job too. A replay that fails before sending (a payload over the size cap after the `webhook:before` hooks, or one that cannot be serialized) gives the `attempts: 0` row that reason instead. A replay that keeps failing files no second row. `openwa_webhook_delivery_failures_total` counts the original shed, and counts once more if a replay also exhausts its retries.

**Errors:** `401` missing/invalid API key · `403` key role below ADMIN

Queued deliveries retain their pre-hook outbox copy until the worker completes delivery or stores a terminal failure. The reconciler checks the current job before replaying, retires the row of a completed job without a replay for as long as the queue still keeps that job (completed jobs are kept for an hour, at most the 1000 most recent), and keeps rows while Redis cannot be inspected. If failure persistence is unavailable after the replay budget is spent, it retries the storage handoff without another POST. This recovery boundary is best effort before the outbox row is created; receivers must still deduplicate repeated POSTs.

#### POST /api/webhooks/delivery-failures/redrive

Replay recorded webhook deliveries that still hold their event data, in one bounded batch, with the lowest attempt counts first, then oldest first. A row is replayable when it is terminal (`attempts` > 0; an `attempts: 0` row is the outbox's to replay) and was recorded while `WEBHOOK_FAILURE_PAYLOAD_RETENTION_HOURS` > 0, until that window passes. With the default `0` nothing is kept and this call replays nothing.

Each replay reuses the row's stored `idempotencyKey` (sent as `X-OpenWA-Idempotency-Key`), so receivers can deduplicate a POST that timed out after processing. `webhook:before` hooks run again on the stored pre-hook data. Each row gets **one direct POST** inside the request, including when ordinary dispatch uses the queue. Success removes its row and closes any outbox copy the sweep still holds for that event, so the sweep does not send it again; another failure keeps it and raises its attempt count. A sweep replay of the same event already under way can still POST it again under the same key. Calls on one node run serially. Graceful shutdown stops a batch at its next row and waits for the replays still running (at most four), including their `webhook:before` hooks, for at most `WEBHOOK_SHUTDOWN_DRAIN_MS`; each POST is bounded by `WEBHOOK_TIMEOUT`. Rows it did not reach stay for a later call, and a call queued behind it replays nothing. The audit log records counts without payload content.

Choose a batch limit that fits the client's timeout, especially for unavailable receivers. A client
timeout does not cancel the batch; inspect the failure log before retrying it.

**Auth:** API key (ADMIN) · **Scope:** rows are confined to the calling key's `allowedSessions`; the body's `sessionId` can only narrow that. A row is replayed only to the webhook it was recorded for, and only while that webhook still belongs to the row's session.

**Request body** (every field optional; an empty body `{}` takes the eligible rows with the fewest attempts)

| Field       | Type            | Required | Validation                                   | Description                                                               |
| ----------- | --------------- | -------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `sessionId` | string          | no       | `@IsString`, `@MaxLength(128)`               | Only rows of this session.                                                |
| `webhookId` | string          | no       | `@IsString`, `@MaxLength(128)`               | Only rows of this webhook.                                                |
| `ids`       | string[]        | no       | `@IsArray`, `@ArrayMaxSize(500)`, each ≤ 128 | Only these failure rows (ids from `GET /api/webhooks/delivery-failures`). |
| `limit`     | integer (1-500) | no       | `@IsInt`, `@Min(1)`, `@Max(500)`             | Max rows replayed by this call. Defaults to `100`.                        |

**Response** `200`

```json
{
  "redriven": 3,
  "delivered": 3,
  "enqueued": 0,
  "failed": 0,
  "skipped": 0,
  "remaining": 0
}
```

`redriven` equals `delivered`; `enqueued` is retained for response compatibility and is always `0`. A row is excluded before applying the limit when it has `attempts: 0`, holds no event data, is past the retention window or out of scope, or its webhook is removed, disabled, unsubscribed from the event or no longer in the row's session. `skipped` counts selected rows cancelled by a hook or invalidated during the call. `remaining` counts eligible rows in the same requested session, webhook and IDs after the call. Retry another batch only after resolving persistent failures or hook cancellations; `remaining > 0` alone does not mean another call will succeed.

**Errors:** `400` body fails validation · `401` missing/invalid API key · `403` key role below ADMIN

#### POST /api/sessions/:sessionId/webhooks

Create a webhook for the session.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                                                                                                                                   |
| --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| sessionId | string | Session the webhook is scoped to; stored as `webhook.sessionId`. The session must exist — an unknown id is refused with `404` at create time. |

**Request body** — `CreateWebhookDto`

| Field      | Type                   | Required | Constraints                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| ---------- | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| url        | string                 | yes      | `@IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] })` (allows hostnames without a dot, e.g. `http://localhost:3000`), `@MaxCodePoints(2048)`; also run through the SSRF guard, which can reject with `400`.                                                                                                                                                                                                                                                                                                                      | Webhook URL to receive events.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| events     | string[]               | no       | `@IsArray`, `@ArrayMinSize(1)`, `@IsIn([...WEBHOOK_EVENTS, '*'], { each: true })`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | Event names to subscribe to (see allowed set above). Defaults to `["message.received"]` when omitted.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| secret     | string                 | no       | `@IsString`, `@MinLength(16)`, `@MaxCodePoints(255)`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | HMAC-SHA256 signing key. **Write-only** — never returned by a webhook route (not returned by `GET /api/infra/export-data` either). Used for `X-OpenWA-Signature`. Defaults to `null`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| headers    | Record<string,string>  | no       | `@IsHeaderMap()`: flat object (not array), ≤50 entries, names match `/^[A-Za-z0-9-]+$/` and are unique ignoring case, values are strings ≤1024 chars with no C0 control/DEL (CR/LF injection guard) and nothing above U+00FF (Latin-1 only).                                                                                                                                                                                                                                                                                                                      | Custom headers added to deliveries. **Write-only** — never returned by a webhook route (not returned by `GET /api/infra/export-data` either). At delivery, `content-type`, `user-agent` and `x-openwa-*` names are stripped, and so are the connection-level names the HTTP client owns: `connection`, `content-length`, `expect`, `keep-alive`, `te`, `trailer`, `transfer-encoding` and `upgrade`. Defaults to `{}`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| filters    | WebhookFilters \| null | no       | `@IsValidWebhookFilters()`, i.e. `{ conditions: [...] }`; each condition `{ field, operator('is'\|'isNot'\|'contains'\|'equals'), value(string\|string[]\|boolean), caseSensitive?:boolean }`; bounds: max 20 conditions, 100 values/condition, 1000-char text values. Message fields: `sender`, `recipient`, `chatId`, `body`, `type`, `isGroup`, `kind`, `fromMe`, `hasMedia`, `mentions`. An id-valued condition accepts a bare phone number, but a group must be written as its full `<id>@g.us` JID: bare digits canonicalise to `@c.us` and match no group. | Optional AND pre-filter; **all** conditions must match for the webhook to fire. Omit/null = fire on every subscribed event. Defaults to `null`. ⚠️ A condition whose field is DEFINED for the event family but absent from that event's payload does not scope the event, it decides it outright: an `is`, `contains` or `equals` condition cannot match and suppresses the event, while an `isNot` condition passes it, and so does a boolean field compared with `false`, because an absent boolean reads as `false` (a field with no definition for the family is skipped instead). `message.ack`/`message.failed` carry `{ id, messageId, status, ack, chatId? }` and `message.reaction` carries `{ messageId, chatId, reaction, senderId }`, none of which has a sender or body, so a `sender` `is` filter silently drops all three and a `sender` `isNot` filter delivers all three. The `chatId` on an ack or failure is the conversation the acked message belongs to, present whenever the engine's update names the chat (both engines do on the live path), so `chatId` and `kind` conditions scope acks and failures to that chat; on a payload without it the generic absent-field rule above applies. Scope the subscription with `events[]` rather than relying on a filter to be inert. Set `LOG_LEVEL=debug` to see each suppression and the payload fields that were available. |
| retryCount | number (int)           | no       | `@IsInt`, `@Min(0)`, `@Max(5)`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | Total delivery attempts per event, including the first: `0` and `1` both mean a single attempt with no retry. An event that exhausts them is recorded in `GET /api/webhooks/delivery-failures`; the webhook stays active. Defaults to `3`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |

```json
{
  "url": "https://your-server.com/webhook",
  "events": ["message.received", "session.status"],
  "secret": "your-webhook-signing-secret",
  "headers": { "X-Custom-Header": "value" },
  "filters": {
    "conditions": [
      { "field": "sender", "operator": "is", "value": ["1234567890@c.us"] },
      { "field": "body", "operator": "contains", "value": "invoice" }
    ]
  },
  "retryCount": 3
}
```

**Response** `201`

```json
{
  "id": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
  "sessionId": "my-session",
  "url": "https://your-server.com/webhook",
  "events": ["message.received", "session.status"],
  "filters": {
    "conditions": [
      { "field": "sender", "operator": "is", "value": ["1234567890@c.us"] },
      { "field": "body", "operator": "contains", "value": "invoice" }
    ]
  },
  "active": true,
  "retryCount": 3,
  "lastTriggeredAt": null,
  "createdAt": "2026-06-25T10:00:00.000Z",
  "updatedAt": "2026-06-25T10:00:00.000Z"
}
```

`secret` and `headers` are deliberately excluded from the response. `active` defaults to `true`, `lastTriggeredAt` is `null` on create.

**Errors:** `400` validation failure, unknown body field (whitelist), URL rejection (SSRF guard, or embedded credentials in the URL), or the per-session webhook limit (`WEBHOOK_MAX_PER_SESSION`, default 16 — delete an existing webhook before registering another; webhooks already above the cap are grandfathered and keep working) · `401` missing/invalid API key · `403` insufficient role · `404` session not found (message `"Session with id '<id>' not found"`)

#### PUT /api/sessions/:sessionId/webhooks/:id

Update a webhook. Partial — only fields present in the body are changed.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type          | Description                                                                                        |
| --------- | ------------- | -------------------------------------------------------------------------------------------------- |
| sessionId | string        | Session scope; the webhook is looked up by `(sessionId, id)` first → `404` if not in this session. |
| id        | string (uuid) | Webhook ID.                                                                                        |

**Request body** — `UpdateWebhookDto` (all fields optional; only fields where the value is not `undefined` are applied)

| Field      | Type                   | Required | Constraints                                                                                                                                                                                                                                                 | Description                                                                   |
| ---------- | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| url        | string                 | no       | omit to skip (explicit `null` → `400`), `@IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] })`, `@MaxCodePoints(2048)`; re-runs the credential and SSRF checks only when it differs from the stored URL → `400` if blocked. | New URL.                                                                      |
| events     | string[]               | no       | omit to skip (explicit `null` → `400`), `@IsArray`, `@ArrayMinSize(1)`, `@IsIn([...WEBHOOK_EVENTS, '*'], { each: true })`                                                                                                                                   | Same allowed set as create (incl. `*`).                                       |
| secret     | string                 | no       | `@IsOptional`, `@IsString`, `@MinLength(16)` (skipped for `""`, which clears it), `@MaxCodePoints(255)`                                                                                                                                                     | **Write-only.** An empty string is normalized to `null`, which disables HMAC. |
| headers    | Record<string,string>  | no       | omit to skip (explicit `null` → `400`), `@IsHeaderMap()` (same constraints as create)                                                                                                                                                                       | **Write-only.** Replaces existing headers wholesale when provided.            |
| filters    | WebhookFilters \| null | no       | `@IsOptional`, `@IsValidWebhookFilters()`                                                                                                                                                                                                                   | Set to `null` or `{ conditions: [] }` to clear filters.                       |
| active     | boolean                | no       | omit to skip (explicit `null` → `400`), `@IsBoolean`                                                                                                                                                                                                        | Enable/disable the webhook. (Present only on update, not create.)             |
| retryCount | number (int)           | no       | omit to skip (explicit `null` → `400`), `@IsInt`, `@Min(0)`, `@Max(5)`                                                                                                                                                                                      | Total delivery attempts per event, including the first.                       |

```json
{
  "events": ["*"],
  "active": false,
  "retryCount": 5,
  "filters": null
}
```

**Response** `200`

```json
{
  "id": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
  "sessionId": "my-session",
  "url": "https://your-server.com/webhook",
  "events": ["*"],
  "filters": null,
  "active": false,
  "retryCount": 5,
  "lastTriggeredAt": null,
  "createdAt": "2026-06-25T10:00:00.000Z",
  "updatedAt": "2026-06-25T12:00:00.000Z"
}
```

Returns the saved entity; `secret` and `headers` excluded.

**Errors:** `400` validation failure, unknown body field (whitelist), or URL rejection (SSRF guard, or embedded credentials in the URL) · `401` missing/invalid API key · `403` insufficient role · `404` webhook not found in this session

#### POST /api/sessions/:sessionId/webhooks/:id/test

Send a synthetic test payload to the webhook URL and report the result. No request body.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type          | Description                                                    |
| --------- | ------------- | -------------------------------------------------------------- |
| sessionId | string        | Session scope; looked up first → `404` if not in this session. |
| id        | string (uuid) | Webhook ID.                                                    |

**Response** `200`

```json
{ "success": true, "statusCode": 200 }
```

On a reachable endpoint the response is `{ success: <response.ok>, statusCode: <response.status> }` — so a non-2xx target returns `200` HTTP with `success: false` and the target's `statusCode`. On an SSRF/timeout/network error the response is `{ "success": false, "error": "<message>" }`. The endpoint never throws on delivery failure; the failure is reflected in the body, not the HTTP status. The test POST sends `{ "event": "test", ... }` with headers `Content-Type`, `User-Agent: OpenWA-Webhook/1.0.0`, `X-OpenWA-Event: test`, `X-OpenWA-Idempotency-Key`, `X-OpenWA-Delivery-Id`, `X-OpenWA-Retry-Count: 0`, and `X-OpenWA-Signature` when a secret is set. Timeout defaults to 10000 ms (`webhook.timeout` config).

Every test call carries a fresh `X-OpenWA-Idempotency-Key` (`test_<deliveryId>_<webhookId>`), so a receiver that dedups on it runs its handler for each test.

**Errors:** `401` missing/invalid API key · `403` insufficient role · `404` webhook not found in this session

#### DELETE /api/sessions/:sessionId/webhooks/:id

Delete a webhook, scoped to the session.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type          | Description                                                    |
| --------- | ------------- | -------------------------------------------------------------- |
| sessionId | string        | Session scope; looked up first → `404` if not in this session. |
| id        | string (uuid) | Webhook ID.                                                    |

**Response** `204`

No content (empty body; explicit `@HttpCode(204)`).

**Errors:** `401` missing/invalid API key · `403` insufficient role · `404` webhook not found in this session

### 6.4.9 API Keys

API keys are managed under `/api/auth/api-keys`. All management routes (create/list/get/update/delete/revoke) require an **ADMIN** key **with no session scope**: the controller is fenced with `@RequireUnscopedKey`, so a key whose `allowedSessions` is non-empty is rejected with `403` whatever its role — otherwise a confined admin key could mint an unrestricted one. The guard evaluates the role requirement _before_ that fence, so a scoped VIEWER/OPERATOR key is refused with `Insufficient permissions. Required: admin`; only a scoped ADMIN key reaches the fence and sees `Session-scoped API keys are not permitted on this route`. Both are `403`. A key restricted with `allowedChats` that passes the role check is refused next, before the session fence, with `403 "API key is restricted to selected chats"`: no management route is open to a chat-scoped key. The plaintext key string is returned **only once**, at creation. Validation of the caller's own key lives at `POST /api/auth/validate` (a separate controller, not fenced) and accepts any valid key except one its `allowedIps` refuses or one restricted with `allowedChats`, each refused with `403` (the latter like every route not open to a chat-scoped key).

#### GET /api/auth/api-keys

List all API keys, newest first. The plaintext key is never returned.

**Auth:** API key (ADMIN)

**Response** `200`

Bare JSON array (no envelope), ordered by `createdAt` DESC. Null array/date fields are omitted. A key stored with an expiry that cannot be read as a date reports `expiresAt` as `1970-01-01T00:00:00.000Z` here and in every other key response, and is refused as expired; give it a new expiry with `PUT /api/auth/api-keys/:id`.

```json
[
  {
    "id": "3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33",
    "name": "Production Bot",
    "keyPrefix": "owa_k1_a1b2c",
    "role": "operator",
    "allowedIps": ["192.168.1.1", "10.0.0.0/8"],
    "allowedSessions": ["session-uuid-1"],
    "isActive": true,
    "expiresAt": "2027-12-31T23:59:59.000Z",
    "lastUsedAt": "2026-06-25T08:14:00.000Z",
    "usageCount": 42,
    "createdAt": "2026-06-01T10:00:00.000Z"
  }
]
```

**Errors:** `401` missing/invalid `X-API-Key` · `403` key role below ADMIN, or the key is session-scoped or restricted with `allowedChats`

#### GET /api/auth/api-keys/:id

Get a single API key's details by id. No plaintext key.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type          | Description                                         |
| ---- | ------------- | --------------------------------------------------- |
| `id` | string (uuid) | API key id. Opaque resource id, not session-scoped. |

**Response** `200`

```json
{
  "id": "3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33",
  "name": "Production Bot",
  "keyPrefix": "owa_k1_a1b2c",
  "role": "operator",
  "allowedIps": ["192.168.1.1", "10.0.0.0/8"],
  "allowedSessions": ["session-uuid-1"],
  "isActive": true,
  "expiresAt": "2027-12-31T23:59:59.000Z",
  "lastUsedAt": "2026-06-25T08:14:00.000Z",
  "usageCount": 42,
  "createdAt": "2026-06-01T10:00:00.000Z"
}
```

**Errors:** `401` missing/invalid key · `403` key role below ADMIN, or the key is session-scoped or restricted with `allowedChats` · `404` `"API key with id '<id>' not found"`

#### POST /api/auth/api-keys

Create a new API key; returns the full plaintext key exactly once.

**Auth:** API key (ADMIN)

**Request body** — `CreateApiKeyDto`

| Field             | Type                                   | Required | Constraints                                                                                                                                                  | Description                                                                       |
| ----------------- | -------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| `name`            | string                                 | yes      | length 3–100                                                                                                                                                 | Friendly name for the key.                                                        |
| `role`            | enum `admin` \| `operator` \| `viewer` | no       | `@IsEnum`                                                                                                                                                    | Defaults to `operator` when omitted.                                              |
| `allowedIps`      | string[]                               | no       | each entry a valid **IPv4** address or IPv4 CIDR `/0-32`; IPv6 rejected                                                                                      | IP whitelist (IPv4-only by design).                                               |
| `allowedSessions` | string[]                               | no       | each `@IsString`, `@ArrayUnique`; each entry non-empty, no surrounding whitespace, no comma                                                                  | Session IDs this key may access.                                                  |
| `allowedChats`    | string[]                               | no       | unique entries, each a group `<id>@g.us`, a contact `<phone>@c.us` / `<lid>@lid`, or a bare phone number; at most 2000                                       | Chat IDs this key may reach (see [Roles & Authorization](#roles--authorization)). |
| `expiresAt`       | string (ISO 8601 date)                 | no       | `@IsDateString`, and must parse as a date: an ISO week, ordinal or basic form such as `2026-W40-1` is refused with `400 "expiresAt is not a parseable date"` | Stored as a `Date`.                                                               |

```json
{
  "name": "Production Bot",
  "role": "operator",
  "allowedIps": ["192.168.1.1", "10.0.0.0/8"],
  "allowedSessions": ["session-uuid-1"],
  "expiresAt": "2027-12-31T23:59:59Z"
}
```

**Response** `201` — `ApiKeyCreatedResponseDto`

Same shape as the read DTO **plus** an `apiKey` field carrying the full plaintext key `owa_k1_<64 hex>`. This is the **only** time the plaintext key is returned. `keyPrefix` is the first 12 chars; `usageCount` starts at `0`, `isActive` is `true`. Null `allowedIps`/`allowedSessions`/`expiresAt`/`lastUsedAt` are omitted.

```json
{
  "id": "3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33",
  "name": "Production Bot",
  "keyPrefix": "owa_k1_01234",
  "role": "operator",
  "allowedIps": ["192.168.1.1", "10.0.0.0/8"],
  "allowedSessions": ["session-uuid-1"],
  "isActive": true,
  "expiresAt": "2027-12-31T23:59:59.000Z",
  "usageCount": 0,
  "createdAt": "2026-06-25T09:30:00.000Z",
  "apiKey": "owa_k1_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
```

**Errors:** `400` validation (bad `name` length, invalid `role` enum, non-IPv4 `allowedIps` entry, more than 2000 `allowedChats` entries, bad `expiresAt`, or any non-whitelisted body field) · `401` missing/invalid key · `403` key role below ADMIN, or the key is session-scoped or restricted with `allowedChats`

#### PUT /api/auth/api-keys/:id

Update mutable fields of an API key. `isActive` is **not** updatable here — use the revoke route to deactivate.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type          | Description |
| ---- | ------------- | ----------- |
| `id` | string (uuid) | API key id. |

**Request body** — `UpdateApiKeyDto`

| Field             | Type                                   | Required | Constraints                                                                                 | Description                                                                                              |
| ----------------- | -------------------------------------- | -------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `name`            | string                                 | no       | length 3–100                                                                                | Applied only if truthy.                                                                                  |
| `role`            | enum `admin` \| `operator` \| `viewer` | no       | `@IsEnum`                                                                                   | Applied only if truthy.                                                                                  |
| `allowedIps`      | string[]                               | no       | IPv4 address / CIDR only                                                                    | Applied if not `undefined` (can be set to `[]` to clear).                                                |
| `allowedSessions` | string[]                               | no       | each `@IsString`, `@ArrayUnique`; each entry non-empty, no surrounding whitespace, no comma | Applied if not `undefined`.                                                                              |
| `allowedChats`    | string[]                               | no       | same as create                                                                              | Applied if not `undefined` (`[]` clears it, making the key unrestricted).                                |
| `expiresAt`       | string (ISO 8601 date) \| null         | no       | `@IsDateString`, and must parse as a date (same as create)                                  | Applied if not `undefined`; `null` clears the expiry (an empty string fails `@IsDateString` with `400`). |

```json
{
  "name": "Renamed Bot",
  "role": "viewer",
  "allowedIps": ["203.0.113.5"],
  "expiresAt": "2028-01-01T00:00:00Z"
}
```

**Response** `200` — `ApiKeyResponseDto`

Returns the updated key (no plaintext).

```json
{
  "id": "3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33",
  "name": "Renamed Bot",
  "keyPrefix": "owa_k1_a1b2c",
  "role": "viewer",
  "allowedIps": ["203.0.113.5"],
  "isActive": true,
  "expiresAt": "2028-01-01T00:00:00.000Z",
  "usageCount": 42,
  "createdAt": "2026-06-01T10:00:00.000Z"
}
```

**Errors:** `400` validation (incl. `forbidNonWhitelisted` for unknown fields such as `isActive`, and more than 2000 `allowedChats` entries), or an `expiresAt` that does not parse as a date · `401` missing/invalid key · `403` key role below ADMIN, or the key is session-scoped or restricted with `allowedChats` · `404` not found · `409` change would remove the last usable admin key, or no other usable admin key lasts at least as long

#### POST /api/auth/api-keys/:id/revoke

Revoke (deactivate) an API key without deleting it. No request body required.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type          | Description |
| ---- | ------------- | ----------- |
| `id` | string (uuid) | API key id. |

**Response** `200` — `ApiKeyResponseDto`

Sets `isActive` to `false` and returns the key with explicit HTTP `200`. After revoke, the key fails validation with `401 "API key is revoked"`.

```json
{
  "id": "3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33",
  "name": "Production Bot",
  "keyPrefix": "owa_k1_a1b2c",
  "role": "operator",
  "isActive": false,
  "usageCount": 42,
  "createdAt": "2026-06-01T10:00:00.000Z"
}
```

**Errors:** `401` missing/invalid key · `403` key role below ADMIN, or the key is session-scoped or restricted with `allowedChats` · `404` not found · `409` target is the last usable admin key, or no other usable admin key lasts at least as long

#### DELETE /api/auth/api-keys/:id

Permanently delete an API key (hard delete). Also drops any un-flushed usage accumulator.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type          | Description |
| ---- | ------------- | ----------- |
| `id` | string (uuid) | API key id. |

**Response** `204`

`@HttpCode(204)` — no response body.

**Errors:** `401` missing/invalid key · `403` key role below ADMIN, or the key is session-scoped or restricted with `allowedChats` · `404` `"API key with id '<id>' not found"` · `409` target is the last usable admin key, or no other usable admin key lasts at least as long

#### POST /api/auth/validate

Validate the supplied `X-API-Key` and report its validity, its role, and the engine the gateway runs.

**Auth:** API key (any valid role — VIEWER+)

The key is read from the `X-API-Key` header, not the body; send an empty body. This route sits behind the global guard (it is not `@Public`), so a missing/invalid/revoked/expired key is rejected with `401` at the guard before the handler runs, and a key refused by its `allowedIps` with `403`. On success it returns the caller's role and `engineType`, the engine the process resolved at boot (`whatsapp-web.js` or `baileys`). The engine is reported to every role because `GET /api/infra/engines/current` is ADMIN-only. It also returns `scoped`, true when the key is restricted to selected sessions (a non-empty `allowedSessions`). A key restricted with `allowedChats` is refused with `403 "API key is restricted to selected chats"`: the route is not open to a chat-scoped key.

**Response** `200`

```json
{ "valid": true, "role": "operator", "engineType": "whatsapp-web.js", "scoped": false }
```

**Errors:** `401` missing/invalid/revoked/expired key (raised by the global guard before the handler); `403` a key refused by its `allowedIps` (blocked or undeterminable client IP), or one restricted with `allowedChats`

> Implemented by `AuthValidateController` (`@Controller('auth')`), sharing the same `/api/auth` base.

### 6.4.10 System (Health, Metrics, Stats, Settings, Audit)

System endpoints expose operational status, Prometheus metrics, aggregate statistics, runtime settings, and the audit log. Health and metrics use non-standard auth (public / Bearer token); stats, settings and audit use the API key, with several routes gated to `ADMIN`.

#### GET /api/health

Basic health check returning status and the current timestamp; the running app version is disclosed only to authenticated callers.

**Auth:** public — the check itself needs no credentials, so uptime probes keep working. The `version` field is added only when the request carries a valid API key (`X-API-Key` header or `Authorization: Bearer`); an absent or invalid key still answers `200` without it.

**Response** `200`

```json
{ "status": "ok", "timestamp": "2026-06-25T12:34:56.789Z", "version": "0.7.3" }
```

Notes: `timestamp` is an ISO-8601 string (`new Date().toISOString()`). `version` is read from `package.json` at module load and present only on an authenticated request. Exempt from rate limiting (`@SkipThrottle`).

#### GET /api/health/live

Kubernetes liveness probe — returns a deliberately static body reflecting only process liveness; it does NOT probe dependencies.

**Auth:** public

**Response** `200`

```json
{ "status": "ok" }
```

Notes: always `{ "status": "ok" }`. Intentionally static so a transient dependency outage does not trigger a pod kill. The handler never returns a non-200.

#### GET /api/health/ready

Readiness probe — verifies the `main` (auth/audit) and `data` TypeORM datasources respond to `SELECT 1` (each bounded to a 3000 ms timeout) and reports `503` while the app is draining/shutting down.

**Auth:** public

**Response** `200`

```json
{
  "status": "ok",
  "details": {
    "mainDatabase": { "status": "up" },
    "dataDatabase": { "status": "up" }
  }
}
```

**Errors:** `503` — either datasource fails or exceeds its 3 s `SELECT 1` timeout, or the app is shutting down. The handler throws `ServiceUnavailableException` with the `{ status, details }` object, and NestJS sends that object as the whole body (no `statusCode`/`message`/`error` wrapper):

```json
{
  "status": "error",
  "details": { "mainDatabase": { "status": "up" }, "dataDatabase": { "status": "down" } }
}
```

During shutdown the `details` instead read `{ "shutdown": { "status": "draining" } }`. Probes run in parallel via `Promise.all`. There is no `health/detailed` route.

#### GET /api/metrics

Prometheus exposition scrape of OpenWA process + session + message metrics; gated by a `METRICS_TOKEN` bearer (disabled when the token is unset).

**Auth:** Bearer METRICS_TOKEN — `Authorization: Bearer <METRICS_TOKEN>`. This route is `@Public()` (it bypasses the `X-API-Key` guard); access is instead validated inside the service with a constant-time compare. The `Bearer ` prefix is stripped case-insensitively. Published in the OpenAPI spec under the `metrics-bearer` security scheme.

**Response** `200`

Content-Type `text/plain; version=0.0.4; charset=utf-8`, `Cache-Control: no-store`. Raw text (no JSON envelope):

When the data database cannot be read the database-derived series (`openwa_sessions*`,
`openwa_messages*`) are OMITTED rather than reported as zero — a zero would fire an alert
claiming every session had dropped. `openwa_stats_available` is what tells the two cases apart,
so alert on it rather than reading a missing series as zero. `docs/10` lists every series.

```
# HELP openwa_up 1 if the OpenWA process is running
# TYPE openwa_up gauge
openwa_up 1
# TYPE openwa_process_uptime_seconds gauge
openwa_process_uptime_seconds 3600
# TYPE openwa_process_resident_memory_bytes gauge
openwa_process_resident_memory_bytes 187432960
# TYPE openwa_process_heap_used_bytes gauge
openwa_process_heap_used_bytes 64512000
# TYPE openwa_stats_available gauge
openwa_stats_available 1
# TYPE openwa_sessions_total gauge
openwa_sessions_total 3
# TYPE openwa_sessions_active gauge
openwa_sessions_active 2
# TYPE openwa_sessions gauge
openwa_sessions{status="ready"} 2
openwa_sessions{status="disconnected"} 1
# TYPE openwa_messages_total gauge
openwa_messages_total{direction="outgoing"} 1280
openwa_messages_total{direction="incoming"} 940
# TYPE openwa_messages_failed_total gauge
openwa_messages_failed_total 4
```

Values come from `StatsService.getOverview()` plus `process.memoryUsage()`/`process.uptime()`. The render is memoized for 5000 ms to avoid re-running the overview query on every scrape, and `getOverview()` is itself memoized for `STATS_CACHE_TTL_MS` (default 30 s), so the database-derived series and `openwa_stats_available` can trail the data database by up to `STATS_CACHE_TTL_MS` + 5 s plus the time the overview query takes (the memo TTL counts from when the query completes).

**Errors:** `401` — `METRICS_TOKEN` is configured but the bearer is missing or does not match (`{ "statusCode": 401, "message": "Invalid metrics token", "error": "Unauthorized" }`) · `404` — `METRICS_TOKEN` is unset/blank, so the endpoint is disabled (`{ "statusCode": 404, "message": "Metrics endpoint is disabled (set METRICS_TOKEN to enable)", "error": "Not Found" }`) · `429`: 10 failed token attempts from one client within a minute; only failures count, and the block lifts as the window slides (`{ "statusCode": 429, "message": "Too many failed metrics token attempts" }`).

The 429 applies to every request from the locked-out client, a valid token included, so scrape from an address that untrusted clients do not share (behind a reverse proxy, set `TRUSTED_PROXIES` so each client resolves to its own address).

#### GET /api/stats/overview

Get overall cross-session aggregate statistics (sessions by status + message totals + today's counts).

**Auth:** API key (ADMIN) that is not restricted to specific sessions — a global cross-tenant aggregate, so a session-scoped key has no claim on it and is rejected with `403` (`@RequireUnscopedKey`).

**Response** `200`

```json
{
  "sessions": {
    "active": 2,
    "total": 3,
    "byStatus": { "ready": 2, "disconnected": 1 }
  },
  "messages": {
    "sent": 1280,
    "received": 940,
    "failed": 4,
    "today": { "sent": 42, "received": 30 }
  }
}
```

Notes: raw handler return (no envelope). `sessions.byStatus` is keyed by the stored `SessionStatus` values — lowercase, per §6.4.1 — with per-status counts; `sessions.active` counts only `ready`. `messages.sent`/`received` are all-time outgoing/incoming COUNTs; `failed` is the `FAILED`-status COUNT; `today.*` are the same counts since local midnight.

**Errors:** `401` — missing/invalid `X-API-Key` · `403` — key role below `ADMIN`, or the key is session-restricted.

#### GET /api/stats/messages

Get message statistics over a period: time series, counts by type, by session, and top chats.

**Auth:** API key (ADMIN) that is not restricted to specific sessions — a cross-session aggregate, so a session-scoped key is rejected with `403` (`@RequireUnscopedKey`).

**Query parameters**

| Name     | Type                     | Required | Default | Description                                                                                                                    |
| -------- | ------------------------ | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `period` | `'24h' \| '7d' \| '30d'` | No       | `24h`   | Window for the report. `@IsIn(['24h','7d','30d'])` — any other value → `400`. Bucket interval is `hour` for `24h`, else `day`. |

**Response** `200`

```json
{
  "timeSeries": [
    { "timestamp": "2026-06-25 10:00:00", "sent": 12, "received": 8 },
    { "timestamp": "2026-06-25 11:00:00", "sent": 20, "received": 14 }
  ],
  "byType": { "chat": 180, "image": 24, "unknown": 3 },
  "bySession": [{ "sessionId": "9f1c…", "name": "support-line", "sent": 200, "received": 140 }],
  "topChats": [{ "chatId": "6281234567890@c.us", "messageCount": 320 }]
}
```

Notes: raw handler return. `timeSeries.timestamp` is a DB-formatted bucket string — hourly `YYYY-MM-DD HH:00:00` for `24h`, daily `YYYY-MM-DD` for `7d`/`30d` — sorted ascending. `byType` keys are message-type strings (a null type becomes `unknown`). `bySession.name` is `Unknown` when the session is not found. `topChats` is the top 10 by `messageCount` DESC; each `chatName` is the contact's push name for a 1:1 chat, taken from its incoming messages, and `null` for a group or when none is known. All counts are numbers.

**Errors:** `400` — `period` not in the enum, or any non-whitelisted query field (strict `whitelist` + `forbidNonWhitelisted`) · `401` — missing/invalid API key · `403` — role below `ADMIN`, or the key is session-restricted.

#### GET /api/stats/sessions/:sessionId

Get statistics for a single session: identity, message counts, top chats, and 24 h hourly activity.

**Auth:** API key — any valid key (VIEWER and up); there is no `@RequireRole`. Scope still applies: the global guard feeds the `:sessionId` route param to the key's `allowedSessions`, so a session-scoped key asking for a session outside its list gets `403 "API key not authorized for this session"`. Only an unscoped key can read any session's stats.

**Path parameters**

| Name        | Type   | Description                                                           |
| ----------- | ------ | --------------------------------------------------------------------- |
| `sessionId` | string | Session entity id. No format validation; `404` if no session matches. |

**Response** `200`

```json
{
  "session": { "id": "9f1c…", "name": "support-line", "status": "ready" },
  "messages": { "sent": 200, "received": 140, "today": 18, "failed": 1 },
  "topChats": [{ "chatId": "6281234567890@c.us", "count": 64, "lastActive": "2026-06-25 11:42:07" }],
  "hourlyActivity": [
    { "hour": 0, "sent": 0, "received": 0 },
    { "hour": 1, "sent": 3, "received": 2 }
  ]
}
```

Notes: raw handler return. `session.status` is the `SessionStatus` enum value. `messages.sent`/`received` are all-time outgoing/incoming COUNTs; `today` is the total message count since local midnight; `failed` is the `FAILED`-status count. `topChats` is the top 10 by count DESC, with `lastActive` = `MAX(createdAt)` as zone-less UTC text, `YYYY-MM-DD HH:MM:SS`, on both SQLite and PostgreSQL and `chatName` as in `GET /api/stats/messages`. `hourlyActivity` always has 24 entries (UTC hour `0..23`), missing hours zero-filled, computed over the last 24 h.

**Errors:** `401` — missing/invalid API key · `403` (the key is not scoped to this session) · `404` — session not found (`Session not found`).

#### GET /api/settings

Get application settings (environment-derived; `general`/`api`/`notifications` groups).

**Auth:** API key (ADMIN) that is not restricted to specific sessions. Settings describe the whole deployment, so the route requires an unrestricted key (`@RequireUnscopedKey`): the role check alone does not exclude a key confined to a subset of sessions, which has no claim on deployment-wide configuration, so a session-scoped ADMIN key is rejected with `403`. A key below `ADMIN` is also rejected with `403`.

**Response** `200`

```json
{
  "general": {
    "apiBaseUrl": "http://localhost:2785",
    "autoReconnect": true,
    "debugMode": false
  },
  "api": {
    "rateLimit": 100,
    "rateLimitWindow": 60000,
    "enableDocs": true
  },
  "notifications": {
    "emailEnabled": false,
    "notificationEmail": "",
    "webhookAlerts": false
  }
}
```

Notes: raw return of an in-memory `Settings` object built once in the controller constructor from the environment (snapshotted at construction, not re-read per request). `general.apiBaseUrl` reads `BASE_URL` (default `http://localhost:<PORT>`) and `general.debugMode` mirrors `DATABASE_LOGGING`. `api.rateLimitWindow` is in ms. `enableDocs` reflects the `ENABLE_SWAGGER` gate (enabled by default outside production; disabled by default in production unless explicitly enabled). `general.autoReconnect` (always `true`) and `notifications.*` (`emailEnabled: false`, `notificationEmail: ''`, `webhookAlerts: false`) are hardcoded; the `notifications.*` fields are fixed placeholders with no feature behind them.

**Errors:** `401` — missing/invalid `X-API-Key` · `403` — API key lacks the ADMIN role, or the key is session-restricted.

#### GET /api/audit

List audit-log entries, newest first. API-key lifecycle changes, session lifecycle events and ADMIN infra operations land here. **The eight actions below are never emitted**, so filtering for any of them returns zero rows by design: `message_sent` and `message_failed` (every send and its outcome is in the `messages` table), `webhook_triggered` and `webhook_failed` (tracked in `webhook_delivery_failures` and the `openwa_webhook_delivery_failures_total` counter), `webhook_created` and `webhook_deleted` (not wired yet), `api_key_used` (it would fire on every authenticated request; failed authentication is audited as `api_key_auth_failed`), and `session_connected` (an engine-level transition already reflected in `sessions.status`; the user-initiated `session_started` and `session_stopped` are audited).

**Auth:** API key (ADMIN) · **Scope:** rows are confined to the calling key's `allowedSessions` — the `sessionId` query may only narrow within that list, never widen it

**Query parameters**

| Name        | Type                          | Required | Default | Description                                                                                                                                                                                 |
| ----------- | ----------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action`    | string                        | No       | —       | Filter by `AuditAction` (e.g. `api_key_created`, `session_started`, `infra_data_imported`). Message, webhook, `api_key_used` and `session_connected` actions are never emitted (see above). |
| `severity`  | `'info' \| 'warn' \| 'error'` | No       | —       | Filter by `AuditSeverity`.                                                                                                                                                                  |
| `sessionId` | string                        | No       | —       | Narrow to one session. A value outside the key's `allowedSessions` returns `{ "data": [], "total": 0 }`.                                                                                    |
| `apiKeyId`  | string                        | No       | —       | Filter by the acting key's id.                                                                                                                                                              |
| `limit`     | integer                       | No       | `50`    | Page size, clamped to a maximum of `200`.                                                                                                                                                   |
| `offset`    | integer                       | No       | `0`     | Rows to skip; a negative value resolves to `0`.                                                                                                                                             |

**Response** `200`

```json
{
  "data": [
    {
      "id": "6b3f…",
      "action": "session_started",
      "severity": "info",
      "apiKeyId": "1c2d…",
      "apiKeyName": "dashboard",
      "sessionId": "9f1c…",
      "sessionName": "support-line",
      "ipAddress": "203.0.113.7",
      "userAgent": null,
      "method": null,
      "path": null,
      "statusCode": null,
      "metadata": null,
      "errorMessage": null,
      "createdAt": "2026-06-25T11:42:07.000Z"
    }
  ],
  "total": 1
}
```

Unlike the other list routes this one is **not** a bare array: `data` is the page and `total` the unpaginated match count. Nullable columns (`apiKeyId`, `sessionId`, `metadata`, `errorMessage`, …) are `null` when the event has no such dimension. `userAgent` and `statusCode` are reserved columns nothing populates, so rows carry `null`. `method` and `path` are populated only where an emitter passes them explicitly (API-key auth failures, key lifecycle changes, queue-board mutations); session/message-flow rows like the sample leave them `null`.

**Errors:** `400` repeated `action` or `severity`, or a value not in its list · `401` missing/invalid API key · `403` key role below ADMIN

### 6.4.11 Administration (Infrastructure, Plugins, MCP)

Admin-facing operations: infrastructure status & config, the data/storage migration tooling, plugin lifecycle, and the optional MCP transport. Almost every route is **API key (ADMIN)**; the two exceptions are the public `GET /api/infra/health` and the `POST /mcp` JSON-RPC endpoint (see end of section). Every `/api/infra/*` and `/api/plugins/*` route except `GET /api/infra/health` and `PUT /api/plugins/:id/config/:sessionId` also refuses a key restricted to specific sessions (`@RequireUnscopedKey`): a key with a non-empty `allowedSessions` gets `403` `Session-scoped API keys are not permitted on this route` whatever its role, so use an unrestricted ADMIN key.

> Note on the MCP request body: the entire `POST /mcp` envelope (mounted as a raw Express handler, outside the Nest pipe chain) is a **plain TS interface, not a class-validator DTO** — the global `whitelist`/`forbidNonWhitelisted` ValidationPipe does **not** run on it. Unknown fields pass through silently and no type/constraint checks happen, except the few field-level guards noted per endpoint. The infra bodies — `ImportDataDto` (`POST /api/infra/import-data`), `SaveConfigDto` (`PUT /api/infra/config`), `RestartDto` (`POST /api/infra/restart`), `ImportStorageDto` (`POST /api/infra/storage/import`) — and the plugin DTOs (`InstallFromUrlDto`, `PluginConfigDto`, `PluginSessionsDto`) _are_ class-validated and reject unknown fields with `400`. `ImportDataDto` additionally accepts, and ignores, the five metadata fields the export wraps `tables` in, so the backup file posts back unmodified.

---

#### GET /api/infra/health

Public liveness probe. It answers `200` through the shutdown drain and `503` from the moment teardown begins, like every route except `/api/health` and `/api/health/*`; probe `GET /api/health/live` for liveness that survives teardown.

**Auth:** public

**Response** `200`

```json
{ "status": "ok", "timestamp": "2026-06-25T12:00:00.000Z" }
```

**Errors:** `503` shutdown teardown has begun

---

#### GET /api/infra/status

Aggregate infrastructure status (database, Redis, queue, storage, engine).

**Auth:** API key (ADMIN)

**Response** `200`

```json
{
  "database": { "connected": true, "type": "sqlite", "host": "localhost", "builtIn": false },
  "redis": { "enabled": false, "connected": false, "host": "localhost", "port": 6379, "builtIn": false },
  "queue": {
    "enabled": false,
    "webhooks": { "pending": 0, "completed": 0, "failed": 0 }
  },
  "storage": { "type": "local", "path": "./data/media", "builtIn": false },
  "engine": {
    "type": "whatsapp-web.js",
    "headless": true,
    "sessionDataPath": "./data/sessions",
    "browserArgs": "--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu --lang=en-US",
    "webVersion": "2.3000.1040641150-alpha",
    "webVersionSource": "auto"
  },
  "envPinned": ["ENGINE_TYPE"]
}
```

The `queue.webhooks` counters are live BullMQ job counts (`pending` = waiting + active + delayed; plus `completed`/`failed`), degrading to zeros when the queue is disabled or Redis is unreachable. `redis.connected` is a live probe. `database.host` is the configured `DATABASE_HOST` (default `localhost`) and is reported on SQLite too, so read `type`, not `host`, to tell the database apart.

`builtIn` (on `database`/`redis`/`storage`) reports whether OpenWA's own bundled container is actually running _and_ backing this service, detected live from the labelled container; when Docker is unreachable it falls back to the saved `*_BUILTIN` intent from `data/.env.generated`. In S3 mode `storage` additionally carries `bucket` (when one is configured) and `s3Available` (re-probed, throttled, while false; once true it stays true until a restart, so a later outage does not clear it); in local mode neither key is present. `engine.webVersion`/`engine.webVersionSource` (`pinned` / `auto` / `native`) appear only on `whatsapp-web.js`; `webVersion` is `null` until the auto-resolve first succeeds. `engine.browserArgs` is the effective launch argument list: `PUPPETEER_ARGS` (or the four defaults shown) with `--lang=en-US` appended unless a `--lang` flag is already present.

`envPinned` lists which of `DATABASE_TYPE`, `REDIS_ENABLED`, `STORAGE_TYPE`, `STORAGE_LOCAL_PATH`, `ENGINE_TYPE`, `PUPPETEER_HEADLESS`, `SESSION_DATA_PATH` and `PUPPETEER_ARGS` the host environment or the project `.env` sets. Those layers win over `data/.env.generated`, so a value saved for one of these keys through `PUT /api/infra/config` cannot take effect until that layer is changed.

**Errors:** `401` missing/invalid key · `403` key role < ADMIN, or key is session-scoped

---

#### GET /api/infra/engines

List available WhatsApp engine plugins.

**Auth:** API key (ADMIN)

**Response** `200` — bare array

```json
[
  {
    "id": "whatsapp-web.js",
    "name": "WhatsApp Web.js Engine",
    "enabled": true,
    "features": ["text-messages", "media-messages", "group-management", "labels", "channels", "status-updates"],
    "library": { "name": "whatsapp-web.js", "version": "1.34.7" }
  }
]
```

`features` is shortened here. It is the engine plugin's own `getFeatures()` list, so it differs per engine:
Baileys adds `catalog` and omits `labels`, while whatsapp-web.js omits `catalog`. `library` is optional and may be
omitted per engine.

**Errors:** `401` · `403`

---

#### GET /api/infra/engines/current

Get the currently active engine type. A non-admin key reads the same value from `POST /api/auth/validate`.

**Auth:** API key (ADMIN)

**Response** `200`

```json
{ "engineType": "whatsapp-web.js" }
```

**Errors:** `401` · `403`

---

#### GET /api/infra/update-check

Compare the running version with the latest published OpenWA release. The dashboard uses it to show
admins a link to the release notes next to the version. The gateway reads GitHub's latest release
through the SSRF-guarded fetch and caches the answer for six hours (fifteen minutes after a failure).
The request goes out directly, not through `HTTP(S)_PROXY`. A pre-release or draft is never offered. A
failure never becomes an error response: an instance that has never reached `api.github.com` answers
`latest: null`, and one that has keeps the last release it saw. `UPDATE_CHECK_ENABLED=false` turns the
outbound request off.

A running pre-release counts as older than its own final release, so `X.Y.Z` is offered to an
instance on `X.Y.Z-rc.1`.

`current` is the running code's `package.json` version, so a build from `main` reports the last
release number until the next release bumps it.

**Auth:** API key (ADMIN)

**Response** `200`

```json
{
  "current": "0.23.5",
  "latest": "0.23.6",
  "updateAvailable": true,
  "releaseUrl": "https://github.com/rmyndharis/OpenWA/releases/tag/v0.23.6"
}
```

**Errors:** `401` · `403`

---

#### GET /api/infra/config

Read the effective infrastructure config used to hydrate the dashboard form. Each field resolves with the boot precedence: a value pinned by the host environment (e.g. Compose `environment:`) or the project `.env` wins over `data/.env.generated`, while a key that only ever lived in the saved file reports the freshly-saved value even before a restart applies it ("saved, pending restart"). **Secrets are never returned** — only `*Set`/`*CredentialsSet` booleans indicate that a secret is stored.

**Auth:** API key (ADMIN)

**Response** `200` — `SavedConfigResponse`

```json
{
  "database": {
    "type": "sqlite",
    "builtIn": false,
    "host": "",
    "port": "",
    "username": "",
    "database": "",
    "schema": "public",
    "poolSize": 10,
    "sslEnabled": false,
    "sslRejectUnauthorized": true,
    "passwordSet": false
  },
  "redis": { "enabled": false, "builtIn": false, "host": "", "port": "", "passwordSet": false },
  "queue": { "enabled": false },
  "storage": {
    "type": "local",
    "builtIn": false,
    "localPath": "./data/media",
    "s3Bucket": "",
    "s3Region": "",
    "s3Endpoint": "",
    "s3CredentialsSet": false
  },
  "engine": {
    "type": "whatsapp-web.js",
    "headless": true,
    "sessionDataPath": "./data/sessions",
    "browserArgs": "--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu"
  }
}
```

When nothing supplies a key — no pinned environment value and the file absent or silent about it — empty-string/default values are returned (`schema='public'`, `poolSize=10`, `sslRejectUnauthorized=true`, `engine.type='whatsapp-web.js'`, `headless=true`).

**Errors:** `401` · `403`

---

#### PUT /api/infra/config

Merge-save infrastructure config to `data/.env.generated` (a `0600` secret file). A partial payload preserves untouched keys; empty/omitted secret fields keep the existing stored secret.

**Auth:** API key (ADMIN)

**Request body** — `SaveConfigDto` (recursively class-validated; unknown or mistyped fields are rejected)

| Field                                                 | Type                     | Required                 | Constraints                               | Description                                                                                                                                                                    |
| ----------------------------------------------------- | ------------------------ | ------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `database`                                            | object                   | No                       | —                                         | DB section (see nested)                                                                                                                                                        |
| `database.type`                                       | `'sqlite' \| 'postgres'` | If `database` is present | enum                                      | `sqlite` drops stale postgres keys; `postgres` writes connection keys                                                                                                          |
| `database.builtIn`                                    | boolean                  | No                       | —                                         | When `true`+postgres, forces the bundled `postgres` container creds + pushes `postgres` Docker profile                                                                         |
| `database.host` / `.port` / `.username` / `.database` | string                   | No                       | `port` is a string                        | External postgres connection (defaults `localhost`/`5432`/`postgres`/`openwa`)                                                                                                 |
| `database.schema`                                     | string                   | No                       | lower-case, no `pg_` prefix (else 400)    | Postgres schema, saved as `POSTGRES_SCHEMA`; an empty value writes `public` (also forced to `public` when switching to the built-in DB)                                        |
| `database.password`                                   | string                   | No                       | secret                                    | Empty/omitted keeps the existing stored secret                                                                                                                                 |
| `database.poolSize`                                   | number                   | No                       | —                                         | Default 10                                                                                                                                                                     |
| `database.sslEnabled`                                 | boolean                  | No                       | —                                         | Default false                                                                                                                                                                  |
| `database.sslRejectUnauthorized`                      | boolean                  | No                       | —                                         | Only written when `sslEnabled` is true; default true                                                                                                                           |
| `redis.enabled` / `.builtIn`                          | boolean                  | No                       | —                                         | `builtIn`+enabled forces `redis` container + profile                                                                                                                           |
| `redis.host` / `.port`                                | string                   | No                       | `port` is a string                        | Defaults `localhost`/`6379`                                                                                                                                                    |
| `redis.password`                                      | string                   | No                       | secret                                    | Empty keeps existing                                                                                                                                                           |
| `queue.enabled`                                       | boolean                  | No                       | —                                         | Writes `QUEUE_ENABLED`                                                                                                                                                         |
| `storage.type`                                        | `'local' \| 's3'`        | If `storage` is present  | enum                                      | `local` drops stale S3 keys; `s3` drops `STORAGE_LOCAL_PATH`                                                                                                                   |
| `storage.builtIn`                                     | boolean                  | No                       | —                                         | `true`+s3 uses bundled MinIO defaults + pushes `minio` profile                                                                                                                 |
| `storage.localPath`                                   | string                   | No                       | —                                         | Default `./data/media`                                                                                                                                                         |
| `storage.s3Bucket` / `.s3Region` / `.s3Endpoint`      | string                   | No                       | —                                         | External S3                                                                                                                                                                    |
| `storage.s3AccessKey` / `.s3SecretKey`                | string                   | No                       | secret                                    | Empty keeps existing                                                                                                                                                           |
| `engine.type`                                         | string                   | No                       | **must be a known engine id, else `400`** | Saved as `ENGINE_TYPE`                                                                                                                                                         |
| `engine.headless`                                     | boolean                  | No                       | —                                         | Default true; saved as `PUPPETEER_HEADLESS`                                                                                                                                    |
| `engine.sessionDataPath`                              | string                   | No                       | —                                         | Default `./data/sessions`                                                                                                                                                      |
| `engine.browserArgs`                                  | string                   | No                       | —                                         | Saved as `PUPPETEER_ARGS`. Empty saves the default `--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu`; a non-empty value replaces that list in full |

```json
{
  "database": {
    "type": "postgres",
    "builtIn": false,
    "host": "db.example.com",
    "port": "5432",
    "username": "openwa",
    "password": "s3cret",
    "database": "openwa",
    "poolSize": 10,
    "sslEnabled": true,
    "sslRejectUnauthorized": false
  },
  "redis": { "enabled": true, "builtIn": true },
  "queue": { "enabled": true },
  "storage": {
    "type": "s3",
    "builtIn": false,
    "s3Bucket": "my-bucket",
    "s3Region": "ap-southeast-1",
    "s3AccessKey": "AKIA...",
    "s3SecretKey": "...",
    "s3Endpoint": "https://s3.example.com"
  },
  "engine": {
    "type": "whatsapp-web.js",
    "headless": true,
    "sessionDataPath": "./data/sessions",
    "browserArgs": "--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu"
  }
}
```

**Response** `200`

```json
{
  "message": "Configuration saved successfully. Docker profiles required: redis. Server restart required to apply changes.",
  "saved": true,
  "envPath": "data/.env.generated",
  "profiles": ["redis"]
}
```

Write/IO errors are caught and returned as HTTP `200` with `{ "saved": false, "envPath": "", "profiles": [], "message": "Failed to save configuration: …" }`. DTO validation, an unknown engine type, an invalid `database.schema`, CR/LF injection, a value that cannot be stored so that it reads back unchanged, and a merged config the production boot guard would reject (an empty or default `DATABASE_PASSWORD` or S3 key on an external datastore, or a placeholder `REDIS_PASSWORD`; message starts `Refusing to save a configuration that would be rejected at production boot.`), and a local `storage.localPath` the app cannot create or write (message starts `Refusing to save the storage path`; the next start would refuse to boot on it, and a path pinned by the host environment or the project `.env` is not judged, as the boot never reads the submitted one) are real HTTP `400` responses, and nothing is written. `profiles` lists newly-required Docker profiles.

**Errors:** `400` unknown/mistyped body field, missing nested `database.type`/`storage.type`, unknown
`engine.type`, invalid `database.schema`, CR-LF in a value, a value that cannot round-trip through the
env file, a config the production boot secret guard would reject, or a local
`storage.localPath` the app cannot create or write · `401` · `403`

---

#### POST /api/infra/restart

Request a graceful server restart, optionally orchestrating Docker profiles (add/remove services). Schedules process shutdown as a side-effect.

**Auth:** API key (ADMIN)

**Request body** — optional `RestartDto` (class-validated; unknown fields and non-string array members reject)

| Field              | Type     | Required | Default | Description                                                          |
| ------------------ | -------- | -------- | ------- | -------------------------------------------------------------------- |
| `profiles`         | string[] | No       | `[]`    | Docker profiles to enable/start (e.g. `postgres`, `redis`, `minio`)  |
| `profilesToRemove` | string[] | No       | `[]`    | Docker profiles whose containers are stopped and kept, never deleted |

```json
{ "profiles": ["postgres", "redis"], "profilesToRemove": ["minio"] }
```

**Response** `200`

```json
{
  "message": "Server is restarting. Enabling: postgres, redis. Disabling: minio.",
  "restarting": true,
  "profiles": ["postgres", "redis"],
  "profilesToRemove": ["minio"],
  "estimatedTime": 53
}
```

`estimatedTime` (seconds, a typical healthy value rather than a bound; teardown is force-exited 67 s after the grace at the default `WEBHOOK_SHUTDOWN_DRAIN_MS`) = base 15 + 20/postgres + 13/redis + 15/minio + 5/removal. The echoed `profilesToRemove`, and the removal count, already omit any bundled service the environment pins the app to (for example `DATABASE_HOST=postgres` set in the host environment or the project `.env`); such a service is never stopped. `orchestration` is present only when Docker is available and at least one managed profile (`postgres`, `redis`, `minio`) remains in `profiles`; `removal` only when Docker is available and at least one managed profile remains in `profilesToRemove` after dropping pinned services and profiles that are also being started. Without Docker, a `data/.orchestration-request.json` signal file is written instead. After responding, `shutdownService.shutdown()` runs (default ~3s grace); readiness returns `503` during drain.

**Errors:** `401` · `403`

---

#### GET /api/infra/export-data

Export every row of the 16 migration tables from the Data DB as JSON. Read-only, but runs raw SQL on the `data` DataSource: `SELECT *` for most tables, while `messages` and `message_batches` are read as ids first and then as full rows in chunks, newest-first, of at most 200 rows and max(`EXPORT_INLINE_MEDIA_BUDGET_BYTES`, 1 MiB) of stored payload each (a larger single row is read on its own). Each chunk's payloads are dropped against the budget below before the next is read, so the export holds the rows it returns, one chunk and the budget's worth of media, never every inline payload in the database at once. Rows keep the order the database returns them in.

**Auth:** API key (ADMIN)

> **Inline media is carried up to a budget, then omitted.** `EXPORT_INLINE_MEDIA_BUDGET_BYTES` (8 MiB of encoded base64 by default) bounds how much inline media one export may hold, counted across both `messages` and `messageBatches`. Within each of those tables it is spent newest-first — messages by `timestamp`, batches by `created_at` — so an export that cannot carry everything keeps the most recent media rather than whatever the database happened to return first. Messages are served before batches, so a long history can exhaust the budget before any batch is reached. An over-budget payload on a `messages` row arrives as the omitted marker — `{ mimetype, filename?, omitted: true, sizeBytes }`, the same shape the engine emits when an inbound payload exceeds `MEDIA_DOWNLOAD_MAX_BYTES` — so those messages restore without their pictures. A `messageBatches` entry carries no marker: it simply loses its `base64` and keeps `url`, `mimetype` and `caption`, which is the shape a batch already has once it reaches a terminal state. Without the bound, one 50 MiB attachment becomes 66 MiB of base64 and exceeds the import's own request-body limit (`BODY_SIZE_LIMIT`, 25mb by default), producing a backup this gateway refuses to restore with `413`.
>
> An **http/https** payload is never counted or dropped: `metadata.media.data` holds either base64 or the URL a send was given, and such a URL is a pointer worth a few dozen bytes. The scheme is matched case-insensitively, as both engine adapters do when they fetch it; a URL with any other scheme is treated as bytes.
>
> This bounds the media, not the export — a large enough text-only history still exceeds the import limit, because every row costs a few hundred bytes of scaffolding whatever was said. For a backup that keeps everything, use `scripts/backup.sh`: it snapshots the database file itself (and `pg_dump`s Postgres), so inline media rides along regardless of this budget.

The migration set (`MigrationTables`) is, in payload-key order: `sessions`, `webhooks`, `messages`, `messageBatches`, `templates`, `baileysStoredMessages`, `lidMappings`, `chatStates`, `pluginInstances`, `conversationMappings`, `ingressEvents`, `webhookDeliveryFailures`, `webhookOutboxEvents`, `integrationDeliveryFailures`, `statusUpdates`, `automationRules`.

**Response** `200`

```json
{
  "exportedAt": "2026-06-25T12:00:00.000Z",
  "dataDbType": "sqlite",
  "tables": {
    "sessions": [
      {
        "id": "s1",
        "name": "main",
        "status": "ready",
        "phone": "15551234567",
        "pushName": "Me",
        "config": {},
        "proxyUrl": null,
        "proxyType": null,
        "connectedAt": "2026-06-25T00:00:00.000Z",
        "lastActiveAt": "2026-06-25T00:00:00.000Z",
        "createdAt": "2026-06-25T00:00:00.000Z",
        "updatedAt": "2026-06-25T00:00:00.000Z"
      }
    ],
    "webhooks": [],
    "messages": [],
    "messageBatches": [],
    "templates": [],
    "baileysStoredMessages": [],
    "lidMappings": [],
    "chatStates": [],
    "pluginInstances": [],
    "conversationMappings": [],
    "ingressEvents": [],
    "webhookDeliveryFailures": [],
    "webhookOutboxEvents": [],
    "integrationDeliveryFailures": [],
    "statusUpdates": [],
    "automationRules": []
  },
  "counts": {
    "sessions": 1,
    "webhooks": 0,
    "messages": 0,
    "messageBatches": 0,
    "templates": 0,
    "baileysStoredMessages": 0,
    "lidMappings": 0,
    "chatStates": 0,
    "pluginInstances": 0,
    "conversationMappings": 0,
    "ingressEvents": 0,
    "webhookDeliveryFailures": 0,
    "webhookOutboxEvents": 0,
    "integrationDeliveryFailures": 0,
    "statusUpdates": 0,
    "automationRules": 0
  },
  "skippedTables": [],
  "omittedInlineMedia": { "messages": 0, "messageBatches": 0 }
}
```

Rows are raw DB column shapes (e.g. `messageBatches` rows use snake_case columns: `batch_id`, `session_id`, `current_index`, `created_at`, …). **`webhooks` rows omit `secret` and `headers`** (webhook credentials are excluded from backups; they restore as `null`/`{}`), while `pluginInstances` rows still carry integration secrets — treat the payload as a credential dump. **`sessions` rows have any `user:pass` stripped from `proxyUrl`** (a value that does not parse as a URL exports as `null`), so a restored authenticated proxy reads `hasCredentials: false` and fails on the session's next start until the credentials are re-entered with `PATCH /api/sessions/:sessionId/proxy`. On Postgres the generated `body_ts` FTS column is stripped from `messages` so archives stay dialect-neutral.

`sessions`/`webhooks` are queried directly, so a hard DB error there yields `500`. The other 14 are queried tolerantly: a _genuinely missing_ table (an older DB that has not run the migration) exports as `[]` and its name is listed in `skippedTables`; any other error (lock, I/O, timeout) fails the export rather than reporting the table as empty. Check `skippedTables` before restoring — a skipped table is "not migrated yet", not "exported empty".

`omittedInlineMedia` counts, per table, the inline media payloads the `EXPORT_INLINE_MEDIA_BUDGET_BYTES` budget dropped. Zeroes mean everything fitted; an archive with a non-zero count still restores, but those rows come back without their media.

**Errors:** `401` · `403` · `409` `IMPORT_ALREADY_RUNNING` (a data import is running; export after it finishes) · `500` DB error

---

#### POST /api/infra/import-data

Replace all Data DB rows with the supplied export. **Destructive and transactional (all-or-nothing).**

> **The replace covers all 16 migration tables, not just the ones you send.** Inside the transaction every table in the migration set is emptied first and only then re-populated from the payload, so a table you omit ends up **empty**, not untouched. Always restore a payload produced by `GET /api/infra/export-data` of the same or a newer build — a hand-built body carrying only a subset silently wipes the rest.

**Auth:** API key (ADMIN)

**Request body** — `ImportDataDto`. Post the whole export file: alongside `tables`, `force` and `stopOrphans`, the DTO accepts and ignores the export's `exportedAt`, `dataDbType`, `counts`, `skippedTables` and `omittedInlineMedia`. Any other property is rejected with `400`.

| Field                         | Type                | Required | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| ----------------------------- | ------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tables`                      | object              | Yes      | Container of per-table row arrays, keyed exactly as the export's `tables`. Absent, `null` or not an object is rejected `400` before the restore runs                                                                                                                                                                                                                                                                                                                              |
| `tables.sessions`             | `SessionRow[]`      | No       | Inserted first; a row whose `id` or `name` is not a safe directory name refuses the whole restore before any engine is stopped or the transaction opens (`imported:false`, the row named in `warnings`; see the orphan-engine pre-flight below). An ACTIVE status in the backup (`ready`, `initializing`, ...) describes the source host's engines: restored as `disconnected` (a notice counts them), unless the session is held by a live peer whose claim the import preserves |
| `tables.webhooks`             | `WebhookRow[]`      | No       | Export rows omit `secret`/`headers`; an absent key restores as `null`/`{}`                                                                                                                                                                                                                                                                                                                                                                                                        |
| `tables.messageBatches`       | `MessageBatchRow[]` | No       | snake_case columns                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `tables.*` (the remaining 13) | `Row[]`             | No       | Same keys as the export; an omitted table restores **zero** rows into an emptied table                                                                                                                                                                                                                                                                                                                                                                                            |
| `stopOrphans`                 | boolean             | No       | Stop the running engines for sessions the backup does not contain, inside this request and before the replace (best-effort, time-bounded per engine). Preferred over `force`                                                                                                                                                                                                                                                                                                      |
| `force`                       | boolean             | No       | Legacy escape hatch: proceed despite orphaned engines and leave them running until a process restart (`restartRequired: true`)                                                                                                                                                                                                                                                                                                                                                    |

```json
{
  "tables": {
    "sessions": [
      {
        "id": "s1",
        "name": "main",
        "status": "ready",
        "phone": "15551234567",
        "pushName": "Me",
        "config": {},
        "proxyUrl": null,
        "proxyType": null,
        "connectedAt": "2026-06-25T00:00:00.000Z",
        "lastActiveAt": "2026-06-25T00:00:00.000Z",
        "createdAt": "2026-06-25T00:00:00.000Z",
        "updatedAt": "2026-06-25T00:00:00.000Z"
      }
    ],
    "webhooks": [],
    "messages": [],
    "messageBatches": [],
    "templates": [],
    "baileysStoredMessages": [],
    "lidMappings": [],
    "chatStates": [],
    "pluginInstances": [],
    "conversationMappings": [],
    "ingressEvents": [],
    "webhookDeliveryFailures": [],
    "webhookOutboxEvents": [],
    "integrationDeliveryFailures": [],
    "statusUpdates": [],
    "automationRules": []
  },
  "stopOrphans": true
}
```

**Response** `200`

```json
{
  "imported": true,
  "counts": {
    "sessions": 1,
    "webhooks": 0,
    "messages": 0,
    "messageBatches": 0,
    "templates": 0,
    "baileysStoredMessages": 0,
    "lidMappings": 0,
    "chatStates": 0,
    "pluginInstances": 0,
    "conversationMappings": 0,
    "ingressEvents": 0,
    "webhookDeliveryFailures": 0,
    "webhookOutboxEvents": 0,
    "integrationDeliveryFailures": 0,
    "statusUpdates": 0,
    "automationRules": 0
  },
  "warnings": [],
  "notices": [],
  "restartRequired": false,
  "orphanedEngines": [],
  "stoppedOrphanEngines": [],
  "failedOrphanEngines": []
}
```

`warnings` are per-row import failures — they force a **rollback** and `imported:false`. On PostgreSQL the import stops at the first row the database rejects, so `warnings` holds that one failure; SQLite lists every rejected row. `notices` are non-fatal operator messages (orphan-engine reconciliation detail, sessions restored as disconnected, a failed plugin binding re-sync) and never roll anything back. `restartRequired` is `true` from any of four causes: engines left pointing at sessions the restore removed (`force`), an orphan teardown that failed, sessions running on another node, which this request has no channel to stop, or plugin instance bindings that could not be re-synced after the commit. `orphanedEngines` lists the orphaned session ids the restored data no longer contains; `stoppedOrphanEngines`/`failedOrphanEngines` report how `stopOrphans` went.

**Orphan-engine pre-flight.** An archive with no rows in any table never reaches it: it is refused first with `200`, `imported:false` and the warning `Backup contained no rows to restore; refused to replace existing data. Check the file.`, so no `409` is raised, no engine is stopped and no transaction opens, whatever `force` or `stopOrphans` say. An archive holding a row the import would refuse (an unsafe session id or name, malformed webhook events or filters, malformed automation-rule conditions, two templates of one session whose names match once NUL characters are dropped) is refused the same way, with each such row named in `warnings`. For any other archive, before the transaction opens, any session whose id is absent from `tables.sessions` and that has a running or starting engine on this node, or is waiting to relaunch after a failed reconnect, is an orphan (the replace would delete its DB row, leaving an unstoppable engine writing into freshly restored tables). Default behaviour is to refuse with `409` listing those ids; `stopOrphans: true` stops them in-request and proceeds (a session with no engine yet has its start aborted or its pending relaunch cancelled, and is named in `notices` rather than `stoppedOrphanEngines`); `force: true` proceeds and leaves them running until restart.

Because that pre-flight runs _before_ the transaction, its teardown is not covered by the rollback. A response with `imported:false` therefore still reports the engines it really stopped, and `restartRequired` on that path means only that a teardown **failed** — a cleanly stopped orphan leaves its session row intact (restart it with `POST /sessions/{sessionId}/start`), and an engine `force` left running was never orphaned after all, since the data that would have orphaned it was not replaced.

Inside the transaction every migration table is emptied. `webhooks` and `sessions` are DELETEd directly, so a missing table there fails the restore; 13 more go through a tolerant helper where a _genuinely missing_ table is skipped; and `automation_rules` is emptied by the `DELETE FROM sessions` cascade rather than by the helper. Any other DELETE failure propagates to the rollback. Rows are then re-inserted, sessions first. JSON object/array fields are auto-stringified before insert, and the Postgres-form `$N` placeholders are rewritten for SQLite. Two guards return `imported:false` after a rollback: any `warnings`, and a payload that holds rows but restores **zero** of them (a wrong backup would otherwise commit a silent wipe — the response then carries `Backup contained no rows to restore; refused to replace existing data. Check the file.`). On commit the lid→phone mirror is reloaded from the restored rows.

On commit, plugin instance bindings are also re-applied: a session bound only by an instance the backup lacks, or restores disabled, is dropped from that plugin's active sessions and its per-session config is cleared, then every restored enabled instance is bound again. Sessions activated through `PUT /api/plugins/:id/sessions` with no instance behind them are left alone. If that re-sync fails the import still commits, with a notice and `restartRequired: true`. A restart re-applies the restored enabled instances but does not retire a binding the restore dropped, so check each plugin with `GET /api/plugins/:id` and correct it with `PUT /api/plugins/:id/sessions`, as the notice says. A session-wide (wildcard) instance's config was merged into the plugin's base config when it was bound, and a restore does not remove it; overwrite those keys with `PUT /api/plugins/:id/config`.

**Errors:** `400` `tables` absent/not an object, a table whose value is not an array of rows, a row that is not an object (`null`, a bare string, a nested array), a flag spelled as anything but a boolean or exact `true`/`false`, or a property the route does not accept — nothing is written, and field-level detail is suppressed in production unless `VALIDATION_ERROR_DETAIL=true` · `401` · `403` · `409` refused, with the reason in `code` — `IMPORT_WOULD_ORPHAN_ENGINES` (sessions the backup does not contain are running, starting or waiting to relaunch; retry with `stopOrphans` or `force`), `IMPORT_ALREADY_RUNNING` (another import is running; wait for it), `EXPORT_IN_PROGRESS` (a data export is running; wait for it), `IMPORT_NESTED_TRANSACTION` (another database transaction holds the connection; retry with nothing else in flight) · `500` unrecoverable DB error

> A malformed archive is answered before the restore opens its transaction. Every table present is checked for being an array, and every row in it for being an object — not just `sessions`, since the rest are read inside the transaction where the same mistake would fail mid-restore instead of ahead of it. A hand-edited or truncated backup therefore reports `400` naming the offending `tables.<name>[<index>]`, rather than the `500` that told the operator the server had broken when their file was simply wrong.

---

#### GET /api/infra/storage/files/count

File count and total size in the active storage backend.

**Auth:** API key (ADMIN)

**Response** `200`

```json
{ "storageType": "local", "count": 128, "sizeBytes": 5242880, "sizeMB": "5.00" }
```

**Errors:** `401` · `403` · `500` · `503` `STORAGE_TYPE=s3` but the bucket has not been reachable since boot, or its credentials are missing (this route, the export and the import refuse rather than report on the local fallback directory; an outage after the bucket was reachable answers `500`)

---

#### GET /api/infra/storage/export

Export all storage files into a `tar.gz` under `data/exports` and return its **server-side path** (not a download stream).

**Auth:** API key (ADMIN)

**Response** `200`

```json
{ "message": "Storage export completed", "download": "data/exports/storage-export-1750000000000-abc.tar.gz" }
```

`download` is a server filesystem path — feed it back to `POST /api/infra/storage/import`. The archive is auto-deleted after `STORAGE_EXPORT_TTL_MS` (default 1h).

**Errors:** `401` · `403` · `500` · `503` S3 configured but not reachable since boot (a later outage answers `500`)

---

#### POST /api/infra/storage/import

Import storage files from a `tar.gz` located inside the `data/` directory.

**Auth:** API key (ADMIN)

**Request body** — `ImportStorageDto` (class-validated; path-safety is additionally enforced manually)

| Field      | Type   | Required | Constraints                                                        | Description                                                     |
| ---------- | ------ | -------- | ------------------------------------------------------------------ | --------------------------------------------------------------- |
| `filePath` | string | Yes      | Must resolve inside `<cwd>/data` **and** exist on disk, else `400` | Path to the archive (constrained to `data/` to block traversal) |

```json
{ "filePath": "./data/exports/storage-export-1750000000000-abc.tar.gz" }
```

**Response** `200`

```json
{ "imported": true, "count": 128, "failed": 0, "storageType": "local" }
```

`failed` counts archive entries the store refused to write; a bad or traversing entry is skipped without failing the rest. `imported` is `false` when entries failed and none was written.

**Errors:** `400` missing/out-of-`data/`/not-found path, or `Storage import failed: <reason>` when the file is not a readable gzip tar archive or exceeds the import resource caps `STORAGE_IMPORT_MAX_BYTES` (per file), `STORAGE_IMPORT_MAX_ENTRIES` or `STORAGE_IMPORT_MAX_TOTAL_BYTES` (an abort keeps the entries written before it, since there is no rollback, and each entry overwrites on a re-run; a re-run under the same caps stops at the same entry, so for a cap abort first raise the matching setting on the destination as the migration guide describes, and for an unreadable archive re-run with a sound copy) · `401` · `403` · `500` · `503` S3 configured but not reachable since boot (a later outage answers `200` with `imported: false` and the entries in `failed`)

---

#### GET /api/plugins

List all loaded plugins (built-in + installed), with secret config values redacted.

**Auth:** API key (ADMIN)

**Response** `200` — `PluginDto[]` (bare array, `[]` if none)

```json
[
  {
    "id": "chat-flow",
    "name": "Chat Flow",
    "version": "1.0.0",
    "type": "extension",
    "description": "Visual reply flows",
    "author": "openwa-plugins",
    "status": "enabled",
    "config": { "apiKey": "********" },
    "builtIn": false,
    "provides": ["message-hook"],
    "ingressCapable": false,
    "sessionScoped": true,
    "activeSessions": ["*"],
    "loadedAt": "2026-06-25T00:00:00.000Z",
    "enabledAt": "2026-06-25T00:01:00.000Z"
  }
]
```

`type` is one of `engine | storage | queue | auth | extension`; `status` is `installed | enabled | disabled | error`. `activeSessions: ["*"]` means all sessions. `ingressCapable` is `true` when the plugin declares an ingress route and the `webhook:ingress` permission. Optional fields: `configSchema`, `configUi`, `i18n`, `sessionConfig` (secrets redacted), `error`.

**Errors:** `401` · `403`

---

#### GET /api/plugins/catalog

List the remote plugin catalog annotated with this instance's install state. (Declared before `:id` so `catalog` is not captured as an id.)

**Auth:** API key (ADMIN)

**Response** `200` — bare array

```json
[
  {
    "id": "group-translate",
    "name": "Group Translate",
    "version": "1.2.0",
    "type": "extension",
    "description": "Auto-translate group messages",
    "author": "openwa-plugins",
    "download": "https://github.com/openwa-plugins/group-translate/releases/download/v1.2.0/group-translate.zip#sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
    "installed": true,
    "installedVersion": "1.1.0",
    "updateAvailable": true
  }
]
```

The catalog is fetched from `PLUGIN_CATALOG_URL`, which defaults to the OpenWA-plugins `plugins.json` on raw.githubusercontent.com. An empty value falls back to that default, so the catalog cannot be disabled, and on a host that cannot reach it this route answers `400`.

The dashboard installs and updates an entry by passing its `download` URL to `POST /api/plugins/install-url` or `POST /api/plugins/:id/update`, so the pin rule of those routes applies: under `NODE_ENV=production` (unless `PLUGIN_INSTALL_REQUIRE_PIN=false`) an entry whose `download` has no `#sha256=` pin cannot be installed. Every entry in the default catalog carries one.

**Errors:** `400` catalog fetch failed / not a JSON array · `401` · `403`

---

#### GET /api/plugins/:id

Get a single plugin by id.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type   | Description |
| ---- | ------ | ----------- |
| `id` | string | Plugin id   |

**Response** `200` — single `PluginDto` (same shape as the list element, secrets redacted).

**Errors:** `401` · `403` · `404` `Plugin {id} not found`

---

#### GET /api/plugins/:id/config-ui

Serve a plugin's sandboxed config-UI entry HTML (for an opaque-origin iframe `srcdoc`; the dashboard
applies its document-specific CSP nonce to inline scripts and keeps any declared schema form available as fallback).

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type   | Description |
| ---- | ------ | ----------- |
| `id` | string | Plugin id   |

**Response** `200` — raw HTML (not JSON). Headers: `Content-Type: text/html; charset=utf-8`, `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`.

**Errors:** `401` · `403` · `404` plugin missing, no `configUi.entry`, file missing, or containment-check failure

---

#### GET /api/plugins/:id/health

Check a plugin's health (delegates to the loader / sandboxed workers).

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type   | Description |
| ---- | ------ | ----------- |
| `id` | string | Plugin id   |

**Response** `200`

```json
{ "healthy": true }
```

Internal failures are reported in-band as `{ "healthy": false, "message": "…" }` with HTTP 200.

**Errors:** `401` · `403` · `404` unknown id

---

#### POST /api/plugins/install

Install a plugin from an uploaded `.zip` package.

**Auth:** API key (ADMIN)

**Request body** — `multipart/form-data` (no DTO)

| Field  | Type            | Required | Constraints                                  | Description                         |
| ------ | --------------- | -------- | -------------------------------------------- | ----------------------------------- |
| `file` | binary (`.zip`) | Yes      | ≤ 5 MB; must contain a valid plugin manifest | Form field name is literally `file` |

**Response** `201` — the newly installed `PluginDto`.

Reinstalling over a plugin whose code went missing is supported and is the recovery the boot warning
prescribes: its `ctx.storage` directory, config, session activations and enabled-on-boot decision are
all kept. A directory the gateway did not install is still refused.

**Errors:** `400` no file / invalid package / install failed · `401` · `403` · `409` plugin already loaded, or a directory under that id the gateway did not install · `413` package larger than 5 MB

---

#### POST /api/plugins/install-url

Install a plugin by downloading its `.zip` from a URL (SSRF-guarded fetch: host validated, redirects followed with every hop re-validated through the guard and the chain capped at 5 hops, size-capped at `plugins.downloadMaxBytes`, default 5 MB). `https://` is accepted (production also requires the content pin, below); plain `http://` is only accepted when the URL carries a content pin (below) — the package is executable code and must be integrity-protected in transit; private-network targets remain subject to the SSRF guard. A redirect hop that downgrades back to plain `http://` mid-chain is likewise refused (set `PLUGIN_DOWNLOAD_ALLOW_INSECURE_REDIRECTS=true` only if your vendor genuinely redirects that way); a chain over the cap fails with an explicit "too many redirects" error.

Content pinning: append `#sha256=<64 hex>` (URL fragment — never sent to the server) to require the downloaded bytes to match that digest; the fragment is the only honored marker — query params are deliberately ignored. A mismatch or a malformed marker fails the install closed. The pin is always REQUIRED over plain HTTP, which is rejected without one. Over HTTPS it is also REQUIRED when `NODE_ENV=production` (the Docker image, compose and Helm default) unless `PLUGIN_INSTALL_REQUIRE_PIN=false`; elsewhere it is optional over HTTPS (TLS + the SSRF guard are the baseline) unless `PLUGIN_INSTALL_REQUIRE_PIN=true`. An unpinned URL the deployment requires a pin for fails with `400`.

**Auth:** API key (ADMIN)

**Request body** — `InstallFromUrlDto` (class-validated; extra fields → `400`)

| Field | Type   | Required | Constraints                                                     | Description                                                                                                        |
| ----- | ------ | -------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `url` | string | Yes      | `@IsUrl({ protocols:['http','https'], require_protocol:true })` | Absolute URL of the package; plain http only with a `#sha256=` digest pin, which production also requires on https |

```json
{
  "url": "https://github.com/openwa-plugins/chat-flow/releases/download/v1.0.0/chat-flow.zip#sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
```

The digest shown is a placeholder: use the SHA-256 of the `.zip` (`sha256sum chat-flow.zip`).

**Response** `201` — the newly installed `PluginDto`.

**Errors:** `400` invalid URL / download, integrity check, or package invalid · `401` · `403` · `409` already installed, or the plugin is still being restored after startup (retry shortly)

---

#### POST /api/plugins/:id/enable

Enable a plugin.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type   | Description |
| ---- | ------ | ----------- |
| `id` | string | Plugin id   |

**Response** `200`

```json
{ "success": true, "message": "Plugin chat-flow enabled successfully" }
```

Enable failures are returned in-band as `{ "success": false, "message": "…" }` (still HTTP 200).

**Errors:** `401` · `403` · `404` unknown id · `409` the plugin is still being restored after startup (retry shortly)

---

#### POST /api/plugins/:id/disable

Disable a plugin.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type   | Description |
| ---- | ------ | ----------- |
| `id` | string | Plugin id   |

**Response** `200`

```json
{ "success": true, "message": "Plugin chat-flow disabled successfully" }
```

The engine plugin `ENGINE_TYPE` selects (`baileys` or `whatsapp-web.js`) cannot be disabled at runtime:
the gateway runs that engine whatever its plugin status says. The request is refused in-band as
`{ "success": false, "message": "…" }` (still HTTP 200) and changes nothing; set `ENGINE_TYPE` and
restart to switch engines.

**Errors:** `401` · `403` · `404` unknown id · `409` the plugin is still being restored after startup (retry shortly)

---

#### PUT /api/plugins/:id/config

Update a plugin's base configuration object.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type   | Description |
| ---- | ------ | ----------- |
| `id` | string | Plugin id   |

**Request body** — `PluginConfigDto` (class-validated; body must be exactly `{config:{…}}`)

| Field    | Type   | Required | Constraints   | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| -------- | ------ | -------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `config` | object | Yes      | `@IsObject()` | Keys to set, merged over the stored config: omitted keys keep their value. Every key sent is stored, and for a built-in engine plugin (`whatsapp-web.js`, `baileys`) a stored key overrides its `.env`-derived setting on every later boot, so send only the keys you mean to change, not the whole object `GET /api/plugins/:id` returns. Masked/sentinel secret values mean "unchanged" and are restored from the stored config, as does an empty string; send `null` for a secret to remove the stored value |

```json
{ "config": { "apiKey": "sk-...", "replyDelayMs": 1500 } }
```

**Response** `200`

```json
{ "success": true, "message": "Plugin chat-flow configuration updated" }
```

Update failures are returned in-band as `{ "success": false, "message": "…" }` (HTTP 200).

**Errors:** `400` extra top-level field · `401` · `403` · `404` unknown id

---

#### PUT /api/plugins/:id/config/:sessionId

Set (or clear) a plugin config override for a specific session.

**Auth:** API key (ADMIN)

**Path parameters**

| Name        | Type   | Description                     |
| ----------- | ------ | ------------------------------- |
| `id`        | string | Plugin id                       |
| `sessionId` | string | Session the override applies to |

**Request body** — `PluginConfigDto`

| Field    | Type   | Required | Constraints   | Description                                                                                                                                                                                                                                   |
| -------- | ------ | -------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `config` | object | Yes      | `@IsObject()` | Per-session override slice. Empty `{}` clears the override (falls back to base config). Masked secrets restored from the existing per-session value; a secret sent as `null` is dropped from the override, so the session uses the base value |

```json
{ "config": { "replyDelayMs": 3000 } }
```

**Response** `200`

```json
{ "success": true, "message": "Plugin chat-flow configuration for session session-1 updated" }
```

**Errors:** `400` plugin is global (not session-scoped) / extra field · `401` · `403` · `404` unknown id

---

#### PUT /api/plugins/:id/sessions

Set which sessions a session-scoped plugin is activated for. This is a **full replacement** of the plugin's global activation set: the supplied `sessions` array overwrites `activeSessions` in its entirety (not a merge), so an omitted session is deactivated and `[]` deactivates the plugin for every session.

**Auth:** API key (ADMIN) that is **not restricted to specific sessions** (`@RequireUnscopedKey`). As on the other plugin lifecycle routes, a session-scoped key is rejected with `403` whatever it sends. Here it matters most: because the route replaces the whole activation set, even a request confined to its own `allowedSessions` would silently delete every other session's activation, so the fence refuses scoped keys before the handler runs. Use an unrestricted ADMIN key. (The per-session config override route `PUT /api/plugins/:id/config/:sessionId` is a different operation and stays scoped to the addressed session.)

**Path parameters**

| Name | Type   | Description |
| ---- | ------ | ----------- |
| `id` | string | Plugin id   |

**Request body** — `PluginSessionsDto` (class-validated)

| Field      | Type     | Required | Constraints                              | Description                                             |
| ---------- | -------- | -------- | ---------------------------------------- | ------------------------------------------------------- |
| `sessions` | string[] | Yes      | `@IsArray()`, `@IsString({ each:true })` | Session ids to activate for. `["*"]` = all, `[]` = none |

```json
{ "sessions": ["*"] }
```

**Response** `200` — the updated `PluginDto` (reflecting the new `activeSessions`).

**Errors:** `400` plugin is global · `401` · `403` key is session-scoped (full activation replacement requires an unrestricted key) · `404` unknown id

---

#### POST /api/plugins/:id/update

Update an installed plugin in place from a URL, preserving config + enabled state. The new package is written to a staging sibling and validated BEFORE the running plugin is stopped, then swapped in with two renames (live → backup, staging → live); a failure before or during the swap restores the previous version, and a process crash mid-swap is reconciled at boot (the backup is restored when the live directory is missing), so an interrupted update can never make the plugin silently vanish. An update of a plugin the operator switched on (not one in `error`) that is still applying once shutdown has begun is refused, or rolled back if the swap already happened, and answered with `503`, since the new version cannot be started to prove it works. The URL follows the same transport and pin rules as `install-url`: plain http only with a `#sha256=` digest pin, and https needs the pin too under `NODE_ENV=production` unless `PLUGIN_INSTALL_REQUIRE_PIN=false` (fail-closed on mismatch).

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type   | Description                                      |
| ---- | ------ | ------------------------------------------------ |
| `id` | string | Plugin id (must match the package's manifest id) |

**Request body** — `InstallFromUrlDto` (class-validated)

| Field | Type   | Required | Constraints                                                     | Description                                                                                                                      |
| ----- | ------ | -------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `url` | string | Yes      | `@IsUrl({ protocols:['http','https'], require_protocol:true })` | Absolute URL of the new `.zip` (SSRF-guarded download); http only with a `#sha256=` pin, which production also requires on https |

```json
{
  "url": "https://example.com/plugins/chat-flow-1.1.0.zip#sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
```

**Response** `201` — the updated `PluginDto`.

**Errors:** `400` download/package invalid, manifest id mismatch, or built-in plugin · `401` · `403` · `404` unknown id · `409` the plugin is still being restored after startup (retry shortly) · `503` shutdown began before the update of a plugin the operator switched on finished applying (the previous version is kept)

---

#### DELETE /api/plugins/:id

Uninstall a plugin: dispatch its `onUnload` lifecycle hook, delete its files, drop its registry entry, and remove its `ctx.storage` data directory (`<dataDir>/plugins/<id>` — a separate tree when `PLUGINS_DIR` points outside the data dir). Built-in plugins are protected. A plugin that is installed but not loaded — one whose code went missing — can be uninstalled too; there is simply no runtime to tear down first. `404` therefore means an id the gateway has no registry entry for.

**Auth:** API key (ADMIN)

**Path parameters**

| Name | Type   | Description |
| ---- | ------ | ----------- |
| `id` | string | Plugin id   |

**Response** `200`

```json
{ "success": true, "message": "Plugin chat-flow uninstalled successfully" }
```

Note: this DELETE returns `200` with a body (not the usual `204`).

**Errors:** `400` cannot uninstall (e.g. built-in) · `401` · `403` · `404` unknown id · `409` the plugin is still being restored after startup (retry shortly)

---

#### POST /api/integration/instances/:pluginId/:instanceId/redrive

Re-dispatch one bounded batch of dead-lettered inbound deliveries for an integration instance (see
**doc 25, Integration Fabric** for the DLQ and redrive design). Rows are drained fewest-attempts-first,
then oldest-first, up to `batchSize` (100) — so a row that keeps failing moves behind never-retried
rows instead of livelocking the window, while staying redrivable. `remaining` reports the DLQ depth
still outstanding after this batch.

**Auth:** API key (ADMIN) · **Scope:** session-scoped (a key restricted to `allowedSessions` may only
redrive an instance whose current `sessionScope` is inside that allowlist; out of scope and missing
instances both answer `404`, so redrive can't be used to probe other sessions). An instance that is
disabled, or deleted when the key is not session-restricted, answers `409`: ingress dispatch refuses
such an instance, so its rows could only fail again. Re-enable the instance to replay its backlog.

**Path parameters**

| Name         | Type   | Description                   |
| ------------ | ------ | ----------------------------- |
| `pluginId`   | string | Plugin id owning the instance |
| `instanceId` | string | Instance id to redrive        |

**Response** `201`

```json
{ "redriven": 3, "remaining": 0, "batchSize": 100 }
```

**Errors:** `401` · `403` key role < ADMIN · `404` a **scoped** key's instance that is missing or outside its `allowedSessions` · `409` the instance is deleted or disabled

---

#### POST /mcp

MCP Streamable-HTTP / JSON-RPC 2.0 transport that exposes the agent-tool registry over the Model Context Protocol. **This is a transport, not a REST resource** — there is no NestJS controller, no DTO, and no `{success,data}` shape.

**Auth:** API key — sent as `X-Api-Key: <key>` **or** `Authorization: Bearer <key>`. Every request, including `initialize` and `tools/list`, needs a valid key: a missing, unknown, revoked or expired key, or a key carrying `allowedIps` (there is no genuine client IP on this mount), is refused before any MCP method runs, with an HTTP error, a JSON-RPC error body (`code: -32000`, `id: null`) and `WWW-Authenticate: Bearer` on a `401`. The role, session and chat checks run **per tool call** inside the MCP layer (not by the global Nest guard) and surface in-band.

Key facts:

- **Path is exactly `POST /mcp` — no `/api` prefix.** The global `api` prefix applies only to Nest controllers; this route is mounted straight on Express.
- Gated by **`MCP_ENABLED=true`**. When off, the module/route is never mounted and `POST /mcp` returns `404`.
- MCP is **read-only by default**: only read-tier tools are registered unless you set `MCP_READONLY=false` to expose write tools. Per-key sliding-window rate limit: `MCP_RATE_LIMIT_MAX` (default 60) per `MCP_RATE_LIMIT_WINDOW_MS` (default 60000).
- Stateless transport: no session id and no standalone stream. Every POST must send `Content-Type: application/json` and `Accept: application/json, text/event-stream` (both types, even though the answer is a single message). A `200` answer is `text/event-stream` carrying one `event: message` frame whose `data:` line is the JSON-RPC envelope; a POST that carries only notifications gets `202` with an empty body. `GET /mcp` and `DELETE /mcp` answer `405` with `Allow: POST` and a JSON-RPC error body.
- Pre-auth per-IP throttle: `MCP_IP_RATE_LIMIT_MAX` (default 120) JSON-RPC messages per `MCP_IP_RATE_LIMIT_WINDOW_MS` (default 60000). Each element of a batch counts; an over-budget request gets HTTP `429` with a JSON-RPC error body and runs none of its messages, but still spends whatever budget the IP had left.

**Request body** — JSON-RPC 2.0 envelope (validated by the MCP SDK, **not** the Nest ValidationPipe)

| Field     | Type                     | Required | Description                                                                                                        |
| --------- | ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------ |
| `jsonrpc` | string                   | Yes      | Must be `"2.0"`                                                                                                    |
| `id`      | string \| number \| null | No       | Request id echoed back; null/absent for notifications                                                              |
| `method`  | string                   | Yes      | `initialize`, `tools/list`, `tools/call`, plus MCP lifecycle methods. Unknown → JSON-RPC error `-32601`            |
| `params`  | object                   | No       | Method-specific. For `tools/call`: `{ name, arguments }` where `arguments` must match the tool's zod `inputSchema` |

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "MessageSendText",
    "arguments": { "sessionId": "default", "chatId": "6281234567890@c.us", "text": "Hello from MCP" }
  }
}
```

`MessageSendText` is a write tool: it is listed only with `MCP_READONLY=false` and needs an `OPERATOR` key.

**Response** `200` — `text/event-stream`; the `data:` line of its `event: message` frame is this JSON-RPC 2.0 envelope

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": { "content": [{ "type": "text", "text": "{\"messageId\":\"…\",\"timestamp\":1719312000}" }] }
}
```

For `tools/call` the result is an MCP `CallToolResult` (`content` array of `text` or embedded base64 `resource` items — payloads over 4096 bytes become a `resource`). **Tool-level failures are returned in-band as `CallToolResult` with `isError:true`** (HTTP stays 200), including a role, session or chat refusal (`name:'ForbiddenException'`), rate-limit hits (`message:'MCP rate limit exceeded'`), and an unknown tool or arguments that fail the tool's `inputSchema` (text `MCP error -32602: …`).

**Errors:** `401` for a missing or invalid key and `403` for a key carrying `allowedIps`, each with a JSON-RPC error body · `406` when `Accept` does not list both `application/json` and `text/event-stream` · `415` for a non-JSON `Content-Type` · `400` for malformed JSON, or with a `-32700` JSON-RPC error body for JSON that is not a valid JSON-RPC message · in-band JSON-RPC errors `-32601` (unknown method) and `-32603` (params that fail the method's schema, such as a `tools/call` without `name`; the message carries the validation issues), at HTTP 200 · `500` if the key lookup fails for a reason other than a refused key, or if the transport throws before headers are sent · `404` when `MCP_ENABLED` is not `true`

> The full catalog of MCP tools (names, tiers, schemas) is documented separately — see **doc 24, MCP Integration**. This section documents only the transport endpoint.

### 6.4.12 Search

Base path `/api/search`. Cross-session full-text message search over an open `SearchProvider` contract;
the built-in database full-text provider (PostgreSQL `tsvector`/`GIN`, SQLite `FTS5`) answers by
default with zero external dependencies. Search is on by default; set `SEARCH_ENABLED=false` to remove
the route and module entirely (the index is DB-maintained regardless — see
[26 - Global Search](./26-global-search.md)). Requires at least `OPERATOR` role.

**Auth:** API key (≥ `OPERATOR`) · **Scope:** session-scoped — a scoped key's `allowedSessions` is
injected server-side from the key (never from the query), so a scoped key cannot broaden its reach; a
key with a non-empty `allowedSessions` searches only those sessions, whatever its role; a key with a
null or empty `allowedSessions` searches all sessions.

#### GET /api/search

Search messages across sessions (active search provider).

**Query parameters**

| Name        | Type                            | Required | Default | Description                                                                                                                                                                                                                                                                                                     |
| ----------- | ------------------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `q`         | string                          | **Yes**  | —       | Search term. Must be non-empty after trim; `400` for whitespace-only, over 1000 characters, or over 32 words (punctuation separates words, a symbol or emoji that stands apart counts as a word, a whitespace-separated word said again counts once). Passed to the active provider's native full-text matcher. |
| `sessionId` | string                          | No       | —       | Restrict to a single session id (intersected with the key's `allowedSessions` scope).                                                                                                                                                                                                                           |
| `chatId`    | string                          | No       | —       | Restrict to a single chat id. A phone also matches the chat's other stored forms (the other user dialect and any lid that resolves to it). For a key restricted to selected chats this must name an allowed chat (`403` otherwise), and its hits are additionally narrowed to the allowlist.                    |
| `direction` | enum (`incoming` \| `outgoing`) | No       | —       | Filter by message direction.                                                                                                                                                                                                                                                                                    |
| `type`      | string                          | No       | —       | Filter by stored message `type` (e.g. `text`, `image`, `video`). Compared against `messages.type`; not an enum validation, any string is accepted and unmatched values simply return no hits.                                                                                                                   |
| `from`      | string                          | No       | —       | Filter by sender; matches `from` or a group message's `author`. A phone also matches any lid that resolves to it.                                                                                                                                                                                               |
| `dateFrom`  | integer (epoch ms)              | No       | —       | Inclusive lower bound on `timestamp`. A non-numeric value is rejected with `400`.                                                                                                                                                                                                                               |
| `dateTo`    | integer (epoch ms)              | No       | —       | Inclusive upper bound on `timestamp`. A non-numeric value is rejected with `400`.                                                                                                                                                                                                                               |
| `limit`     | integer (≥ 1)                   | No       | `50`    | Max hits to return. Clamped to `SEARCH_LIMIT_MAX` (default `100`). A non-integer value is rejected with `400`.                                                                                                                                                                                                  |
| `offset`    | integer (≥ 0)                   | No       | `0`     | Pagination offset, at most `100000`. A non-integer or larger value is rejected with `400`.                                                                                                                                                                                                                      |

**Response** `200` — `SearchResults`

```json
{
  "hits": [
    {
      "messageId": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
      "waMessageId": "true_62812...@c.us_3A...",
      "sessionId": "a1b2c3d4-...",
      "chatId": "6281234567890@c.us",
      "body": "full original message body...",
      "snippet": "...order <mark>confirmed</mark> for tomorrow…",
      "timestamp": 1751904000000,
      "type": "text",
      "direction": "incoming",
      "from": "6281234567890@c.us",
      "score": 0.075
    }
  ],
  "total": 23,
  "tookMs": 6,
  "provider": "builtin-fts"
}
```

- `snippet` is a text excerpt with `<mark>`/`</mark>` highlight markers around the matched term (both
  dialects emit the same markers). The body text inside it is not HTML-escaped: split on the literal
  markers and render each segment as text, never the raw snippet as HTML.
- `total` is an exact count for pagination, computed lazily only when the returned page could be full.
- `provider` names which backend answered (e.g. `builtin-fts`); it is the registered `SearchProvider`
  id, so it changes when a plugin backend is selected via `SEARCH_PROVIDER`.
- `score` is optional and provider-specific (rank ordering is stable within a provider; cross-provider
  scores are not comparable). The built-in provider on SQLite leaves it out for a key restricted to
  selected sessions or chats, since it is computed over the whole index.

**Errors:** `400` empty/whitespace `q`, a non-numeric `dateFrom`/`dateTo`/`limit`/`offset`, an `offset` above `100000`, or a malformed
SQLite FTS5 query (unbalanced quote/paren, bare operator) — Postgres's `websearch_to_tsquery` is
tolerant and has no equivalent · `401` missing/invalid `X-API-Key` · `403` key role below `OPERATOR`, or a chat-restricted key
that names a `chatId` outside its allowlist or uses a plugin provider ·
`501` no search provider configured (including a non-FTS5 SQLite build, where the provider is absent) ·
`502` the active plugin provider returned an invalid result shape (not retryable until the plugin is fixed) ·
`503` the active plugin provider did not answer (worker not running, timed out, or reported a failure;
retryable). The built-in provider returns neither `502` nor `503`.

> Scoping is authoritative: a scoped API key's `allowedSessions` is applied server-side and cannot be
> overridden via the query — there is no `sessionIds` query parameter, and `SearchService` overwrites
> any session scope at the provider boundary. The same holds for the chat scope: a key restricted to
> selected chats sees only hits inside its allowlist with the built-in provider (expanded lid-aware
> before SQL filtering, so `limit`/`offset`/`total` all describe the scoped result set). A compiled
> chat scope with no valid ids returns no hits and a zero total. Plugin search refuses these keys
> before invoking the worker. There is no `chatIds` query parameter.

### 6.4.13 Profile (own account)

Manage the linked account's own profile. All routes are nested under `/api/sessions/:sessionId/profile` and require an **OPERATOR** key.

#### PUT /api/sessions/:sessionId/profile/name

Set the account display name (max 25 chars).

**Auth:** API key (OPERATOR)

```json
{ "name": "ACME Support" }
```

**Response** `200` — `{ "success": true, "message": "Profile name updated" }`

**Errors:** `400` session is not started / invalid name · `401` · `403` the whatsapp-web.js engine refused the change (the Baileys engine has no acceptance signal and answers `200`) · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### PUT /api/sessions/:sessionId/profile/status

Set the account about/status text (max 139 chars; empty string clears it).

**Auth:** API key (OPERATOR)

```json
{ "status": "We reply within one business day" }
```

**Response** `200` — `{ "success": true, "message": "Profile status updated" }`

**Errors:** `400` session is not started / status too long · `401` · `403` · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

#### PUT /api/sessions/:sessionId/profile/picture

Set the account profile picture from a URL or base64 image (same media DTO conventions as message sends, §6.3).

**Auth:** API key (OPERATOR)

```json
{ "url": "https://example.com/avatar.png" }
```

or

```json
{ "base64": "iVBORw0KGgo...", "mimetype": "image/png" }
```

**Response** `200` — `{ "success": true, "message": "Profile picture updated" }`

**Errors:** `400` neither `url` nor `base64` provided / base64 without `mimetype` / a `url` that answers non-2xx, times out or cannot be reached / an image that cannot be read (Baileys refuses an SVG) · `401` · `403` the whatsapp-web.js engine refused the change (the Baileys engine has no acceptance signal and answers `200`) · `409` conflict or engine not ready (retryable) · `413` base64 or downloaded image over the media cap (see §6.3) · `503` session not ready, dependency unavailable, or a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault (retryable)

#### DELETE /api/sessions/:sessionId/profile/picture

Remove the account profile picture, leaving the account with WhatsApp's default avatar.

**Auth:** API key (OPERATOR)

No request body.

**Response** `200` — `{ "success": true, "message": "Profile picture removed" }`

Removing a picture that is already absent is a no-op and also answers `200`, so the call is safe to
repeat.

**Errors:** `400` session is not started · `401` · `403` engine refused the removal · `409` conflict or engine not ready (retryable) · `503` session not ready or dependency unavailable (retryable)

### 6.4.14 Calls

Incoming-call management. A `call.received` webhook/socket event (§6.6) announces an incoming ringing call and carries the `callId` used below.

> **Call rejection and the call outcome events are Baileys only, and `call.received` is not reliable on whatsapp-web.js.** Both engines dispatch `call.received`, but on whatsapp-web.js it fired in a live test on 2026-09-17 and did not in one on 2026-08-10. whatsapp-web.js cannot reject a call: a rejection it sent did not stop the call from ringing in a live test, so the reject route answers `501` there and `autoRejectCalls` logs a failed auto-reject instead. The caller id in `from` may be an `@lid` privacy id on either engine; resolve it with `GET /api/sessions/:sessionId/contacts/:contactId/phone`. `POST .../calls/link` works on both engines.

#### POST /api/sessions/:sessionId/calls/link

Generate a shareable WhatsApp call link.

**Auth:** API key (OPERATOR) · **Scope:** session-scoped

**Request body** — `CreateCallLinkDto`

| Field       | Type   | Required | Constraints                                   | Description                                           |
| ----------- | ------ | -------- | --------------------------------------------- | ----------------------------------------------------- |
| `type`      | string | Yes      | `@IsIn(['audio','video'])`                    | Which kind of call the link opens                     |
| `startTime` | number | Yes      | `@IsInt`; `@Min(1)`; `@Max(8640000000000000)` | Epoch **milliseconds** the call is scheduled to start |

```json
{ "type": "video", "startTime": 1800000000000 }
```

**Response** `200`

```json
{ "link": "https://call.whatsapp.com/video/XxXxXxXxXxXxXx" }
```

> **`startTime` is required on purpose.** whatsapp-web.js generates an _event-linked_ call and has no
> notion of "no start time", so a link for right now is `Date.now()` rather than an omitted field.
> Sending it in seconds instead of milliseconds produces a link scheduled in 1970.

> **`audio` is the neutral spelling; WhatsApp's own URL path is `/voice/`.** Baileys uses `audio`,
> whatsapp-web.js uses `voice`, and the generated link reads
> `https://call.whatsapp.com/voice/…` either way.

> **A refusal is a `403`, never a `200` with an empty link.** whatsapp-web.js returns an empty string
> when generation fails and Baileys returns no token; both become `EngineRefusedError`, because a
> caller handed `{ "link": "" }` — or a bare prefix with nothing after it — would pass it to a user
> before discovering it is dead.

**Errors:** `400` session not ready, or an invalid `type`/`startTime` · `401` missing/invalid API key · `403` WhatsApp generated no link · `409` conflict or engine not ready (retryable) · `500` the whatsapp-web.js page died mid-request, or on Baileys WhatsApp did not answer within the request budget; a link may have been minted on either engine, so this is deliberately not `503`: each call mints a new link, so a client replaying a `503` would create a second one

#### POST /api/sessions/:sessionId/calls/:callId/reject

Reject a currently ringing incoming call. **Baileys only**: the whatsapp-web.js engine answers `501`. Only a live call can be rejected: the id is valid while the call rings (a short server-side cache); afterwards it expires.

**Auth:** API key (OPERATOR)

**Path parameters**

| Name      | Type   | Description                            |
| --------- | ------ | -------------------------------------- |
| sessionId | string | Session ID                             |
| callId    | string | Call ID from the `call.received` event |

**Request body** — none.

**Response** `200` — `{ "success": true }`

**Errors:** `400` session is not started · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role · `404` call not found or no longer ringing · `409` conflict or engine not ready (retryable) · `501` the whatsapp-web.js engine cannot reject a call · `503` session not ready or dependency unavailable (retryable)

> **Auto-reject per session.** Set `"config": { "autoRejectCalls": true }` when creating a session to have the server reject every incoming call automatically, and the `call.received` event is still dispatched first, so automations keep full visibility. Baileys only; see the note at the top of this section.

### 6.4.15 Media conversion (opt-in)

Server-side transcoding into the shapes WhatsApp clients actually play. Disabled by default; set
`MEDIA_CONVERSION_ENABLED=true`. The official Docker image already ships the `ffmpeg` binary these
endpoints run — on a source install it must be present, or they answer `503`. Video conversion
needs ffmpeg 4.4 or newer.

Nothing is converted implicitly: sends behave exactly as before unless a caller runs media through
these endpoints first and posts the result.

A `url` is fetched by the gateway through the egress proxy of the session in the path, exactly as a
send by URL is (see "Per-session egress proxy" under `POST /api/sessions`); `SESSION_PROXY_URL_FETCH=false`
sends it direct instead. ffmpeg never sees the URL either way: it is handed bytes this gateway already
fetched and checked.

> **Why voice conversion matters.** WhatsApp renders a playable voice-note bubble only for Ogg/Opus.
> Posting MP3 bytes to `send-audio` with `ptt: true` sends those bytes as they are, so the recipient
> gets a mic bubble that will not play. Converting first is what produces a real voice note.

#### GET /api/sessions/:sessionId/media/convert

Report whether conversion is both switched on and actually runnable here, so a client can choose
between converting server-side and converting before it sends.

**Auth:** API key

**Response** `200` — `{ "available": true }`

#### POST /api/sessions/:sessionId/media/convert/voice

Convert audio (or the audio track of a video) into a WhatsApp voice note: Ogg/Opus, mono, 48 kHz,
tuned for speech. Post the returned `base64` to `send-audio` with `ptt: true`.

**Auth:** API key (OPERATOR)

**Request body**

| Name   | Type   | Description                                             |
| ------ | ------ | ------------------------------------------------------- |
| url    | string | Public http(s) URL to fetch (server-side, SSRF-guarded) |
| base64 | string | Inline bytes. Takes precedence when both are given      |

At least one of `url` / `base64` is required; when both are given, `base64` is used.
No `mimetype` is accepted: the input format is
identified from the bytes. Both conversion routes accept single-file media inputs only: MP4, M4A,
3GP and MOV, Matroska and WebM, Ogg, MP3, WAV and W64, AAC, AC-3 and E-AC-3, FLAC, WavPack, AU,
AMR, AVI, MPEG-PS and MPEG-TS, ASF, FLV, CAF, AIFF, GIF, and raw H.264 and HEVC. Anything else,
including playlists and manifests, is refused with `400`.

**Response** `200`

```json
{ "base64": "T2dnUwACAAAA...", "mimetype": "audio/ogg; codecs=opus", "bytes": 14970 }
```

**Errors:** `400` neither field given, a `url` that is not an absolute http(s) URL or answers non-2xx, times out or cannot be reached, or ffmpeg refused the input (its reason is included) · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role · `413` media above the size cap · `503` conversion is disabled, the ffmpeg binary is not runnable, the conversion queue is saturated, or a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault

#### POST /api/sessions/:sessionId/media/convert/video

Convert video into an MP4 every WhatsApp client accepts: baseline H.264 with AAC audio, frame
fitted inside 1280x720, or 720x1280 for portrait (never upscaled), frame rate capped at 30 fps, index moved to the front so playback can start before the whole
file arrives.

**Errors:** `400` neither field given, a `url` that is not an absolute http(s) URL or answers non-2xx, times out or cannot be reached, or ffmpeg refused the input (its reason is included) · `401` missing/invalid `X-API-Key` · `403` key lacks OPERATOR role · `413` media above the size cap · `503` conversion is disabled, the ffmpeg binary is not runnable, the conversion queue is saturated, or a `url` fetch through the session's egress proxy failed before any response, the proxy or the target at fault

**Response** `200`

```json
{ "base64": "AAAAIGZ0eXBpc29t...", "mimetype": "video/mp4", "bytes": 90660 }
```

**Size note.** Both endpoints return the converted media inline, so the response is bounded by the
same `MEDIA_CONVERSION_MAX_OUTPUT_BYTES` cap (default 50 MiB) — and a client posting it onward is
still bound by `BODY_SIZE_LIMIT` (default 25 MiB) on that next request.
ffmpeg stops writing once its output passes that cap, and such a conversion fails with `400`.

### 6.4.16 Automation rules (autoreply)

Single-message autoreply rules, managed under `/api/sessions/:sessionId/automation-rules`
(`AutomationRuleController`). Every route requires an API key with **OPERATOR** role or higher.

When an inbound message arrives, the session's enabled rules are evaluated in order — creation
time, `id` as the same-second tiebreak — and the **first** rule whose `conditions` match replies
into the chat with its `replyText`. The reply goes through the ordinary send path, so send pacing
and plugin vetoes apply to it like any other outbound message. Evaluation is fire-and-forget off
the receive path and runs at most once per message (engine re-fires are deduplicated).

`conditions` uses the **webhook filter format** (`message` family — see 6.4.8): a flat AND list of
conditions over `sender`, `recipient`, `chatId`, `body`, `type`, `isGroup`, `kind`, `fromMe`, `hasMedia`, `mentions`.
Omitted or empty conditions match every inbound message except channel, broadcast-list and status
messages: a rule answers those only when its conditions include a `kind` condition that matches
them (for example `kind is channel`). A rule without one skips those chats, because a reply into a
channel the account administers is published to every follower, and anywhere else WhatsApp refuses
it. On Baileys a message the account received through a contact's broadcast list is filed under the
sender's own chat (`kind` `individual`), as WhatsApp lists it, so a rule without a `kind` condition
matches it.

Loop safety: a rule never answers the account's own (`fromMe`) messages, messages older than
5 minutes get no automated answer (so a reconnect never burst-replies the offline-queued backlog),
and `cooldownSeconds` (default 60, `0` disables, max 86400) keeps the rule quiet per chat after it
fires. Be clear about what this guarantees: the cooldown **rate-bounds** an
autoreply-vs-autoreply exchange with another bot, it does not terminate one — and
`cooldownSeconds: 0` removes that bound entirely, so disable it only for rules whose conditions
cannot match another bot's replies. The cooldown state is in-process: it resets on restart.

#### POST /api/sessions/:sessionId/automation-rules

Create a rule. **Auth:** API key (OPERATOR)

**Request body**

| Field           | Type    | Required | Description                                                                                 |
| --------------- | ------- | -------- | ------------------------------------------------------------------------------------------- |
| name            | string  | yes      | Display name, max 100 chars.                                                                |
| replyText       | string  | yes      | Reply content, max 4096 chars (the send-text limit).                                        |
| conditions      | object  | no       | Webhook-filter conditions (`message` family). Omitted = match all (see above for channels). |
| cooldownSeconds | number  | no       | Per-chat quiet period, 0–86400. Default `60`.                                               |
| enabled         | boolean | no       | Default `true`.                                                                             |

**Response** `201`

```json
{
  "id": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
  "sessionId": "0d7a2a4e-...",
  "name": "Greet new enquiries",
  "enabled": true,
  "conditions": { "conditions": [{ "field": "body", "operator": "contains", "value": "price" }] },
  "replyText": "Thanks for reaching out — we reply within the hour.",
  "cooldownSeconds": 60,
  "createdAt": "2026-08-04T10:00:00.000Z",
  "updatedAt": "2026-08-04T10:00:00.000Z"
}
```

`400`: invalid conditions (unknown field/operator, over-limit values) or over-limit text, or the session already holds `AUTOMATION_MAX_PER_SESSION` rules (default 32; `0` disables the cap). `404` when no session has this id.

#### GET /api/sessions/:sessionId/automation-rules

List the session's rules in evaluation order. **Auth:** API key (OPERATOR) · **Response** `200` — array of the shape above.

#### GET /api/sessions/:sessionId/automation-rules/:ruleId

Get one rule. **Auth:** API key (OPERATOR) · `200` or `404` when the rule does not belong to the session.

#### PUT /api/sessions/:sessionId/automation-rules/:ruleId

Partial update (any subset of the create fields). **Auth:** API key (OPERATOR) · `200`, `400` for an invalid field (same rules as create), or `404`.

#### DELETE /api/sessions/:sessionId/automation-rules/:ruleId

Delete a rule. **Auth:** API key (OPERATOR) · **Response** `204`.

### 6.4.17 Integration fabric (ingress & instances)

The operator surface of the Integration Fabric — **doc 25** holds the design (DLQ, ordering,
per-instance fairness); this section is the route reference. An ADMIN key provisions per-plugin
instances — one bound provider account, e.g. one Chatwoot inbox — and each instance gets one
ingress URL per route the plugin declares. Only a plugin that declares an ingress route AND the
`webhook:ingress` permission can have instances; any other plugin is rejected before persistence.

An instance's ingress `secret` and `verifyToken` are revealed **once** — in the create and
regenerate-secret responses — and masked (`***`) on every later read. `config` fields flagged
`secret` in the plugin's config schema stay masked even in those one-shot reveal responses.

Every instance route is session-scope aware: a key restricted to `allowedSessions` only sees
instances bound to one of its own sessions, and an out-of-scope instance answers `404` (identical
to a missing one), so the routes cannot probe which instances exist on other sessions. The DLQ
redrive route is documented with the administration routes in §6.4.11.

#### POST /api/integration/plugins/:pluginId/instances

Create an instance of an ingress-capable plugin.

**Auth:** API key (ADMIN)

**Path parameters**

| Name       | Type   | Description                                                      |
| ---------- | ------ | ---------------------------------------------------------------- |
| `pluginId` | string | Plugin to instantiate (must declare ingress + `webhook:ingress`) |

**Request body** — `CreateInstanceDto`

| Field          | Type   | Required | Constraints                                 | Description                                                                                |
| -------------- | ------ | -------- | ------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `instanceId`   | string | Yes      | `^[a-zA-Z0-9_-]{1,64}$`                     | Operator-chosen id, unique within the plugin; namespaces the ingress URL and the secret    |
| `sessionScope` | string | No       | non-empty when present, ≤ 256 chars         | Session id the instance is bound to. Omit for all sessions                                 |
| `verifyToken`  | string | No       | ≤ 512 chars                                 | Token echoed in the provider's verification handshake. Auto-generated when omitted         |
| `secret`       | string | No       | 16–512 chars                                | Ingress HMAC secret shared with the provider. Omit to auto-generate a random 64-hex secret |
| `config`       | object | No       | shape defined by the plugin's config schema | Per-instance config slice passed to the adapter                                            |

```json
{ "instanceId": "chatwoot-prod-1", "sessionScope": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a" }
```

**Response** `201` — the `InstanceView`, with the plaintext `secret` and `verifyToken` revealed
**in this response only** — store them immediately.

```json
{
  "id": "chatwoot:chatwoot-prod-1",
  "pluginId": "chatwoot",
  "instanceId": "chatwoot-prod-1",
  "sessionScope": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
  "secret": "9f2c…64-hex…",
  "verifyToken": "a1b2c3d4e5f6",
  "config": { "inboxId": 42 },
  "enabled": true,
  "createdAt": "2026-08-01T10:00:00.000Z",
  "updatedAt": "2026-08-01T10:00:00.000Z",
  "ingressUrls": [
    { "route": "chatwoot", "url": "https://wa.example.com/api/ingress/chatwoot/chatwoot-prod-1/chatwoot" }
  ]
}
```

`ingressUrls[].url` is absolute when `BASE_URL` is set, otherwise a relative path to prepend with
the deployment's own host.

**Errors:** `400` validation, or the plugin is not ingress-capable · `401` · `403` key role < ADMIN, or `sessionScope` outside the key's `allowedSessions` · `404` unknown plugin · `409` instance id already exists

#### GET /api/integration/plugins/:pluginId/instances

List the plugin's instances visible to the calling key (secrets masked).

**Auth:** API key (ADMIN)

**Response** `200` — bare array of `InstanceView`. An instance whose `sessionScope` is outside the
key's `allowedSessions` is filtered out of the list.

**Errors:** `401` · `403`

#### GET /api/integration/plugins/:pluginId/instances/:instanceId

Get one instance (secret masked).

**Auth:** API key (ADMIN)

**Path parameters**

| Name         | Type   | Description |
| ------------ | ------ | ----------- |
| `pluginId`   | string | Plugin id   |
| `instanceId` | string | Instance id |

**Response** `200` — the `InstanceView`.

**Errors:** `401` · `403` · `404` unknown instance, or one outside the key's `allowedSessions`

#### PATCH /api/integration/plugins/:pluginId/instances/:instanceId

Update an instance (secret masked in the response). Any subset of:

| Field          | Type           | Description                                                                                                                                                                                                                  |
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`      | boolean        | Whether ingress is accepted and dispatch is active                                                                                                                                                                           |
| `sessionScope` | string \| null | Re-bind to another session, or `null` to serve all sessions again (the new scope must be inside the key's `allowedSessions`, so a key restricted to sessions cannot send `null`; the old scope's binding is torn down first) |
| `config`       | object         | Replace the per-instance config slice; a masked secret is restored from the stored one, and a secret sent as `null` is removed                                                                                               |

**Auth:** API key (ADMIN)

**Response** `200` — the updated `InstanceView`.

**Errors:** `400` validation · `401` · `403` key role < ADMIN, or the new `sessionScope` outside the key's `allowedSessions` (`null` included) · `404` unknown instance, or one outside the key's scope

#### DELETE /api/integration/plugins/:pluginId/instances/:instanceId

Delete the instance and tear down its session-scope binding.

**Auth:** API key (ADMIN)

**Response** `204`

**Errors:** `401` · `403` · `404` unknown instance, or one outside the key's scope

#### POST /api/integration/plugins/:pluginId/instances/:instanceId/regenerate-secret

Rotate the instance's ingress HMAC secret.

**Auth:** API key (ADMIN)

**Response** `200` — the `InstanceView` with the **new** plaintext `secret` revealed in this
response only; the `verifyToken` is also shown (unchanged).

**Errors:** `401` · `403` · `404` unknown instance, or one outside the key's scope

#### GET /api/ingress/:pluginId/:instanceId/:path

**Errors:** `400` an encoded NUL (`%00`) in the path or query, or a JSON body that does not parse · `401` signature verification failed (missing, stale, or wrong per-instance secret) · `413` payload too large · `415` body in a content type no parser reads · `429` rate limit exceeded · `503` a route declaring a `session-alive` preflight whose bound session has no running engine, or its engine has failed

#### POST /api/ingress/:pluginId/:instanceId/:path

**Errors:** `400` an encoded NUL (`%00`) in the path or query, or a JSON body that does not parse · `401` signature verification failed (missing, stale, or wrong per-instance secret) · `413` payload too large · `415` body in a content type no parser reads · `429` rate limit exceeded · `503` a route declaring a `session-alive` preflight whose bound session has no running engine, or its engine has failed

#### PUT /api/ingress/:pluginId/:instanceId/:path

**Errors:** `400` an encoded NUL (`%00`) in the path or query, or a JSON body that does not parse · `401` signature verification failed (missing, stale, or wrong per-instance secret) · `413` payload too large · `415` body in a content type no parser reads · `429` rate limit exceeded · `503` a route declaring a `session-alive` preflight whose bound session has no running engine, or its engine has failed

#### PATCH /api/ingress/:pluginId/:instanceId/:path

**Errors:** `400` an encoded NUL (`%00`) in the path or query, or a JSON body that does not parse · `401` signature verification failed (missing, stale, or wrong per-instance secret) · `413` payload too large · `415` body in a content type no parser reads · `429` rate limit exceeded · `503` a route declaring a `session-alive` preflight whose bound session has no running engine, or its engine has failed

#### DELETE /api/ingress/:pluginId/:instanceId/:path

One route, any HTTP method: the inbound webhook endpoint an external provider delivers to. The
trailing `:path` is the plugin-declared route, a single path segment (e.g. `chatwoot`): only the
first segment selects the route and any further segments are ignored. A plugin whose manifest
declares a route with a `/` is refused at install and at boot. A delivery to a route the plugin did
not claim is a `404`.

**Auth:** **none** — `@Public` by design, because a provider cannot present an API key. What
authenticates a delivery is the per-instance HMAC signature over the exact raw body bytes (the body
is read raw, never DTO-bound, so the signed bytes reach the verifier unchanged). A `GET` on a route
that declares `challenge` is the provider's verification handshake and answers only when its
`verifyToken` matches; any other request, a `GET` included, is a signed delivery.

**Response** — the primary success path is `202`: the delivery is persisted and queued for async
plugin processing. `200` means the `GET` verification-challenge echo, or a route whose declared
`ack` sets it. A route may shape the synchronous status/body/headers the provider sees via its
declarative `ack` config (doc 25); the plugin itself always runs async. A re-delivery of an
already-persisted event is answered with the route's ack, with the same status and headers as the
first delivery, and is not enqueued again. The ack is rendered from the retry, so `{timestamp}` in
an ack body is the retry's time and `{rawBody}` is the retry's body. A retry still passes the checks
that run before dedup: while a `session-alive` route's bound session has no running engine, or its
engine has failed, it gets the `503`.

**Errors:** `400` an encoded NUL (`%00`) in the path or query, or a JSON body that does not parse · `401` signature verification failed (missing, stale, or wrong secret) · `403` `GET` verification challenge failed on a route that declares `challenge` (`verifyToken` mismatch) · `404` unknown pluginId/instanceId, or no such claimed route · `413` body over the route's `maxBodyBytes` · `415` a body in a content type no parser reads (only `application/json` and `application/x-www-form-urlencoded` are read) · `429` rate limit: the per-instance bucket (`INGRESS_INSTANCE_LIMIT`, counted only for deliveries that pass signature verification) or the per-client-IP bucket (`INGRESS_IP_LIMIT`), both per `INGRESS_INSTANCE_TTL`; the global per-IP tiers skip this route, so these two are its bounds, and `Retry-After-instance` / `Retry-After-ingress-ip` names the one that shed the request, alongside a plain `Retry-After` carrying the same delay · `503` a route declaring a `session-alive` preflight whose bound session has no running engine, or its engine has failed (a session that is starting, reconnecting, waiting for a QR scan, or disconnected but still loaded is answered with the route's ack and queued): the delivery is not persisted, so the provider's retry is treated as a new delivery, and `Retry-After` carries the delay

## 6.5 Real-time API (WebSocket)

Live events are delivered over a **Socket.IO** connection (not a raw WebSocket). The server mounts a single Socket.IO namespace, **`/events`**, on the same port as the REST API. There are no REST routes in this module.

### Connecting

Point a Socket.IO client at `<host>:2785` with path-less namespace `/events`:

```
ws://<host>:2785/events      (or wss:// behind TLS)
```

The client must authenticate during the Socket.IO handshake. Two sources are accepted, in this precedence order:

1. **Handshake `auth` (recommended)** — `io(url, { auth: { apiKey } })`. Not written to URLs or access logs.
2. **Header** — `x-api-key: <key>`.

> The former `?apiKey=<key>` query fallback was **removed** — it leaked the credential into proxy/access logs. Pass the key via the handshake `auth` field or the `x-api-key` header only.

If no key is supplied, or validation fails, the server emits an `error` message (`code: "UNAUTHORIZED"`) on the `message` event and immediately disconnects the socket. CORS for the namespace reuses the HTTP `CORS_ORIGINS` policy (dev allows any origin; production uses the allowlist).

### Protocol — client → server

All client commands are sent on the Socket.IO event named **`message`** using a single **flat** envelope:

```
{ type, sessionId, events, requestId }
```

| Field       | Type                                     | Applies to             | Description                                                                                                                              |
| ----------- | ---------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `type`      | `"subscribe" \| "unsubscribe" \| "ping"` | all                    | Command discriminator.                                                                                                                   |
| `sessionId` | string                                   | subscribe, unsubscribe | A session id, or `"*"` for all sessions.                                                                                                 |
| `events`    | string[]                                 | subscribe              | Event names to subscribe to, or `["*"]` for all.                                                                                         |
| `requestId` | string (optional)                        | all                    | Echoed back on the matching server reply for correlation. At most 128 characters; any other value is dropped and the reply carries none. |

A `ping` carries only `{ type: "ping", requestId? }`.

### Protocol — server → client

All server replies and pushed events also arrive on the Socket.IO event named **`message`**.

Command acknowledgements are **flat** and include an ISO-8601 `timestamp`:

```json
{
  "type": "subscribed",
  "sessionId": "main",
  "events": ["message.received", "session.status"],
  "requestId": "r1",
  "timestamp": "2026-06-25T10:00:00.000Z"
}
```

```json
{ "type": "unsubscribed", "sessionId": "main", "requestId": "r2", "timestamp": "2026-06-25T10:00:01.000Z" }
```

```json
{ "type": "pong", "requestId": "r3", "timestamp": "2026-06-25T10:00:02.000Z" }
```

```json
{
  "type": "error",
  "code": "FORBIDDEN_SESSION",
  "message": "API key is not authorized for this session",
  "requestId": "r1",
  "timestamp": "2026-06-25T10:00:00.000Z"
}
```

Live events are pushed as a **nested** envelope (note: `data` is under `payload`, and there is no `requestId`):

```json
{
  "type": "event",
  "timestamp": "2026-06-25T10:00:05.000Z",
  "payload": {
    "event": "message.received",
    "sessionId": "main",
    "data": { "id": "ABCD1234", "from": "6281234567890@c.us", "body": "hi", "timestamp": 1750000000 }
  }
}
```

Error `code` values include `UNAUTHORIZED`, `INVALID_MESSAGE`, `INVALID_SESSION`, `INVALID_EVENTS`, `FORBIDDEN_SESSION`, `FORBIDDEN_EVENTS`, `TOO_MANY_SUBSCRIPTIONS`, and `RATE_LIMITED`. `RATE_LIMITED` is followed by a disconnect when a handshake exceeds the per-IP connection-attempt window or the key's concurrent-socket cap; a frame over the per-key frame budget gets `RATE_LIMITED` and is dropped undispatched, and the socket stays open. A subscribe answers `INVALID_SESSION` when `sessionId` is missing, or is neither `"*"` nor a session id of at most 128 letters, digits and hyphens. An unsubscribe answers `INVALID_SESSION` when `sessionId` is missing, empty, not a string, or neither `"*"` nor such a session id. A subscribe answers `TOO_MANY_SUBSCRIPTIONS` when it would take the connection past 4096 rooms; chat-restricted subscriptions count every literal chat address form for each session and event. When the rooms already held are the cause, unsubscribe before subscribing to more. A chat-restricted frame can also need more than 4096 on its own (its address forms times the events it names); unsubscribing cannot help there: name fewer events or subscribe with `"*"`, and a list stored before the 2000-entry bound whose address forms alone exceed 4096 has to be shortened.

### Subscribable events

`events` accepts the wildcard `"*"` (all of the below except `message.receipt`, which must be named explicitly) or any of these exact names:

```
message.received
message.sent
message.ack
message.receipt
message.revoked
message.reaction
message.edited
session.status
session.qr
session.authenticated
session.disconnected
session.restriction
presence.update
event.response
group.join
group.leave
group.update
group.join_request
call.received
call.accepted
call.rejected
call.missed
status.received
```

A subscribe request whose `events` array contains no recognized name (after filtering) is rejected with `INVALID_EVENTS`. Unknown names mixed with valid ones are silently dropped; the `subscribed` reply echoes only the accepted events.

> `message.failed` and `session.reconnect_loop` are webhook-only — they are not subscribable on the socket.

### Wildcards and scoping

- **Chat-restricted keys receive only events for `allowedChats`.** Message, presence and `event.response` events require `data.chatId`; group events require `data.groupId`. Persisted phone/LID aliases are matched at delivery time, including mappings learned after subscribing. An event without a resolvable chat is omitted. Account events, calls, statuses, QR codes, and `group.join_request` (it names people outside the allowed chats, as the membership-request routes do) are omitted even with `events: ["*"]`; a request containing only account events answers `FORBIDDEN_EVENTS`. These filters apply across Redis replicas. A changed chat authorization disconnects the socket on its next subscribe.
- **`sessionId: "*"`** subscribes to every session; **`events: ["*"]`** subscribes to every subscribable event except `message.receipt`, whose per-recipient volume makes it opt-in by name. They combine (e.g. `"*"` + `["*"]` = every wildcard-covered event of every session).
- The API key is **re-validated on every `subscribe`** (not just at connect), so a key revoked or expired mid-connection is caught — the server replies `UNAUTHORIZED` and disconnects.
- **Per-key session scope is enforced** against the fresh key: a key restricted via `allowedSessions` may NOT subscribe to `"*"` and may NOT subscribe to a session outside its allowlist — either is rejected with `FORBIDDEN_SESSION`. An unrestricted key (no `allowedSessions`) may subscribe to anything, including `"*"`.
- **Live sockets are re-validated against the database once a minute**, with no client activity required. A socket carries the key as it stood when it connected, and rooms joined earlier are never revisited, so that snapshot is what the sweep compares against the current row, along with any later `subscribe` whose key no longer matched it (what that `subscribe` granted outlives the change, so putting the row back does not spare the socket). It closes the key's sockets with an `UNAUTHORIZED` frame naming the cause: `API key has been deleted`, `API key has been revoked`, `API key has expired`, or `API key authorization changed; please reconnect` when `role`, `allowedIps`, `allowedSessions`, `allowedChats` or `expiresAt` moved. A change made through this API still evicts synchronously in the same request; the sweep is what catches an expiry, a change written straight to this node's main database, or one committed in the instant a socket was connecting. API keys are per node (see [13 - Horizontal Scaling](./13-horizontal-scaling.md)), so a change made on another node never reaches this node's sockets; repeat it on every node. A rename, and the usage counters the gateway itself writes, evict nobody. Treat these frames as "reconnect and resubscribe", not as fatal.
- **`session.qr` requires the OPERATOR role**, matching `GET /api/sessions/{sessionId}/qr`. A VIEWER key may still subscribe to it, by name or through a wildcard, but the QR is never delivered to its sockets; every other event is. The role is read from the key re-validated on each `subscribe`, so a key narrowed to VIEWER stops receiving the QR from its next `subscribe`. Between subscribes the QR gate rests on the same snapshot as every other event: a key demoted right after a `subscribe` keeps receiving the QR until its sockets are evicted, which is immediate on the node processing the change; other nodes keep their own copy of the key and are unaffected until the change is repeated there.

### Example (socket.io-client)

```js
import { io } from 'socket.io-client';

const socket = io('ws://localhost:2785/events', {
  auth: { apiKey: process.env.OPENWA_API_KEY },
});

socket.on('connect', () => {
  socket.emit('message', {
    type: 'subscribe',
    sessionId: 'main',
    events: ['message.received', 'message.ack', 'session.status'],
    requestId: 'sub-1',
  });
});

socket.on('message', msg => {
  if (msg.type === 'event') {
    console.log(`[${msg.payload.event}]`, msg.payload.sessionId, msg.payload.data);
  } else {
    console.log('reply:', msg); // subscribed | unsubscribed | pong | error
  }
});
```

## 6.6 Webhook Events & Delivery Semantics

Every registered webhook receives an HTTP `POST` with a JSON body of this shape:

```json
{
  "event": "message.received",
  "timestamp": "2026-02-02T10:00:00.000Z",
  "sessionId": "my-session",
  "idempotencyKey": "msg_my-session_3EB0ABC123_f1e2d3c4-b5a6-7890-1234-567890abcdef",
  "deliveryId": "dlv_550e8400-e29b-41d4-a716-446655440000",
  "data": {}
}
```

`event`, `timestamp` (ISO-8601 dispatch time), `sessionId`, `idempotencyKey`, and `deliveryId` are always present; `data` holds the event-specific payload. The same values are mirrored into request headers (below). The HMAC `signature` is **not** in the body — it travels in the `X-OpenWA-Signature` header.

### Event catalog

These are the events OpenWA actually emits. A webhook is registered with an `events` list; an event is delivered to a webhook when its `events` array includes the event name or `"*"` (`"*"` excludes `message.receipt`, which is delivered only when named).

| Event                                             | When it fires                                                                                                                                                                                                                         | `data` payload sketch                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message.received`                                | An inbound message arrives                                                                                                                                                                                                            | The full message object: `id`, `from`, `to`, `body`, `type`, `timestamp` (epoch **seconds**), `isGroup`, `kind` (user-facing chat discriminator of `chatId` — `individual\|group\|channel\|status\|broadcast\|unknown`), `fromMe`, `chatId`, `media{mimetype,filename?,data?,omitted?,sizeBytes?}` (media messages only), `contact{…}` (plus optional `senderPhone` for `@lid` senders, `order{orderId,token?}` / `product{productId,…}` on `order` / `product` messages, and `poll{name,options,allowMultipleAnswers}` on a poll creation message; on Baileys also `buttons:[{id,text}]` on a WhatsApp Business prompt that offers choices, and `button{id,text?}` when the sender tapped one) |
| `message.sent`                                    | An outbound message is created/sent from this session                                                                                                                                                                                 | Same message object shape as `message.received`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `message.ack`                                     | A delivery/read receipt updates an outbound message. On Baileys only 1:1 receipts advance it; opt-in `message.receipt` reports group and status receipts per participant and leaves the message at `sent`, see below                  | `{ id, messageId, status, ack, chatId? }` — `status` is the canonical state (`pending`/`sent`/`delivered`/`read`/`failed`); `ack` is the deprecated legacy integer derived from it; `chatId` is the conversation the acked message belongs to, present whenever the engine's update names the chat (both engines do on the live path)                                                                                                                                                                                                                                                                                                                                                           |
| `message.receipt`                                 | One recipient receives, reads or plays a group or status message the account sent (**Baileys only**; not included in `*`)                                                                                                             | `{ id, messageId, chatId, participant, status, timestamp }` ; one event per recipient and status; `status` is `delivered`/`read`/`played`, `timestamp` is epoch **seconds**. Receipts from the account's own linked devices are not reported. On Baileys 7.0.0-rc14 a played voice note reports `read`. Nothing is stored and `message.ack` is not advanced by it                                                                                                                                                                                                                                                                                                                               |
| `message.failed`                                  | A receipt resolves to `failed` (dispatched in addition to `message.ack`)                                                                                                                                                              | `{ id, messageId, status: "failed", ack: -1, chatId? }`; `chatId` follows the same rules as `message.ack`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `message.revoked`                                 | A message is deleted/recalled                                                                                                                                                                                                         | `{ id, revokedId?, chatId, from, to, type: "revoked", body: "", timestamp }` — **reconcile on `revokedId`** (the original deleted message's id), falling back to `id`. On whatsapp-web.js `id` is the _revocation notification_ (a distinct message that won't match a stored id) and `revokedId` may be absent when the original isn't cached locally; on Baileys the two coincide                                                                                                                                                                                                                                                                                                             |
| `message.reaction`                                | A reaction is added, changed, or removed                                                                                                                                                                                              | `{ messageId, chatId, reaction, senderId, reactions? }` — `reactions` is the post-apply `{ senderId: emoji }` snapshot, omitted when the gateway holds no stored copy of the message to compute it from (an ephemeral message, or one predating the session going live); treat it as unknown rather than empty and keep the map you already hold. `reaction` is empty when removed                                                                                                                                                                                                                                                                                                              |
| `message.edited`                                  | A message body or media caption is edited                                                                                                                                                                                             | `{ messageId, chatId, body, senderId, from, to, fromMe, isGroup, type, hasMedia, author?, mentionedIds?, timestamp }` — `messageId` is the original message id, `body` is the latest text/caption, and `timestamp` is the edit occurrence time in epoch **seconds** (not the original creation time)                                                                                                                                                                                                                                                                                                                                                                                            |
| `session.qr`                                      | A new pairing QR is generated                                                                                                                                                                                                         | `{ sessionId, qr }` — `qr` is a **PNG data URL** (`data:image/png;base64,…`), the same rendered value `GET /api/sessions/{sessionId}/qr` returns as `qrCode`, not the raw `2@…` linking ref. Render it directly in an `<img src>`; the raw ref never leaves the engine adapter                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `session.authenticated`                           | The session pairs and becomes ready                                                                                                                                                                                                   | `{ sessionId, phone, pushName }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `session.disconnected`                            | The session disconnects on the engine or WhatsApp side (drop, conflict, or a phone-initiated unlink). Not fired for API-initiated stop/logout/delete — those are acknowledged by the API response and the `session.status` transition | `{ sessionId, reason }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `session.reconnect_loop`                          | Every 5th consecutive reconnect attempt is scheduled (attempt 5, 10, 15, …) — the session is failing to come back up                                                                                                                  | `{ sessionId, attempts, nextDelayMs }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `presence.update`                                 | A subscribed chat's presence changed — someone came online, started typing, or stopped. Only actual CHANGES are dispatched: WhatsApp repeats itself freely, and every repeat would otherwise be a delivery                            | `{ sessionId, chatId, participants: [{ id, state, lastSeen? }], groupOnlineCount? }` — `state` is `available`/`unavailable`/`composing`/`recording`/`paused`; `lastSeen` is epoch **seconds** and absent when the contact hides it. Requires `POST .../presence/subscribe` first, and Baileys — whatsapp-web.js cannot observe presence                                                                                                                                                                                                                                                                                                                                                         |
| `event.response`                                  | A guest answered a WhatsApp event invitation (going, not going, maybe). **Baileys only**; see the payload note below                                                                                                                  | `{ sessionId, eventMessageId, chatId, responderId, contact?, fromMe, response, extraGuestCount?, eventName?, responseMessageId, timestamp }`. `response` is `going`/`not_going`/`maybe`/`unknown`; `timestamp` is epoch **seconds** on the guest's clock. Order a guest's answers by `timestamp`, not by delivery                                                                                                                                                                                                                                                                                                                                                                               |
| `session.restriction`                             | WhatsApp places a restriction on the account, or lifts one. Deduped: an unchanged restriction is not re-announced, and a lift is only sent when one was in force                                                                      | `{ sessionId, active, kind, code, expiresAt }` — `active` is `false` for a lift and `kind`/`code` then describe the restriction that ended; `expiresAt` is an ISO timestamp or `null`. See `restriction` on the session response for the `kind` values                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `session.status`                                  | The session status transitions                                                                                                                                                                                                        | `{ sessionId, status }` where `status` is one of `created` / `initializing` / `qr_ready` / `authenticating` / `ready` / `disconnected` / `action_required` / `failed`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `group.join`                                      | Participant(s) are added to or join a group this session is in                                                                                                                                                                        | `{ groupId, actorId?, participantIds, timestamp }`. `actorId` is the admin/inviter when known. **Baileys**: a session added to or joining a group reports only its own id in `participantIds` (members added with it are not listed)                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `group.leave`                                     | Participant(s) leave or are removed from a group                                                                                                                                                                                      | `{ groupId, actorId?, participantIds, timestamp }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `group.update`                                    | Group metadata changes (subject, description, announce/locked settings)                                                                                                                                                               | `{ groupId, actorId?, participantIds, changes?, timestamp }` — `changes` carries only the fields that changed: `subject?`, `description?`, `announce?`, `locked?`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `group.join_request`                              | Someone asked to join a group this session administers (join-approval mode on)                                                                                                                                                        | `{ groupId, actorId?, participantIds, timestamp }` — `participantIds` are the users asking to join; `actorId` is who created the request when the engine reports one (differs from the requester on a non-admin add). Act on it via `GET/POST .../groups/:groupId/membership-requests[...]` **Baileys caveat**: the library (7.0.0-rc14) emits this only for non-admin-add requests — an invite-link self-request may produce no event there (upstream TODO); the membership-requests list endpoint still reports it.                                                                                                                                                                           |
| `call.received`                                   | An incoming voice/video call starts ringing. Dispatched by both engines, but **not reliable on whatsapp-web.js**: it fired in a live test on 2026-09-17 and did not in one on 2026-08-10                                              | `{ callId, from, isVideo, isGroup, timestamp }` — `callId` is the id to pass to `POST /sessions/:sessionId/calls/:callId/reject` (Baileys only). `from` may be an `@lid` privacy id on either engine; resolve it with `GET /api/sessions/:sessionId/contacts/:contactId/phone`                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `call.accepted` / `call.rejected` / `call.missed` | A ringing call ended, whether answered, declined, or never picked up. **Baileys only**: whatsapp-web.js emits no call-outcome event                                                                                                   | `{ sessionId, callId, from, outcome, isVideo, isGroup, timestamp }`. `callId` matches the `call.received` that preceded it, so the pair can be correlated. The engines report _what_ happened, never _who_ did it: an accept can come from any linked device. An outcome is only sent for a call this session saw ring, and offline-replayed signalling for calls that ended while disconnected is dropped. WhatsApp's `terminate` is deliberately unmapped: it covers both a caller hanging up before answer and either side ending an answered call, with nothing to tell them apart                                                                                                          |
| `status.received`                                 | A contact posts a status/story (opt-in — see below)                                                                                                                                                                                   | `{ sessionId, statusId, contact: { id, name?, pushName? }, type, caption?, hasMedia, mediaOmitted, omitReason?, postedAt, expiresAt }` — `statusId` is the store's `id` (usable with the status endpoints below); `postedAt`/`expiresAt` are epoch **milliseconds** (unlike the epoch-seconds convention for message timestamps), matching the `GET /status` store's own `Date`-backed fields                                                                                                                                                                                                                                                                                                   |

> **`message.ack` on Baileys.** An own group or status message stays at `sent` unless the server rejects it, which still arrives as `failed`. The event is not limited to outbound messages: when the account's own phone reads a 1:1 chat, the messages received there are acked as `read` too, with no `fromMe` in the payload, and their stored copy moves to `read`.

> **`status.received` is opt-in and carries no media blob.** `status.received` is only delivered to a webhook whose `events` list explicitly includes `"status.received"` (or `"*"`) — registering for other events does not implicitly subscribe you to it. The payload never embeds media bytes: when `hasMedia` is `true`, fetch the file separately via `GET /api/sessions/:sessionId/status/:statusId/media`. Your own posted statuses never trigger this event — only inbound stories from contacts (an own-send echo is dropped before ingest).

> **`STORE_EPHEMERAL_MESSAGES=false` affects `message.received`.** When `STORE_EPHEMERAL_MESSAGES` is set to `false`, incoming disappearing messages (those with `ephemeralDuration > 0`) are **not** persisted nor dispatched — no DB insert, no webhook delivery, and no websocket event. Downstream consumers and the dashboard both stop seeing them. Default is `true` (backward compatible — store and dispatch everything).

> **`senderPhone` on `message.received` is opt-in (`RESOLVE_LID_TO_PHONE`).** When a sender is identified by a WhatsApp privacy id (`…@lid`) rather than a phone number, setting `RESOLVE_LID_TO_PHONE=true` attaches a best-effort `senderPhone` — MSISDN digits, or `null` when the engine cannot map the id — before dispatch, so the webhook and the websocket event both carry it in the same pass. Default is **off**, and while it is off the field is absent from every payload: resolving costs a per-sender lookup (cached). Only inbound privacy-id senders are resolved — a sender that already is a phone number needs no lookup, and own-sends are skipped. The on-demand `GET /api/sessions/:sessionId/contacts/:contactId/phone` works regardless of this flag.

> **`contact` on `message.received` is minimal unless you opt in (`WEBHOOK_CONTACT_DETAILS`).** By default the payload carries only `contact { name?, pushName? }`. Setting `WEBHOOK_CONTACT_DETAILS=true` adds `id`, `number`, `shortName`, `type`, `isMyContact`, `isWAContact`, `isBusiness`, `isEnterprise`, `verifiedName`, `verifiedLevel`, `isBlocked` and `labels`, each present only when the engine has a value for it. Every field is copied from the contact record the engine has already loaded for the message, so opting in costs no extra WhatsApp lookup and cannot add rate-limiting pressure. The same enrichment reaches the websocket event, since both are dispatched from one payload. `contact` itself is omitted entirely when the engine has no value for any of its fields, so treat the whole object as optional rather than just its members. **whatsapp-web.js only** — the Baileys path emits `contact { pushName }` when a push name is available, and never reads this flag.

> **Large media is not inlined into webhook payloads.** A `media` blob whose decoded size exceeds `WEBHOOK_MEDIA_INLINE_MAX_BYTES` (default **1 MiB**; `0` = never inline) is replaced — before the payload is fanned out to your webhook — with the marker form `media: { mimetype, filename?, omitted: true, sizeBytes }`, the same shape the engine emits for capped inbound media. Media at or under the cap stays inline unchanged. Additionally, if the serialized body still exceeds `WEBHOOK_MAX_PAYLOAD_BYTES` (default **1 MiB**) after `webhook:before` hooks ran, any remaining inline media is shed the same way so the event is still delivered; only a payload that is over budget _without_ inline media is dropped (recorded in `GET /api/webhooks/delivery-failures`). Because shedding happens before enqueue, queued and failed BullMQ jobs in Redis never carry the blob either — failed-job retention is bounded by the queue's `removeOnComplete`/`removeOnFail` windows (1h/1000 completed, 24h/5000 failed). The two limits compound: `WEBHOOK_MAX_PAYLOAD_BYTES` is measured on the serialized JSON body after `WEBHOOK_MEDIA_INLINE_MAX_BYTES` has been applied, and base64 inflates media by a third, so at the defaults media above roughly **768 KiB** reaches your webhook as the omitted marker. To inline larger media, raise both together and keep the payload limit at least 4/3 of the inline limit plus room for the rest of the event (for example `5242880` inline with `7340032` payload). The **WebSocket** `message.received` / `message.sent` events apply only the inline cap, with the same marker: an over-cap blob is never broadcast to subscribed sockets (or, with the Redis adapter enabled, replicated across every replica's pub/sub link). On either sink, media the engine downloaded stays retrievable from [`GET /api/sessions/:sessionId/messages/:chatId/:messageId/media`](#get-apisessionssessionidmessageschatidmessageidmedia), which returns `404` when nothing was stored.

> **`event.response` reports RSVPs to WhatsApp events (Baileys only).** A WhatsApp event (the calendar-style invitation created from the attach menu, with "Going" / "Not going" / "Maybe" buttons) arrives as an ordinary `message.received` when it is posted. Each answer to it arrives as `event.response`:
>
> ```json
> {
>   "sessionId": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
>   "eventMessageId": "3EB0C767D26B8A3F1A2B",
>   "chatId": "120363000000000000@g.us",
>   "responderId": "6281111111111@c.us",
>   "fromMe": false,
>   "response": "going",
>   "extraGuestCount": 1,
>   "eventName": "Game night",
>   "responseMessageId": "3EB0D1E2F3A4B5C6D7E8",
>   "timestamp": 1786000000,
>   "contact": { "name": "Ada Lovelace", "pushName": "Ada" }
> }
> ```
>
> `response` is one of `going`, `not_going`, `maybe` or `unknown`. A guest who changes their answer sends a new RSVP with a new `responseMessageId`, so the event with the latest `timestamp` per `responderId` is the current answer. Order by `timestamp` rather than by arrival: delivery order is not guaranteed (see the note below). `timestamp` has one-second resolution, so two answers of one guest inside the same second cannot be told apart by it. `extraGuestCount` is present only when WhatsApp sends it, and `eventName` only when the event's creation message can still be read from the message store; it is the name the event was created with, and a later rename is not reflected. `contact` names the responder like `message.received` names its sender: `name` from the account's addressbook, `pushName` from their own profile, each present only when known, and the whole field absent when neither is. It is read from the contacts the session already holds, with no WhatsApp lookup per answer, so a responder the session knows nothing about yet arrives without one. Webhook `filters` define no fields for this event, so a webhook's conditions do not narrow it: scope RSVPs to a chat by checking `chatId` on receipt.
>
> WhatsApp encrypts every RSVP with a key derived from the event creation message, so only answers to events **this session received and still holds in its message store** can be reported: an event posted before the number was linked, or one evicted past `BAILEYS_MESSAGE_STORE_LIMIT`, yields no `event.response`. This covers events in groups and in 1:1 chats, with one known gap: in a 1:1 chat, an answer from the person who posted the event, whether the other person or this account answering its own event, cannot be decrypted (Baileys derives the event's creator from the chat partner's side of the chat) and is not reported. Answers given while the session was offline are reported when WhatsApp replays them on reconnect. Baileys merges the updates it receives within one short buffer window per event, so several answers to one event that arrive in the same window, even from different guests (the whole replay after a reconnect, or answers a fraction of a second apart live), arrive as the last one only. whatsapp-web.js accepts the subscription and never fires the event.

> WhatsApp also sends a contact's edit, and a reaction in some chats, encrypted under a secret carried by the message it changes. On Baileys they are read with the stored copy of that message, so `message.edited` fires for a message **this session received** and `message.reaction` for one it **received or sent**, while its message store still holds it; an edit or reaction of one the store no longer holds (evicted past `BAILEYS_MESSAGE_STORE_LIMIT`, or from before the number was linked) is not delivered. An edit the account makes from its own phone to its own message is applied under the account's own id the same way, whether it arrives as a plain or as an encrypted edit. whatsapp-web.js gets both already decrypted from WhatsApp Web.

> There is **no** `contact.update` event. `presence.update` is emitted only for chats you have subscribed to (see the presence-subscription endpoint); call outcomes are reported as `call.accepted`, `call.rejected` and `call.missed` alongside `call.received`.

> **Group-event timing caveat (Baileys).** Membership/metadata changes that occurred while a Baileys session was offline are replayed by WhatsApp on reconnect and are dispatched like live events — but the engine does not forward their original occurrence time, so they carry the **receipt time** as `timestamp`. Treat `group.*` payloads as change notifications rather than a precise clock; stale offline _calls_ are never emitted this way (they are filtered by the `offline` flag).

### Delivery semantics — at-least-once

Webhook delivery is **at-least-once**. A consumer can legitimately receive the same logical event more than once because:

- The underlying WhatsApp engine can re-fire an event for a single message.
- A failed delivery (non-2xx response, timeout, or network error) is retried.

**Crash boundary.** For an ordinary dispatch, a successfully written outbox record retains the pre-hook event data until delivery succeeds, the subscription becomes ineligible, or the terminal failure is stored. Queued records keep this copy even after enqueue; the worker retires it only after one of those outcomes. If failure recording fails, the copy remains eligible for recovery. The bounded sweep (`WEBHOOK_RECONCILE_INTERVAL_MS`, default 60s) checks the stored job before replaying, leaves active jobs alone, retires the row of a completed job without a replay for as long as the queue still keeps that job (completed jobs are kept for an hour, at most the 1000 most recent), and reuses the stored `X-OpenWA-Idempotency-Key`. If Redis cannot confirm the job state, replay waits. A missing or failed job, or a stranded direct delivery, can be replayed within `WEBHOOK_RECONCILE_MAX_ATTEMPTS`; a completed job already evicted from the queue reads as missing. A replay sent directly (the queue off, or its enqueue failed) that exhausts its retries stores its terminal failure row but keeps the outbox copy pending, so later sweeps replay it until that budget is spent; exhausting it retires the outbox only after terminal failure recording succeeds. Once a replay to a webhook has failed, that webhook's remaining rows in the same sweep make one direct attempt each, so a receiver that is down does not hold the sweep for a full retry schedule per row. Receiver deduplication remains necessary because a crash after receiver processing can cause another POST.

Outbox creation is best effort, and message persistence and outbox insertion are separate transactions. A write failure or crash between them can still lose the webhook delivery. Failure records with zero attempts are excluded from operator redrive. Capacity and shutdown rejections can still be recovered from an unsettled outbox record; shutdown during retry backoff may follow earlier POSTs. The direct dispatcher leaves its local in-flight work alone during reconciliation. Graceful shutdown drains current queue jobs for at most 10 s; direct sends, an operator redrive replay in flight, and an outbox replay in flight with its plugin hooks, get up to `WEBHOOK_SHUTDOWN_DRAIN_MS` (default 5s). That replay sends at most one more POST, bounded by `WEBHOOK_TIMEOUT`, and no further row is replayed; the next start replays what it left pending.

Before downgrading to a version that does not recognize the `queued` outbox state, drain or recover those records and take a backup. Its cleanup may otherwise treat an unsettled queued record as settled. Reverting the terminal-identity migration removes its index but does not restore historical duplicates.

**Design your handler to be idempotent**, keyed on the `X-OpenWA-Idempotency-Key` header (see below). As a server-side safety net, OpenWA de-duplicates inbound `message.received` before dispatch (a re-fired event for an already-persisted message is dropped), so one webhook normally sees each inbound message once — but this is best-effort defense-in-depth and does not remove the need for consumer-side idempotency.

### HMAC signature

When a webhook is registered with a `secret`, each delivery carries:

```
X-OpenWA-Signature: sha256=<hex>
```

The hex is an HMAC-SHA256 computed over the **raw JSON request body** (exactly the bytes sent) using the webhook's `secret`. Verify by recomputing over the raw body — not over a re-serialized parse — and compare in constant time:

```javascript
const crypto = require('crypto');

function verify(rawBody, header, secret) {
  if (typeof header !== 'string') return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  // timingSafeEqual throws on a length mismatch, so a short or forged header must return false first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Complete Express and FastAPI receivers are in [`examples/webhook-signature-verification.md`](examples/webhook-signature-verification.md). Since SDK 0.5.1, the SDKs ship this check: `verifyWebhookSignature` (JavaScript), `verify_webhook_signature` (Python), `VerifyWebhookSignature` (Go), `WebhookSignature.verify` (Java) and `WebhookSignature::verify` (PHP).

If no `secret` is configured the `X-OpenWA-Signature` header is omitted entirely.

### Idempotency & delivery headers

Every delivery includes:

| Header                     | Meaning                                                                                                      |
| -------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `X-OpenWA-Event`           | The event name (mirrors `event`)                                                                             |
| `X-OpenWA-Idempotency-Key` | Content-derived key; **stable across retries** of the same occurrence — dedupe on this                       |
| `X-OpenWA-Delivery-Id`     | `dlv_<uuid>` per webhook per event, **same on every automatic retry**; replays get a new one. Not for dedup. |
| `X-OpenWA-Retry-Count`     | Retry attempt number (`0` = first attempt)                                                                   |
| `X-OpenWA-Signature`       | HMAC (only when a secret is set)                                                                             |

**Idempotency key derivation.** The key is content-derived so duplicates of the same logical event collapse to one value. Every key below is then suffixed with `_{webhookId}`, so two webhooks subscribed to the same event never share a key:

- `message.received` / `message.sent`: `msg_{sessionId}_{messageId}`
- `message.ack`: `ack_{sessionId}_{messageId}_{status}`
- `message.receipt`: `receipt_{sessionId}_{messageId}_{participant}_{status}`
- `message.failed`: `failed_{sessionId}_{messageId}_{status}`
- `message.revoked`: `rev_{sessionId}_{messageId}`
- `message.edited`: `edit_{sessionId}_{messageId}_{occurredAt}`
- `message.reaction`: `react_{sessionId}_{messageId}_{senderId}_{occurredAt}`
- `session.qr`: `qr_{sessionId}_{hash(qr)}`
- `session.status`: `sess_{sessionId}_{status}_{occurredAt}`
- `session.authenticated`: `auth_{sessionId}_{hash(data)}_{occurredAt}`
- `session.disconnected`: `disc_{sessionId}_{hash(reason)}_{occurredAt}`
- `session.reconnect_loop`: `loop_{sessionId}_{attempts}_{occurredAt}`
- `group.join` / `group.leave`: `grp_{groupId}_{hash(participantIds)}_{join|leave}_{occurredAt}`
- `group.update`: `grp_{groupId}_update_{hash(changes)}_{occurredAt}`
- `group.join_request`: `grp_{groupId}_{hash(participantIds)}_join_request_{occurredAt}` (salted like `group.join` — a rejected user can legitimately ask again)
- `call.received`: `call_{sessionId}_{callId}` (a call id is unique per call, so no `occurredAt` salt)
- `call.accepted` / `call.rejected` / `call.missed`: `call_{sessionId}_{callId}_{outcome}` (each call ends once, so no `occurredAt` salt)
- `session.restriction`: `restr_{sessionId}_{kind}_{active}_{occurredAt}`
- `presence.update`: `pres_{sessionId}_{chatId}_{hash(participants)}_{occurredAt}`
- `event.response`: `rsvp_{sessionId}_{responseMessageId}` (every answer is its own message with its own id, so no `occurredAt` salt)
- any other event (for example `status.received`): `evt_{event with . replaced by _}_{hash(data)}`

Recurring lifecycle events (and `message.reaction` / `message.edited` / `presence.update`) carry the same content across occurrences — the same phone on every reconnect, a constant disconnect reason, a re-applied emoji, or editing the same message multiple times — so they are salted with an `occurredAt` timestamp captured **once per dispatch and reused across that dispatch's retries**. This gives distinct occurrences distinct keys while keeping retries of one occurrence stable. Message keys are scoped by `sessionId` because WhatsApp message ids are unique per account, not globally.

### Retries with exponential backoff

When the queue is enabled, a non-2xx response, timeout (`WEBHOOK_TIMEOUT`, default `10000` ms), or network error schedules a retry. The number of attempts comes from the webhook's `retryCount` (default `3`) and the delay grows **exponentially** from a base of `WEBHOOK_RETRY_DELAY` (default `5000` ms). Each retry reuses the same `idempotencyKey` and increments `X-OpenWA-Retry-Count`. If Redis/BullMQ rejects the initial enqueue, OpenWA logs a `webhook:error` hook event and falls back to direct delivery with the same inline retry budget. When the queue is disabled, delivery is direct with the same retry budget and the same exponential backoff applied inline. Every retry, queued or direct, re-reads the webhook first: a webhook deleted, disabled or unsubscribed from the event since the dispatch gets no further attempt and no delivery-failure row, and a changed URL, secret or header map applies from the next attempt. With the queue disabled, a delivery that waited behind the failing-webhook cap (`WEBHOOK_DEGRADED_SESSION_CONCURRENCY`) re-reads the webhook the same way before its first attempt; if that read fails, the attempt uses the webhook as loaded at dispatch.

### SSRF guard on registration

Webhook URLs are validated at **registration time**, not just at delivery. When SSRF protection is enabled (the default), creating a webhook, or updating one to a different URL, with a URL that resolves to a private/internal/loopback address is rejected synchronously with `400 Bad Request` instead of failing silently later at delivery. The `SSRF_ALLOWED_HOSTS` escape-hatch applies equally to registration and delivery. Independently of the SSRF flag, a URL embedding credentials (`https://user:pass@host/hook`) is rejected with `400` — such credentials would otherwise be persisted and echoed into delivery logs and dead-letter rows. An update that re-sends the stored URL unchanged skips both checks, so a webhook whose host became blocked can still be deactivated or re-filtered; delivery still applies the SSRF guard. Operator-supplied custom headers that target reserved names (`Content-Type`, `User-Agent` or any `X-OpenWA-*`, in any letter case) are stripped, so a webhook config cannot forge the signature, event, or idempotency headers. A header map with two names that differ only in case is rejected with `400`. The connection-level names the HTTP client owns (`Connection`, `Content-Length`, `Expect`, `Keep-Alive`, `TE`, `Trailer`, `Transfer-Encoding`, `Upgrade`) are stripped as well, since setting one would fail or corrupt the delivery.

---

--- METRICS ---
- Files Extracted: 17
- Estimated Token Budget: ~218286 tokens
- Canonical Reference: https://codewiki.google/github.com/rmyndharis/OpenWA
