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:
- Sending space invitations to users who haven't messaged first
- Following up on conversations after the window expires
- Proactively reaching out to users with contextual updates
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:
- Port 3080 (not WAHA's default 3000) to avoid collisions. Set via
WHATSAPP_API_PORT. 127.0.0.1binding — not exposed to the internet. Nginx proxies externally.- Volume mounts for
/app/.sessions(auth state) and/app/.media(media files) are critical. Without.sessionsmount, QR code re-scan required after every container restart. WHATSAPP_FILES_LIFETIME=0disables auto-deletion. Default is 180s in/tmp/which loses all media.WHATSAPP_RESTART_ALL_SESSIONS=Trueauto-recovers sessions on container restart.
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:
message— inbound messages from users replying to Vitto's personal numbermessage.reaction— emoji reactions on messagessession.status— connection state changes (WORKING, SCAN_QR_CODE, FAILED)
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
- Nginx layer: TLS termination only for
/waha/*. Auth is handled by WAHA itself viaWAHA_API_KEY(not nginx). WAHA dashboard (/waha/dashboard) should be IP-restricted (admin IP only) or disabled after initial QR scan. - WAHA API key: All API requests to WAHA require
X-Api-Keyheader matchingWAHA_API_KEYenv var. Dashboard uses separate basic auth credentials (WAHA_DASHBOARD_USERNAME/PASSWORD). - Webhook HMAC: WAHA signs outbound webhooks with SHA512 (
X-Webhook-Hmac-SHA512header). Controller verifies before processing. Prevents spoofed inbound messages.
Ban mitigation
- Typing indicators:
POST /api/startTypingbefore every message. Duration scales with message length (1-3s). - Random micro-delay: 200-800ms between typing end and message send.
- Rate limits enforced server-side: Max 30 messages/hour, 200/day. Exceeding queues instead of firing.
- No bulk broadcasts: This channel is for 1-on-1 contextual conversations only.
- Content variety: Vitto's messages are naturally varied (AI-generated, contextual). No identical templates.
Disposable number strategy
- Prepaid SIM dedicated to Vitto (~$5).
- If banned: swap SIM, re-register WhatsApp, re-scan QR. ~15 min downtime.
- Keep a backup SIM ready.
- Business-critical messages (transactional, billing) stay on Twilio. Always.
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:
deployment/shared/.env.example(documentation).env.local(local development)- Railway env vars (production deployment)
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)
- Get a prepaid SIM card, register WhatsApp on a phone
- SSH into Hetzner, create
/data/waha/directories - Create
/data/waha/.envwith generated API key and HMAC secret - Install systemd unit, start WAHA container
- Add nginx
/waha/location block, reload nginx - Open
https://sandbox.gograb.space/waha/dashboard, scan QR from Vitto's phone - Create session via
POST /api/sessionswith webhook config - Verify webhook delivery to backend-vitto-api
- Add WAHA env vars to backend-vitto-api deployment
- Deploy backend changes
- Test: send a message via
WahaChannelService, verify delivery + inbound reply
Future Extensions (Not in Scope)
- Per-space numbers: Each space gets its own SIM + WAHA session. Requires WAHA Plus ($19/mo) for multi-session.
- WhatsApp Groups: WAHA supports group management (
POST /api/groups). Vitto could create/manage groups. - Media-rich messages: Location sharing, contacts, documents via WAHA's extended API.
- Read receipts: WAHA emits
message.ackevents. Could update message status in UI. - Multi-device: Link multiple phones to the same WAHA session for redundancy.