18 — WhatsApp System¶
Table of Contents¶
- Architecture Overview
- WhatsappSession Model
- WhatsAppService
- Template System
- Template Keys Reference
- whatsapp_logs Table
- SendWhatsAppNotificationJob
- Outbox Management
- How to Add a New Template
- Enabling and Disabling WhatsApp
- 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
WhatsappSessionrecord. Multiple sessions can exist simultaneously (e.g., one per dealer). - Driver: The system supports two drivers —
openwa(production sends) andlog(silent no-op for testing/development). - Templates: All messages are driven by stored templates in the
whatsapp_templatestable. Messages are never hardcoded in PHP. - Async Delivery: Messages are dispatched through
SendWhatsAppNotificationJobso 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_logsfor 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.
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.
- Looks up
whatsapp_templateswherekey = $templateKey AND dealer_id = $dealerId AND enabled = true. - Falls back to
whatsapp_templateswherekey = $templateKey AND dealer_id IS NULL AND enabled = trueif no dealer-specific template is found. - Replaces
{placeholder}tokens in the template body with values from the$variablesarray. - 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:
- Calls
enabled()— returnsfalseif disabled. - Calls
render($templateKey, $variables, $customer->dealer_id). - Resolves the target phone number from
$customer->mobile(normalised to international format). - Calls
send($phone, $message, $customer->dealer_id). - Records a
whatsapp_logsentry regardless of outcome.
send(string $phone, string $message, ?int $dealerId = null): bool¶
Low-level send method.
- Determines the appropriate
WhatsappSession(dealer-specific if$dealerIdprovided, else global). - If
driver() === 'log', writes to Laravel log and returnstrue. - If
driver() === 'openwa', posts to the OpenWA HTTP endpoint: - Parses the OpenWA response. Returns
trueon success,falseon failure. - Updates
whatsapp_logswith 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¶
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
SendWhatsAppNotificationJobfor a failed log entry. Only available forfailedstatus 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¶
- Navigate to WhatsApp → Sessions (
/whatsapp/sessions). - Click Add Session, enter a name (e.g.
indus), assign to a dealer (optional). - Click Start — the gateway initializes Chrome and generates a QR code.
- The QR code appears in the browser. Scan it with the target WhatsApp phone (WhatsApp → Linked Devices → Link a Device).
- Session status changes to
authenticated→ready. 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:
- The WhatsApp Web session was invalidated (WhatsApp kicked it out, or too many linked devices).
- Go to the phone → WhatsApp → Linked Devices → find the stale "WhatsApp Web" entry → Remove it.
- 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