# WhatsApp Service — Usage Guide

A multi-session WhatsApp bot backend. Other services (e.g. Laravel) talk to it over HTTP + Socket.IO.

---

## Table of Contents

1. [Setup](#1-setup)
2. [Environment Variables](#2-environment-variables)
3. [Authentication](#3-authentication)
4. [Session Lifecycle](#4-session-lifecycle)
5. [REST API Reference](#5-rest-api-reference)
6. [Sending Messages](#6-sending-messages)
7. [Real-time Events — Socket.IO](#7-real-time-events--socketio)
8. [Webhooks — What Laravel Receives](#8-webhooks--what-laravel-receives)
9. [Bot Flow — What Laravel Serves](#9-bot-flow--what-laravel-serves)
10. [Flow Node Types](#10-flow-node-types)
11. [Integration Checklist](#11-integration-checklist)
12. [Session Statuses](#12-session-statuses)
13. [Error Responses](#13-error-responses)
14. [Logging](#14-logging)
15. [Test Console](#15-test-console)
16. [Connecting Laravel on Firebase Studio](#16-connecting-laravel-on-firebase-studio-to-wapezapocom)
17. [Keepalive — Shared Hosting](#17-keepalive--shared-hosting)
18. [Wake Endpoint — Start from Laravel](#18-wake-endpoint--start-from-laravel)
19. [Auto-Recovery — Handling Failures in Laravel](#19-auto-recovery--handling-failures-in-laravel)
20. [Known Gotchas & Production Fixes](#20-known-gotchas--production-fixes)
21. [Bot Use Case — Conversation Design](#21-bot-use-case--conversation-design)

---

## 1. Setup

```bash
npm install
npm start              # production
npm run dev            # development — auto-restarts on code changes
```

A `.env` file is already included in the project. Fill in the values before starting — see [Section 2](#2-environment-variables).

Sessions are saved to `./sessions/{sessionId}/` on disk. Restarting the service keeps all sessions alive — no re-scanning needed.

---

## 2. Environment Variables

This service has its own `.env` file, completely separate from Laravel's `.env`. Each app reads only its own file from its own directory. To share a secret between the two (e.g. `API_SECRET`), copy the same value into both files manually.

The `.env` file is already in the project root. Open it and fill in your values:

```env
# The port this service listens on
PORT=3000

# Base URL of your Laravel app — used for webhooks and flow fetching
# On Firebase Studio dev: use the port 8000 preview URL
LARAVEL_URL=https://8000-YOUR_WORKSPACE_ID.cloudworkstations.dev

# Secret token — Laravel must send this in every request to this service
# Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
API_SECRET=change-me-to-a-long-random-string

# Allowed origin for Socket.IO browser connections
# On Firebase Studio dev: use your frontend preview URL
SOCKET_CORS_ORIGIN=https://YOUR_FRONTEND_URL

# Pino log level: trace | debug | info | warn | error | fatal
# Default is 'warn' — set to 'info' or 'debug' for more verbosity
LOG_LEVEL=warn
```

**Generate a secure `API_SECRET`:**

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```

Copy the output and paste it as the `API_SECRET` value.

### What happens if variables are missing

| Variable | Missing behaviour |
|---|---|
| `LARAVEL_URL` | Warns at startup; webhooks and flow fetching will fail silently |
| `API_SECRET` | Warns at startup; **all endpoints are publicly accessible** |
| `SOCKET_CORS_ORIGIN` | Warns at startup; Socket.IO accepts connections from any origin |
| `PORT` | Defaults to `3000` |
| `LOG_LEVEL` | Defaults to `warn` |

---

## 3. Authentication

Every HTTP request must include the API secret as a Bearer token:

```
Authorization: Bearer <API_SECRET>
```

The comparison is **timing-safe** (immune to timing-based token enumeration). A missing or wrong token returns:

```json
{ "error": "Unauthorized" }
```
HTTP `401`.

If `API_SECRET` is not set, auth is bypassed entirely — fine for local dev, never do this in production.

### Sharing the secret with Laravel

Copy the same value into Laravel's `.env`:

```env
# Laravel .env
WHATSAPP_SERVICE_URL=https://wa.pezapo.com   # production
# WHATSAPP_SERVICE_URL=http://localhost:3000 # local dev
WHATSAPP_SERVICE_SECRET=change-me-to-a-long-random-string
```

Then in Laravel, attach it to every outgoing request:

```php
Http::withToken(config('services.whatsapp.secret'))
    ->post(config('services.whatsapp.url') . '/session', [...]);
```

---

## 4. Session Lifecycle

```
POST /session   →   status: "initializing"
                        │
                        ├── (QR flow)     status: "qr_pending"  → user scans QR
                        │
                        └── (pairing)     pairing code emitted  → user enters on phone
                                                  │
                                      status: "connected"
                                                  │
                              receives messages → runs bot flow
                              sends webhooks to Laravel
                                                  │
                                    DELETE /session/:id
                                                  │
                                      status: "disconnected"
                                      credentials wiped from disk
```

**Auto-reconnect:** On unexpected disconnect the service retries up to 5 times (3 s apart). If you explicitly `DELETE` a session, auto-reconnect is suppressed.

**Pairing code:** Requested only once per session, when `phoneNumber` is provided and the device is not yet registered.

---

## 5. REST API Reference

Base URL: `https://wa.pezapo.com` (production) or `http://localhost:3000` (local dev)
All requests: `Content-Type: application/json` + `Authorization: Bearer <secret>`

**Rate limits**
- All endpoints: 120 requests / minute per IP
- `POST /session`: 10 requests / minute per IP

---

### Start a session

```
POST /session
```

| Field | Type | Required | Description |
|---|---|---|---|
| `sessionId` | string | yes | Your unique identifier for this WhatsApp number |
| `phoneNumber` | string | no | E.164 format without `+` (e.g. `254712345678`). Supply to receive a pairing code instead of QR. |

```json
{ "sessionId": "tenant_42" }
```

```json
{ "sessionId": "tenant_42", "status": "initializing" }
```

After this call, poll `/session/:id/status` or subscribe via Socket.IO to know when it is ready.

---

### Get session status

```
GET /session/:id/status
```

```json
{ "sessionId": "tenant_42", "status": "connected" }
```

---

### Get QR code

```
GET /session/:id/qr
```

Returns the raw QR string while the session is in `qr_pending` state. Render it with any QR library.

```json
{ "sessionId": "tenant_42", "qr": "2@abc123..." }
```

Returns `404` once the QR has been scanned or the session is not yet ready.

---

### Get pairing code

```
GET /session/:id/pairing-code
```

Only available after starting a session with `phoneNumber`.

```json
{ "sessionId": "tenant_42", "pairingCode": "ABCD-EFGH" }
```

---

### Disconnect a session

```
DELETE /session/:id
```

Logs out the device, removes it from memory, and **wipes saved credentials** from disk. The next `POST /session` for the same ID will require a fresh QR or pairing code.

```json
{ "sessionId": "tenant_42", "status": "disconnected" }
```

---

### Set conversation node (custom commands)

```
POST /session/:id/conversation
```

Tells the service which flow node to jump to for a specific JID. Use this from Laravel when a custom command (`/price`, `@products`) is matched — the next message the customer sends will pick up from that node.

| Field | Type | Required | Description |
|---|---|---|---|
| `jid` | string | yes | Customer's JID |
| `nodeId` | string\|null | no | Node ID to jump to. `null` resets to the beginning. |

```json
{ "jid": "254712345678@s.whatsapp.net", "nodeId": "price_node_1" }
```

```json
{ "sessionId": "tenant_42", "jid": "254712345678@s.whatsapp.net", "nodeId": "price_node_1" }
```

**Laravel example — custom command handler:**

```php
// In your webhook handler, after detecting a custom command
$trigger = WhatsappTrigger::where('session_id', $sessionId)
    ->where('keyword', $incoming)
    ->first();

if ($trigger) {
    Http::withToken(config('services.whatsapp.secret'))
        ->post(config('services.whatsapp.url') . "/session/{$sessionId}/conversation", [
            'jid'    => $request->jid,
            'nodeId' => $trigger->flow_node_id,
        ]);
}
```

---

### Health check

```
GET /health
```

```json
{ "status": "ok", "sessionCount": 2 }
```

Session details are intentionally not exposed publicly. Use `GET /session/:id/status` (authenticated) to query individual sessions.

---

## 6. Sending Messages

```
POST /session/:id/message
```

The session must be `connected`. Returns `503` otherwise.

**Common fields (all types)**

| Field | Type | Required |
|---|---|---|
| `jid` | string | yes |
| `type` | string | yes |

**JID formats**

| Target | Format |
|---|---|
| Individual | `254712345678@s.whatsapp.net` |
| Group | `120363000000000001@g.us` |

**Response**

```json
{ "sessionId": "tenant_42", "messageId": "XXXXXXXXXXXXXX" }
```

---

### Text

```json
{
  "jid":  "254712345678@s.whatsapp.net",
  "type": "text",
  "text": "Hello from Laravel!"
}
```

---

### Image

```json
{
  "jid":     "254712345678@s.whatsapp.net",
  "type":    "image",
  "url":     "https://example.com/photo.jpg",
  "caption": "Check this out"
}
```

---

### Video

```json
{
  "jid":     "254712345678@s.whatsapp.net",
  "type":    "video",
  "url":     "https://example.com/video.mp4",
  "caption": "Watch this"
}
```

---

### Audio / Voice Note

```json
{
  "jid":      "254712345678@s.whatsapp.net",
  "type":     "audio",
  "url":      "https://example.com/audio.mp3",
  "mimetype": "audio/mp4",
  "ptt":      true
}
```

`ptt: true` = voice note. `ptt: false` (default) = audio file.

---

### Document / File

```json
{
  "jid":      "254712345678@s.whatsapp.net",
  "type":     "document",
  "url":      "https://example.com/report.pdf",
  "mimetype": "application/pdf",
  "fileName": "report.pdf"
}
```

---

### Location

```json
{
  "jid":       "254712345678@s.whatsapp.net",
  "type":      "location",
  "latitude":  -1.286389,
  "longitude": 36.817223
}
```

---

### Reaction

```json
{
  "jid":             "254712345678@s.whatsapp.net",
  "type":            "reaction",
  "targetMessageId": "XXXXXXXXXXXXXX",
  "emoji":           "👍"
}
```

Send `"emoji": ""` to remove a reaction.

---

## 7. Real-time Events — Socket.IO

Connect from a browser or Node client to receive live updates without polling.

```js
import { io } from 'socket.io-client'

// Production
const socket = io('https://wa.pezapo.com')
// Local dev
// const socket = io('http://localhost:3000')

socket.emit('subscribe', 'tenant_42')    // join the session room
socket.emit('unsubscribe', 'tenant_42') // leave it
```

Connections are restricted to the origin set in `SOCKET_CORS_ORIGIN`.

### Events

| Event | Payload | When |
|---|---|---|
| `status` | `{ sessionId, status }` | Any status change |
| `qr` | `{ sessionId, qr }` | QR string is ready |
| `pairing-code` | `{ sessionId, pairingCode }` | Pairing code is ready |

### Browser example

```js
socket.on('status', ({ status }) => {
  if (status === 'connected') showSuccessBanner()
})

socket.on('qr', ({ qr }) => {
  // render with e.g. qrcode.js
  QRCode.toCanvas(document.getElementById('qr'), qr)
})

socket.on('pairing-code', ({ pairingCode }) => {
  document.getElementById('code').textContent = pairingCode
})
```

---

## 8. Webhooks — What Laravel Receives

Webhooks are retried up to **3 times with exponential backoff** (1 s, 2 s, 4 s) before giving up. Final failures are logged at `warn` level.

---

### Status webhook

```
POST {LARAVEL_URL}/webhooks/baileys/status
```

Fired on every connect and disconnect.

```json
{
  "sessionId": "tenant_42",
  "status":    "connected"
}
```

Possible values: `connected`, `disconnected`.

---

### Inbound message webhook

```
POST {LARAVEL_URL}/webhooks/baileys/message
```

Fired for **every message** — both inbound from customers (`fromMe: false`) and outbound from the business owner (`fromMe: true`). Fires before the bot flow runs so Laravel always gets the raw message. Use `fromMe: true` to detect when the owner is replying manually and switch the conversation to `manual_chat` state.

```json
{
  "sessionId": "tenant_42",
  "jid":       "254712345678@s.whatsapp.net",
  "messageId": "XXXXXXXXXXXXXX",
  "fromMe":    false,
  "timestamp": 1712000000,
  "type":      "conversation",
  "text":      "Hello!",
  "message":   {}
}
```

| Field | Description |
|---|---|
| `sessionId` | Which WhatsApp session received this |
| `jid` | Sender's JID |
| `messageId` | Use this for reactions and replies |
| `fromMe` | `true` when the business owner replied manually — use this to switch conversation to `manual_chat` state |
| `timestamp` | Unix timestamp |
| `type` | Baileys proto key: `conversation`, `imageMessage`, `audioMessage`, etc. |
| `text` | Extracted plain text, or `null` for media-only messages |
| `message` | Raw Baileys proto object |

---

## 9. Bot Flow — What Laravel Serves

On each inbound text message the service fetches the active flow from:

```
GET {LARAVEL_URL}/api/internal/flow/{sessionId}
```

The response is **cached for 30 seconds**. To force a refresh after publishing a new flow, wait 30 s or restart the service.

**Expected response:**

```json
{
  "nodes": [
    { "id": "1", "type": "start",   "data": {} },
    { "id": "2", "type": "message", "data": { "text": "Welcome!" } },
    { "id": "3", "type": "menu",    "data": {
        "text": "Choose an option:",
        "options": [
          { "label": "Support" },
          { "label": "Sales"   }
        ]
      }
    },
    { "id": "4", "type": "end", "data": { "text": "Goodbye!" } }
  ],
  "edges": [
    { "from": "1", "fromPort": null, "to": "2", "toPort": null },
    { "from": "2", "fromPort": null, "to": "3", "toPort": null },
    { "from": "3", "fromPort": 0,    "to": "4", "toPort": null },
    { "from": "3", "fromPort": 1,    "to": "4", "toPort": null }
  ]
}
```

If the endpoint returns an error or no flow exists, the bot stays silent. The message webhook to Laravel still fires.

---

## 10. Flow Node Types

| Type | `data` fields | Behaviour |
|---|---|---|
| `start` | — | Entry point. Every new conversation begins here. Must be exactly one per flow. |
| `message` | `text` | Sends the text and automatically advances to the next node. |
| `menu` | `text`, `options: [{label}]` | Sends a numbered list and waits for a reply. Routes by 0-based index. Re-sends the menu on invalid input. |
| `end` | `text?` | Optionally sends a farewell message and ends the session. The next message starts the flow over. |

### Edge `fromPort` values

| Edge source | `fromPort` value |
|---|---|
| `start` or `message` node | `null` |
| `menu` node, option 1 | `0` |
| `menu` node, option 2 | `1` |
| `menu` node, option N | `N - 1` |

Each sender JID has independent conversation state inside the session. State is **persisted to disk** (`sessions/{sessionId}/flow-state.json`) — a service restart resumes conversations from where they left off.

---

## 11. Integration Checklist

### This service — env vars (set in cPanel Node.js App → Environment Variables, or `.env` for local dev)

- [ ] `LARAVEL_URL` set to your Laravel app base URL
  - Production Laravel: `https://pezapo.com`
  - Firebase Studio dev: `https://8000-WORKSPACE_ID.cloudworkstations.dev`
  - **Note:** Firebase preview URLs change each workspace restart — update `LARAVEL_URL` in cPanel when this happens
- [ ] `API_SECRET` set to a long random string
- [ ] `SOCKET_CORS_ORIGIN` set to your frontend origin (e.g. `https://pezapo.com,https://edu.pezapo.com`)
- [ ] `PORT` set if `3000` is taken
- [ ] `./sessions/` directory is writable

### Laravel `.env`

- [ ] `WHATSAPP_SERVICE_URL=https://wa.pezapo.com`
- [ ] `WHATSAPP_SERVICE_SECRET` set to the **same value** as `API_SECRET` above

### Laravel routes / controllers

- [ ] `GET  /api/internal/flow/{sessionId}` — returns the flow JSON
- [ ] `POST /webhooks/baileys/status` — handles connect/disconnect
- [ ] `POST /webhooks/baileys/message` — handles inbound messages
- [ ] All calls to this service include `Authorization: Bearer {WHATSAPP_SERVICE_SECRET}`

---

## 12. Session Statuses

| Status | Meaning |
|---|---|
| `initializing` | Socket created, connecting to WhatsApp servers |
| `qr_pending` | QR generated, waiting for user to scan |
| `connected` | Authenticated and ready |
| `disconnected` | Logged out or connection dropped |
| `not_found` | No session with that ID in memory |

---

## 13. Error Responses

All errors use the same shape:

```json
{ "error": "human-readable message" }
```

| HTTP | Cause |
|---|---|
| `400` | Missing required field or unsupported message type |
| `401` | Missing or invalid `Authorization` header |
| `404` | Session not found, or QR / pairing code not yet available |
| `429` | Rate limit exceeded |
| `503` | Session exists but is not `connected` |
| `500` | Unexpected internal error — check service logs |

---

## 14. Logging

Logging uses [pino](https://getpino.io). Control verbosity with the `LOG_LEVEL` env var:

| Level | What you see |
|---|---|
| `warn` (default) | Failed webhooks, pairing code errors, reconnect attempts |
| `info` | Above + reconnect notices |
| `debug` | Above + Baileys internal events |
| `trace` | Everything |

In production `warn` is recommended. In development `info` or `debug` gives useful visibility.

Webhook failures (status, message, flow fetch) are always logged at `warn` — they are never silently dropped.

---

## 15. Test Console

A browser-based test console is available at:

```
https://wa.pezapo.com/test/test.html
```

It lets you test every endpoint and Socket.IO event without writing any code. Enter your Service URL and API Secret, then:

- **Check Health** — verifies the service is reachable
- **Connect Socket.IO** — opens a live event stream
- **Start Session / Fetch QR** — start a new session and scan the QR
- **Send Message** — send text, image, video, audio, document, location, or reaction
- **Wake Session** — ensure a session is running without restarting it

No auth is required to load the page — only the API Secret field is needed when making calls.

---

## 16. Connecting Laravel on Firebase Studio to wa.pezapo.com

The WhatsApp service at `wa.pezapo.com` is always reachable from anywhere — including a Laravel app running inside Firebase Studio. Just set these two vars in your Laravel `.env`:

```env
WHATSAPP_SERVICE_URL=https://wa.pezapo.com
WHATSAPP_SERVICE_SECRET=<same value as API_SECRET on the server>
```

**The only gotcha — `LARAVEL_URL` must point back to Laravel.**

The WhatsApp service needs to know where to send webhooks and fetch flows. If Laravel is on Firebase Studio, its preview URL looks like:

```
https://8000-WORKSPACE_ID.cloudworkstations.dev
```

This URL **changes every time you restart the Firebase workspace.** When it changes:

1. Copy the new port-8000 preview URL from Firebase Studio
2. Go to cPanel → Node.js App → Environment Variables
3. Update `LARAVEL_URL` to the new URL
4. Click **Save** — Passenger will reload the app automatically

For a stable development setup, consider exposing Laravel via [ngrok](https://ngrok.com) or deploying to a fixed URL so `LARAVEL_URL` never needs to change.

---

## 17. Keepalive — Shared Hosting

Shared hosting (Namecheap Stellar Plus, etc.) kills processes that are idle for ~5 minutes. This service has a built-in self-pinger that hits `/health` every **4 minutes** to keep the process alive. It starts automatically when the server boots — no configuration needed.

```
[keepalive] pinging /health every 240s to stay alive
```

### Second layer — cPanel Cron Job

For extra reliability, add a cron job in cPanel as a backup. If the process dies and the self-ping can't save it, the cron job restarts it.

In cPanel → **Cron Jobs**, add a job running **every 5 minutes**:

```bash
* * * * */5 curl -s -o /dev/null https://your-service-url.com/health || cd /home/yourusername/whatsapp-service && node index.js &
```

Or if you use the Node.js App setup in cPanel, Passenger handles restarts automatically — the cron job is just an extra safety net.

### `KEEPALIVE_INTERVAL`

To change the ping interval, set in `.env`:

```env
# milliseconds — default is 240000 (4 minutes)
KEEPALIVE_INTERVAL=240000
```

---

## 18. Wake Endpoint — Start from Laravel

```
POST /wake
```

**Use this to ensure sessions are running** after a service restart, from a Laravel cron job, or on application boot. It is safe to call at any time — already-connected sessions are left untouched.

### Single session

```json
{
  "sessionId":   "tenant_42",
  "phoneNumber": "254712345678"
}
```

### Multiple sessions at once

```json
{
  "sessions": [
    { "sessionId": "tenant_42", "phoneNumber": "254712345678" },
    { "sessionId": "tenant_07" }
  ]
}
```

### Response

```json
{
  "results": [
    { "sessionId": "tenant_42", "status": "initializing", "action": "started" },
    { "sessionId": "tenant_07", "status": "connected",    "action": "already_running" }
  ]
}
```

| `action` | Meaning |
|---|---|
| `started` | Session was dead or missing — booted now |
| `already_running` | Session was already `connected`, `initializing`, or `qr_pending` — untouched |

### Laravel integration examples

**On application boot** — call `/wake` for all active sessions when Laravel starts:

```php
// app/Providers/AppServiceProvider.php
public function boot(): void
{
    if (app()->runningInConsole()) return;

    $sessions = \App\Models\WhatsappSession::active()->get()
        ->map(fn($s) => ['sessionId' => $s->session_id, 'phoneNumber' => $s->phone_number])
        ->toArray();

    if (empty($sessions)) return;

    Http::withToken(config('services.whatsapp.secret'))
        ->post(config('services.whatsapp.url') . '/wake', ['sessions' => $sessions]);
}
```

**As a scheduled job** — wake all sessions every 10 minutes as a safety net:

```php
// routes/console.php  (Laravel 11+)
Schedule::call(function () {
    $sessions = \App\Models\WhatsappSession::active()->get()
        ->map(fn($s) => ['sessionId' => $s->session_id])
        ->toArray();

    Http::withToken(config('services.whatsapp.secret'))
        ->post(config('services.whatsapp.url') . '/wake', ['sessions' => $sessions]);
})->everyTenMinutes();
```

**When a user connects a new number:**

```php
Http::withToken(config('services.whatsapp.secret'))
    ->post(config('services.whatsapp.url') . '/wake', [
        'sessionId'   => $session->session_id,
        'phoneNumber' => $session->phone_number,
    ]);
```

---

## 19. Auto-Recovery — Handling Failures in Laravel

The WhatsApp service has built-in reconnect logic (5 retries, 3 s apart) for brief WhatsApp blips. But if Passenger kills the entire Node.js process (memory limit, server restart, crash), all sessions die silently — no webhook fires because the process itself is gone.

Two layers of defence in Laravel cover this:

---

### Layer 1 — React immediately on disconnect

The status webhook fires the moment a session drops. Use it to schedule a restart job:

```php
// app/Http/Controllers/Webhooks/BaileysController.php

public function status(Request $request): Response
{
    $session = WhatsappSession::where('session_id', $request->sessionId)->first();
    if (!$session) return response()->noContent();

    $session->update(['status' => $request->status]);

    if ($request->status === 'disconnected') {
        // Wait 5 s then attempt restart — gives Baileys time to clean up
        RestartWhatsappSession::dispatch($session)->delay(5);
    }

    return response()->noContent();
}
```

```php
// app/Jobs/RestartWhatsappSession.php

public function handle(): void
{
    Http::withToken(config('services.whatsapp.secret'))
        ->post(config('services.whatsapp.url') . '/wake', [
            'sessionId'   => $this->session->session_id,
            'phoneNumber' => $this->session->phone_number,
        ]);
}
```

---

### Layer 2 — Scheduled safety net (catches full process crashes)

If the Node.js process crashes, the disconnect webhook never fires. A scheduled job that wakes all active sessions every 10 minutes catches this case:

```php
// routes/console.php (Laravel 11+)

Schedule::call(function () {
    $sessions = WhatsappSession::active()->get()
        ->map(fn($s) => ['sessionId' => $s->session_id, 'phoneNumber' => $s->phone_number])
        ->toArray();

    if (empty($sessions)) return;

    Http::withToken(config('services.whatsapp.secret'))
        ->post(config('services.whatsapp.url') . '/wake', ['sessions' => $sessions]);
})->everyTenMinutes();
```

`/wake` is safe to call at any time — already-connected sessions are left untouched.

---

### Optional — notify the tenant when their session stays down

If a session hasn't recovered after a few minutes, notify the business owner:

```php
// Inside RestartWhatsappSession job, or a separate monitor job

if ($this->session->updated_at < now()->subMinutes(15)
    && $this->session->status === 'disconnected') {

    $this->session->tenant->notify(new WhatsappSessionDownNotification($this->session));
}
```

---

### What each failure mode looks like

| Failure | What fires | How it recovers |
|---|---|---|
| WhatsApp drops the connection briefly | Built-in Baileys reconnect (5 × 3 s) | Automatic, no Laravel involvement |
| Session logged out by phone | `status` webhook → `disconnected` | Layer 1 job calls `/wake` |
| Passenger kills the Node process | Nothing (process is dead) | Layer 2 scheduler calls `/wake` within 10 min |
| Server reboot | Nothing | Layer 2 scheduler calls `/wake` within 10 min |

---

## 20. Known Gotchas & Production Fixes

### Pairing code — wait before polling

`GET /session/:id/pairing-code` returns `{"error":"pairing code not available"}` if called immediately after `POST /session`. Baileys needs ~3 s to establish its WebSocket before `requestPairingCode()` can be called.

The service handles this automatically — when `phoneNumber` is provided, `requestPairingCode()` is called 3 s after socket creation. On your side, **wait at least 5 s before polling**, or poll with retries until a code is returned:

```php
// Poll up to 10 times, 1s apart
for ($i = 0; $i < 10; $i++) {
    $res = Http::withToken($secret)->get("$url/session/$id/pairing-code");
    if ($res->successful()) {
        $code = $res->json('pairingCode');
        break;
    }
    sleep(1);
}
```

---

### QR endpoint returns a raw string, not an image

`GET /session/:id/qr` returns `{ "qr": "2@abc..." }` — a raw QR string. Render it with a QR library on the frontend:

```js
// Browser — using qrcode.js
QRCode.toCanvas(document.getElementById('qr-canvas'), data.qr)
```

```php
// Laravel — using bacon/bacon-qr-code
$qr = QrCode::format('png')->size(300)->generate($data['qr']);
```

---

### phoneNumber is optional — omit it for QR mode

`POST /session` accepts `phoneNumber` as optional. Omit it entirely to use QR scan mode. Only supply it when you want pairing code mode:

```json
{ "sessionId": "tenant_42" }                          // QR mode
{ "sessionId": "tenant_42", "phoneNumber": "254712345678" }  // pairing code mode
```

---

### Socket.IO does not work on Namecheap shared hosting

Passenger/LiteSpeed on shared hosting does not support persistent connections. Socket.IO `xhr poll error` is expected — REST API polling is the workaround:

```js
// Poll status every 2s while waiting for QR scan or connection
setInterval(async () => {
    const res = await fetch(`/session/${id}/status`, { headers: { Authorization: `Bearer ${secret}` } });
    const { status } = await res.json();
    if (status === 'connected') clearInterval(this);
}, 2000);
```

---

### LARAVEL_URL webhook failures are silent

If `LARAVEL_URL` is wrong or Laravel is unreachable, webhooks fail silently after 3 retries (logged at `warn` level). Always verify `LARAVEL_URL` is reachable from the server:

```bash
curl -s https://wa.pezapo.com/health   # confirm service is up
# Then check server logs for webhook warn entries
```

During development on Firebase Studio, the preview URL changes on every workspace restart — update `LARAVEL_URL` in cPanel each time.

---

### SOCKET_CORS_ORIGIN must include every browser origin

Every domain that opens a page connecting to Socket.IO must be listed. This includes the test console's own domain and any Firebase Studio preview URLs during development:

```
SOCKET_CORS_ORIGIN=https://pezapo.com,https://edu.pezapo.com,https://wa.pezapo.com
```

After changing this in cPanel, save and restart the app — the change doesn't take effect until Passenger reloads.

---

## 21. Bot Use Case — Conversation Design

This service is designed for **inbound-only auto-reply bots**. The bot never initiates contact — it only responds to customers who message first. This section describes the intended conversation patterns and features to implement in Laravel.

---

### The Gatekeeper Pattern

The bot does not jump straight into a menu when someone messages. It first sends a welcome and waits for the customer to opt in. This avoids disturbing users who messaged by accident or who prefer a human response.

```
Customer sends any message (first contact)
        ↓
Bot sends Welcome message (set by the business owner)
"Hi! Thanks for reaching out to Pezapo 👋
Reply *hi* or press *1* to chat with our assistant.
Otherwise we'll get back to you shortly."
        ↓
    ┌───────────────────┬─────────────────────┐
    ↓                   ↓                     ↓
Customer sends      Customer sends        Customer ignores
trigger word        anything else
    ↓                   ↓                     ↓
Bot starts          Bot stays silent      Bot stays silent
full flow menu      Owner notified        Owner notified
```

---

### Conversation States (manage in Laravel DB)

| State | Meaning | Bot behaviour |
|---|---|---|
| `new` | First message ever from this JID | Send welcome, move to `waiting_optin` |
| `waiting_optin` | Welcome sent, waiting for trigger word | Silent until trigger received |
| `in_flow` | Customer opted in, flow is running | Bot handles all replies |
| `waiting_manual` | No trigger, owner should reply | Bot silent, notify owner |
| `manual_chat` | Owner is actively replying | Bot completely silent |
| `timed_out` | Customer went silent during flow | Send timeout message, reset to `waiting_optin` |

Store these per `(session_id, jid)` pair in a `whatsapp_conversations` table.

---

### Trigger Words (opt-in)

Trigger words start the bot flow. The business owner sets them in Laravel — they are not hardcoded. Examples: `hi`, `hello`, `1`, `start`, `menu`, `help`.

**How it works:**
1. Inbound message webhook fires → Laravel checks conversation state
2. If state is `waiting_optin` and message matches a trigger word → set state to `in_flow`, call `POST /session/:id/message` to send the first menu
3. If no match → stay silent or notify owner

```php
$triggerWords = $session->trigger_words; // ['hi', 'hello', '1', 'start']
$incoming = strtolower(trim($request->text));

if (in_array($incoming, $triggerWords)) {
    $conversation->update(['state' => 'in_flow']);
    // flow runner takes over from here
}
```

---

### Custom Command Triggers (slash/at commands)

Beyond the opt-in trigger, business owners can define their own shortcut commands that instantly jump to a specific part of the flow — without going through the full menu. These can be used by the business owner in the chat or by customers.

**Examples:**
- `/price` → shows the pricing list
- `@products` → shows product catalogue menu
- `/hours` → sends opening hours
- `/contact` → sends contact details

**How it works in Laravel:**
```php
// whatsapp_triggers table: session_id, keyword, flow_node_id
$trigger = WhatsappTrigger::where('session_id', $sessionId)
    ->where('keyword', $incoming)
    ->first();

if ($trigger) {
    // Jump directly to that node in the flow
    $conversation->update(['current_node_id' => $trigger->flow_node_id, 'state' => 'in_flow']);
}
```

**Trigger format options to support:**
| Format | Example | Notes |
|---|---|---|
| Slash command | `/price` | Clean, familiar to users |
| At command | `@products` | Good for catalogue browsing |
| Plain keyword | `prices` | Most natural for customers |
| Number shortcut | `2` | Works alongside menu numbers |

---

### Inactivity Timeout

If a customer starts a flow but goes silent mid-conversation, the bot should close the session gracefully after a set period and invite them to restart.

**Recommended timeout:** 15–30 minutes of silence during an active flow.

**Laravel implementation — scheduled job:**

```php
// Check for timed-out conversations every 5 minutes
Schedule::call(function () {
    $timedOut = WhatsappConversation::where('state', 'in_flow')
        ->where('last_message_at', '<', now()->subMinutes(20))
        ->get();

    foreach ($timedOut as $conversation) {
        // Send timeout message
        Http::withToken(config('services.whatsapp.secret'))
            ->post(config('services.whatsapp.url') . "/session/{$conversation->session_id}/message", [
                'jid'  => $conversation->jid,
                'type' => 'text',
                'text' => "Your session has ended due to inactivity.\nSay *hi* to start a new conversation. 👋",
            ]);

        // Reset state
        $conversation->update([
            'state'           => 'waiting_optin',
            'current_node_id' => null,
        ]);
    }
})->everyFiveMinutes();
```

---

### Owner Silence Mode

When the business owner replies manually in WhatsApp, the bot should go silent and not interfere. Two ways to implement this:

**Option A — Automatic:** If an outbound message comes from the session's own number (Baileys `msg.key.fromMe === true`), set that conversation to `manual_chat` state.

**Option B — Manual:** Owner types a command like `/bot off` or `/bot on` in the chat to toggle the bot for that contact.

---

### Future: AI Auto-Responder Node

When you're ready to add AI, it plugs in as a new node type in the flow graph:

```json
{ "id": "5", "type": "ai", "data": { "prompt": "You are a helpful assistant for Pezapo. Answer the customer's question." } }
```

The flow runner would call the Claude/OpenAI API when it hits an `ai` node, passing the customer's message and the system prompt set by the business owner. Everything else in the flow (menus, messages, routing) stays exactly the same.

---

### Summary — What to Build in Laravel

| Feature | Where | Notes |
|---|---|---|
| `whatsapp_conversations` table | Laravel DB | Tracks state, JID, last message time, current node |
| `whatsapp_triggers` table | Laravel DB | Maps keywords to flow nodes per session |
| Webhook handler checks state | `BaileysController` | Routes inbound message based on conversation state |
| Inactivity timeout job | Laravel Scheduler | Fires every 5 min, resets silent conversations |
| Trigger word management UI | Laravel admin | Business owner sets welcome message + trigger words |
| Custom command management UI | Laravel admin | Business owner defines `/price`, `@products` etc |
