2026 03 20 Vitto Personal Whatsapp Channel Design

Documentation for Plans

Vitto Personal WhatsApp Channel — Design Spec

Session: S250 Date: 2026-03-20 Status: Draft — Paused (requires physical SIM card) Approach: B — WAHA proxied behind sandbox-orchestrator nginx Dependency: Physical prepaid SIM card + spare phone for one-time WhatsApp registration Related: Unified Channel Completion Spec (independent — can proceed in parallel)


Problem Statement

Twilio's WhatsApp Business API enforces a 24-hour session window. Outside this window, only pre-approved Content Templates can be sent — no freeform messages. This blocks Vitto from:

Error 63016 ("Failed to send freeform message because you are outside the allowed window") is the symptom. The root cause is that the Business API is designed for transactional, template-driven communication — not for an AI agent that has real, contextual conversations.

Solution

Give Vitto a personal WhatsApp account — a dedicated SIM card registered as a regular WhatsApp user, connected to an automation server (WAHA) running on the existing Hetzner infrastructure. To WhatsApp's servers, Vitto is a person. No windows. No templates. No approval process.

This is a new channel, not a replacement. Twilio remains the business channel for formal/transactional messaging. Vitto's personal number is for unrestricted, conversational communication.

Design Decisions

Decision Choice Rationale
Number scope One global number, per-space later Simplest start. Per-space = more SIMs, more containers, more QR scans.
Conversation model Unified context, separate threads Users see two WhatsApp threads on their phone (business + personal). Platform UI should match. Vitto's Brain/Memory provides cross-channel context.
Channel routing Automatic fallback + explicit override Business channel first → 63016 error → retry via personal. Admin can also force personal for specific flows.
Infrastructure WAHA behind sandbox-orchestrator nginx Reuses existing TLS, domain, API key auth. No new infrastructure patterns.
Engine NOWEB WebSocket-based (Baileys), ~50MB/session, no Chromium. More mature than GOWS.
WAHA tier Core (free) Single session is all we need. Plus ($19/mo) only for multi-session/S3/dashboard-auth.

Architecture

Infrastructure — Hetzner

