Skip to content

18 — WhatsApp System

Table of Contents

  1. Architecture Overview
  2. WhatsappSession Model
  3. WhatsAppService
  4. Template System
  5. Template Keys Reference
  6. whatsapp_logs Table
  7. SendWhatsAppNotificationJob
  8. Outbox Management
  9. How to Add a New Template
  10. Enabling and Disabling WhatsApp
  11. OpenWA Session Management

Architecture Overview

PyroRadius delivers WhatsApp notifications through OpenWA (open-wa/wa-automate), a Node.js-based WhatsApp Web automation library that runs as a sidecar process alongside the Laravel application. The Laravel backend communicates with OpenWA over HTTP using session-specific endpoints.

Laravel App
WhatsAppService
    ├─── driver = 'openwa' ──► OpenWA Node.js Process
    │                               │
    │                         WhatsappSession (ready)
    │                               │
    │                         WhatsApp Web ──► Recipient
    └─── driver = 'log' ────► Laravel Log (no actual send)

Key Concepts

  • Sessions: Each WhatsApp number connected through OpenWA is represented by a WhatsappSession record. Multiple sessions can exist simultaneously (e.g., one per dealer).
  • Driver: The system supports two drivers — openwa (production sends) and log (silent no-op for testing/development).
  • Templates: All messages are driven by stored templates in the whatsapp_templates table. Messages are never hardcoded in PHP.
  • Async Delivery: Messages are dispatched through SendWhatsAppNotificationJob so that billing operations (payment recording, invoice generation) do not block waiting for WhatsApp API responses.
  • Logging: Every send attempt — successful or failed — is recorded in whatsapp_logs for audit and retry purposes.

WhatsappSession Model

Table: whatsapp_sessions

Column Type Description
id bigint (PK) Auto-increment
name varchar Human-readable label (e.g., "Main Number")
session_id varchar Unique key passed to the OpenWA API endpoint
phone_number varchar The WhatsApp number tied to this session
status enum ready, connecting, disconnected
webhook_url varchar nullable Callback URL if OpenWA pushes delivery receipts
last_seen_at timestamp nullable Last time OpenWA reported this session as alive
dealer_id bigint nullable (FK) If set, this session belongs to a specific dealer
created_at timestamp
updated_at timestamp

Status Lifecycle

[disconnected] ──scan QR──► [connecting] ──QR accepted──► [ready]
     ▲                                                        │
     └────────────────── session expired / logout ───────────┘
  • disconnected: OpenWA process is not maintaining a connection for this session. Messages cannot be sent.
  • connecting: QR code has been generated and is waiting for the phone to scan it. Transient state.
  • ready: Session is authenticated and messages can be dispatched immediately.

Model Relationships

// WhatsappSession.php
public function dealer(): BelongsTo
{
    return $this->belongsTo(Dealer::class);
}

Selecting the Active Session

WhatsAppService selects the first session with status = 'ready'. If a dealer-specific session exists and is ready, it is preferred over the global session. If no ready session exists, the send is either skipped silently or logged as failed, depending on configuration.


WhatsAppService

Namespace: App\Services\WhatsAppService

This service is the single entry point for all WhatsApp operations in PyroRadius. Controllers and jobs never call the OpenWA HTTP API directly.

Method Reference

enabled(): bool

Returns true if WhatsApp is globally enabled via Setting::get('whatsapp_enabled', false). Used as a guard at the start of every send method.

if (! $this->enabled()) {
    return false;
}

driver(): string

Reads Setting::get('whatsapp_driver', 'log'). Returns either 'openwa' or 'log'. When 'log', all send operations write to the Laravel log channel instead of contacting OpenWA.


render(string $templateKey, array $variables, ?int $dealerId = null): string

Resolves and renders a WhatsApp message template.

  1. Looks up whatsapp_templates where key = $templateKey AND dealer_id = $dealerId AND enabled = true.
  2. Falls back to whatsapp_templates where key = $templateKey AND dealer_id IS NULL AND enabled = true if no dealer-specific template is found.
  3. Replaces {placeholder} tokens in the template body with values from the $variables array.
  4. Returns the rendered string.

If no template is found (neither dealer-specific nor global), an empty string is returned and the calling method logs a warning.

$message = $this->render('payment_received', [
    'customer_name' => $customer->name,
    'amount'        => number_format($payment->amount, 2),
    'payment_info'  => $payment->reference,
], $customer->dealer_id);

