Skip to content

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.ts files 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 configuration
  • package.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 (mediaResponse confirmation, flowResponse form confirmations/rejection notices, interactiveListResponse switch confirmation, processQuickReplyResponse invite activation, unsupportedResponse notices) 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: messageId is absent on a delivery failure (the outbound row's external_id only exists once a send succeeded — see the next bullet), and templateKey appears 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's log_context, which the enqueueing call site threads via buildReplyOutboxOptions — 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. ingestionOutcome uses the INGESTION_OUTCOMES enum (constants/whatsApp.ts): INGESTED = inbound accepted and committed; REJECTED = refused (stale/invalid/orphaned/unsupported), nothing committed. REJECTED notices additionally carry raiseCase: true so they escalate like other error-path sends.
  • Correlate on parentMessageId, not messageId. An outbound message's external_id is only persisted after the dispatcher's send succeeds, so on either failure messageId is 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.
  • sendGenericError never throws — a failed error-message enqueue is logged and swallowed. It also logs raiseCase: true before attempting the send, so the case is raised regardless of outcome. sendSpamMessage inherits 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.
  • buildAndSendMessageFromTemplate throws 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 reach processWebhook's catch (user gets the generic error — correct, nothing was queued).
  • The read receipt (sendReadMessage) is enqueued, not sent inline, with maxAttempts: 1 and the key read:{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/common logger
  • 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

  1. Error Handling: Always handle WhatsApp API errors gracefully
  2. Rate Limiting: Respect WhatsApp API rate limits
  3. User Privacy: Handle phone numbers and messages securely
  4. Opt-in Management: Always verify user consent
  5. 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