Hetzner AX42-U (sandbox.gograb.space)
+----------------------------------------------------------+
|  Nginx (:443 TLS - existing cert)                        |
|  |-- /health, /sandboxes/*  -> 127.0.0.1:3010 (orch)    |
|  +-- /waha/*                -> 127.0.0.1:3080 (WAHA)    |
|         ^ strips /waha/ prefix, WAHA handles own auth    |
|                                                          |
|  systemd: sandbox-orchestrator  -> Node.js :3010         |
|  systemd: waha                  -> Docker  :3080         |
|                                                          |
|  /data/waha/                                             |
|  |-- .sessions/   <- auth state (survives restarts)      |
|  +-- .media/      <- downloaded media files              |
+----------------------------------------------------------+

Docker container configuration:

docker run -d \
  --name waha \
  -p 127.0.0.1:3080:3080 \
  -v /data/waha/.sessions:/app/.sessions \
  -v /data/waha/.media:/app/.media \
  -e WHATSAPP_DEFAULT_ENGINE=NOWEB \
  -e WHATSAPP_API_PORT=3080 \
  -e WAHA_API_KEY=<64-char-random> \
  -e WAHA_BASE_URL=https://sandbox.gograb.space/waha \
  -e WAHA_DASHBOARD_ENABLED=true \
  -e WAHA_LOG_LEVEL=info \
  -e WHATSAPP_FILES_FOLDER=/app/.media \
  -e WHATSAPP_FILES_LIFETIME=0 \
  -e WHATSAPP_RESTART_ALL_SESSIONS=True \
  -e WAHA_WORKER_ID=vitto-personal \
  --restart unless-stopped \
  --log-driver json-file \
  --log-opt max-size=10m \
  --log-opt max-file=5 \
  devlikeapro/waha:latest

Key configuration notes:

Nginx location block (added to existing config):

location /waha/ {
    proxy_pass http://127.0.0.1:3080/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_buffering off;
    proxy_read_timeout 86400s;
}

WebSocket upgrade headers are required — WAHA dashboard uses WebSocket for live QR code display.

WAHA session creation (one-time, after QR scan):

curl -X POST https://sandbox.gograb.space/waha/api/sessions \
  -H "X-Api-Key: <WAHA_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "vitto",
    "engine": "NOWEB",
    "config": {
      "webhooks": [{
        "url": "https://<backend-vitto-api-host>/webhooks/waha/messages",
        "events": ["message", "message.reaction", "session.status"],
        "hmac": { "key": "<shared-hmac-secret>" },
        "retries": { "policy": "exponential", "delaySeconds": 2, "attempts": 5 }
      }],
      "noweb": { "store": { "enabled": true } }
    }
  }'

Webhook events:


Backend — Channel Routing

Important: The codebase already has ChannelGatewayService (APIs/backend-vitto-api/src/app/connectivity/channel-gateway.service.ts) which handles outbound routing via sendResponse() (switches on channelType) and inbound normalization via normalizeInbound(). We extend this existing service rather than creating a parallel router.

                        +----------------------+
                        | VittoMessagingService |
                        | (backend-vitto-api)  |
                        +---------+------------+
                                  |
                       +----------v-----------+
                       | ChannelGatewayService |  <-- EXISTING, extended
                       | sendResponse()        |
                       +----------+-----------+
                                  |
              +-------------------+-------------------+
              |                   |                   |
     +--------v--------+ +-------v--------+ +--------v-------+
     | TwilioService    | | WahaChannel    | | WebChannel     |
     | (business WA)    | | (personal WA)  | | (socket.io)    |
     | via Twilio API   | | via Hetzner    | | via port 3003  |
     +------------------+ +----------------+ +----------------+

ChannelGatewayService extension — add case 'whatsapp_personal' to sendResponse():

// In existing ChannelGatewayService.sendResponse()
case 'whatsapp':
  return this.sendWhatsAppViaChannel(response, channel);

// NEW case added:
case 'whatsapp_personal':
  return this.sendWhatsAppViaWaha(response, channel);

Fallback logic — extend existing sendWhatsAppViaChannel():

The existing method logs 63016 errors (lines 318-327) and checks sessionWindow.requiresTemplate for template fallback. We add WAHA as a secondary fallback when the template path is not available:

// In existing sendWhatsAppViaChannel(), after the 63016 warning log:
if (result.error?.includes('63016') && this.wahaChannelService.isEnabled()) {
  this.logger.log('Falling back to WAHA personal channel (63016 — outside session window)');
  return this.sendWhatsAppViaWaha(response);
}

New method in ChannelGatewayService:

private async sendWhatsAppViaWaha(
  response: ChannelResponse,
): Promise<boolean> {
  if (!this.wahaChannelService.isEnabled()) {
    this.logger.warn('WAHA channel disabled, skipping personal WhatsApp');
    return false;
  }

  try {
    // WahaChannelService.sendText() internally checks session status
    await this.wahaChannelService.sendText(
      response.channelId,   // recipient phone (same field used by Twilio path)
      response.text
    );
    return true;
  } catch (error) {
    this.logger.error(`WAHA send failed: ${error.message}`);
    return false;
  }
}

Note: response.channelId is the recipient phone number — the same field the existing Twilio path uses. No need for a separate channel.targetPhone — that field is for the "from" number configuration.

Inbound normalization — add case 'whatsapp_personal' to normalizeInbound():

The existing signature is normalizeInbound(raw: Record<string, unknown>, channelType: ChannelType). WAHA payloads have a different structure than Twilio's, so the new case must handle the WAHA format:

// In existing ChannelGatewayService.normalizeInbound(raw, channelType)
case 'whatsapp_personal':
  return {
    from: (raw.from as string).replace('@c.us', ''),
    body: raw.body as string,
    channel: 'whatsapp_personal',
    timestamp: raw.timestamp as number,
    mediaUrl: (raw.media as any)?.url
  };

Rule: if a user messages Vitto's personal number, replies go back via personal. No channel-hopping mid-conversation. This is enforced by the existing "reply on same channel" logic in sendResponse().

WahaChannelService — thin REST wrapper:

Location: libs/libs-api/messaging-core/src/lib/services/waha-channel.service.ts

Provided by MessagingCoreModule — any backend importing the module gets WahaChannelService via DI. This is the same pattern used by TwilioService today.

@Injectable()
export class WahaChannelService {
  private readonly logger = new Logger(WahaChannelService.name);
  private readonly baseUrl: string;
  private readonly apiKey: string;
  private readonly session: string;
  private readonly enabled: boolean;

  constructor(private readonly configService: ConfigService) {
    this.baseUrl = this.configService.get('WAHA_API_URL', '');
    this.apiKey = this.configService.get('WAHA_API_KEY', '');
    this.session = this.configService.get('WAHA_SESSION_NAME', 'vitto');
    this.enabled = this.configService.get('WAHA_ENABLED', 'false') === 'true';
  }

  isEnabled(): boolean {
    return this.enabled;
  }

  /**
   * Send text message via WAHA. Caller (ChannelGatewayService) is responsible
   * for checking isEnabled() and handling errors. This method checks session
   * status internally and throws if not WORKING.
   */
  async sendText(to: string, body: string): Promise<WahaResponse> {
    const status = await this.getSessionStatus();
    if (status !== 'WORKING') {
      throw new Error(`WAHA session not ready: ${status}`);
    }

    await this.simulateTyping(to, body.length);
    await this.humanDelay();

    return this.post('/api/sendText', {
      session: this.session,
      chatId: this.toChatId(to),
      text: body
    });
  }

  async sendMedia(to: string, url: string, caption?: string): Promise<WahaResponse> {
    return this.post('/api/sendFile', {
      session: this.session,
      chatId: this.toChatId(to),
      file: { url },
      caption
    });
  }

  async checkNumberExists(phone: string): Promise<boolean> {
    const res = await this.post('/api/contacts/check-exists', {
      session: this.session,
      phone
    });
    return res.numberExists;
  }

  async getSessionStatus(): Promise<WahaSessionStatus> {
    const res = await this.get(`/api/sessions/${this.session}`);
    return res.status;
  }

  // --- HTTP helpers (native fetch, matching TwilioService pattern) ---

  private async post(path: string, body: Record<string, unknown>): Promise<any> {
    const res = await fetch(`${this.baseUrl}${path}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Api-Key': this.apiKey,
      },
      body: JSON.stringify(body),
    });
    if (!res.ok) {
      const text = await res.text();
      throw new Error(`WAHA ${path} failed (${res.status}): ${text}`);
    }
    return res.json();
  }

  private async get(path: string): Promise<any> {
    const res = await fetch(`${this.baseUrl}${path}`, {
      headers: { 'X-Api-Key': this.apiKey },
    });
    if (!res.ok) {
      throw new Error(`WAHA ${path} failed (${res.status})`);
    }
    return res.json();
  }

  // --- Human-like behavior ---

  private toChatId(phone: string): string {
    return phone.replace(/\+/g, '') + '@c.us';
  }

  private async simulateTyping(to: string, messageLength: number): Promise<void> {
    const typingDuration = Math.min(1000 + messageLength * 20, 3000);
    try {
      await this.post('/api/startTyping', {
        session: this.session,
        chatId: this.toChatId(to)
      });
      await this.delay(typingDuration);
      await this.post('/api/stopTyping', {
        session: this.session,
        chatId: this.toChatId(to)
      });
    } catch {
      // Typing indicator failure is non-fatal — proceed to send
    }
  }

  private async humanDelay(): Promise<void> {
    await this.delay(200 + Math.random() * 600);
  }

  /** Matches TwilioService.delay() pattern including timer.unref() */
  private delay(ms: number): Promise<void> {
    return new Promise(resolve => {
      const timer = setTimeout(resolve, ms);
      if (timer.unref) timer.unref();
    });
  }
}

export type WahaSessionStatus = 'STARTING' | 'SCAN_QR_CODE' | 'WORKING' | 'FAILED' | 'STOPPED';

export interface WahaResponse {
  id?: string;
  status?: string;
  [key: string]: unknown;
}

export interface WahaEvent {
  event: 'message' | 'message.reaction' | 'session.status';
  payload: any;
}

export interface WahaMessage {
  from: string;        // e.g. "[email protected]"
  body: string;
  timestamp: number;
  media?: { url: string };
}

Module registration — add to MessagingCoreModule.providers and exports:

// In libs/libs-api/messaging-core/src/lib/messaging-core.module.ts
@Module({
  providers: [TwilioService, ChannelService, WahaChannelService],
  exports: [TwilioService, ChannelService, WahaChannelService],
})
export class MessagingCoreModule {}

This ensures both backend-api (for space invitations) and backend-vitto-api (for conversational messaging) can inject WahaChannelService via the shared module — same DI pattern as TwilioService.

Webhook controller — inbound message handling:

Location: APIs/backend-vitto-api/src/app/connectivity/waha-webhook.controller.ts

Inbound messages from WAHA enter the existing pipeline via ChannelGatewayService.normalizeInbound() (which gets a new case 'whatsapp_personal') and then follow the same path as Twilio inbound — the controller submits the normalized message to the Vitto engine via engine.submitTask(), matching the pattern in vitto.controller.ts (lines 2027-2035).

@Controller('webhooks/waha')
export class WahaWebhookController {
  private readonly logger = new Logger(WahaWebhookController.name);

  constructor(
    private readonly channelGateway: ChannelGatewayService,
    private readonly engine: VittoEngineService,
    private readonly configService: ConfigService,
  ) {}

  @Post('messages')
  async handleWebhook(
    @Body() event: WahaEvent,
    @Req() req: RawBodyRequest<Request>,
    @Headers() headers: Record<string, string>
  ) {
    // 1. Verify HMAC signature using raw body (avoids JSON re-serialization issues)
    this.verifyHmac(req.rawBody, headers['x-webhook-hmac-sha512']);

    // 2. Route by event type
    switch (event.event) {
      case 'message':
        await this.handleInboundMessage(event.payload);
        break;
      case 'message.reaction':
        // Future: handle emoji reactions
        break;
      case 'session.status':
        await this.handleSessionStatus(event.payload);
        break;
    }
  }

  private async handleInboundMessage(payload: WahaMessage) {
    // normalizeInbound(raw, channelType) — note argument order
    const normalized = this.channelGateway.normalizeInbound(
      payload as unknown as Record<string, unknown>,
      'whatsapp_personal'
    );

    // Submit to Vitto engine — same pattern as Twilio inbound in vitto.controller.ts
    await this.engine.submitTask({
      type: 'inbound_message',
      channel: 'whatsapp_personal',
      message: normalized,
    });
  }

  private async handleSessionStatus(payload: { status: string }) {
    this.logger.log(`WAHA session status: ${payload.status}`);
    if (payload.status === 'SCAN_QR_CODE') {
      this.logger.warn('WAHA session disconnected — QR re-scan needed');
      // TODO: integrate with existing alert/notification system
    }
  }

  private verifyHmac(rawBody: Buffer | undefined, signature: string): void {
    if (!rawBody || !signature) {
      throw new UnauthorizedException('Missing WAHA webhook signature');
    }
    const hmacKey = this.configService.get('WAHA_WEBHOOK_HMAC_KEY');
    const expected = createHmac('sha512', hmacKey)
      .update(rawBody)
      .digest('hex');

    if (signature !== expected) {
      throw new UnauthorizedException('Invalid WAHA webhook HMAC');
    }
  }
}

Note on raw body: NestJS requires rawBody: true in the app bootstrap options to make req.rawBody available. This should be verified during implementation — if already enabled for Twilio signature verification, no change needed.


Frontend — Minimal Changes

Channel badge distinction in message-bubble.component.ts:

const badges: Record<string, { icon: string; label: string }> = {
  whatsapp:          { icon: '📱', label: 'WhatsApp' },
  whatsapp_personal: { icon: '📱', label: 'WhatsApp (Vitto)' },
  slack:             { icon: '💬', label: 'Slack' },
  email:             { icon: '✉️', label: 'Email' },
  telegram:          { icon: '✈️', label: 'Telegram' },
  discord:           { icon: '🎮', label: 'Discord' },
};

Admin invite dialog — new delivery option in user-management.component.html:

<option value="sms">SMS</option>
<option value="whatsapp">WhatsApp (Business)</option>
<option value="whatsapp_personal">WhatsApp (Vitto Personal)</option>
<option value="manual">Manual (copy code)</option>

Thread separation — messages from WAHA webhook create/join sessions tagged with channel: 'whatsapp_personal'. This produces a separate conversation thread from Twilio-originated channel: 'whatsapp' messages. No new UI components needed — the existing conversation list and message rendering handle this via the channel field.


Type Extensions

ChannelType in libs/libs-api/messaging-core/src/lib/interfaces/messaging.interfaces.ts:

// Before
export type ChannelType = 'web' | 'slack' | 'discord' | 'whatsapp' | 'sms' | 'email' | 'webhook' | 'telegram';

// After
export type ChannelType = 'web' | 'slack' | 'discord' | 'whatsapp' | 'whatsapp_personal' | 'sms' | 'email' | 'webhook' | 'telegram';

SyncMessage in libs/libs-ai/chat-sync/src/lib/models/chat-sync.models.ts:

export interface SyncMessage {
  channel?: string;  // Add 'whatsapp_personal' to accepted values
}

CreateInvitationParams in APIs/backend-api/src/app/spaces/space-invitation.service.ts:

// Before
channel: 'sms' | 'whatsapp' | 'manual';

// After
channel: 'sms' | 'whatsapp' | 'whatsapp_personal' | 'manual';

Frontend invite form in apps/gograb-admin/src/app/features/user-management/user-management.component.ts:

// Before
channel: 'sms' as 'sms' | 'whatsapp' | 'manual',

// After
channel: 'sms' as 'sms' | 'whatsapp' | 'whatsapp_personal' | 'manual',

CreateInvitationResponse.deliveryChannel in APIs/backend-api/src/app/spaces/space-invitation.service.ts:

// Before
deliveryChannel: 'whatsapp' | 'sms';

// After
deliveryChannel: 'whatsapp' | 'whatsapp_personal' | 'sms';

Space invitation delivery — inject WahaChannelService and add routing in sendInviteMessage():

// In SpaceInvitationService constructor — add:
private readonly wahaChannelService: WahaChannelService,

// In sendInviteMessage(), add case for personal channel:
case 'whatsapp_personal':
  if (!this.wahaChannelService.isEnabled()) {
    throw new BadRequestException('Personal WhatsApp channel is not enabled');
  }
  const wahaResult = await this.wahaChannelService.sendText(
    normalizedPhone,
    inviteMessage   // same message body as SMS/WhatsApp
  );
  return {
    success: true,
    deliveryChannel: 'whatsapp_personal',
    messageId: wahaResult.id,
  };

Security

Authentication layers

External request -> Nginx (TLS only) -> WAHA (X-Api-Key auth via WAHA_API_KEY)
                                                            |
Webhook outbound -> HMAC SHA512 (X-Webhook-Hmac-SHA512) -> backend-vitto-api verifies

Ban mitigation

Disposable number strategy


Operations

Health monitoring

// Cron job in backend-vitto-api (every 5 min)
@Cron('*/5 * * * *')
async checkWahaHealth() {
  const status = await this.wahaService.getSessionStatus();

  if (status === 'SCAN_QR_CODE') {
    this.alertService.notify('WAHA session needs QR re-scan');
    this.wahaService.pauseOutbound();
  }

  if (status === 'FAILED') {
    await this.wahaService.restartSession();
  }
}

Session states: STARTING | SCAN_QR_CODE | WORKING | FAILED | STOPPED. Only send messages when WORKING.

Environment variables (backend-vitto-api)

# WAHA Personal WhatsApp Channel
WAHA_ENABLED=true
WAHA_API_URL=https://sandbox.gograb.space/waha
WAHA_API_KEY=<64-char-random>
WAHA_SESSION_NAME=vitto
WAHA_WEBHOOK_HMAC_KEY=<shared-secret>
WAHA_RATE_LIMIT_HOURLY=30
WAHA_RATE_LIMIT_DAILY=200

WAHA_ENABLED acts as a kill switch. Set to false and all personal WhatsApp routing stops — the sendWhatsAppViaWaha() method in ChannelGatewayService and WahaChannelService.isEnabled() both check this before any WAHA operation. Falls back to Twilio/SMS only.

These vars go in:

Systemd unit

[Unit]
Description=WAHA WhatsApp API
After=docker.service
Requires=docker.service

[Service]
Type=simple
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker stop waha
ExecStartPre=-/usr/bin/docker rm waha
ExecStart=/usr/bin/docker run --name waha \
  -p 127.0.0.1:3080:3080 \
  -v /data/waha/.sessions:/app/.sessions \
  -v /data/waha/.media:/app/.media \
  --env-file /data/waha/.env \
  --log-driver json-file \
  --log-opt max-size=10m \
  --log-opt max-file=5 \
  devlikeapro/waha:latest
ExecStop=/usr/bin/docker stop waha

[Install]
WantedBy=multi-user.target

Risk Assessment

Risk Severity Likelihood Mitigation
Account ban by Meta Medium Medium Disposable SIM, human-like behavior, backup number ready
WAHA protocol break (WhatsApp update) Low-medium Low WAHA tracks upstream Baileys fixes, usually resolved in hours
Session disconnect (phone off, battery, network) Low Medium Health cron detects within 5 min, alerts admin
Webhook delivery failure Low Low Exponential retry (5 attempts), messages queue in WAHA
Legal/compliance Low Low 1-on-1 conversations with known contacts, not spam

Files to Create/Modify

New files

File Purpose
libs/libs-api/messaging-core/src/lib/services/waha-channel.service.ts WAHA REST API wrapper (provided by MessagingCoreModule)
APIs/backend-vitto-api/src/app/connectivity/waha-webhook.controller.ts Inbound webhook handler (feeds into existing ChannelGatewayService pipeline)
APIs/backend-vitto-api/src/app/connectivity/waha-health.service.ts Session health monitoring cron

Modified files

File Change
libs/libs-api/messaging-core/src/lib/interfaces/messaging.interfaces.ts Add 'whatsapp_personal' to ChannelType
libs/libs-api/messaging-core/src/lib/messaging-core.module.ts Add WahaChannelService to providers + exports
APIs/backend-vitto-api/src/app/connectivity/channel-gateway.service.ts Add case 'whatsapp_personal' to sendResponse() and normalizeInbound(), add WAHA fallback in sendWhatsAppViaChannel()
libs/libs-ai/vitto-chat/src/lib/components/message-bubble/message-bubble.component.ts Add whatsapp_personal badge
libs/libs-ai/chat-sync/src/lib/models/chat-sync.models.ts Add whatsapp_personal to channel values
apps/gograb-admin/src/app/features/user-management/user-management.component.html Add WhatsApp (Vitto Personal) delivery option
apps/gograb-admin/src/app/features/user-management/user-management.component.ts Update channel type union to include 'whatsapp_personal'
APIs/backend-api/src/app/spaces/space-invitation.service.ts Update CreateInvitationParams.channel type, add case 'whatsapp_personal' routing to WahaChannelService
deployment/shared/.env.example Add WAHA env vars
.env.local Add WAHA env vars for local development

Hetzner files (manual deployment)

File Purpose
/data/waha/.env WAHA container environment
/etc/systemd/system/waha.service Systemd unit
/etc/nginx/sites-available/sandbox Add /waha/ location block

Setup Checklist (One-Time)

  1. Get a prepaid SIM card, register WhatsApp on a phone
  2. SSH into Hetzner, create /data/waha/ directories
  3. Create /data/waha/.env with generated API key and HMAC secret
  4. Install systemd unit, start WAHA container
  5. Add nginx /waha/ location block, reload nginx
  6. Open https://sandbox.gograb.space/waha/dashboard, scan QR from Vitto's phone
  7. Create session via POST /api/sessions with webhook config
  8. Verify webhook delivery to backend-vitto-api
  9. Add WAHA env vars to backend-vitto-api deployment
  10. Deploy backend changes
  11. Test: send a message via WahaChannelService, verify delivery + inbound reply

Future Extensions (Not in Scope)