sendToCustomer(Customer $customer, string $templateKey, array $variables = []): bool

High-level convenience method. Internally:

  1. Calls enabled() — returns false if disabled.
  2. Calls render($templateKey, $variables, $customer->dealer_id).
  3. Resolves the target phone number from $customer->mobile (normalised to international format).
  4. Calls send($phone, $message, $customer->dealer_id).
  5. Records a whatsapp_logs entry regardless of outcome.

send(string $phone, string $message, ?int $dealerId = null): bool

Low-level send method.

  1. Determines the appropriate WhatsappSession (dealer-specific if $dealerId provided, else global).
  2. If driver() === 'log', writes to Laravel log and returns true.
  3. If driver() === 'openwa', posts to the OpenWA HTTP endpoint:
    POST http://localhost:3000/api/{session_id}/send-message
    Body: { "phone": "+923001234567", "message": "..." }
    
  4. Parses the OpenWA response. Returns true on success, false on failure.
  5. Updates whatsapp_logs with the result.

getReadySession(?int $dealerId = null): ?WhatsappSession

Internal helper. Queries for a ready session, preferring dealer-scoped sessions:

WhatsappSession::query()
    ->where('status', 'ready')
    ->when($dealerId, fn($q) => $q->where('dealer_id', $dealerId)->orWhereNull('dealer_id'))
    ->orderByRaw('dealer_id IS NULL ASC') // dealer-specific first
    ->first();

Template System

Templates are stored in the whatsapp_templates database table and managed from the admin UI under Settings → WhatsApp Templates.

whatsapp_templates Table

Column Type Description
id bigint (PK) Auto-increment
key varchar Machine-readable identifier (e.g., payment_received)
body text Message body with {placeholder} tokens
dealer_id bigint nullable (FK) If set, this template overrides the global one for this dealer
enabled boolean Whether this template is active
created_at timestamp
updated_at timestamp

Dealer-Specific Template Fallback

The system supports a two-level hierarchy:

Request: template 'payment_received' for Dealer ID 5
    ├─ Found? whatsapp_templates WHERE key='payment_received' AND dealer_id=5 AND enabled=1
    │         └── YES → use this body
    └─ NOT FOUND → whatsapp_templates WHERE key='payment_received' AND dealer_id IS NULL AND enabled=1
                   └── use global body

This allows dealers to have branded message wording while keeping a system-wide default for dealers that haven't customised their templates.

Available Placeholders

All templates may use any of these tokens:

Placeholder Source
{customer_name} customers.name
{username} customers.username (PPPoE username)
{package} packages.name of customer's current package
{amount} Payment or invoice amount (formatted)
{expiry_date} customers.expiry_date formatted as d M Y
{payment_info} Payment reference or receipt number
{helpline} Setting::get('company_helpline')
{whatsapp_number} Setting::get('whatsapp_contact_number')

Unused placeholders left in a template body are passed through as literal text, so ensure variable arrays are complete when rendering.


Template Keys Reference

Key When Sent Placeholders Used
payment_received After a payment is recorded {customer_name}, {amount}, {payment_info}, {expiry_date}, {helpline}
invoice_generated When a new invoice is created {customer_name}, {amount}, {expiry_date}, {package}
expiry_reminder Scheduled reminder before expiry (cron) {customer_name}, {expiry_date}, {amount}, {whatsapp_number}
account_suspended When customer status → suspended {customer_name}, {username}, {helpline}
account_reactivated When customer status → active after suspension {customer_name}, {username}, {package}, {expiry_date}
account_created On new customer registration {customer_name}, {username}, {package}, {helpline}
password_changed After PPPoE password update {customer_name}, {username}, {helpline}
package_changed After package upgrade/downgrade {customer_name}, {package}, {expiry_date}
balance_low When prepaid balance falls below threshold {customer_name}, {amount}, {whatsapp_number}
custom_message Used by Campaigns for freeform bulk sends All placeholders available

whatsapp_logs Table

Every send attempt (success or failure) is recorded here for traceability and support.

Column Type Description
id bigint (PK) Auto-increment
customer_id bigint nullable (FK) Customer the message was sent to
phone varchar Destination phone number
template_key varchar Template key used
message text Rendered message body (after placeholder substitution)
status enum sent, failed, pending
error_message text nullable OpenWA error response if failed
session_id varchar nullable OpenWA session used
dealer_id bigint nullable (FK) Dealer context
created_at timestamp When the log entry was created
updated_at timestamp Last status update

