WaFine docs
Project docsResearch & decisions

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+).

#PurposeMethod + pathRoleSource (file:line)
1Send textPOST /api/sessions/:sessionId/messages/send-textoperatorsrc/modules/message/message.controller.ts:161
2Send media (image)POST /api/sessions/:sessionId/messages/send-imageoperatormessage.controller.ts:207
2bSend videoPOST /api/sessions/:sessionId/messages/send-videooperatormessage.controller.ts:235
2cSend audio/voicePOST /api/sessions/:sessionId/messages/send-audiooperatormessage.controller.ts:261
2dSend documentPOST /api/sessions/:sessionId/messages/send-documentoperatormessage.controller.ts:287
2eSend stickerPOST /api/sessions/:sessionId/messages/send-stickeroperatormessage.controller.ts:351
3List sessionsGET /api/sessionsanysrc/modules/session/session.controller.ts:118
4Create sessionPOST /api/sessionsoperator (+ unscoped key)session.controller.ts:84
5Get QR codeGET /api/sessions/:sessionId/qroperatorsession.controller.ts:477
6Session statusGET /api/sessions/:sessionIdanysession.controller.ts:158
7List chatsGET /api/sessions/:sessionId/chatsany (chat-filtered)session.controller.ts:552
8List messagesGET /api/sessions/:sessionId/messagesany (chat-fenced)message.controller.ts:83
9Chat history (live)GET /api/sessions/:sessionId/messages/:chatId/historyanymessage.controller.ts:565
—Validate keyPOST /api/auth/validateanysrc/modules/auth/auth-validate.controller.ts:13
—HealthGET /api/health (+/live,/ready)publicsrc/modules/health/health.controller.ts:37,62,112,121
—MCP transportPOST /mcp (NOT under /api)key-gatedsrc/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> (or Authorization: Bearer <key>), Content-Type: application/json.
  • Body (SendTextMessageDto, src/modules/message/dto/send-message.dto.ts:90):
    fieldtyperequirednotes
    chatIdstringyesphone@c.us or groupId@g.us (:97)
    textstringyesmax 4096 (MESSAGE_TEXT_MAX_LENGTH, :42,106)
    mentionsstring[]no≤1024 WIDs, each ≤64 (:122)
    linkPreviewbooleannostrict bool; engine-dependent (:137)
    customLinkPreviewobjectnoBaileys only, 501 on wwebjs (:150)
    quotedMessageIdstringnoengine-specific id dialect (:159)
  • Success: 201 → MessageResponseDto { messageId, timestamp } (send-message.dto.ts:342-362). 201 = handed to the WhatsApp client, not delivered.
  • Errors: 400 session inactive/invalid, 409 engine not ready, 501 custom 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):
    fieldtyperequirednotes
    chatIdstringyes:196
    urlstringone of url/base64http(s) only, SSRF-guarded (:205)
    base64stringone of url/base64wins over url when it holds data (:213)
    mimetypestringrequired with base64:221
    filenamestringno≤255, documents only (:232)
    captionstringno≤1024 (:242)
    mentionsstring[]no≤1024 (:257)
    quotedMessageIdstringno:266
    • SendAudioMessageDto adds ptt: boolean (voice note) send-message.dto.ts:329-339.
  • Success: 201 → MessageResponseDto. Errors add 413 (too large) and 503 (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), optional config object (only autoRejectCalls, maxReconnectAttempts, reconnectBaseDelay are read, :38-49), optional proxyUrl (ADMIN only, else 403 — session.controller.ts:107).
  • Success: 201 → SessionResponseDto. Errors: 409 name exists, 400 validation.

1.5 Get QR code — GET /api/sessions/:sessionId/qr

  • @Get(':sessionId/qr') :477, @RequireRole(OPERATOR) :478.
  • Success: 200 → QRCodeResponseDto (QR data). 400 if QR not ready or already authenticated; 404 session not found (:486-490). Also emitted as the session.qr webhook 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 (includes status). 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 allowedChats before pagination (:577).
  • Success: 200 → ChatSummary[] (most-recent first). 503 if 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. 400 if after names no message; 403 if a chat-restricted key omits/escapes chatId (:120-132).
  • Live chat history (bypasses local DB): GET .../messages/:chatId/history :565, query limit, 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 is API_MASTER_KEY (trimmed) → owa_k1_… random (secure default) → dev-admin-key only if ALLOW_DEV_API_KEY=true.
  • Stored keyPrefix is 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; also active-key-index.ts:87.
  • Validation: validateApiKey() auth.service.ts:494-557 — trims the key (:505), hashes, looks up the row by keyHash (:506), then checks isActive (:517), expiresAt (:523), allowedIps (:532), allowedSessions (:546).
  • Operational trap (documented in code): enabling or changing API_KEY_PEPPER invalidates every pre-existing key, admin included (api-key-hash.ts:7-12; main.ts:100-106 warns 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):

