Project docsProduct & integrations
WhatsApp sessions, API keys and MCP for users
Decision 2026-10-14 (captain): a signed-in user creates their own WhatsApp session in the web app, scans its QR, and gets an API key that works on a public REST passthrough and a public MCP endpoint, with read and send.
Flow
wa.<your-domain>/whatsapp (Supabase session)
-> /api/bff/api/wa/session POST create + start (one session per user, name u-<userId>)
-> /api/bff/api/wa/session/qr poll every 3 s while qr_ready (data:image/png;base64 from OpenWA)
-> /api/bff/api/wa/session status; when ready, the linked phone must equal the invited number
(otherwise the BFF logs the device out: status phone_mismatch)
-> /api/bff/api/wa/keys POST mint key: OpenWA role operator, allowedSessions=[own session],
plaintext shown once, BFF stores only SHA-256 (wa_api_keys); max 5 active
user / agent with the key:
https://api.<your-domain>/wa/<path> -> OpenWA /api/<path> (REST)
https://api.<your-domain>/mcp -> OpenWA /mcp (Streamable HTTP, POST only)Gates on the public passthrough (app/bff/src/adapters/http/routes/gateway-proxy.ts)
- The key's SHA-256 must be an active, user-issued key in
wa_api_keys. The engine's admin key, or any key not minted through the web app, never works from the internet. - OpenWA enforces the key scope (
allowedSessions, role operator) on every route and every MCP tool. - REST paths are confined to
sessions(list, filtered by OpenWA) andsessions/<own id>/.... Refused before the engine: other sessions, every non-session route (auth/api-keys,settings,webhooks, …),DELETE sessions/<id>,force-kill,proxy,config, and any decoded segment with/,\,..or NUL. - 120 requests per minute per key; 16 MiB body cap; 60 s timeout; only
content-type,accept,mcp-protocol-version,idempotency-keyare forwarded, and the key is re-sent asX-API-Key. - API send budget per WhatsApp session (default 300/day UTC, 10/min; admins change the global caps and
per-session overrides live from
/admin). Charged: RESTmessages/send-*,reply,forward(one each),send-bulk(one per item), MCPtools/callofMessageSend*,MessageReply,MessageForward, and workflow auto-replies. Reads, reactions and everything the owner sends from the phone or WhatsApp Web are never counted. A send the engine refuses (HTTP ≥ 400) is refunded. Over the cap: REST answers429 {code: API_SEND_LIMIT}withRetry-After; MCP answers a tool error (isError: true) so the agent can tell its user. Counters live in Valkey. - A user disabled from
/admingets 403 on/api/*,/wa/*and/mcp(keys and connectors included).
Engine settings (VM stack/.env)
MCP_ENABLED=true,MCP_READONLY=false(send tools on; captain decision). MCP sends are capped by the BFF's API send budget (gate 5), not by the paced sender.SEND_PACING_ENABLED=truewithSEND_PACING_WARMUP_SCHEDULE=100000andSEND_PACING_COLD_DAILY_CAP=off. OpenWA's pacing counts every OUTGOING row of the session, including the owner's own phone and WhatsApp Web messages, so on a personal number its daily caps ran out with zero API sends ("Daily send allowance of 80 reached", 2026-10-16). It stays on only for its consecutive-failure circuit breaker.- The BFF's
OPENWA_API_KEYmust be an unscoped admin key (it mints and revokes user keys). PUBLIC_API_URL(BFF) has no production default: set it in every deployment (dev falls back tohttp://localhost:4000). It is the public origin the BFF shows users as their REST base and MCP URL.
Database
supabase/migrations/20261014180000_wa_sessions_keys.sql: wa_sessions (user -> OpenWA session) and wa_api_keys
(hashes only). Both cascade from user_profiles, so removing a number from the allowlist removes the user's session
mapping and keys. RLS on, no policies; the BFF connects as the table owner.
Known limits
- One session per user. Deleting the session revokes all its keys.
- MCP and REST sends bypass the BFF's per-contact pacing, and with the engine's cold-reachout cap off nothing limits first messages to strangers specifically; the API send budget bounds volume only. Ban risk sits with the number owner.
- Budget tables:
supabase/migrations/20261016120000_admin_backoffice.sql(bff_settings,wa_session_limits,user_profiles.disabled_at). /mcpon the gateway hostname stays 404 (terraform/cloudflare); the public MCP is the BFF's, key-gated.