Querying Logs

// All failed messages in last 24 hours
WhatsAppLog::where('status', 'failed')
    ->where('created_at', '>=', now()->subDay())
    ->get();

// All messages sent to a specific customer
WhatsAppLog::where('customer_id', $customerId)
    ->orderByDesc('created_at')
    ->paginate(20);

SendWhatsAppNotificationJob

Class: App\Jobs\SendWhatsAppNotificationJob

Queue: whatsapp (configured in config/queue.php)

Purpose: Decouples WhatsApp sending from the synchronous HTTP request lifecycle. Billing actions (payments, suspensions) dispatch this job rather than calling WhatsAppService directly.

Constructor Parameters

public function __construct(
    public readonly int    $customerId,
    public readonly string $templateKey,
    public readonly array  $variables = [],
) {}

handle() Method Flow

1. Load Customer by $customerId (fail silently if deleted)
2. Check WhatsAppService::enabled() — release job back to queue if disabled
3. Call WhatsAppService::sendToCustomer($customer, $templateKey, $variables)
4. If send fails → log error, optionally retry (max 3 attempts, backoff 60s)
5. If send succeeds → job completes, log entry already written by service

Dispatching the Job

// From PaymentController after recording payment
SendWhatsAppNotificationJob::dispatch(
    $customer->id,
    'payment_received',
    [
        'customer_name' => $customer->name,
        'amount'        => number_format($payment->amount, 2),
        'payment_info'  => $payment->reference_no,
        'expiry_date'   => $customer->expiry_date->format('d M Y'),
        'helpline'      => Setting::get('company_helpline'),
    ]
)->onQueue('whatsapp');

Retry Policy

public int $tries = 3;
public int $backoff = 60; // seconds between retries

Failed jobs after 3 attempts are moved to the failed_jobs table and will appear in the Outbox as permanently failed.


Outbox Management

Controller: App\Http\Controllers\OutboxController
Route: GET /admin/whatsapp/outbox
View: resources/js/Pages/WhatsApp/Outbox.jsx

The Outbox page provides operations staff visibility into pending and failed WhatsApp messages.

What the Outbox Shows

Column Description
Customer Name + phone number
Template Key of the template used
Status pending, sent, failed
Error Error message if failed
Attempts Number of send attempts made
Sent At Timestamp of last attempt

Actions Available

  • Retry: Re-dispatches SendWhatsAppNotificationJob for a failed log entry. Only available for failed status entries.
  • View Message: Expands the rendered message body inline.
  • Delete: Removes the log entry (does not unsend).

OutboxController Key Methods

// List all pending/failed logs with pagination
public function index(Request $request): Response

// Retry a specific failed log entry
public function retry(WhatsAppLog $log): RedirectResponse

// Bulk retry all failed entries for a dealer
public function bulkRetry(Request $request): RedirectResponse

The bulk retry feature was built to handle situations where an OpenWA session drops temporarily, causing a batch of messages to fail. Once the session is restored to ready, staff can retry all failures from the outbox in one action.


How to Add a New Template

Step 1 — Define the Template Key

Choose a snake_case key that describes the event. Add it to the Template Keys Reference table in this document.

Step 2 — Create the Database Record

Run a migration or insert directly via the admin UI:

INSERT INTO whatsapp_templates (key, body, dealer_id, enabled, created_at, updated_at)
VALUES (
    'credit_note_issued',
    'Dear {customer_name}, a credit note of PKR {amount} has been applied to your account. Helpline: {helpline}',
    NULL,  -- global template
    true,
    NOW(),
    NOW()
);

Step 3 — Dispatch the Job from the Relevant Controller

// In CreditNoteController::store()
SendWhatsAppNotificationJob::dispatch(
    $customer->id,
    'credit_note_issued',
    [
        'customer_name' => $customer->name,
        'amount'        => number_format($creditNote->amount, 2),
        'helpline'      => Setting::get('company_helpline'),
    ]
)->onQueue('whatsapp');

Step 4 — Test

Set whatsapp_driver = log in Settings. Trigger the event and verify the rendered message appears in storage/logs/laravel.log with the correct placeholder substitutions.

Step 5 — Enable for Production

Switch whatsapp_driver = openwa in Settings. Confirm a session is in ready state. Trigger the event again and verify the message appears in whatsapp_logs with status = sent.