ScopeColumnSemantics
IP allowlistallowedIpsexact IP or CIDR; fail-closed if IP undeterminable (auth.service.ts:530-543)
Session allowlistallowedSessions:546-550; also filters GET /sessions (session.controller.ts:149)
Chat allowlistallowedChats: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.missed

WEBHOOK_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-webhook secret.
  • 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:

HeaderValue
X-OpenWA-Signaturesha256=<hex> (only if secret set)
X-OpenWA-Eventevent name, e.g. message.received
X-OpenWA-Idempotency-Keystable per event occurrence
X-OpenWA-Delivery-Idunique per delivery (stable across retries)
X-OpenWA-Retry-Countinteger retry count
Content-Typeapplication/json
User-AgentOpenWA-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):

  1. Read the raw request body (e.g. express.raw({ type: 'application/json' })) — parse JSON only after verification.
  2. Compute expected = 'sha256=' + HMAC_SHA256(secret, rawBody).hex.
  3. Compare with X-OpenWA-Signature using a constant-time compare (crypto.timingSafeEqual, or hmac.compare_digest in Python); length-check first.
  4. Reject with 401 on mismatch; return 2xx only after accepting.
  5. Dedupe on X-OpenWA-Idempotency-Key to 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 /api prefix (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 as basePath: '/mcp' from src/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 /mcp and DELETE /mcp answer 405 with Allow: POST (:315-322). The POST reply is SSE-framed, so a client must send Accept: application/json, text/event-stream or get 406 (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 — including initialize and tools/list. Same X-API-Key / Bearer extraction as REST (:40-47). Note: a key with allowedIps is 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-IP MCP_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:

  1. Warm-up daily cap — total outgoing messages/day by session age in days, ramped (warmupSchedule, :267-272).
  2. Cold-reachout daily cap — first message to a chat with no prior history in either direction (coldSchedule, :273).
  3. 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 varDefaultMeaning
SEND_PACING_ENABLEDoff (=== 'true', :80)opt-in; an upgrade never starts refusing sends
SEND_PACING_WARMUP_SCHEDULE20,40,80,160,320,640,1000 (:36)daily allowance by session age (day 0 → last rung forever)
SEND_PACING_COLD_DAILY_CAP5,10,20,40,60,80,100 (:38)cold-reachout allowance; 0/off/empty disables the rule (:60-62)
SEND_PACING_BREAKER_THRESHOLD5 (:39)consecutive failures to trip
SEND_PACING_BREAKER_COOLDOWN_MS900000 (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-public POSTGRES_SCHEMA sets both TypeORM's schema option and the session search_path (options: '-c search_path=<schema>,public', :114) so raw, unqualified migration DDL lands in the configured schema, not public.
  • Validation (postgresSchemaError, src/config/env.validation.ts:52-57): must be a valid lower-case Postgres identifier (≤63 chars), and must not use the reserved pg_ 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 / openwa on PG).
  • main — a separate small DB for auth/audit (MAIN_DATABASE_NAME, default ./data/main.sqlite, configuration.ts:175), holding api_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

  1. 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.
  2. Auth: one operator-scoped key (X-API-Key) is enough for the send/list surface. Mint a dedicated key with allowedSessions (and optionally allowedChats) to bound the BFF's blast radius — but note POST /api/sessions requires an unscoped key, so session creation needs a separate unscoped key.
  3. Webhook ingress: register a webhook per session (POST /api/sessions/:sessionId/webhooks) with a ≥16-char secret; verify X-OpenWA-Signature over the raw body before parsing; dedupe on X-OpenWA-Idempotency-Key.
  4. MCP: if the BFF proxies the gateway's MCP surface, keep MCP_READONLY at its secure default (true) unless write tools are genuinely required, and keep /mcp tailnet-only (the infra ingress already 404s /mcp publicly).
  5. 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 from openapi.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.

On this page