Architecture
Monorepo layout, the webhook to reply pipeline, and the tenant-scoped data model.
Monorepo layout
A pnpm workspace orchestrated by Turborepo.
| Path | Package | Role |
|---|---|---|
apps/engine | @wa/engine | Cloudflare Worker built with Hono. Receives OpenWA webhooks, runs QStash job handlers (inbound, answer, send, ingest), and exposes a bearer-protected /admin API (sessions, groups, contacts). |
apps/admin | @wa/admin | Next.js app with shadcn/ui and Better Auth, deployed to Cloudflare through OpenNext. Workspace sign-in, WhatsApp linking, assistant settings, knowledge uploads, inbox, groups, usage. |
apps/docs | @wa/docs | This site (Fumadocs, static export). |
packages/db | @wa/db | Drizzle ORM schema and migrations on Neon Postgres, withTenant helper, UUIDv7 newId(). Row level security is forced on tenant tables. |
packages/core | @wa/core | The answerer (Claude call, citation validation), chunking, full-text retrieval, pricing, AES-GCM secret envelope, and the OpenWA client, webhook normalizer, signature check, and error classifier. |
packages/email | @wa/email | Resend client and React Email templates (handoff alert, action email). |
External services: Neon (Postgres), Cloudflare R2 (binding BLOB), Cloudflare KV (binding ROUTES), Upstash QStash (queue) and Redis (rate limit), Anthropic (answers), Sarvam and ElevenLabs (voice, optional), Resend (email), and the self-hosted OpenWA gateway that holds the WhatsApp sessions (see OpenWA gateway).
Request flow
- Webhook (
POST /webhooks/openwa). The handler reads the raw body and verifiesX-OpenWA-Signature(HMAC-SHA256 withOPENWA_WEBHOOK_SECRET) before parsing. Invalid signature returns 401. A signed but malformed payload is stored underraw/openwa/invalid/and acknowledged so OpenWA stops retrying. No model or database work happens in this request. - Raw payload to R2. Valid payloads are written to
raw/openwa/<date>/<uuidv7>.json, which keeps an inspectable record of what the gateway sent. Inline media (up to 1 MB) is moved to its own R2 object first. - One QStash job per event. The payload is normalized into events (
message,own,status,session) and published to/jobs/inboundin batches, withretries: 5. Each job carries a deduplication ID:msg:<sessionId>:<messageId>for messages,own:...for messages sent from the linked account,st:<sessionId>:<messageId>:<status>for acks, andev:<idempotencyKey>for session and group events. Message jobs also carry a flow-control key (conv:<sessionId>:<sender>, or the group id for groups) withparallelism: 1, so one conversation is processed strictly in order. If enqueueing fails the webhook returns 5xx and OpenWA redelivers; dedup IDs make that safe. - Inbound job. Verified by QStash signature. It looks up the tenant by session ID in
channel_route(cached in KV), then does everything else insidewithTenant. It upserts the contact and conversation, and inserts the message (unique on provider message ID, so redelivery is a no-op). It archives inbound media tows/<workspaceId>/media/<messageId>in R2, marks the message read and shows a typing indicator (private chats only), skips conversations inhumanmode and blocked contacts, and ignores silent types. Messages the account sent that the engine did not send put the conversation intohumanmode. - Answer job. About 3 seconds later
/jobs/answeranswers the newest unanswered message together with any earlier ones (see Voice notes, groups and calling). Retrieval is Postgres full-text search over the current ready version of active sources (see Knowledge and answers). - Claude answer. One structured-output call returns
action,reply,citations,quickReplies, andlanguage. Before it is accepted, code checks that every cited chunk ID was actually retrieved. Ananswerwith no valid citation is downgraded to a handoff. Quick replies are appended to the text as a short "• option" list (omitted in groups) because linked accounts cannot send buttons. - Outbox. The reply is inserted into
outboxwith idempotency keyreply:<messageId>and the set of source versions it cited. A retry of the answer job finds the existing row and only re-enqueues the send. - Send job (
/jobs/send). Enqueued with dedup IDsend:<outboxId>and flow keysend:<conversationId>. It claims the row underSELECT ... FOR UPDATE, runs freshness checks, marks itsending, shows typing (or recording) for a human-like delay, calls the gateway, then recordssentand inserts the outbound message. Details in Operations. - Acks.
message.ackandmessage.failedevents updatemessage.delivery_statusmonotonically (sent,delivered,read), so out-of-order webhooks never move a message backwards.
Retryable conditions (Anthropic 429, 5xx, 529, 408, connection errors; gateway 409, 429 and 503; the per-tenant answer rate limit) make the worker return 503 {"error":"retry"} so QStash redelivers with backoff. Everything else returns 500 and is logged with the request's X-Trace-Id (a UUIDv7 set on every response).
Other engine routes
| Route | Purpose |
|---|---|
GET /health | Liveness. |
POST /webhooks/openwa | Signed OpenWA webhook receiver. |
POST /jobs/inbound, /jobs/answer, /jobs/send | QStash job handlers. |
POST /jobs/ingest | Chunk an uploaded source version. Enqueued by the admin app. |
POST /admin/* | Bearer-token API for the admin app: session/connect, session/qr, session/pairing-code, session/status, session/logout, contact block and unblock, and group operations. |
Data model
All primary keys are UUIDv7 (newId() from the uuidv7 package), which sort by creation time. Tenant tables carry workspace_id referencing the Better Auth organization.
| Table | Notes |
|---|---|
assistant, assistant_version | Versioned AssistantConfig (persona, instructions, languages, AI disclosure, handoff and fallback messages, model, effort). The published version is what the engine reads. |
channel_connection | OpenWA session ID and name, last session status, linked phone number and push name, status (pending, active, paused, disconnected). Unique on session ID. No credentials are stored per tenant. |
channel_route | Cross-tenant routing index (session ID to workspace). No secrets or content, and no RLS, because it is read before the tenant is known. |
contact, contact_identity | Customer identity keyed on the WhatsApp id (<digits>@c.us or <id>@lid) with phone as optional. |
conversation | One private conversation per (channel connection, contact), or one per group. mode is assistant or human. |
message | Inbound and outbound messages. Unique on (workspace, provider message ID). delivery_status: sent, delivered, read, failed. |
source, source_version, chunk | Documents, their immutable versions (processing, ready, failed), and searchable chunks with a generated tsvector and GIN index. |
outbox | Send intents. Unique on (workspace, idempotency key). See Operations. |
channel_event | Session and group webhook events (status changes, restrictions). |
usage_event, audit_event | Metering (tokens, cost in micro-USD, chunks) and an actor-attributed audit trail. |
Tenant isolation in one paragraph
Every tenant table has a tenant_isolation policy comparing workspace_id to the transaction-local setting app.workspace_id, and FORCE ROW LEVEL SECURITY so even the owner role obeys it. withTenant(db, workspaceId, fn) opens a transaction and runs set_config('app.workspace_id', ..., true) first. See Security.
Why Workers need a per-request pool
createDb builds a Neon WebSocket Pool (needed for interactive transactions with SET LOCAL). On Workers a pool must not outlive the request, so the engine middleware ends it with ctx.waitUntil(pool.end()) after each handler.