Enabling and Disabling WhatsApp

WhatsApp can be toggled globally without touching code. Navigate to Settings → WhatsApp:

Setting Key Values Effect
whatsapp_enabled true / false Master switch; disabling stops all message dispatch
whatsapp_driver openwa / log log mode sends nothing; useful during maintenance
whatsapp_contact_number Phone number string Injected as {whatsapp_number} placeholder

When whatsapp_enabled = false, WhatsAppService::enabled() returns false and all callers short-circuit immediately. No jobs are dispatched and no logs are written.

When switching from log to openwa, ensure at least one WhatsappSession has status = ready before enabling, otherwise all sends will fail and create failed log entries.


WhatsApp Gateway — Session Management

Route: GET /whatsapp/sessions
Controller: App\Http\Controllers\WhatsappSessionsController

Important: The gateway is whatsapp-web.js (a Node.js WhatsApp Web automation library using headless Chrome via Puppeteer). It is not OpenWA/open-wa/wa-automate — those are different libraries with different APIs. Any reference to "OpenWA" in this file is historical and incorrect.

Gateway Architecture

Laravel App (PHP)
    │  HTTP + X-API-Key header
    │  POST http://localhost:2785/api/sessions/.../messages/send-text
wajs.service (Node.js — /opt/wajs/server.js)
    │  whatsapp-web.js library
    │  Puppeteer (headless Chrome)
WhatsApp Web (web.whatsapp.com)
Recipient's phone

Gateway Service

  • Binary: /usr/bin/node /opt/wajs/server.js
  • Port: 2785 (localhost only, never exposed publicly)
  • Managed by: systemctl status wajs
  • Log file: /var/log/wajs.log
  • Session storage: /opt/wajs/sessions/{session_uuid}/session-{name}/ (Chrome user data dir)

Auth recovery: On service restart, sessions are automatically restored from disk. The whatsapp-web.js LocalAuth strategy persists the WhatsApp Web cookie/session in Chrome's profile directory. No QR scan is needed on restart if the session was previously authenticated.

Gateway API Endpoints

All endpoints require header: X-API-Key: your-api-key-here

Method Path Description
GET /api/health Gateway health check
GET /api/sessions List all sessions with status
POST /api/sessions Create a new session (body: {name})
POST /api/sessions/{uuid}/start Start/initialize a session
GET /api/sessions/{uuid} Get session detail + phone number
GET /api/sessions/{uuid}/qr Get QR code as base64 PNG
DELETE /api/sessions/{uuid} Destroy session
POST /api/sessions/{uuid}/messages/send-text Send text message
POST /api/sessions/{uuid}/messages/send-document Send PDF/document
GET /api/sessions/{uuid}/contacts/check/{number} Check if number is on WhatsApp

Session Lifecycle

[created] ──start()──► [starting] ──QR generated──► [qr]
                                              user scans QR on phone
                                                  [authenticated]
                                                     [ready]  ◄─── auto-restore on restart
                                        session expires / WhatsApp kicks out
                                                 [disconnected]
                                              scheduleRestart() fires
                                                    [starting]  (retry loop)

Connecting a New Session

  1. Navigate to WhatsApp → Sessions (/whatsapp/sessions).
  2. Click Add Session, enter a name (e.g. indus), assign to a dealer (optional).
  3. Click Start — the gateway initializes Chrome and generates a QR code.
  4. The QR code appears in the browser. Scan it with the target WhatsApp phone (WhatsApp → Linked Devices → Link a Device).
  5. Session status changes to authenticatedready. WhatsApp messages can now be sent.

After first scan: The session persists on disk. Service restarts auto-reconnect without needing another QR scan.

Troubleshooting Disconnected Sessions

If a session shows "Scan QR" but the phone shows WhatsApp connected:

  1. The WhatsApp Web session was invalidated (WhatsApp kicked it out, or too many linked devices).
  2. Go to the phone → WhatsApp → Linked Devices → find the stale "WhatsApp Web" entry → Remove it.
  3. Scan the new QR code shown in PyroRadius to re-link.

For service restart failures: journalctl -u wajs -n 50 --no-pager

.env Configuration

OPENWA_BASE_URL=http://localhost:2785   # Gateway URL (whatsapp-web.js, not OpenWA)
OPENWA_API_KEY=your-api-key-here        # API key header value
OPENWA_URL=http://localhost:2785        # Alias (same value)

Last updated: 2026-07-26