R2 — OpenWA Gateway HTTP API & MCP Surface (as vendored)
Source of truth: submodule /home/m7r/Work/openwa-oci-infra/OpenWA, pinned to upstream
rmyndharis/OpenWA v0.24.0 (package.json:3, git HEAD e9afb97).
Every path/line below is a real file in that tree. Claims that exist only in docs/ are marked
DOC-ONLY; claims I could not pin to code are marked UNVERIFIED.
Global prefix: Nest sets app.setGlobalPrefix('api') — src/config/app-validation.ts:49.
So the real wire path is /api + every controller path below. Internal port is 2785
(.env.minimal:12, PORT=2785). The committed openapi.json (837 KB, 160 paths) confirms the
/api/... prefix on every route.
Auth model in one line: multi-session, single-operator; one X-API-Key header
(or Authorization: Bearer) authenticates every REST call; keys carry a role
(admin/operator/viewer) plus optional allowedIps / allowedSessions / allowedChats
scopes. There is no tenant/user concept — see §6.
0. Endpoint summary table
All paths are the wire path (prefix /api already included). "Role" = minimum ApiKeyRole
enforced by @RequireRole; a route with no role is reachable by any valid key (viewer+).
| # | Purpose | Method + path | Role | Source (file:line) |
|---|---|---|---|---|
| 1 | Send text | POST /api/sessions/:sessionId/messages/send-text | operator | src/modules/message/message.controller.ts:161 |
| 2 | Send media (image) | POST /api/sessions/:sessionId/messages/send-image | operator | message.controller.ts:207 |
| 2b | Send video | POST /api/sessions/:sessionId/messages/send-video | operator | message.controller.ts:235 |
| 2c | Send audio/voice | POST /api/sessions/:sessionId/messages/send-audio | operator | message.controller.ts:261 |
| 2d | Send document | POST /api/sessions/:sessionId/messages/send-document | operator | message.controller.ts:287 |
| 2e | Send sticker | POST /api/sessions/:sessionId/messages/send-sticker | operator | message.controller.ts:351 |
| 3 | List sessions | GET /api/sessions | any | src/modules/session/session.controller.ts:118 |
| 4 | Create session | POST /api/sessions | operator (+ unscoped key) | session.controller.ts:84 |
| 5 | Get QR code | GET /api/sessions/:sessionId/qr | operator | session.controller.ts:477 |
| 6 | Session status | GET /api/sessions/:sessionId | any | session.controller.ts:158 |
| 7 | List chats | GET /api/sessions/:sessionId/chats | any (chat-filtered) | session.controller.ts:552 |
| 8 | List messages | GET /api/sessions/:sessionId/messages | any (chat-fenced) | message.controller.ts:83 |
| 9 | Chat history (live) | GET /api/sessions/:sessionId/messages/:chatId/history | any | message.controller.ts:565 |
| — | Validate key | POST /api/auth/validate | any | src/modules/auth/auth-validate.controller.ts:13 |
| — | Health | GET /api/health (+/live,/ready) | public | src/modules/health/health.controller.ts:37,62,112,121 |
| — | MCP transport | POST /mcp (NOT under /api) | key-gated | src/modules/mcp/mcp.server.ts:304 |
Full route inventory (all 160) is in openapi.json; the session/message/webhook/auth families are
enumerated in the sections below.
1. Exact HTTP endpoints
1.1 Send text — POST /api/sessions/:sessionId/messages/send-text
- Controller:
@Controller('sessions/:sessionId/messages')message.controller.ts:72; handler@Post('send-text'):161,@RequireRole(ApiKeyRole.OPERATOR):162,@ChatScoped('fenced'):160. - Headers:
X-API-Key: <key>(orAuthorization: Bearer <key>),Content-Type: application/json. - Body (
SendTextMessageDto,src/modules/message/dto/send-message.dto.ts:90):field type required notes chatIdstring yes phone@c.usorgroupId@g.us(:97)textstring yes max 4096 ( MESSAGE_TEXT_MAX_LENGTH,:42,106)mentionsstring[] no ≤1024 WIDs, each ≤64 ( :122)linkPreviewboolean no strict bool; engine-dependent ( :137)customLinkPreviewobject no Baileys only, 501 on wwebjs ( :150)quotedMessageIdstring no engine-specific id dialect ( :159) - Success:
201→MessageResponseDto { messageId, timestamp }(send-message.dto.ts:342-362).201= handed to the WhatsApp client, not delivered. - Errors:
400session inactive/invalid,409engine not ready,501custom preview/quoted-send on wwebjs (message.controller.ts:173-178).
1.2 Send media — POST /api/sessions/:sessionId/messages/send-{image|video|audio|document|sticker}
- Handlers:
send-image:207,send-video:235,send-audio:261,send-document:287,send-sticker:351; each@RequireRole(OPERATOR)+@ChatScoped('fenced'). - Body (
SendMediaMessageDto,send-message.dto.ts:189):field type required notes chatIdstring yes :196urlstring one of url/base64 http(s) only, SSRF-guarded ( :205)base64string one of url/base64 wins over urlwhen it holds data (:213)mimetypestring required with base64 :221filenamestring no ≤255, documents only ( :232)captionstring no ≤1024 ( :242)mentionsstring[] no ≤1024 ( :257)quotedMessageIdstring no :266SendAudioMessageDtoaddsptt: boolean(voice note)send-message.dto.ts:329-339.
- Success:
201→MessageResponseDto. Errors add413(too large) and503(media URL proxy)message.controller.ts:225-226.
1.3 List sessions — GET /api/sessions
@Get()session.controller.ts:118; class-level@SessionScoped():70.- Query:
limit(1–1000, default 1000),offset,name(exact, case-sensitive; empty/repeated → 400):125-135. - Scoping: results filtered to the key's
allowedSessions(:149); a scoped key cannot enumerate all sessions. - Success:
200→SessionResponseDto[].
1.4 Create session — POST /api/sessions
@Post():84,@RequireRole(OPERATOR):85,@RequireUnscopedKey():89— a session-scoped key is refused (cannot create a session outside its allowlist).- Body (
CreateSessionDto,src/modules/session/dto/create-session.dto.ts:11):name(required, 3–50,[a-zA-Z0-9-]+,:18-26), optionalconfigobject (onlyautoRejectCalls,maxReconnectAttempts,reconnectBaseDelayare read,:38-49), optionalproxyUrl(ADMIN only, else 403 —session.controller.ts:107). - Success:
201→SessionResponseDto. Errors:409name exists,400validation.
1.5 Get QR code — GET /api/sessions/:sessionId/qr
@Get(':sessionId/qr'):477,@RequireRole(OPERATOR):478.- Success:
200→QRCodeResponseDto(QR data).400if QR not ready or already authenticated;404session not found (:486-490). Also emitted as thesession.qrwebhook event (session-engine-event-wiring.ts:206).
1.6 Session status — GET /api/sessions/:sessionId
@ChatScoped('agnostic'):157,@Get(':sessionId'):158,@Param('sessionId', ParseUUIDPipe):168(non-UUID → 400).- Success:
200→SessionResponseDto(includesstatus). Status enum (src/modules/session/entities/session.entity.ts:7-16):created, initializing, qr_ready, authenticating, ready, disconnected, action_required, failed. - Related lifecycle routes:
POST :sessionId/start:293,POST :sessionId/stop:345,POST :sessionId/logout:382,POST :sessionId/force-kill:448,POST :sessionId/pairing-code:499(all OPERATOR).GET /api/sessions/stats/overview:941.
1.7 List chats — GET /api/sessions/:sessionId/chats
@ChatScoped('filtered'):551,@Get(':sessionId/chats'):552.- Query
limit/offset(1–1000, default 1000):566-567. - Scoping: filtered to the key's
allowedChatsbefore pagination (:577). - Success:
200→ChatSummary[](most-recent first).503if the wwebjs page died (not reported as empty):559-565.
1.8 List messages — GET /api/sessions/:sessionId/messages
@ChatScoped('fenced'):82,@Get():83.- Query (
:86-114):chatId(required for a chat-restricted key),from,limit(default 50),offset,inlineMedia(default true),after(keyset cursor, takes precedence over offset). - Success:
200→MessageListResponseDto.400ifafternames no message;403if a chat-restricted key omits/escapeschatId(:120-132). - Live chat history (bypasses local DB):
GET .../messages/:chatId/history:565, querylimit,includeMedia,deep(wwebjs only; raises ceiling 100→2000):574-589.
1.9 Other message actions (all @RequireRole(OPERATOR) + @ChatScoped('fenced'))
send-template :184, send-location :315, send-contact :333, send-poll :377,
reply :401, click-button :419, forward :450, react :469, delete :702,
vote-poll :733, pin :756, unpin :785, star :814, edit :842,
send-bulk :878 (async, 202). Batch status GET .../messages/batch/:batchId :498,
cancel POST .../batch/:batchId/cancel :530.
1.10 Webhook CRUD (BFF will need these)
@Controller('sessions/:sessionId/webhooks') src/modules/webhook/webhook.controller.ts:9:
POST :13, GET :32, GET :id :45, PUT :id :60, POST :id/test :83,
DELETE :id :98 — all @RequireRole(OPERATOR). Plus @Controller('webhooks')
webhooks-list.controller.ts:10: GET :48 (OPERATOR), GET delivery-failures :14 (ADMIN).
2. API-key auth — how it actually works
2.1 Key format
Generated at src/modules/auth/auth.service.ts:59 and :201:
owa_k1_<64 hex chars> // owa_k1_ + randomBytes(32).toString('hex')resolveSeedApiKey():49-60: precedence isAPI_MASTER_KEY(trimmed) →owa_k1_…random (secure default) →dev-admin-keyonly ifALLOW_DEV_API_KEY=true.- Stored
keyPrefixis the first 12 chars (:187,:203; column length 12,entities/api-key.entity.ts:23). The dashboard only ever shows the prefix.
2.2 Where the pepper is used
src/modules/auth/api-key-hash.ts:14-18:
export function hashApiKey(rawKey: string, pepper?: string): string {
return pepper
? createHmac('sha256', pepper).update(rawKey).digest('hex') // API_KEY_PEPPER set
: createHash('sha256').update(rawKey).digest('hex'); // fallback, no pepper
}- Called via
AuthService.hashKey()→hashApiKey(rawKey, process.env.API_KEY_PEPPER)auth.service.ts:559-561; alsoactive-key-index.ts:87. - Validation:
validateApiKey()auth.service.ts:494-557— trims the key (:505), hashes, looks up the row bykeyHash(:506), then checksisActive(:517),expiresAt(:523),allowedIps(:532),allowedSessions(:546). - Operational trap (documented in code): enabling or changing
API_KEY_PEPPERinvalidates every pre-existing key, admin included (api-key-hash.ts:7-12;main.ts:100-106warns at boot when unset in production). Set it before first boot.
2.3 Scope names
Roles (ApiKeyRole, entities/api-key.entity.ts:3-7): admin, operator, viewer.
Role hierarchy auth.service.ts:571-575: viewer=1 < operator=2 < admin=3; hasPermission is >=.
Key-level scopes (all optional, api-key.entity.ts:33-44):
| Scope | Column | Semantics |
|---|---|---|
| IP allowlist | allowedIps | exact IP or CIDR; fail-closed if IP undeterminable (auth.service.ts:530-543) |
| Session allowlist | allowedSessions | :546-550; also filters GET /sessions (session.controller.ts:149) |
| Chat allowlist | allowedChats | :39-44; default-deny — a restricted key may only reach @ChatScoped handlers (api-key.guard.ts:120-134) |
2.4 What a key looks like in a header
ApiKeyGuard.extractApiKey() src/modules/auth/guards/api-key.guard.ts:230-236:
const xApiKey = request.headers['x-api-key'] as string; // X-API-Key wins
if (xApiKey) return xApiKey;
return bearerToken(request.headers['authorization']); // else Authorization: Bearer <key>So either X-API-Key: owa_k1_<hex> or Authorization: Bearer owa_k1_<hex>. HTTP header names are
case-insensitive; the MCP mount reads the same two (mcp.server.ts:40-47). A missing key → 401;
a valid key refused by a scope → 403 (api-key.guard.ts:116-146).
2.5 Key-management routes
@Controller('auth/api-keys') src/modules/auth/auth.controller.ts:19 — POST :72,
GET :99, GET :id :112, PUT :id :126, DELETE :id :156, POST :id/revoke :175,
all @RequireRole(ADMIN). POST /api/auth/validate (auth-validate.controller.ts:13) returns
{ valid, role, engineType, scoped } for any valid key.
3. Webhook events — names, payload, signature (CRITICAL for BFF ingress)
3.1 Event names (exact)
WEBHOOK_EVENTS, src/modules/webhook/dto/webhook.dto.ts:77-102:
message.received message.sent message.ack message.failed
message.revoked message.reaction message.edited status.received
session.status session.qr session.authenticated session.disconnected
session.reconnect_loop session.restriction presence.update
group.join group.leave group.update group.join_request
call.received call.accepted call.rejected call.missedWEBHOOK_RESERVED_EVENTS = [] (:75) — nothing is declared-but-unemitted today. '*' subscribes
to all (:122). Emit sites verified in code: message.received message-projector.service.ts:597;
message.sent :763; message.ack :833; message.failed :839; message.revoked :893;
status.received :955; session.status session-status-broadcaster.ts:67;
session.qr session-engine-event-wiring.ts:206; session.reconnect_loop :339;
session.restriction :455. The group/call/presence emitters are in the engine adapters
(src/engine/adapters/) — UNVERIFIED individually here, but the catalog drift guard
(webhook.dto.ts:71-74 comment) asserts every catalog entry has an emitter.
3.2 Payload envelope
WebhookPayload, src/modules/webhook/webhook-delivery.service.ts:30-37:
{
"event": "message.received",
"timestamp": "2026-10-02T12:00:00.000Z",
"sessionId": "<uuid>",
"idempotencyKey": "<stable per event occurrence>",
"deliveryId": "<unique per delivery>",
"data": { /* event-specific */ }
}Built in preflightDelivery() webhook-delivery.service.ts:381-388. The four identity fields are
re-asserted after the (untrusted) webhook:before hook chain (:429-438) — a hook may rewrite
data, never event/sessionId/timestamp/idempotencyKey/deliveryId. Body is serialized
once and reused for the size gate, the HMAC, and the POST (:442-448).
data shapes (verified emit sites):
message.received/message.sent— the projected message object (message-projector.service.ts:594-597).session.status—{ sessionId, status }(session-status-broadcaster.ts:67).session.qr—{ sessionId, qr }(session-engine-event-wiring.ts:206).session.reconnect_loop—{ sessionId, attempts, nextDelayMs }(:339-343).session.restriction—{ sessionId, active, kind, code, expiresAt }(:449-455).
3.3 HMAC signature — computation
src/modules/webhook/utils/deliver-once.ts:43-48:
export function generateSignature(payload: string, secret: string): string {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload);
return `sha256=${hmac.digest('hex')}`;
}- Algorithm: HMAC-SHA256 over the exact raw serialized body bytes (
JSON.stringify(finalPayload),webhook-delivery.service.ts:448), keyed by the per-webhooksecret. - Header:
X-OpenWA-Signature: sha256=<hex>— present only when the webhook has a secret (deliver-once.ts:72-74).
3.4 Delivery headers (all system headers)
buildDeliveryHeaders() deliver-once.ts:55-76:
| Header | Value |
|---|---|
X-OpenWA-Signature | sha256=<hex> (only if secret set) |
X-OpenWA-Event | event name, e.g. message.received |
X-OpenWA-Idempotency-Key | stable per event occurrence |
X-OpenWA-Delivery-Id | unique per delivery (stable across retries) |
X-OpenWA-Retry-Count | integer retry count |
Content-Type | application/json |
User-Agent | OpenWA-Webhook/1.0.0 |
Custom headers are sanitized so a webhook config cannot forge the system headers: any
Content-Type, X-OpenWA-* (any case), or connection/framing header is dropped
(sanitizeCustomHeaders deliver-once.ts:16-27).
3.5 How the BFF must verify
Per docs/examples/webhook-signature-verification.md:39-124 (matches the code above):
- Read the raw request body (e.g.
express.raw({ type: 'application/json' })) — parse JSON only after verification. - Compute
expected = 'sha256=' + HMAC_SHA256(secret, rawBody).hex. - Compare with
X-OpenWA-Signatureusing a constant-time compare (crypto.timingSafeEqual, orhmac.compare_digestin Python); length-check first. - Reject with
401on mismatch; return2xxonly after accepting. - Dedupe on
X-OpenWA-Idempotency-Keyto survive retries.
The secret is set at webhook creation (CreateWebhookDto, min 16 chars — webhook.dto.ts:227-238)
and is never returned by any webhook route (:138). Delivery is SSRF-guarded
(postWebhookPayload uses withSafeFetch, deliver-once.ts:84-104), so the BFF ingress URL must
be reachable from the OpenWA container and not resolve to a blocked internal address unless
SSRF_ALLOWED_HOSTS permits it.
Retries: retryCount (default 3, webhook entity / migration default) with exponential backoff
(webhook-delivery.service.ts:558-562); terminal failures land in webhook_delivery_failures
(pruned after WEBHOOK_FAILURE_RETENTION_DAYS, default 90 — webhook.service.ts:57-82).
4. MCP endpoint & tools
4.1 Endpoint
- Mount path:
POST /mcp— outside the/apiprefix (mounted on the raw Express adapter, not a Nest controller).mountMcpServer()src/modules/mcp/mcp.server.ts:247-323; base path default/mcp(:256), passed asbasePath: '/mcp'fromsrc/app.module.ts:79. - Opt-in: only mounted when
MCP_ENABLED === 'true'(app.module.ts:72-84); otherwise the module and the MCP SDK are never loaded. - Transport: stateless Streamable-HTTP (
sessionIdGenerator: undefined,:276).GET /mcpandDELETE /mcpanswer 405 withAllow: POST(:315-322). The POST reply is SSE-framed, so a client must sendAccept: application/json, text/event-streamor get406(docs/24-mcp-integration.md:41-43). - Auth: every POST passes a per-IP throttle then a key gate (
createIpThrottle:180,createKeyGate:210) before the transport answers anything — includinginitializeandtools/list. SameX-API-Key/Bearerextraction as REST (:40-47). Note: a key withallowedIpsis refused on MCP because the gate validates with no client IP (:206). - Rate limits: per-key
MCP_RATE_LIMIT_MAX(default 60) /MCP_RATE_LIMIT_WINDOW_MS(default 60000); per-IPMCP_IP_RATE_LIMIT_MAX(default 120) (src/modules/mcp/mcp-rate-limit.ts:3-8).
4.2 Read-only default & toggle
resolveMcpReadOnly() mcp.server.ts:243-245:
return optionsReadOnly ?? process.env.MCP_READONLY !== 'false';Read-only is the default — write tools are exposed only when MCP_READONLY=false is set
explicitly. (This was a security fix: unset previously defaulted to read-write, :238-241.)
registry.list({ readOnly }) filters to tier === 'read' when read-only
(src/core/agent-tools/tool-registry.service.ts:17-20).
4.3 Tools exposed
Tools are registered from the protocol-neutral registry (buildServer mcp.server.ts:99-155).
Names + tiers (source: src/core/agent-tools/tools/*.tools.ts):
Read tier (available in default read-only mode):
MessageList (message.tools.ts:86), MessageHistory :107, MessageGetReactions :129,
SessionFindAll (session.tools.ts:17), SessionFindOne :32, SessionGetChats :41,
SessionGetStats :53, SessionGetPresence :76, ContactFindAll (contact.tools.ts:11),
ContactFindOne :23, ContactCheckNumber :34, ContactResolvePhone :50,
ContactGetProfilePicture :65, GroupFindAll (group.tools.ts:16), GroupFindOne :28,
GroupGetInviteCode :39, LabelFindAll (label.tools.ts:19), LabelFindOne :29,
LabelListChats :39, LabelListForChat :49, WebhooksList (webhook.tools.ts:12),
WebhookFindBySession :27, WebhookFindOne :36, AutomationRuleFindAll
(automation.tools.ts:12), AutomationRuleFindOne :24.
Write tier (only with MCP_READONLY=false):
MessageSendText :141, MessageSendImage :172, MessageSendVideo :201,
MessageSendAudio :230, MessageSendDocument :261, MessageSendLocation :290,
MessageSendContact :315, MessageSendSticker :340, MessageSendTemplate :369,
MessageReply :396, MessageForward :417, MessageReact :436,
SessionSubscribePresence (session.tools.ts:60), SessionMarkChatRead :90,
SessionMarkChatUnread :114, SessionSendChatState :126,
ContactBlock (contact.tools.ts:79), ContactUnblock :94,
GroupCreate (group.tools.ts:56), GroupAddParticipants :73, GroupSetSubject :105,
GroupSetDescription :122, LabelUpsert (label.tools.ts:58), LabelDelete :86,
LabelAddToChat :98, LabelRemoveFromChat :108.
docs/24-mcp-integration.md:19 states 51 tools; the code above enumerates 25 read + 26 write =
51. Each tool re-runs the same auth/role/scope pipeline as REST via invokeTool
(mcp.server.ts:132-143); annotations set readOnlyHint/destructiveHint/idempotentHint
(:123-127).
5. Rate limiting / send pacing — SEND_PACING_ENABLED
5.1 What it does
SEND_PACING_ENABLED=true turns on a per-session outbound send governor
(SendPacingService.assertSendAllowed(), src/modules/message/send-pacing.service.ts:250), called
at the top of every send path. It enforces three independent limits:
- Warm-up daily cap — total outgoing messages/day by session age in days, ramped
(
warmupSchedule,:267-272). - Cold-reachout daily cap — first message to a chat with no prior history in either direction
(
coldSchedule,:273). - Failure breaker — consecutive account-level send failures trip a cooldown that refuses all
sends (
assertBreakerClosed,:258).
A refusal is a 429 with body code: "SEND_PACING_LIMITED" (SEND_PACING_LIMITED,
send-pacing.service.ts:19, :723) — distinct from the throttler's own 429 (which has no code).
Only account-standing failures count toward the breaker; 4xx/501/503 client/engine-state errors do
not (countsTowardSendBreaker :36-59). Blocked sends are audited as send_pacing_blocked
(≤1 row per session per window, audit-log.entity.ts:45-50).
5.2 Defaults
computeSendPacingConfig() src/modules/message/send-pacing.config.ts:78-94:
| Env var | Default | Meaning |
|---|---|---|
SEND_PACING_ENABLED | off (=== 'true', :80) | opt-in; an upgrade never starts refusing sends |
SEND_PACING_WARMUP_SCHEDULE | 20,40,80,160,320,640,1000 (:36) | daily allowance by session age (day 0 → last rung forever) |
SEND_PACING_COLD_DAILY_CAP | 5,10,20,40,60,80,100 (:38) | cold-reachout allowance; 0/off/empty disables the rule (:60-62) |
SEND_PACING_BREAKER_THRESHOLD | 5 (:39) | consecutive failures to trip |
SEND_PACING_BREAKER_COOLDOWN_MS | 900000 (15 min, :40) | breaker open duration |
Clamps: daily caps [1, 100000] (:45-46), threshold ≥1, cooldown ≥1000 ms (:47-48). A
malformed schedule falls back to the whole default, never a partial ramp (:50-68). Age is
measured from createdAt, not connectedAt (:264-266). The breaker and the group-reachout tally
are in-memory (reset on restart) (:89, :205-215); the chat-cold count is read from the
messages table and is restart-proof.
BFF implication: if the stack enables pacing, a 429 with code: SEND_PACING_LIMITED is a
policy refusal, not a delivery failure — surface it as retry-after, don't mark the message failed.
6. POSTGRES_SCHEMA and the tables OpenWA owns
6.1 What it does
src/config/configuration.ts:194-199:
schema: process.env.POSTGRES_SCHEMA || 'public',- Default
public. When set, OpenWA places all its tables AND the TypeORM migration ledger in that schema. The schema must already exist — a missing one fails fast at migration time (:197-198). src/database/data-source.ts:74-116: a non-publicPOSTGRES_SCHEMAsets both TypeORM'sschemaoption and the sessionsearch_path(options: '-c search_path=<schema>,public',:114) so raw, unqualified migration DDL lands in the configured schema, notpublic.- Validation (
postgresSchemaError,src/config/env.validation.ts:52-57): must be a valid lower-case Postgres identifier (≤63 chars), and must not use the reservedpg_prefix. - SQLite ignores it (
configuration.ts:198).
6.2 Two databases
OpenWA uses two connections:
data— the main business DB (DATABASE_NAME, default./data/openwa.sqlite/openwaon PG).main— a separate small DB for auth/audit (MAIN_DATABASE_NAME, default./data/main.sqlite,configuration.ts:175), holdingapi_keys+audit_logs(src/database/data-source-main.ts:36).
POSTGRES_SCHEMA applies to the data connection (the data-source.ts builder). Whether it
also applies to the main connection is UNVERIFIED from the code read here.
6.3 Tables OpenWA owns (the BFF must NOT collide)
From @Entity(...) decorators (src/**/*.entity.ts):
data DB: sessions, messages, message_batches, chat_states, baileys_stored_messages,
lid_mappings, status_updates, contacts* , groups* , labels* , templates, webhooks,
webhook_outbox_events, webhook_delivery_failures, automation_rules, conversation_mappings,
ingress_events, integration_delivery_failures, plugin_instances.
main DB: api_keys, audit_logs.
(*contacts/groups/labels entity names are inferred from the module layout — UNVERIFIED
as exact @Entity strings; the confirmed set from the grep is: api_keys, audit_logs,
automation_rules, baileys_stored_messages, chat_states, conversation_mappings,
ingress_events, integration_delivery_failures, lid_mappings, message_batches, messages,
plugin_instances, sessions, status_updates, templates, webhook_delivery_failures,
webhook_outbox_events, webhooks.)
Plus the TypeORM migration ledger table (migrations / typeorm_metadata) in whichever schema
POSTGRES_SCHEMA selects.
BFF guidance: if the BFF shares a Postgres instance, either give it its own schema/database or
prefix its tables (e.g. bff_*) — do not reuse any name above, and do not assume public is free
if POSTGRES_SCHEMA is left at its default.
7. Notes for the BFF integration
- Internal URL: call OpenWA at
http://<service>:2785/api/...over the compose network. Every route is under/api; the MCP mount is the lone exception at/mcp. - Auth: one operator-scoped key (
X-API-Key) is enough for the send/list surface. Mint a dedicated key withallowedSessions(and optionallyallowedChats) to bound the BFF's blast radius — but notePOST /api/sessionsrequires an unscoped key, so session creation needs a separate unscoped key. - Webhook ingress: register a webhook per session (
POST /api/sessions/:sessionId/webhooks) with a ≥16-charsecret; verifyX-OpenWA-Signatureover the raw body before parsing; dedupe onX-OpenWA-Idempotency-Key. - MCP: if the BFF proxies the gateway's MCP surface, keep
MCP_READONLYat its secure default (true) unless write tools are genuinely required, and keep/mcptailnet-only (the infra ingress already 404s/mcppublicly). - Pacing: treat
429 { code: "SEND_PACING_LIMITED" }as a retryable policy refusal.
DOC-ONLY / not implemented
docs/28-multitenancy.md— explicitly a design proposal: "nothing here is implemented" (:3). No tenant entity, users, memberships, or 2FA exist in code. The/api/platform/tenants,/api/tenants/:tenantId/members, and/api/auth/2fa/*routes it lists (:163-167) are DOC-ONLY and absent fromopenapi.json. OpenWA today is multi-session, single-operator.docs/24-mcp-integration.md— marked "✅ Implemented — opt-in, off by default" (:5); the OAuth 2.1 public-exposure and agent-audit-provenance rows are "🔜 Planned" (:22-23) — DOC-ONLY.- SDK webhook-verify helpers (
docs/examples/webhook-signature-verification.md:27) — "From the next SDK release (after 0.5.0)"; the JS/Python/Go/Java/PHP helpers are DOC-ONLY for v0.24.0.