Sana Module¶
Overview¶
The Sana module (located at packages/app/src/sana) provides WhatsApp bot integration for the Delta platform. It handles WhatsApp message processing, user interactions, and is deployed as a Vercel API Function within the main app.
Purpose¶
- WhatsApp message handling and processing
- User authentication and opt-in management
- Integration with Delta's services via direct imports
- Message formatting and delivery
Technology Stack¶
- Runtime: Node.js with TypeScript (as part of Next.js app)
- Deployment: Vercel API Functions
- Testing: Jest with comprehensive test coverage
- Logging: Uses Sana-specific logger
- HTTP Client: Alova for WhatsApp API requests
Key Components¶
Main Service¶
- Location:
packages/app/src/sana/src/index.ts - Purpose: Main entry point for WhatsApp bot
- Features:
- WhatsApp webhook handling
- Message processing pipeline
- User authentication
Library Components¶
- Location:
packages/app/src/sana/src/lib/ - Purpose: Core WhatsApp bot functionality
- Features:
- Logger utilities
- Message processors
- Helper functions
Development¶
Local Development¶
Since Sana is now part of the app package, development is integrated:
# Start the app development server (includes Sana API routes)
cd packages/app
yarn dev
Environment Setup¶
Add to packages/app/.env.local:
- WhatsApp API credentials
- Webhook verification token
- WhatsApp business phone number
- Meta API endpoints
Testing¶
Unit Tests¶
# Run all app tests (includes Sana tests)
cd packages/app
yarn test
# Run only Sana tests
yarn test src/sana
Test Configuration¶
- Jest Config:
jest.config.js - Test Setup:
jest.setup.js - Test Files:
*.test.tsfiles alongside source code
Build and Deployment¶
Production Build¶
# Build TypeScript
yarn run build
# The built files will be in the dist/ directory
Module Structure¶
Sana is integrated within the app's structure:
- API Route:
packages/app/src/app/api/sana/webhook/route.ts - Services:
packages/app/src/sana/src/services/ - Utilities:
packages/app/src/sana/src/lib/
Configuration¶
tsconfig.json- TypeScript configurationpackage.json- Dependencies and scripts.env.local.example- Environment variables template
Dependencies¶
Sana uses the app's dependencies plus:
WhatsApp-specific¶
- Alova for WhatsApp API requests
- Direct imports from app services and types
Integration¶
Sana is accessed via:
- API endpoint:
/api/sana/webhook - Direct imports within the app
Integration Points¶
- App Services: Direct access to user, notification, and other services
- Database: Uses app's database helpers and Supabase client
- WhatsApp API: Communicates with Meta's WhatsApp Business API
WhatsApp Features¶
Message Handling¶
- Webhook verification
- Message processing pipeline
- Response formatting
User Management¶
- WhatsApp opt-in/opt-out
- User authentication via phone number
- Preference management
Webhook ACK policy — a 2xx promises durability¶
Meta treats any non-2xx response (or a timeout of its ~3s ACK window) as a failed delivery and redelivers the identical payload — the same wamid — with backoff for up to 7 days. The status code the webhook returns is therefore a claim about ownership, not merely about success:
- A 2xx says "this message is durably ours". Returning it before ownership is secured loses the message permanently — Meta never redelivers.
- A non-2xx says "try again". Returning it for a permanent condition buys seven days of pointless retries against a situation no retry can change.
Nothing in the message path may be left un-awaited: a serverless function can be frozen the instant the response is written, so an un-awaited continuation is not work — it is a promise that will not be kept. receiveInner classifies every branch accordingly:
| Condition | Status | Rationale |
|---|---|---|
| App secret unavailable (vault failure) | 500 | Cannot authenticate the caller; retry may succeed |
| Missing / invalid signature | 401 | Not a trusted caller |
| Malformed JSON body | 200 | Permanent garbage — redelivery reproduces it exactly |
| Business-account id not ours | 200 | Permanent — misconfiguration, not a transient failure |
| Sender lookup returned a transient error | 503 | Unverified; the message is not secured yet |
| Sender verified absent for the phone number | 200 | Permanent config state; 7 days of retries help nobody |
| Status webhook (processed inline) | 200 | Delivery receipts are low-stakes and monotonic |
| Message enqueued to QStash | 200 | Ownership transferred; QStash owns delivery now |
| Enqueue failed | 503 | Nothing owns the message — Meta must redeliver |
| Unexpected throw after the body parsed | 503 | Assume transient |
Two consequences worth holding on to:
- There is no inline fallback on enqueue failure. Processing the message in-process while telling Meta 200 was the original design and it was the loss mechanism: a freeze killed the fallback and the message was gone. The correct answer is to refuse the delivery and let Meta redeliver.
- Status webhooks are awaited but always ACK 200. Delivery receipts are monotonic — a later receipt supersedes a lost one — so a persistent failure must not trigger a seven-day retry storm. The path is two reads and at most two writes, comfortably inside the ACK window.
Deliberate redelivery is safe by construction: the publish carries the wamid as its QStash deduplicationId, and messages.external_id is uniquely indexed. A duplicate that outruns both guards loses the insert with a messages_external_id_unique violation, which the inbound path treats as "already processed" (isDuplicateExternalMessageIdError, utils/message.ts) — it logs and returns without replying, calling AI, or surfacing an error to the user. Only that one constraint is treated as benign; any other unique violation on a message write remains a genuine failure.
The consumer (/api/sana/process) mirrors the same permanence logic: a missing notification sender returns 200 with an error body rather than a 400, because QStash retrying the identical payload cannot make the sender appear. Its maxDuration (660s) sits 60s above the 600s destination timeout the publisher requests, so successful long-running AI work is not killed mid-response.
Error Handling — ingestion, enqueue, and delivery are three separate scopes¶
The inbound pipeline distinguishes processing failures (the user's message could not be handled) from failures of the outbound reply. Since every send is now ENQUEUED onto the durable outbox and delivered later by the dispatcher (see MESSAGING_AND_NOTIFICATIONS_ARCHITECTURE.md § Outbound WhatsApp outbox & dispatcher), the reply side splits in two:
| Scope | Meaning | Event | Emitted by |
|---|---|---|---|
| Enqueue failure | Nothing was queued; the reply will NOT be sent | sana.reply_enqueue_failed |
The call site |
| Delivery failure | Queued, but undeliverable after all attempts | sana.reply_delivery_failed |
The dispatcher |
The invariants:
- Once the inbound's outcome is decided (committed or rejected), no downstream send failure may surface a processing-failure message to the user. Every post-outcome send (
mediaResponseconfirmation,flowResponseform confirmations/rejection notices,interactiveListResponseswitch confirmation,processQuickReplyResponseinvite activation,unsupportedResponsenotices) is wrapped in its own try/catch that logs and contains. Those guards now catch ENQUEUE failure — the wire send happens later — and they remain mandatory: an unguarded enqueue reintroduces the "we can't get your conversation up and running" false-failure bug. - The two events must stay distinct. They demand different operator responses — an enqueue failure means the user got nothing and no retry is pending; a delivery failure means the outbox exhausted its attempts. Never collapse one into the other. Both are declared in
MessageOutboxLogEvent(constants/messageOutbox.ts) so the emitters and the dashboards cannot drift. - Both events draw their correlation fields from the same source, so one query joins them:
parentMessageId,ingestionOutcome, the serialized error, and whatever else the call site threaded. Two of those fields are conditional, not guaranteed:messageIdis absent on a delivery failure (the outbound row'sexternal_idonly exists once a send succeeded — see the next bullet), andtemplateKeyappears only where the emitting site threads it, so a query must treat both as optional rather than as join keys. The dispatcher gets these from the outbox row'slog_context, which the enqueueing call site threads viabuildReplyOutboxOptions— the dispatcher has no view of the conversation that produced the send, so a site that omits the log context produces an uncorrelatable delivery failure.ingestionOutcomeuses theINGESTION_OUTCOMESenum (constants/whatsApp.ts):INGESTED= inbound accepted and committed;REJECTED= refused (stale/invalid/orphaned/unsupported), nothing committed. REJECTED notices additionally carryraiseCase: trueso they escalate like other error-path sends. - Correlate on
parentMessageId, notmessageId. An outbound message'sexternal_idis only persisted after the dispatcher's send succeeds, so on either failuremessageIdis undefined;parent_external_id(the inbound WhatsApp id) is always present. It is also the idempotency-key correlate — see the key scheme in the messaging doc. sendGenericErrornever throws — a failed error-message enqueue is logged and swallowed. It also logsraiseCase: truebefore attempting the send, so the case is raised regardless of outcome.sendSpamMessageinherits this contract, and passes its own key purpose so a spam notice and a plain generic error for the same inbound message stay separate outbox entries.buildAndSendMessageFromTemplatethrows when no template resolves for the given key/id (config errors must be visible, never a silent no-op). Guarded callers contain it as an enqueue failure; unguarded pre-mutation callers let it reachprocessWebhook's catch (user gets the generic error — correct, nothing was queued).- The read receipt (
sendReadMessage) is enqueued, not sent inline, withmaxAttempts: 1and the keyread:{wamid}. Its enqueue is still fire-and-forget at the call site: failure must never block or abort inbound processing.
Monitoring and Logging¶
- Integration with
@delta/commonlogger - Request/response logging
- Performance metrics
- Error tracking and alerting
Usage Examples¶
// In API route: packages/app/src/app/api/sana/webhook/route.ts
import { processWhatsAppMessage } from '@/sana/src/services/messageService';
// Handle incoming WhatsApp webhook
export async function POST(request: Request) {
const body = await request.json();
await processWhatsAppMessage(body);
return Response.json({ status: 'ok' });
}
Best Practices¶
- Error Handling: Always handle WhatsApp API errors gracefully
- Rate Limiting: Respect WhatsApp API rate limits
- User Privacy: Handle phone numbers and messages securely
- Opt-in Management: Always verify user consent
- Testing: Test webhook handling and message processing
Troubleshooting¶
Common Issues¶
- Authentication Errors: Check API credentials and tokens. The Cloud API
bearer token is read from the vault once per process and memoised in
sana/client/whatsAppRequest.ts. The memo caches SUCCESS only — a rejected read is discarded before the failure propagates, so the next send re-queries the vault. Caching the rejection would poison a warm serverless instance for its entire lifetime, failing every subsequent send without ever asking again; concurrent callers still share the one in-flight read. - Rate Limiting: Implement proper retry mechanisms
- Network Issues: Handle timeouts and connection errors
- Data Validation: Ensure data format compliance
Debug Mode¶
Enable debug logging in development:
# Sana uses its own logger which outputs to console in development
cd packages/app
yarn dev