qwen_2.5_model / docs /06-api-specification.md
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
|
Raw
History Blame Contribute Delete
179 kB
# 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; the WebSocket (Socket.IO) handshake β€” see Β§6.5 Real-time API β€” accepts it 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.
The metrics endpoint is the lone exception to the API-key scheme: it authenticates with `Authorization: Bearer <METRICS_TOKEN>` instead of `X-API-Key`.
### 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 `401` (not `403`) even if the role would otherwise allow it.
### 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 | failed`.
### Error Response
Errors use the NestJS default shape. The HTTP status is on the status line and mirrored in `statusCode`; there is no application-specific `code` field:
```json
{
"statusCode": 404,
"message": "Session 'my-session' not found",
"error": "Not Found"
}
```
Validation failures (`statusCode: 400`) return `message` as an **array** of field-level strings. A global `ValidationPipe` runs with `whitelist` + `forbidNonWhitelisted`, so any request-body field not declared on the DTO is rejected with `400`.
### General Error Codes
| HTTP Status | Meaning | When |
| --- | --- | --- |
| `400` | Bad Request | DTO validation failed, unknown body field, 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), a blocked source IP, or a key used outside its `allowedSessions` scope |
| `403` | Forbidden | A valid, in-scope key whose **role** is below the route's `@RequireRole` requirement |
| `404` | Not Found | The addressed resource (session, message, webhook, batch, …) does not exist |
| `409` | Conflict | A uniqueness constraint was violated (e.g. duplicate name) |
| `413` | Payload Too Large | Base64 media exceeds the media byte cap (see Β§6.3) |
| `500` | Internal Server Error | Send failed at the WhatsApp engine or an unexpected server error |
### 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 | 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 yields `400` |
| `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 | 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`.
```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. A non-positive or garbage override falls back to the default.
### 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` and the media send routes accept an optional `mentions` array of WIDs (`<phone>@c.us`) to tag participants β€” most useful in groups. 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. **WhatsApp does not reject an unregistered recipient synchronously.** A message to a number that is not on WhatsApp still returns `201` with a valid `messageId`. Whether it later delivers, stalls, or is reported as an error reaches you asynchronously, if at all.
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 }`; 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`. Read routes return data shaped by `SessionResponseDto.fromEntity` (via `transformSession`), which **strips** `config`, `proxyUrl`, and `proxyType` and renames the entity field `lastActiveAt` to `lastActive`. The one exception is `POST /api/sessions`, which returns the **raw `Session` entity** and therefore *does* expose `config`/`proxyUrl`/`proxyType`/`lastActiveAt`. Session `status` wire values are lowercase: `created | initializing | qr_ready | authenticating | ready | disconnected | failed`.
#### GET /api/sessions
List all sessions, scoped to the API key's `allowedSessions`, ordered `createdAt` DESC.
**Auth:** API key Β· **Scope:** session-scoped (a scoped key sees only its `allowedSessions`; an ADMIN / null-allowlist key 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`. |
**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
}
]
```
`lastError` is non-null only when `status` is `failed`. `config`/`proxyUrl`/`proxyType` are not present (stripped by `fromEntity`).
**Errors:** `401` missing/invalid `X-API-Key`
#### GET /api/sessions/:id
Get a single session by ID.
**Auth:** API key Β· **Scope:** session-scoped (key's `allowedSessions` enforced against `:id`)
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | 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
}
```
**Errors:** `401` missing/invalid key, or key not scoped to this session Β· `404` session not found
#### GET /api/sessions/:id/qr
Get the QR code (PNG data URL) for session authentication.
**Auth:** API key (OPERATOR) Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | 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/:id/groups
Get all groups the session is a member of (paginated).
**Auth:** API key Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | 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
#### GET /api/sessions/:id/chats
Get active chats for a session, most-recent first (paginated).
**Auth:** API key Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | 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,
"unreadCount": 2,
"timestamp": 1719306115,
"lastMessage": "See you tomorrow"
}
]
```
Sorted by `timestamp` DESC (most recent first) then paginated. `timestamp` is an epoch number (seconds).
**Errors:** `400` session not started Β· `401` Β· `403` Β· `404` session not found
#### 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. 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)
**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` (arbitrary object, no shape validation) | Opaque engine config; defaults to `{}`; never returned by read routes |
| `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; not SSRF-blocked. ⚠ **Must be a real, reachable proxy** β€” an unreachable value silently blocks the WhatsApp WebSocket (no QR, start β†’ `504`); leave unset unless you need it. See "Per-session egress proxy" below. |
| `proxyType` | `http` \| `https` \| `socks4` \| `socks5` | No | `@IsOptional`; `@IsIn([...])` | Proxy protocol |
```json
{
"name": "my-bot",
"config": { "autoReconnect": true }
}
```
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`/`proxyType` on the same request:
```json
{
"name": "my-bot",
"proxyUrl": "http://user:pass@your-real-proxy.host:8080",
"proxyType": "http"
}
```
> ⚠ `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/:id/start`
> returns `504 Gateway Timeout` after ~30s. Leave `proxyUrl` unset unless you genuinely need a proxy.
**Response** `201`
```json
{
"id": "8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a",
"name": "my-bot",
"status": "created",
"phone": null,
"pushName": null,
"config": { "autoReconnect": true },
"proxyUrl": null,
"proxyType": null,
"connectedAt": null,
"lastActiveAt": null,
"createdAt": "2026-06-25T09:00:00.000Z",
"updatedAt": "2026-06-25T09:00:00.000Z"
}
```
This route returns the **raw `Session` entity** (not via `fromEntity`), so `config`/`proxyUrl`/`proxyType`/`lastActiveAt` are present here only. Newly created `status` is `created`.
**Errors:** `400` validation (bad `name`/`proxyUrl`/`proxyType`, or an extra non-whitelisted field) Β· `401` Β· `403` key lacks OPERATOR role Β· `409` session name already exists
#### POST /api/sessions/:id/start
Start a session and initialize the WhatsApp connection.
**Auth:** API key (OPERATOR) Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | 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
}
```
Returned via `transformSession`. Status typically transitions to `initializing` / `qr_ready`.
**Errors:** `400` session already started / already starting Β· `401` Β· `403` Β· `404` not found
#### POST /api/sessions/:id/stop
Stop a session and disconnect WhatsApp.
**Auth:** API key (OPERATOR) Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | 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": null,
"lastActive": "2026-06-25T09:01:55.000Z",
"createdAt": "2026-06-20T11:30:00.000Z",
"updatedAt": "2026-06-25T09:10:00.000Z",
"lastError": null
}
```
Returned via `transformSession`; status typically becomes `disconnected`.
**Errors:** `401` Β· `403` Β· `404` not found
#### POST /api/sessions/:id/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 |
| --- | --- | --- |
| `id` | 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": null,
"lastActive": "2026-06-25T09:01:55.000Z",
"createdAt": "2026-06-20T11:30:00.000Z",
"updatedAt": "2026-06-25T09:12:00.000Z",
"lastError": null
}
```
Returned via `transformSession`.
**Errors:** `401` Β· `403` Β· `404` not found
#### POST /api/sessions/:id/pairing-code
Request an 8-char pairing code to link via phone number (alternative to QR).
**Auth:** API key (OPERATOR) Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | 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
#### POST /api/sessions/:id/chats/read
Mark a chat as read/seen.
**Auth:** API key (OPERATOR) Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | string | Session UUID |
**Request body** β€” `MarkChatReadDto`
| Field | Type | Required | Constraints | Description |
| --- | --- | --- | --- | --- |
| `chatId` | string | Yes | `@IsString`; `@IsNotEmpty`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace) | Engine-native JID, e.g. `1234567890@c.us` (wwebjs) or `1234@s.whatsapp.net` (Baileys) |
```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
#### POST /api/sessions/:id/chats/unread
Mark a chat as unread.
**Auth:** API key (OPERATOR) Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | string | Session UUID |
**Request body** β€” `MarkChatReadDto` (reused)
| Field | Type | Required | Constraints | Description |
| --- | --- | --- | --- | --- |
| `chatId` | string | Yes | `@IsString`; `@IsNotEmpty`; `@Matches(/^[^\s@]+@[^\s@]+$/)` | Engine-native JID, e.g. `1234567890@c.us` |
```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
#### POST /api/sessions/:id/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 |
| --- | --- | --- |
| `id` | string | Session UUID |
**Request body** β€” `DeleteChatDto`
| Field | Type | Required | Constraints | Description |
| --- | --- | --- | --- | --- |
| `chatId` | string | Yes | `@IsString`; `@IsNotEmpty`; `@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.
**Errors:** `400` validation, or session not started Β· `401` Β· `403` Β· `404` session not found
#### POST /api/sessions/:id/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 |
| --- | --- | --- |
| `id` | string | Session UUID |
**Request body** β€” `SendChatStateDto`
| Field | Type | Required | Constraints | Description |
| --- | --- | --- | --- | --- |
| `chatId` | string | Yes | `@IsString`; `@IsNotEmpty` (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
#### DELETE /api/sessions/:id
Delete a session.
**Auth:** API key (OPERATOR) Β· **Scope:** session-scoped
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | string | Session UUID |
**Response** `204` β€” empty body (`@HttpCode(204)`, returns void). A `findOne` lookup runs first, so a missing id yields `404`.
**Errors:** `401` missing/invalid key, or key not scoped to this session Β· `403` key role below OPERATOR Β· `404` session not found
### 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`.
#### 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. |
| from | string | No | β€” | Filter by sender. 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. |
**Response** `200`
```json
{
"messages": [
{
"id": "9f1c2e7a-2b3d-4c5e-8a91-0d1e2f3a4b5c",
"sessionId": "my-session",
"waMessageId": "true_628123456789@c.us_3EB0ABCD",
"chatId": "628123456789@c.us",
"from": "628123456789@c.us",
"to": "628987654321@c.us",
"body": "Hello from OpenWA!",
"type": "text",
"direction": "outgoing",
"timestamp": 1719312000,
"metadata": null,
"status": "sent",
"createdAt": "2026-06-25T09:20:00.000Z"
}
],
"total": 1
}
```
Each `Message`: `{ id (uuid), sessionId, waMessageId (string|null), chatId, from, to, body (string|null), type, direction ('incoming'|'outgoing'), timestamp (number|null), metadata (object|null), status ('pending'|'sent'|'delivered'|'read'|'failed'), createdAt (ISO date) }`. Ordered by `createdAt` DESC. The response is the raw service object (no envelope).
**Errors:** `401` missing/invalid API key
#### 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:
```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,
"author": "628123456789@c.us",
"senderPhone": "628123456789"
}
]
```
Each item may also include `isStatusBroadcast`, `mentionedIds`, `isLidSender`, `contact`, `media { mimetype, filename?, data?, omitted?, sizeBytes? }`, `quotedMessage { id, body }`, `call { video, missed }` (for `call` messages), and `location { latitude, longitude, description?, address?, url? }`. `type` is one of `text|image|video|audio|voice|document|sticker|location|contact|call|revoked|masked|unknown`. 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.
**Errors:** `400` session not active Β· `401` missing/invalid API key Β· `500` engine error
#### 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 Β· `500` engine error
#### 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"
},
{ "chatId": "628222222222@c.us", "status": "pending" }
],
"startedAt": "2026-06-25T09:20:55.000Z",
"completedAt": null
}
```
`status` is one of `pending|processing|completed|cancelled|failed`; per-result `status` is `pending|sent|failed|cancelled`. 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
#### 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 | `phone@c.us` or `groupId@g.us` |
| text | string | Yes | non-empty, max 4096 | Message text |
| mentions | string[] | No | array of WIDs | WIDs to @mention (e.g. `["62811@c.us"]`). See **Mentions** below |
```json
{ "chatId": "628123456789@c.us", "text": "Hello from OpenWA!" }
```
```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. An optional `SIMULATE_TYPING` humanising pause may run before send.
**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` session not found Β· `500` engine error
#### 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 | 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 `{}` |
```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, or session not active Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `404` session or template not found Β· `500` engine error
#### 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 | Target chat |
| url | string | Conditional | URL; required when `base64` is absent | http/https media URL (SSRF-guarded; a blocked internal 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 |
```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`, base64 over media cap, SSRF-blocked URL, session not active, or unknown body field Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `500` engine error
#### 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` β€” 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 / session not active / unknown body field Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `500` engine error
#### 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 β€” 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"`.
**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` β€” 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 / session not active / unknown body field Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `500` engine error
#### 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` β€” 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 }
```
**Errors:** `400` media validation failure / session not active / unknown body field Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `500` engine error
#### 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 | 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 | Pin description |
| address | string | No | string | Pin address |
```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 Β· `500` engine error
#### 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 | Target chat |
| contactName | string | Yes | non-empty | Display name for the contact card |
| contactNumber | string | Yes | non-empty | Contact phone number |
```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 Β· `500` engine error
#### POST /api/sessions/:sessionId/messages/send-sticker
Send a sticker (by URL or base64; typically webp). Reuses `SendMediaMessageDto`.
**Auth:** API key (OPERATOR)
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| sessionId | string | Session ID |
**Request body** β€” `SendMediaMessageDto` (fields `chatId`, `url`, `base64`, `mimetype`, `filename`, `caption` β€” 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 / session not active / unknown body field Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `500` engine error
#### 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 | 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) |
```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 Β· `500` engine error
#### 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 | Target chat |
| quotedMessageId | string | Yes | non-empty | WhatsApp id of the message being quoted |
| text | string | Yes | non-empty | Reply text |
```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 Β· `500` engine error
#### 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 | Source chat |
| toChatId | string | Yes | non-empty | 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 Β· `500` engine error
#### 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 | Target chat |
| messageId | string | Yes | non-empty | Message to react to |
| emoji | string | Yes | string (may be empty) | 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 / message not found / unknown body field Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `500` engine error
#### POST /api/sessions/:sessionId/messages/delete
Delete a message (for everyone by default); also flags the stored record as `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 | 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 body is cleared and its `type` set to `revoked`. Note the `200` status (via `@HttpCode`).
```json
{ "success": true }
```
**Errors:** `400` session not active / message not found / unknown body field Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `500` engine error
#### 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 | 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 |
```json
{ "chatId": "628123456789@c.us", "messageId": "true_628123456789@c.us_3EB0ABCD", "body": "Corrected text" }
```
**Response** `200`
The edited message keeps its original id.
```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 Β· `404` message not found Β· `500` engine error (e.g. editing another account's message, which WhatsApp forbids)
#### 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 | Auto-generated `batch_<hex>` if omitted; a duplicate id returns `400` |
| messages | BulkMessageItemDto[] | Yes | array, max 100, nested-validated | The batch items (see below) |
| 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? }`, `caption?: string`.
`BulkMessageOptionsDto`: `{ delayBetweenMessages?: number (1000–60000, default 3000), randomizeDelay?: boolean (default true), stopOnError?: boolean (default false) }`.
```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.
```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`, base64 over media cap, or DTO/nested validation failure (unknown nested field rejected) Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `500` engine error
#### 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 or cancelled Β· `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; the block/unblock writes require an `OPERATOR` key. Every route returns `400 "Session is not started"` when the target session exists but is not in a started/ready state, and `404` when the session itself does not exist. 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.
#### GET /api/sessions/:sessionId/contacts
List all contacts for a session, returned as an in-memory paginated window.
**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, or key not scoped to this session Β· `404` session not found
#### 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
**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: the literal `check/` segment disambiguates this route from `GET /:contactId`. A contact whose id is literally `check` would be shadowed by this handler.
**Errors:** `400` session is not started Β· `401` missing/invalid API key Β· `404` session not found
#### 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), or session not found
#### 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, the contact's privacy hides it, or it cannot be resolved.
> The path segment is `profile-picture` (hyphenated), not `profile-pic`.
**Errors:** `400` session is not started Β· `401` missing/invalid API key Β· `404` session not found
#### 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).
**Errors:** `400` session is not started Β· `401` missing/invalid API key Β· `404` session not found
#### 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 an empty body `{}` (the global `whitelist` + `forbidNonWhitelisted` ValidationPipe rejects any unexpected field with `400`).
**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 Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `404` session not found
#### 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 Β· `401` missing/invalid API key Β· `403` key role below OPERATOR Β· `404` session not found
### 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; 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()` then paginates to bound the window.
```json
[
{
"id": "120363021234567890@g.us",
"name": "Project Team",
"participantsCount": 12,
"isAdmin": true,
"linkedParentJID": null
}
]
```
**Errors:** `400` session is not started Β· `401` missing/invalid `X-API-Key`
#### 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` Β· `404` `Group <groupId> not found`
#### GET /api/sessions/:sessionId/groups/:groupId/invite-code
Get the group invite code and full invite link.
**Auth:** API key
**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`
#### 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` | Group subject/name |
| participants | string[] | Yes | `@IsArray`, `@ArrayNotEmpty`, `@IsString({each:true})` | 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 Β· `401` missing/invalid `X-API-Key` Β· `403` key lacks OPERATOR role
#### 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})` | 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). The body is a fixed acknowledgement after the void engine call.
```json
{ "success": true, "message": "Participants added" }
```
**Errors:** `400` validation / session not started Β· `401` missing/invalid `X-API-Key` Β· `403` key lacks OPERATOR role
#### 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})` | Non-empty array of WhatsApp IDs |
```json
{ "participants": ["628123456789@c.us"] }
```
**Response** `200`
No `@HttpCode`, so NestJS uses the DELETE default of `200`.
```json
{ "success": true, "message": "Participants removed" }
```
**Errors:** `400` validation / session not started Β· `401` missing/invalid `X-API-Key` Β· `403` key lacks OPERATOR role
#### 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})` | Non-empty array of WhatsApp IDs |
```json
{ "participants": ["628123456789@c.us"] }
```
**Response** `200` (forced via `@HttpCode(HttpStatus.OK)`)
```json
{ "success": true, "message": "Participants promoted to admin" }
```
**Errors:** `400` validation / session not started Β· `401` missing/invalid `X-API-Key` Β· `403` key lacks OPERATOR role
#### 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})` | Non-empty array of WhatsApp IDs |
```json
{ "participants": ["628123456789@c.us"] }
```
**Response** `200` (forced via `@HttpCode(HttpStatus.OK)`)
```json
{ "success": true, "message": "Participants demoted from admin" }
```
**Errors:** `400` validation / session not started Β· `401` missing/invalid `X-API-Key` Β· `403` key lacks OPERATOR role
#### 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` | 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
#### 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`) | 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
#### 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
#### 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
#### 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 | non-empty | 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
#### 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` is present only when the engine reports a disappearing-message timer.
```json
{ "announce": false, "locked": false, "ephemeralSeconds": 604800 }
```
**Errors:** `400` session is not started Β· `401` missing/invalid `X-API-Key` Β· `404` group not found
#### 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` |
```json
{ "announce": true, "ephemeralSeconds": 86400 }
```
**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 Β· `501` `ephemeralSeconds` on the whatsapp-web.js engine (library limitation)
### 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 but is **not** validated against an existing session in these handlers.
#### 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`. Not validated against an existing session in this handler. |
**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 Β· `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`. Duplicate name β†’ `409`. |
| body | string | no | if present: non-empty, max 4096 chars | Applied only when not `undefined`. |
| header | string | no | max 1024 chars | Applied only when not `undefined`. The update path does **not** coerce to `null`, so passing explicit `null` fails `@IsString`; omit the key to leave it unchanged. |
| footer | string | no | max 1024 chars | Applied only when not `undefined`. |
```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 two product/catalog **send** routes live under the `/messages` path and require an **OPERATOR** key. Channel read routes require any valid API key; subscribe/unsubscribe require **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",
"description": "Best products in town",
"productCount": 12,
"url": "https://wa.me/c/6281234567890"
}
```
**Not implemented on any engine.** whatsapp-web.js returns `null` unconditionally (it has no native Catalog API and the adapter stubs without calling the client); Baileys raises `501`. The shape below documents the contract, not a response you can obtain today. The response is the raw `engine.getCatalog()` return β€” no envelope.
**Errors:** `401` missing/invalid API key Β· `404` `Session <sessionId> not found or not connected` Β· `500` engine error
#### 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": "Rp 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 }
}
```
**Errors:** `400` invalid `page`/`limit` or unknown query key Β· `401` missing/invalid API key Β· `404` `Session <sessionId> not found or not connected` Β· `500` engine error
#### 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": "Rp 49.990",
"imageUrl": "https://example.com/img/earbuds.jpg",
"url": "https://wa.me/p/PROD_12345/6281234567890",
"isAvailable": true,
"retailerId": "SKU-EB-01"
}
```
An unknown `productId` returns `200` with body `null` β€” it is **not** a `404`. Only a missing/disconnected session yields `404`.
**Errors:** `401` missing/invalid API key Β· `404` `Session <sessionId> not found or not connected` Β· `500` engine error
#### 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` | Target chat/recipient id (e.g. `6281234567890@c.us`). |
| `productId` | string | Yes | `@IsString` | Catalog product id to send. |
| `body` | string | No | `@IsString` | Optional message body/caption. |
```json
{
"chatId": "6281234567890@c.us",
"productId": "PROD_12345",
"body": "Check out this item!"
}
```
**Response** `501` β€” always (not implemented)
```json
{
"statusCode": 501,
"message": "Operation not supported by the active engine: sendProduct",
"error": "Not Implemented"
}
```
No engine implements this. whatsapp-web.js and Baileys both raise `EngineNotSupportedError`, so the
route cannot return a success body on any deployment; it is documented here because it is mounted.
**Errors:** `400` missing `chatId`/`productId`, wrong types, or any field not on the DTO Β· `401` missing/invalid API key Β· `403` API-key role below OPERATOR Β· `404` `Session <sessionId> not found or not connected` Β· `500` engine error
#### POST /api/sessions/:sessionId/messages/send-catalog
Send the business catalog link 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** β€” `SendCatalogDto`
| Field | Type | Required | Constraints | Description |
| --- | --- | --- | --- | --- |
| `chatId` | string | Yes | `@IsString` | Target chat/recipient id. |
| `body` | string | No | `@IsString` | Optional message body/caption. |
```json
{
"chatId": "6281234567890@c.us",
"body": "Browse our full catalog here"
}
```
**Response** `501` β€” always (not implemented)
```json
{
"statusCode": 501,
"message": "Operation not supported by the active engine: sendCatalog",
"error": "Not Implemented"
}
```
No engine implements this. whatsapp-web.js and Baileys both raise `EngineNotSupportedError`, so the
route cannot return a success body on any deployment; it is documented here because it is mounted.
**Errors:** `400` missing/invalid `chatId` or any non-DTO field Β· `401` missing/invalid API key Β· `403` API-key role below OPERATOR Β· `404` `Session <sessionId> not found or not connected` Β· `500` engine error
#### 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,
"picture": "https://example.com/ch.jpg",
"verified": true,
"createdAt": 1717200000
}
]
```
Bare array, no envelope.
**Errors:** `400` `Session is not started` Β· `401` missing/invalid API key
#### 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,
"picture": "https://example.com/ch.jpg",
"verified": true,
"createdAt": 1717200000
}
```
**Errors:** `400` `Session is not started` Β· `401` missing/invalid API key Β· `404` `Channel <channelId> not found` (engine returned null)
#### 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 | engine default (Swagger notes 50) | Max messages to return. Taken as a raw query string and run through `parseInt(limit, 10)` when present, else `undefined` is passed to the engine. There is **no** DTO/ValidationPipe on this value β€” a non-numeric `limit` becomes `NaN` and is forwarded to the engine. |
**Response** `200`
```json
[
{
"id": "false_120363000000000000@newsletter_3EB0...",
"body": "v0.7.3 is out!",
"timestamp": 1719331200,
"hasMedia": false,
"mediaUrl": null
}
]
```
Bare array. `timestamp` is an epoch number (seconds).
**Errors:** `400` `Session is not started` Β· `401` missing/invalid API key
#### 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,
"picture": "https://example.com/ch.jpg",
"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
#### 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: this is the one route in the module that returns a literal `{ success: true }` (hard-coded by the controller after the void engine call resolves) rather than the raw engine return. 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
### 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`.
#### GET /api/sessions/:sessionId/labels
List all labels defined for the session (WhatsApp Business accounts only).
**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), or the account is not a WhatsApp Business account Β· `401` missing/invalid API key
#### 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`
#### 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
#### 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
#### 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
#### GET /api/sessions/:sessionId/status
Get all contact status updates (stories) visible to the session.
**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": "https://…",
"backgroundColor": "#25D366",
"font": 2,
"timestamp": "2026-06-25T08:30:00.000Z",
"expiresAt": "2026-06-26T08:30:00.000Z"
}
]
}
```
The controller wraps the engine array in `{ statuses }`. `type` is one of `text | image | video`; `caption`, `mediaUrl`, `backgroundColor`, `font` are optional. `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, or key not scoped to this session Β· `404` `Session {id} not found or not connected`
#### GET /api/sessions/:sessionId/status/:contactId
Get status updates posted by a specific contact.
**Auth:** API key
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| sessionId | string | WhatsApp session identifier |
| contactId | string | Contact JID/id (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.
**Errors:** `401` missing/invalid API key, or key not scoped to this session Β· `404` session not found / not connected
#### 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 | β€” | Status text body |
| recipients | string[] | yes | 1–256 items, each matching `^\d+@(c\.us\|lid)$` | JIDs of the contacts permitted to view the status. **Honored on Baileys only** (passed as `statusJidList`); whatsapp-web.js ignores the allow-list and broadcasts to the account's status-privacy audience. Empty array β†’ `400` |
| 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 | integer `0`–`5` | Font index |
```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, missing/empty `recipients`, a JID not matching `@c.us`/`@lid`, or more than 256 recipients, bad `backgroundColor`/`font`), or session is not started Β· `401` missing/invalid API key Β· `403` key lacks `OPERATOR` role Β· `404` session not found / not connected
#### 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 (`MediaInput`) | yes | validated nested object (an empty `{}` passes β€” there is no `@IsNotEmpty`) | Media source wrapper |
| image.url | string | no | β€” | Media source URL |
| image.base64 | string | no | β€” | Base64-encoded media data |
| image.mimetype | string | no | β€” | Media MIME type; if omitted the service defaults to `image/jpeg` |
| recipients | string[] | yes | 1–256 items, each matching `^\d+@(c\.us\|lid)$` | JIDs of the contacts permitted to view the status (`statusJidList`). Empty array β†’ `400` |
| caption | string | no | β€” | Optional caption |
The service resolves the media as `image.url || image.base64 || ''` 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, missing/empty `recipients`, a JID not matching `@c.us`/`@lid`, or more than 256 recipients), or session is not started Β· `401` missing/invalid API key Β· `403` key lacks `OPERATOR` role Β· `404` session not found / not connected
#### 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 (`MediaInput`) | yes | validated nested object (an empty `{}` passes) | Media source wrapper |
| video.url | string | no | β€” | Media source URL |
| video.base64 | string | no | β€” | Base64-encoded media data |
| video.mimetype | string | no | β€” | Media MIME type; if omitted the service defaults to `video/mp4` |
| recipients | string[] | yes | 1–256 items, each matching `^\d+@(c\.us\|lid)$` | JIDs of the contacts permitted to view the status (`statusJidList`). Empty array β†’ `400` |
| caption | string | no | β€” | Optional caption |
The service resolves the media as `video.url || video.base64 || ''` 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, missing/empty `recipients`, a JID not matching `@c.us`/`@lid`, or more than 256 recipients), or session is not started Β· `401` missing/invalid API key Β· `403` key lacks `OPERATOR` role Β· `404` session not found / not connected
#### DELETE /api/sessions/:sessionId/status/:statusId
Delete one of the session's own posted statuses.
**Auth:** API key (OPERATOR)
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| sessionId | string | WhatsApp session identifier |
| statusId | 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 Β· `404` `Session {id} not found or not connected`
### 6.4.8 Webhooks (management)
Webhooks are configured per session and managed under `/api/sessions/:sessionId/webhooks` (handled by `WebhookController`). A separate cross-session list endpoint lives at `/api/webhooks` (handled by `WebhooksListController`). Every 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 in any response (the response DTO has no `@Expose` for them, so `fromEntity` drops them). 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.failed`, `message.revoked`, `message.reaction`, `message.edited`, `session.status`, `session.qr`, `session.authenticated`, `session.disconnected`, `session.reconnect_loop`, `group.join`, `group.leave`, `group.update`, `call.received`. All of them are actively dispatched by the engines.
#### 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
#### 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`. No session-existence check is performed at create time. |
**Request body** β€” `CreateWebhookDto`
| Field | Type | Required | Constraints | Description |
| --- | --- | --- | --- | --- |
| url | string | yes | `@IsUrl({ require_tld: false })` (allows hostnames without a dot, e.g. `http://localhost:3000`); also run through the SSRF guard, which can reject with `400`. Entity column max 2048 chars. | 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`, `@MaxLength(255)` | HMAC-SHA256 signing key. **Write-only** β€” never returned. 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-]+$/`, values are strings ≀1024 chars with no C0 control/DEL (CR/LF injection guard). | Custom headers added to deliveries. **Write-only** β€” never returned. At delivery, `content-type` and `x-openwa-*` names are stripped. Defaults to `{}`. |
| filters | WebhookFilters \| null | no | `@IsValidWebhookFilters()` β€” `{ 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`, `body`, `type`, `isGroup`, `fromMe`, `hasMedia`, `mentions`. | Optional AND pre-filter; **all** conditions must match for the webhook to fire. Omit/null = fire on every subscribed event. Defaults to `null`. |
| retryCount | number (int) | no | `@IsInt`, `@Min(0)`, `@Max(5)` | Delivery retry attempts on failure. Defaults to `3`. |
```json
{
"url": "https://your-server.com/webhook",
"events": ["message.received", "session.status"],
"secret": "your-secret-key",
"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), or SSRF URL rejection Β· `401` missing/invalid API key Β· `403` insufficient role
#### 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 | `@IsOptional`, `@IsUrl({ require_tld: false })`; re-runs the SSRF guard when provided β†’ `400` if blocked. | New URL. |
| events | string[] | no | `@IsOptional`, `@IsArray`, `@ArrayMinSize(1)`, `@IsIn([...WEBHOOK_EVENTS, '*'], { each: true })` | Same allowed set as create (incl. `*` and reserved `group.*`). |
| secret | string | no | `@IsOptional`, `@IsString`, `@MaxLength(255)` | **Write-only.** An empty string is normalized to `null`, which disables HMAC. |
| headers | Record<string,string> | no | `@IsOptional`, `@IsHeaderMap()` (same constraints as create) | **Write-only.** Replaces existing headers wholesale when provided. |
| filters | WebhookFilters \| null | no | `@IsOptional`, `@IsValidWebhookFilters()` | Set to `null` to clear filters. |
| active | boolean | no | `@IsOptional`, `@IsBoolean` | Enable/disable the webhook. (Present only on update, not create.) |
| retryCount | number (int) | no | `@IsOptional`, `@IsInt`, `@Min(0)`, `@Max(5)` | Retry attempts. |
```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 SSRF URL rejection Β· `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).
**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. The plaintext key string is returned **only once**, at creation. Validation of the caller's own key lives at `POST /api/auth/validate` and accepts any valid 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.
```json
[
{
"id": "3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33",
"name": "Production Bot",
"keyPrefix": "owa_k1_a1b2",
"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
#### 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_a1b2",
"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 Β· `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` | Session IDs this key may access. |
| `expiresAt` | string (ISO 8601 date) | no | `@IsDateString` | 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_a1b2",
"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, bad `expiresAt`, or any non-whitelisted body field) Β· `401` missing/invalid key Β· `403` key role below ADMIN
#### 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` | Applied if not `undefined`. |
| `expiresAt` | string (ISO 8601 date) | no | `@IsDateString` | Applied if not `undefined`; empty/falsy clears to `null`. |
```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_a1b2",
"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`) Β· `401` missing/invalid key Β· `403` key role below ADMIN Β· `404` not found Β· `409` change would remove the last usable admin key
#### 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_a1b2",
"role": "operator",
"isActive": false,
"usageCount": 42,
"createdAt": "2026-06-01T10:00:00.000Z"
}
```
**Errors:** `401` missing/invalid key Β· `403` key role below ADMIN Β· `404` not found Β· `409` target is the last usable admin key
#### 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 Β· `404` `"API key with id '<id>' not found"` Β· `409` target is the last usable admin key
#### POST /api/auth/validate
Validate the supplied `X-API-Key` and report its validity and role.
**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. On success it returns the caller's role.
**Response** `200`
```json
{ "valid": true, "role": "operator" }
```
**Errors:** `401` missing/invalid/revoked/expired key (raised by the global guard before the handler)
> Implemented by `AuthValidateController` (`@Controller('auth')`), sharing the same `/api/auth` base.
### 6.4.10 System (Health, Metrics, Stats, Settings)
System endpoints expose operational status, Prometheus metrics, aggregate statistics, and runtime settings. Health and metrics use non-standard auth (public / Bearer token); stats and settings use the API key, with several routes gated to `ADMIN`.
#### GET /api/health
Basic health check returning status, the current timestamp, and the running app version.
**Auth:** public
**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. 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`, so NestJS wraps the custom `{ status, details }` object as the `message` field:
```json
{
"statusCode": 503,
"message": {
"status": "error",
"details": { "mainDatabase": { "status": "up" }, "dataDatabase": { "status": "down" } }
},
"error": "Service Unavailable"
}
```
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. Hidden from Swagger.
**Response** `200`
Content-Type `text/plain; version=0.0.4; charset=utf-8`, `Cache-Control: no-store`. Raw text (no JSON envelope):
```
# 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_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.
**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" }`).
#### GET /api/stats/overview
Get overall cross-session aggregate statistics (sessions by status + message totals + today's counts).
**Auth:** API key (ADMIN) β€” deliberately ADMIN-only (global cross-tenant aggregate).
**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 `SessionStatus` enum values 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. Side effect: caches the `sessions` block via `CacheService`.
**Errors:** `401` β€” missing/invalid `X-API-Key` Β· `403` β€” key role below `ADMIN`.
#### GET /api/stats/messages
Get message statistics over a period: time series, counts by type, by session, and top chats.
**Auth:** API key (ADMIN) β€” cross-session aggregate.
**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. 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`.
#### 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`. Note: the handler does NOT verify the key is scoped to this session, so any authenticated 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 a DB-native datetime string. `hourlyActivity` always has 24 entries (hour `0..23`), missing hours zero-filled, computed over the last 24 h.
**Errors:** `401` β€” missing/invalid API key Β· `404` β€” session not found (`Session not found`).
#### GET /api/settings
Get application settings (environment-derived; `general`/`api`/`notifications` groups).
**Auth:** API key (ADMIN). Settings expose server configuration, so a VIEWER or session-scoped key is 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": true
}
}
```
Notes: raw return of an in-memory `Settings` object built once in the controller constructor from `ConfigService` (snapshotted at construction, not re-read per request). `api.rateLimitWindow` is in ms. `enableDocs` reflects the `ENABLE_SWAGGER` gate (enabled by default outside production; disabled by default in production unless explicitly enabled). Only `notifications.*` is currently hardcoded (`emailEnabled: false`, `notificationEmail: ''`, `webhookAlerts: true`).
**Errors:** `401` β€” missing/invalid `X-API-Key` Β· `403` β€” API key lacks the ADMIN role.
#### PUT /api/settings
Update settings β€” intentionally not implemented; always returns `501` (settings are env-derived and read-only at runtime).
**Auth:** API key (ADMIN)
**Request body** β€” none. There is no request DTO; any body is ignored (the exception is thrown before validation matters).
**Response** `501`
```json
{
"statusCode": 501,
"message": "Settings are derived from environment configuration and are read-only at runtime. Change the corresponding environment variable and restart the service.",
"error": "Not Implemented"
}
```
Notes: the handler unconditionally throws `NotImplementedException`. Even an ADMIN key always gets `501`; there is no success response.
**Errors:** `401` β€” missing/invalid key Β· `403` β€” key lacks `ADMIN` Β· `501` β€” always (not implemented).
### 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).
> Note on the infra/MCP request bodies: the `PUT /api/infra/config`, `POST /api/infra/restart`, `POST /api/infra/import-data`, `POST /api/infra/storage/import` bodies and the entire `POST /mcp` envelope are **plain TS interfaces, not class-validator DTOs** β€” the global `whitelist`/`forbidNonWhitelisted` ValidationPipe does **not** run on them. Unknown fields pass through silently and no type/constraint checks happen, except the few field-level guards noted per endpoint. Plugin DTOs (`InstallFromUrlDto`, `PluginConfigDto`, `PluginSessionsDto`) *are* class-validated and reject unknown fields with `400`.
---
#### GET /api/infra/health
Public liveness probe.
**Auth:** public
**Response** `200`
```json
{ "status": "ok", "timestamp": "2026-06-25T12:00:00.000Z" }
```
---
#### 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": "" },
"redis": { "enabled": false, "connected": false, "host": "localhost", "port": 6379 },
"queue": {
"enabled": false,
"webhooks": { "pending": 0, "completed": 0, "failed": 0 }
},
"storage": { "type": "local", "path": "./data/media" },
"engine": {
"type": "whatsapp-web.js",
"headless": true,
"sessionDataPath": "./data/sessions",
"browserArgs": "--no-sandbox --disable-gpu"
}
}
```
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. `storage` only ever returns `type`+`path` here (no `bucket`).
**Errors:** `401` missing/invalid key Β· `403` key role < ADMIN
---
#### 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",
"enabled": true,
"features": ["send", "receive", "media", "groups"],
"library": { "name": "whatsapp-web.js", "version": "1.34.7" }
}
]
```
`library` is optional and may be omitted per engine.
**Errors:** `401` Β· `403`
---
#### GET /api/infra/engines/current
Get the currently active engine type.
**Auth:** API key (ADMIN)
**Response** `200`
```json
{ "engineType": "whatsapp-web.js" }
```
**Errors:** `401` Β· `403`
---
#### GET /api/infra/config
Read the saved infrastructure config from `data/.env.generated` (used to hydrate the dashboard form). **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": "", "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-gpu"
}
}
```
When `data/.env.generated` does not exist, empty-string/default values are returned (`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.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`** | The only validated field in the body |
| `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` |
```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-gpu" }
}
```
**Response** `200`
```json
{ "message": "Configuration saved. Server restart required.", "saved": true, "envPath": "data/.env.generated", "profiles": ["postgres", "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, and CR/LF injection are real HTTP `400` responses. `profiles` lists newly-required Docker profiles.
**Errors:** `400` unknown/mistyped body field, missing nested `database.type`/`storage.type`, unknown
`engine.type`, or CR-LF in a value Β· `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 should be stopped/removed |
```json
{ "profiles": ["postgres", "redis"], "profilesToRemove": ["minio"] }
```
**Response** `200`
```json
{
"message": "Restarting…",
"restarting": true,
"profiles": ["postgres", "redis"],
"profilesToRemove": ["minio"],
"estimatedTime": 48
}
```
`estimatedTime` (seconds) = base 15 + 20/postgres + 13/redis + 15/minio + 5/removal. `orchestration` is present only when Docker is available and `profiles` is non-empty; `removal` only when Docker is available and `profilesToRemove` is non-empty. 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 from the Data DB (sessions, webhooks, messages, batches, templates, Baileys stored messages) as JSON for migration. Read-only, but runs raw `SELECT *` on the `data` DataSource.
**Auth:** API key (ADMIN)
**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": []
},
"counts": { "sessions": 1, "webhooks": 0, "messages": 0, "messageBatches": 0, "templates": 0, "baileysStoredMessages": 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 include `secret` in cleartext.** `messages`/`messageBatches`/`templates`/`baileysStoredMessages` default to `[]` if their table is missing; `sessions`/`webhooks` are not try-wrapped, so a hard DB error there yields `500`.
**Errors:** `401` Β· `403` Β· `500` DB error
---
#### POST /api/infra/import-data
Replace all Data DB rows with the supplied export. **Destructive and transactional (all-or-nothing).**
**Auth:** API key (ADMIN)
**Request body** β€” inline `{ tables: Partial<MigrationTables> }` (plain interface, not class-validated)
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `tables` | object | Yes | Container of per-table row arrays. Accessing `data.tables` directly means a missing/null value throws `500` |
| `tables.sessions` | `SessionRow[]` | No | Inserted first; skipped if absent/empty |
| `tables.webhooks` | `WebhookRow[]` | No | Includes `secret` |
| `tables.messages` | `MessageRow[]` | No | β€” |
| `tables.messageBatches` | `MessageBatchRow[]` | No | snake_case columns |
| `tables.templates` | `TemplateRow[]` | No | β€” |
| `tables.baileysStoredMessages` | `BaileysStoredMessageRow[]` | No | β€” |
```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": []
}
}
```
**Response** `200`
```json
{
"imported": true,
"counts": { "sessions": 1, "webhooks": 0, "messages": 0, "messageBatches": 0, "templates": 0, "baileysStoredMessages": 0 },
"warnings": []
}
```
Inside a transaction it DELETEs all rows from webhooks/messages/message_batches/templates/baileys_stored_messages/sessions, then re-inserts. If **any** row insert fails, `warnings` is non-empty, the transaction is **rolled back**, and `imported:false` is returned (counts reflect rows inserted before the failure). JSON object/array fields are auto-stringified before insert.
**Errors:** `401` Β· `403` Β· `500` `tables` missing/null or unrecoverable DB error
---
#### 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`
---
#### 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`
---
#### 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, "storageType": "local" }
```
**Errors:** `400` missing/out-of-`data/`/not-found path Β· `401` Β· `403` Β· `500`
---
#### 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"],
"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. 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",
"installed": true,
"installedVersion": "1.1.0",
"updateAvailable": true
}
]
```
Returns `[]` when no `plugins.catalogUrl` is configured.
**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`.
**Errors:** `400` no file / invalid package / install failed Β· `401` Β· `403` Β· `409` plugin id or directory already exists
---
#### POST /api/plugins/install-url
Install a plugin by downloading its `.zip` from an HTTP(S) URL (SSRF-guarded fetch: host validated, redirects refused, size-capped at `plugins.downloadMaxBytes`, default 5 MB).
**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 http(s) URL of the package |
```json
{ "url": "https://github.com/openwa-plugins/chat-flow/releases/download/v1.0.0/chat-flow.zip" }
```
**Response** `201` β€” the newly installed `PluginDto`.
**Errors:** `400` invalid URL / download or package invalid Β· `401` Β· `403` Β· `409` already installed
---
#### 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 enabled successfully" }
```
Enable failures are returned in-band as `{ "success": false, "message": "…" }` (still HTTP 200).
**Errors:** `401` Β· `403` Β· `404` unknown id
---
#### 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 disabled successfully" }
```
**Errors:** `401` Β· `403` Β· `404` unknown id
---
#### 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()` | Whole config object. Masked/sentinel secret values mean "unchanged" and are restored from the stored config |
```json
{ "config": { "apiKey": "sk-...", "replyDelayMs": 1500 } }
```
**Response** `200`
```json
{ "success": true, "message": "Plugin 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 |
```json
{ "config": { "replyDelayMs": 3000 } }
```
**Response** `200`
```json
{ "success": true, "message": "Plugin 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.
**Auth:** API key (ADMIN) Β· **Scope:** session-scoped (the key's `allowedSessions` is enforced)
**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`).
A session-restricted key requesting `"*"` or out-of-scope sessions gets `403 API key not authorized for session(s): …`.
**Errors:** `400` plugin is global Β· `401` Β· `403` key not authorized for requested sessions Β· `404` unknown id
---
#### POST /api/plugins/:id/update
Update an installed plugin in place from a URL, preserving config + enabled state (old directory backed up and restored on failure).
**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 http(s) URL of the new `.zip` (SSRF-guarded download) |
```json
{ "url": "https://example.com/plugins/chat-flow-1.1.0.zip" }
```
**Response** `201` β€” the updated `PluginDto`.
**Errors:** `400` download/package invalid, manifest id mismatch, or built-in plugin Β· `401` Β· `403` Β· `404` unknown id
---
#### DELETE /api/plugins/:id
Uninstall a plugin and delete its files. Built-in plugins are protected.
**Auth:** API key (ADMIN)
**Path parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | string | Plugin id |
**Response** `200`
```json
{ "success": true, "message": "Plugin 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
---
#### 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>`. Auth is enforced **per tool call** inside the MCP layer (not by the global Nest guard), so an auth failure surfaces in-band, not as an HTTP `401`.
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 SSE/session id for normal calls).
**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": "session_send_text", "arguments": { "sessionId": "default", "to": "6281234567890", "text": "Hello from MCP" } } }
```
**Response** `200` β€” JSON-RPC 2.0 envelope
```json
{
"jsonrpc": "2.0",
"id": 3,
"result": { "content": [ { "type": "text", "text": "{\"success\":true,\"messageId\":\"…\"}" } ] }
}
```
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 missing/invalid API key (`name:'UnauthorizedException'`) and rate-limit hits (`message:'MCP rate limit exceeded'`).
**Errors:** in-band JSON-RPC errors `-32601` (unknown method), `-32602` (invalid params / unknown tool), `-32700` (parse error), all at HTTP 200 Β· `500` only 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; an
ADMIN / null-allowlist key 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; whitespace-only is rejected with `400`. 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. |
| `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. |
| `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-numeric value is rejected with `400`. |
| `offset` | integer (β‰₯ 0) | No | `0` | Pagination offset. A non-numeric 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 an XSS-safe text excerpt with `<mark>`/`</mark>` highlight markers around the matched
term (both dialects emit the same markers). Render it as text, never 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).
**Errors:** `400` empty/whitespace `q`, a non-numeric `dateFrom`/`dateTo`/`limit`/`offset`, 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` Β·
`501` no search provider configured (including a non-FTS5 SQLite build, where the provider is absent) Β·
`503` active provider unhealthy (**reserved**: the built-in provider does not return it; it is the
contract surface for a future plugin provider whose `search()` throws `ServiceUnavailableException`).
> 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.
### 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` Β· `500` engine refused the change
#### 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`
#### 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` Β· `401` Β· `403` Β· `500` engine refused the change
### 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.
#### POST /api/sessions/:sessionId/calls/:callId/reject
Reject a currently ringing incoming call. 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
> **Auto-reject per session.** Set `"config": { "autoRejectCalls": true }` when creating a session to have the server reject every incoming call automatically β€” the `call.received` event is still dispatched first, so automations keep full visibility.
## 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. |
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`, and `FORBIDDEN_SESSION`.
### Subscribable events
`events` accepts the wildcard `"*"` (all of the below) or any of these exact names:
```
message.received
message.sent
message.ack
message.revoked
message.reaction
message.edited
session.status
session.qr
session.authenticated
session.disconnected
group.join
group.leave
group.update
call.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
- **`sessionId: "*"`** subscribes to every session; **`events: ["*"]`** subscribes to every subscribable event. They combine (e.g. `"*"` + `["*"]` = every 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 `"*"`.
### 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",
"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 `"*"`.
| 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`, `hasMedia`, `contact{…}` (plus optional `senderPhone` for `@lid` senders) |
| `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 | `{ id, messageId, status, ack }` β€” `status` is the canonical state (`pending`/`sent`/`delivered`/`read`/`failed`); `ack` is the deprecated legacy integer derived from it |
| `message.failed` | A receipt resolves to `failed` (dispatched in addition to `message.ack`) | `{ id, messageId, status: "failed", ack: -1 }` |
| `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; `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 }` (raw QR string) |
| `session.authenticated` | The session pairs and becomes ready | `{ sessionId, phone, pushName }` |
| `session.disconnected` | The session disconnects | `{ 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 }` |
| `session.status` | The session status transitions | `{ sessionId, status }` where `status` is one of `created` / `initializing` / `qr_ready` / `authenticating` / `ready` / `disconnected` / `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 |
| `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?` |
| `call.received` | An incoming voice/video call starts ringing | `{ callId, from, isVideo, isGroup, timestamp }` β€” `callId` is the id to pass to `POST /sessions/:sessionId/calls/:callId/reject` |
> **`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).
> There is **no** `contact.update` or `presence.update` event, and no `call.accepted` / `call.terminated` lifecycle event yet β€” only `call.received` is emitted.
> **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.
**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) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
```
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` | A fresh `dlv_<uuid>` generated **per delivery** (differs per retry and per webhook) β€” for tracing, not 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:
- `message.received` / `message.sent`: `msg_{sessionId}_{messageId}`
- `message.ack`: `ack_{sessionId}_{messageId}_{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}`
- `group.join` / `group.leave`: `grp_{groupId}_{hash(participantIds)}_{join|leave}_{occurredAt}`
- `group.update`: `grp_{groupId}_update_{hash(changes)}_{occurredAt}`
- `call.received`: `call_{sessionId}_{callId}` (a call id is unique per call, so no `occurredAt` salt)
Recurring lifecycle events (and `message.reaction` / `message.edited`) 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 applied inline.
### SSRF guard on registration
Webhook URLs are validated at **registration time**, not just at delivery. When SSRF protection is enabled (the default), creating or updating a webhook 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. Operator-supplied custom headers that target reserved names (`Content-Type` or any `X-OpenWA-*`) are stripped, so a webhook config cannot forge the signature, event, or idempotency headers.