API Reference

Libericano WhatsApp API — Reference

REST API for the Libericano WhatsApp automation platform: manage linked WhatsApp devices, send one-off messages and broadcasts, run interactive 1-on-1 chats with realtime delivery status (Reverb websockets), and receive inbound messages via webhooks.

Machine-readable spec: openapi.yaml (OpenAPI 3.0.3). This document is the human-readable companion. Public JSON API only — the Blade web UI is not part of this contract.


1. Overview & Architecture

                    ┌──────────────────────────────────────────────┐
  Your client ─────►│  Laravel API  (public)                       │
  (curl / JS)       │                                              │
      │             │  POST /api/v1/auth/tokens  → Sanctum bearer  │
      │ Bearer      │  /api/v1/devices /chats /messages /broadcasts│
      ▼             └──────┬───────────────────────────────────────┘
                           │ loopback (WHATSAPP_WORKER_URL)
                           ▼
                    ┌──────────────────┐   webhook POST /api/webhooks/baileys
                    │  Node worker     │   (<webhook_header>)
                    │  (Baileys)│──────────────────────────────► Laravel
                    └──────────────────┘
                           │ internal auth API (<internal_header>)
                           ▼
                    ┌──────────────────────────────────────────────┐
                    │  Reverb (:<port>)  ◄── realtime events ────────│
                    │  private-chats.{tenantId} (chat/message)     │
                    │  private-devices.{tenantId} (status/pairing) │
                    └──────────────────────────────────────────────┘
Component Where Purpose
Laravel API http://<host>/api Tenant-scoped REST API, queue jobs, Reverb broadcasting
Node worker http://<worker_url> Baileys socket sessions; only reachable from Laravel (loopback)
Reverb ws://<host>:<port> Realtime channels private-chats.{tenantId} and private-devices.{tenantId}
Webhook POST /api/webhooks/baileys Worker → Laravel inbound events (messages, receipts, connection state, pairing code)

Sending pipelines

There are two send paths — they differ in pacing and history tracking:

Path Endpoint Pacing Recorded in chat history
Chat message POST /api/v1/chats/{chat}/messages none (instant, interactive) Yes — chat_messages row + realtime chat.message.* events
One-off POST /api/v1/messages/send-single none (sent immediately) No — broadcast_messages only
Broadcast POST /api/v1/broadcasts per-broadcast throttle: default 3–7 s random, Off = 0 s, or custom min/max seconds; runs as a deterministic job chain No — broadcast_messages only

v1 scope

  • 1-on-1 chats only. Group messages are skipped by the webhook (participant set ⇒ ignored) even if they arrive.
  • Media is metadata-only. Inbound media stores MIME/size/caption/name; media_url is always null in v1 (no blob download).
  • History = messages since rollout. syncFullHistory: false on the socket; no back-filling of pre-rollout chats.
  • Send is immediate for chats (no anti-ban pacing on the interactive path).

2. Conventions

Base URL

All public routes live under /api/v1; webhooks and internal routes are outside it. Use the base URL that matches your deployment:

Environment Base URL
Local dev http://localhost/api
Production https://api.libericano.app/api

Content type

Always send Content-Type: application/json on requests with a body. Endpoints that accept files (media[]) — template create/update, broadcast create, and send-single — use multipart/form-data instead; text-only bodies on those same endpoints still accept JSON. Every successful response body is JSON (except 204 No Content and the raw-bytes internal media download).

Error responses

  • 401 — missing/invalid credentials. {"error": "Unauthorized"} or a Sanctum-style message.
  • 403 — token is valid but lacks the required ability.
  • 404 — resource not found (or not part of your tenant).
  • 422 — validation failed:
    {
      "message": "The given data was invalid.",
      "errors": { "field": ["Message."] }
    }
    
  • Business check failures (e.g. device not connected) also use 422 with a short {"error": "..."} body.

Pagination

List endpoints use Laravel's standard paginator. ?per_page= controls the page size (default 20 for chats list, 50 for messages). The envelope:

{
  "current_page": 1,
  "data": [ "…items…" ],
  "first_page_url": "http://localhost/api/v1/chats?page=1",
  "from": 1,
  "last_page": 3,
  "last_page_url": "http://localhost/api/v1/chats?page=3",
  "links": [ { "url": null, "label": "&laquo; Previous", "active": false } ],
  "next_page_url": "http://localhost/api/v1/chats?page=2",
  "path": "http://localhost/api/v1/chats",
  "per_page": 20,
  "prev_page_url": null,
  "to": 20,
  "total": 57
}

Note: messages inside a chat page are returned newest-first within the page but reversed into chronological order — i.e. the page reads oldest→newest top-to-bottom (the server calls ->latest()->paginate()->reverse()).


3. Authentication & Abilities

3.1 Tenant-scoped bearer tokens (Sanctum)

One token per tenant user. The token scope is derived from the authenticated user's tenant_id (the tenant middleware), and its abilities gate individual endpoints.

Ability Grants
messages:send Send chat messages, send-single, create broadcasts
devices:read Read/manage devices, templates, broadcasts, chats, contacts, groups; mark chat read

3.2 Shared secrets (server-to-server)

(Header names, environment variables, and secret-management endpoints are redacted from the public docs.)

4. Quick Start

The full lifecycle, end to end:

# 1. Get a bearer token
curl -sS http://localhost/api/v1/auth/tokens \
  -H 'Content-Type: application/json' \
  -d '{"tenant_id": 1, "email": "operator@example.com", "password": "<redacted>",
       "name": "my-bot", "abilities": ["messages:send", "devices:read"]}'
# → {"token":"1|...","abilities":[...],"tenant_id":1,"user":{...}}
TOKEN='1|abc123...'                       # keep; shown only once

# 2. Create a device → begins pairing session
curl -sS http://localhost/api/v1/devices \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name": "Sales Bot"}'
# → {"id":1,"status":"disconnected","session_id":"session_…",...}

# 3a. Option A — Scan QR: delivered live via Reverb private-devices.{tenantId}
#     (or via webhook / GET /devices/{id} polling) until status == "connected".

# 3b. Option B — Phone pairing code (link with 8-character code without camera):
curl -sS -X POST http://localhost/api/v1/devices/1/pairing-code \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"phone_number": "+6281293599424"}'
# → {"status":"pairing","pairing_code":"ABC12345","device":{...}}
# Enter this code into WhatsApp on your phone (Linked devices > Link with phone number).
# Device updates in realtime to status == "connected".

# 4. Start a chat with a phone number on the connected device
curl -sS http://localhost/api/v1/chats \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"phone_number": "+6285179909424", "device_id": 1}'
# → {"chat":{"id":4,"contact_phone":"6285179909424","device":{...}}}

# 5. Send a message in the chat (instant)
curl -sS http://localhost/api/v1/chats/4/messages \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"text": "Halo, ada yang bisa dibantu?"}'
# → {"message":{"id":12,"direction":"outgoing","status":"pending",...}}

# 6a. Inbound replies arrive via webhook → stored in the chat →
#     pushed live on Reverb private-chats.{tenantId} + visible via GET.

# 6b. Realtime: subscribe to the Reverb private channel to watch
#     message status go pending → sent → delivered → read.

5. Endpoints — Public API v1

5.1 Auth

POST /api/v1/auth/tokens — issue a tenant-scoped token

Authenticates a user within a tenant and returns a Sanctum bearer token with the requested abilities. No auth required.

Request

Field Type Req Notes
tenant_id int ✅ Tenant the user belongs to
email string ✅ User email (unique per tenant)
password string ✅ User password
name string – Token label, default api-token
abilities string[] – Default ["messages:send","devices:read"]
curl -sS http://localhost/api/v1/auth/tokens \
  -H 'Content-Type: application/json' \
  -d '{
    "tenant_id": 1,
    "email": "operator@example.com",
    "password": "<redacted>",
    "name": "staging-bot",
    "abilities": ["messages:send", "devices:read"]
  }'

Response 201

{
  "token": "1|abcdef1234567890...",
  "abilities": ["messages:send", "devices:read"],
  "tenant_id": 1,
  "user": { "id": 1, "name": "John Doe", "email": "operator@example.com" }
}

The plain-text token is shown once. On failure, 422 with {"errors":{"email":["These credentials do not match our records."]}}.

DELETE /api/v1/auth/tokens/current — revoke the current token

Requires Authorization: Bearer <token>. Deletes the token used for the request.

curl -sS -X DELETE http://localhost/api/v1/auth/tokens/current \
  -H "Authorization: Bearer $TOKEN"

Response 200 → {"message": "Token revoked."}


5.2 Devices — devices:read

GET /api/v1/devices — list devices

curl -sS http://localhost/api/v1/devices -H "Authorization: Bearer $TOKEN"

Response 200

[
  {
    "id": 1,
    "tenant_id": 1,
    "name": "Sales Bot",
    "phone_number": "6281293599424:30@s.whatsapp.net",
    "session_id": "session_q51yK1GwUehsU6NF",
    "status": "connected",
    "qr_code": null,
    "pairing_code": null,
    "import_history": true,
    "history_unread": false,
    "created_at": "2026-09-22T14:10:00.000000Z",
    "updated_at": "2026-09-22T14:12:00.000000Z"
  }
]
Field Type Notes
phone_number string|null Device's own WhatsApp JID once paired (me@…:tag@s.whatsapp.net)
session_id string session_<16 random chars>; the join key to worker + creds tables
status enum disconnected · pairing · connected
qr_code string|null Base64 PNG while pairing via QR; null otherwise
pairing_code string|null 8-character pairing code when linking with phone number; null otherwise

POST /api/v1/devices — create device & start pairing

curl -sS http://localhost/api/v1/devices \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{ "name": "Sales Bot", "phone_number": "6281293599424", "sync_full_history": true }'
Field Type Req Notes
name string ✅ Friendly device name
phone_number string – Optional phone number
sync_full_history bool – Enable full WhatsApp history sync upon pairing (default: true)

phone_number is optional best-effort metadata. Response 201 — the device record (status: disconnected initially). A session_id is generated and the worker is asked to start a socket; a QR webhook follows shortly.

GET /api/v1/devices/{device} — show device

POST /api/v1/devices/{device}/connect — reconnect with existing creds

Restarts the device's Baileys socket using the stored credentials (preserved on stop/logout). No new QR. Status is updated asynchronously via the connection.update webhook and broadcast on private-devices.{tenantId}.

curl -sS -X POST http://localhost/api/v1/devices/1/connect \
  -H "Authorization: Bearer $TOKEN"

Response 200 — the device (status: disconnected while reconnecting; connected follows via webhook and Reverb).

POST /api/v1/devices/{device}/pair — (re)pair with a fresh QR

Logs out any existing WhatsApp link and purges stored credentials, then starts a brand-new pairing session. A qr webhook follows shortly. Use this to (re)pair a device whose phone link was lost or changed.

curl -sS -X POST http://localhost/api/v1/devices/1/pair \
  -H "Authorization: Bearer $TOKEN"

Response 200 — the device (status: disconnected, qr_code: null, pairing_code: null; the QR arrives via Reverb private-devices.{tenantId} / webhook / GET /devices/{id} polling).

⚠️ Re-pairing logs the currently-linked phone out of WhatsApp. The old link is destroyed; the new QR must be scanned on the target phone.

POST /api/v1/devices/{device}/unpair — log out of WhatsApp

Logs the session out of WhatsApp (unlinks the device on the phone) and purges stored credentials and signal keys. Automatically flushes and cancels any pending background key writes to guarantee clean, race-free unlinking without resurrecting deleted credentials. The device record stays so it can be paired again later.

curl -sS -X POST http://localhost/api/v1/devices/1/unpair \
  -H "Authorization: Bearer $TOKEN"

Response 200 — the device (status: disconnected, phone_number: null, qr_code: null, pairing_code: null). Emits DeviceStatusUpdated on private-devices.{tenantId}.

POST /api/v1/devices/{device}/wake — wake up or verify socket session

Pings or re-establishes the device's Baileys socket connection.

  • If the socket session is already connected, it sends a WhatsApp presence update (available) to keep the connection warm and returns status: "connected".
  • If the session is disconnected, dormant, or was stopped, it initializes and boots the socket from stored credentials in PostgreSQL, returning status: "restarted" or "reconnected".

This endpoint is also invoked automatically before sending messages if a device is not currently marked as connected.

curl -sS -X POST http://localhost/api/v1/devices/1/wake \
  -H "Authorization: Bearer $TOKEN"

Response 200

{
  "status": "connected",
  "session_id": "session_q51yK1GwUehsU6NF",
  "message": "Session already active and available",
  "device": {
    "id": 1,
    "tenant_id": 1,
    "name": "Sales Bot",
    "phone_number": "6281293599424",
    "session_id": "session_q51yK1GwUehsU6NF",
    "status": "connected",
    "qr_code": null,
    "pairing_code": null,
    "import_history": true,
    "history_unread": false,
    "created_at": "2026-09-22T14:10:00.000000Z",
    "updated_at": "2026-09-22T14:12:00.000000Z"
  }
}

Errors: 502 if the worker is unreachable or fails to wake the session:

{
  "error": "Failed to wake device session",
  "details": "worker unreachable"
}

POST /api/v1/devices/{device}/pairing-code — request 8-digit pairing code

Requests an 8-character pairing code from WhatsApp using the device's phone number via Baileys' native sock.requestPairingCode(phoneNumber). Allows users to link their WhatsApp account by typing a code into WhatsApp (Linked devices → Link with phone number instead) rather than scanning a QR code with the camera.

The device status transitions to pairing, pairing_code is stored on the record, and a realtime update is broadcast on private-devices.{tenantId}.

Request

Field Type Req Notes
phone_number string ✅ International format (min 7, max 25 chars, e.g. +6281293599424 or 6281293599424)
curl -sS -X POST http://localhost/api/v1/devices/1/pairing-code \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+6281293599424"}'

Response 200

{
  "status": "pairing",
  "pairing_code": "ABC12345",
  "device": {
    "id": 1,
    "tenant_id": 1,
    "name": "Sales Bot",
    "phone_number": "6281293599424",
    "session_id": "session_q51yK1GwUehsU6NF",
    "status": "pairing",
    "qr_code": null,
    "pairing_code": "ABC12345",
    "created_at": "2026-09-22T14:10:00.000000Z",
    "updated_at": "2026-09-22T14:12:00.000000Z"
  }
}

Errors:

  • 422 if phone_number is missing or invalid.
  • 502 if the worker is unreachable or WhatsApp rejects the code request.

GET /api/v1/devices/{device}/chat-list — fetch device chat list & contacts

Fetches phonebook contacts and chat list metadata stored in the worker's session store without requiring "Share chats" to be enabled on the phone.

curl -sS http://localhost/api/v1/devices/1/chat-list \
  -H "Authorization: Bearer $TOKEN"

Response 200

{
  "contacts": [
    { "id": "6285179909424@s.whatsapp.net", "name": "Budi Santoso", "notify": "Budi" }
  ],
  "chats": [
    { "id": "6285179909424@s.whatsapp.net", "conversationTimestamp": 1758624600, "unreadCount": 0 }
  ]
}

Errors: 422 if device is not connected · 502 if worker is unreachable.

POST /api/v1/devices/{device}/history — import older chat history

Asks the worker to paginate older messages (Baileys on-demand history sync) for the device's cached chats. Results arrive asynchronously as message.history webhooks and are merged into existing rooms (see § 6 Webhooks). Idempotent — re-importing the same messages only skips them.

curl -sS -X POST http://localhost/api/v1/devices/1/history \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mark_unread": false, "per_chat": 50}'

Body (all optional):

Field Default Notes
mark_unread device history_unread default true = imported incoming messages count as unread; false = imported as read
per_chat 50 messages requested per chat per round (1–50)

Errors: 422 if the device is not connected · 502 if the worker cannot be reached.

Response 202 — { status, session_id, chats, eligible, requests, mark_unread, note } where chats = chats actually requested, eligible = 1:1 chats the round found to paginate, and requests = sync requests issued. When eligible is 0 the device has no importable 1:1 history yet — note explains the most likely fix (enable Share chats on the phone → Linked devices, then re-link). Imported history arrives via webhooks shortly after.

GET /api/v1/devices/{device}/import-progress — realtime import progress

Live counters for the device's most recent import round, fed by the worker's import.progress webhooks. Poll this every ~2 s while the web UI shows the progress bar. Returns active: false when nothing has been reported yet (or the cache expired).

curl -sS http://localhost/api/v1/devices/1/import-progress \
  -H "Authorization: Bearer $TOKEN"

Response 200:

{
  "active": true,
  "roundId": "session_q51yK1GwUehsU6NF:1758624600000",
  "chatsTotal": 8,
  "chatsDone": 3,
  "messagesImported": 42,
  "startedAt": 1758624600000,
  "done": false,
  "note": null
}
Field Notes
active true once any progress has been reported for the session
chatsTotal 1:1 chats the round will request (worker round cap)
chatsDone chats whose fetchMessageHistory call has completed
messagesImported messages forwarded to Laravel via message.history so far
done true once the round finished (or false while still running)
note non-null when the round found nothing importable (chatsTotal 0) — points the user at the phone's Share chats setting

Bar width = chatsDone / chatsTotal. Keep polling until done: true; after a generous timeout (e.g. 120 s) treat an unconverging round as interrupted — the next import call simply starts a fresh round.

POST /api/v1/devices/{device}/full-sync — trigger full history & contact sync

Triggers full pagination of older conversation history and automatically extracts all participants into the contacts table. Enables sync_full_history on the device.

curl -sS -X POST http://localhost/api/v1/devices/1/full-sync \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"per_chat": 100, "mark_unread": false}'
Field Type Default Notes
per_chat int 100 Number of messages to request per conversation (10–500)
mark_unread bool false Whether to flag imported messages as unread

Response 202 Accepted

{
  "status": "full_sync_triggered",
  "device_id": 1,
  "session_id": "session_q51yK1GwUehsU6NF",
  "sync_full_history": true,
  "history_request": {
    "ok": true,
    "chats": 12,
    "requests": 12
  },
  "contact_sync": {
    "total": 35,
    "created": 10,
    "updated": 25
  },
  "message": "Full history sync initiated. Older chats and contacts are being synchronized in the background."
}

PATCH /api/v1/devices/{device} — update history import settings

curl -sS -X PATCH http://localhost/api/v1/devices/1 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"import_history": true, "history_unread": false, "sync_full_history": true}'
Field Type Default Notes
import_history bool true auto-merge message.history push syncs into chats (on link/reconnect)
history_unread bool false default: count imported history messages as unread (used when a sync carries no explicit choice)
sync_full_history bool true full WhatsApp history sync enabled on pair and reconnect

Response 200 — the updated device.

DELETE /api/v1/devices/{device} — delete device

Response 204 (empty body). Logs the session out of WhatsApp (best-effort), purges stored credentials + signal keys, then deletes the device record (which cascades chats/broadcasts). No orphan worker session or credentials are left behind.

Pairing options:

  • QR Code: delivered via webhook (event: "qr", data.qr = base64 PNG) and pushed live to private-devices.{tenantId}. GET /api/v1/devices/{id} returns status: "pairing" + qr_code as a fallback.
  • Pairing Code: generated via POST /api/v1/devices/{id}/pairing-code (event: "pairing_code", data.pairing_code = 8-character string) and pushed live to private-devices.{tenantId}. On link completion, the worker pushes connection.update (status: "connected" + phone_number), broadcasting the live status update immediately.

5.3 Templates — devices:read

Reusable message bodies with placeholders and optional media attachments. Template media is copied onto every broadcast that uses the template (plus anything uploaded in the composer), and can be previewed/removed through the web Templates page or the API below.

Media is uploaded as multipart/form-data with a repeating media[] file field (max 10 files, 20 MB each). Allowed types: images (jpeg,png,webp, gif), videos (mp4,3gp), audio (ogg,mp3,amr) and documents (pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip).

Media object (appears as the media array on every template):

{
  "id": 7,
  "tenant_id": 1,
  "owner_type": "App\\Models\\Template",
  "owner_id": 2,
  "disk": "local",
  "file_path": "media/template/1/20260924/0ab1…f9.png",
  "original_name": "flyer.png",
  "mime_type": "image/png",
  "size_bytes": 24576,
  "url": "<internal_api_url>/media/7",
  "created_at": "2026-09-24T10:00:00.000000Z",
  "updated_at": "2026-09-24T10:00:00.000000Z"
}

Files are stored on the private disk; url is the worker's internal download endpoint (needs the <internal_header> header — not usable from a browser). The web UI streams previews through the authenticated templates/{id}/media/{id} route instead. Deleting a media row removes its file too.

GET /api/v1/templates — list templates (with media)

curl -sS http://localhost/api/v1/templates -H "Authorization: Bearer $TOKEN"

Response 200

[
  {
    "id": 2,
    "tenant_id": 1,
    "title": "Flash Sale",
    "body": "Halo {{name}}, promo {{1}} berlaku sekarang!",
    "created_at": "2026-09-22T10:00:00.000000Z",
    "updated_at": "2026-09-22T10:00:00.000000Z",
    "media": [ "…Media object…" ]
  }
]

Placeholders: {{name}} → recipient name, {{1}}, {{2}}, … → positional template_tags values supplied at broadcast time.

POST /api/v1/templates — create template (multipart/form-data)

Use multipart/form-data when attaching media; application/json also works for text-only templates.

curl -sS http://localhost/api/v1/templates \
  -H "Authorization: Bearer $TOKEN" \
  -F "title=Order Notification" \
  -F "body=Hi {{name}}, your order {{1}} has shipped!" \
  -F "media[]=@flyer.png" \
  -F "media[]=@brochure.pdf"
Field Type Req Notes
title string ✅ max 255
body string ✅ max 10000; placeholders as above
media[] file – one or more files; max 10, 20 MB each, allowed types above

Response 201 — the template object (with media).

Errors: 422 on invalid/missing fields or a disallowed/unreadable file.

GET /api/v1/templates/{template} — show template

Response 200 — the template object (with media). 404 if the template belongs to another tenant.

PATCH /api/v1/templates/{template} — update template

Appends newly uploaded media[] files and removes attachments listed in remove_media_ids. Existing attachments are never wiped by an update that omits them.

curl -sS -X PATCH http://localhost/api/v1/templates/2 \
  -H "Authorization: Bearer $TOKEN" \
  -F "title=Order Notification 2026" \
  -F "body=Hi {{name}}, your order {{1}} has shipped!" \
  -F "media[]=@banner.png" \
  -F "remove_media_ids[]=7"
Field Type Req Notes
title / body string ✅ updated values
media[] file – appended (never replaced)
remove_media_ids[] int[] – attachments to delete (must belong to the template)

Response 200 — the updated template (with media). 404 cross-tenant.

DELETE /api/v1/templates/{template} — delete template

Removes the template and deletes all its media files and rows.

**Response 204 No Content**.** 404` cross-tenant.


5.4 Messages — messages:send

POST /api/v1/messages/send-single — send a single immediate message

Queues SendWhatsAppMessageJob; the message is sent immediately (single sends carry no broadcast throttle, so there is no delay). Not part of chat history.

Supports media attachments (multipart/form-data): either text, at least one media[] file, or both is required. When media is present the worker sends the text first (if any) and then one WhatsApp message per attachment (images/videos/audio sent as media, other types as documents); wa_message_id records the last item sent in media-only requests.

Shared files are stored on the private disk as one-off media rows (owner_type: "one-off") and garbage-collected by the media:prune scheduled command once they are older than 24 h — the 202 response only means the job was queued.

Request (with media)

curl -sS http://localhost/api/v1/messages/send-single \
  -H "Authorization: Bearer $TOKEN" \
  -F "device_id=1" \
  -F "to=+6281234567890" \
  -F "text=Pesan sekali kirim" \
  -F "media[]=@receipt.png"

Text-only (application/json) also works:

curl -sS http://localhost/api/v1/messages/send-single \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "device_id": 1,
    "to": "+6281234567890",
    "text": "Pesan sekali kirim"
  }'
Field Type Req Notes
device_id int ✅ Must be a connected device (422 otherwise)
to string ✅ Recipient phone (digits; +/- allowed, stripped) or full JID
text string – Message body — required or at least one media[]
media[] file – One or more files (max 10, 20 MB each); allowed types as in §5.3

Response 202

{
  "status": "queued",
  "device_id": 1,
  "to": "+6281234567890",
  "media": 1
}

If the device isn't connected:

{ "error": "Device is not connected.", "status": "disconnected" }   // 422

5.5 Broadcasts

POST /api/v1/broadcasts — create a broadcast — messages:send

Combines up to three recipient sources into one broadcast — pasted numbers, saved contacts, and WhatsApp groups (including community-announce channels). For each recipient a BroadcastMessage row is created and one SendWhatsAppMessageJob is enqueued; all sends run as a single deterministic job chain, paced by a per-broadcast throttle. The body is either a template (with placeholder tags) or a custom message, which now supports the same placeholders. Custom wins when both are provided.

Request

curl -sS http://localhost/api/v1/broadcasts \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "name": "Flash Sale Announcement",
    "device_id": 1,
    "template_id": 2,
    "recipients": "+6285179909424\n+6281234567890,John Doe",
    "contact_ids": [5, 9],
    "group_ids": [12],
    "template_tags": { "1": "flash-sale-40" },
    "throttle_enabled": true,
    "throttle_min_seconds": 3,
    "throttle_max_seconds": 7
  }'
Field Type Req Notes
name string ✅ Broadcast label (max 255)
device_id int ✅ Connected device (else 422)
template_id int – Template used for bodies; required unless message is provided
message string – Custom message. Supports the same placeholders as templates — {{name}} (recipient name) and {{1}}, {{2}}, … (template_tags values). Wins when both template_id and message are given
recipients string | string[] – Newline-separated pasted numbers (+628… or +628…,Name per line); an array of lines is accepted too
contact_ids int[] – Saved contacts from GET /api/v1/contacts
group_ids int[] – WhatsApp groups from GET /api/v1/groups — incl. announce-only & community-announce channels (posting needs no admin access)
template_tags object – {{1}}→value substitutions, applied to both template and custom-message bodies
media[] file – Composer uploads (max 10, 20 MB each). Merged with the template's own attachments — template files are copied before dispatch (existing rows never mutated) so the broadcast ships a self-contained snapshot; custom-message broadcasts use uploads only
throttle_enabled bool – Pace sends; default true. false = send each message immediately
throttle_min_seconds int – Min seconds between sends (0–3600); default 3
throttle_max_seconds int – Max seconds between sends (0–3600, ≥ min); default 7

Constraints (422): at least one recipient source (recipients, contact_ids, group_ids) and at least one body source (template_id or message) are required; contact_ids / group_ids must belong to the device. The web UI's throttle presets map to min/max pairs — Fast 1–2 s, Normal 3–7 s, Slow 10–20 s — which work unchanged via the API.

Response 201

{
  "broadcast": {
    "id": 3,
    "tenant_id": 1,
    "device_id": 1,
    "name": "Flash Sale Announcement",
    "status": "processing",
    "throttle_enabled": true,
    "throttle_min_seconds": 3,
    "throttle_max_seconds": 7,
    "created_at": "2026-09-22T16:00:00.000000Z",
    "updated_at": "2026-09-22T16:00:00.000000Z",
    "device": { "id": 1, "name": "Sales Bot", "status": "connected", "…": "…" },
    "messages": [
      {
        "id": 21,
        "broadcast_id": 3,
        "recipient_type": "contact",
        "recipient_name": null,
        "recipient_phone": "+6285179909424",
        "recipient_jid": "6285179909424@s.whatsapp.net",
        "message_body": "Promo flash-sale-40 berlaku sekarang!",
        "status": "pending",
        "error_reason": null,
        "wa_message_id": null,
        "started_at": null,
        "delay_applied_seconds": null,
        "sent_at": null,
        "created_at": "2026-09-22T16:00:00.000000Z",
        "updated_at": "2026-09-22T16:00:00.000000Z"
      },
      {
        "id": 22,
        "broadcast_id": 3,
        "recipient_type": "group",
        "recipient_name": "Pengumuman Warga",
        "recipient_phone": null,
        "recipient_jid": "1203630000000000006@g.us",
        "message_body": "Promo flash-sale-40 berlaku sekarang!",
        "status": "pending",
        "error_reason": null,
        "wa_message_id": null,
        "started_at": null,
        "delay_applied_seconds": null,
        "sent_at": null,
        "created_at": "2026-09-22T16:00:00.000000Z",
        "updated_at": "2026-09-22T16:00:00.000000Z"
      }
    ]
  },
  "total_recipients": 2,
  "contacts": 1,
  "groups": 1
}

BroadcastMessage.status: pending → sent (with sent_at + wa_message_id) or failed (with error_reason). Group targets have recipient_phone: null; the routable target is recipient_jid (@g.us). total_recipients, contacts, and groups summarize the resolved message set.

GET /api/v1/broadcasts — list broadcasts — devices:read

Each item includes its device and messages.

GET /api/v1/broadcasts/{broadcast} — show broadcast — devices:read

GET /api/v1/broadcasts/{broadcast}/progress — realtime snapshot — devices:read

Current progress snapshot for a broadcast — the same payload as the broadcast.progress_updated websocket event (see §7.3). Used for the monitor's initial detail render and as the polling fallback when websockets are down.

curl -sS "http://localhost/api/v1/broadcasts/3/progress" \
  -H "Authorization: Bearer $TOKEN"

Response 200 — a single snapshot object:

{
  "broadcast_id": 3,
  "status": "processing",
  "total": 120,
  "sent": 47,
  "failed": 2,
  "pending": 71,
  "success_rate": 95.9,
  "progress_percent": 41,
  "contacts": 110,
  "groups": 10,
  "current_recipient": { "id": 67, "type": "contact", "name": "John Doe", "jid": "6281234567890@s.whatsapp.net" },
  "queue_count": 71,
  "next_up": { "id": 68, "type": "contact", "name": "Jane Roe", "jid": "6289876543210@s.whatsapp.net" },
  "last_message": { "id": 66, "type": "group", "name": "Pengumuman Warga", "jid": "1203630000000000006@g.us", "error_reason": null, "sent_at": "2026-09-24T16:00:05.000000Z" },
  "throttle": {
    "enabled": true,
    "min_seconds": 3,
    "max_seconds": 7,
    "last_applied_seconds": 4.0,
    "avg_applied_seconds": 5.1,
    "avg_gap_seconds": 5.3,
    "msgs_per_minute": 11.3,
    "eta_seconds": 376
  },
  "started_at": "2026-09-24T15:58:00.000000Z",
  "finished_at": null
}
Field Notes
status draft/queued/processing/completed (derived completed when all messages resolved)
success_rate sent / (sent + failed) × 100, or null when nothing resolved
progress_percent (sent + failed) / total × 100, truncated
current_recipient / next_up { id, type, name, jid } for the send in flight and the next queued one (null when none)
queue_count messages still pending (incl. the send in flight)
last_message most recent sent/failed message — drives live feed-row coloring; includes error_reason + sent_at
throttle.last_applied_seconds / avg_applied_seconds actual delay applied by the last send / running average (from BroadcastMessage.delay_applied_seconds)
throttle.avg_gap_seconds measured inter-send interval over the last 21 sends
throttle.msgs_per_minute 60 / expected gap (observed gap, else avg applied, else nominal window)
throttle.eta_seconds queue_count × expected gap (null when the queue is empty)
started_at / finished_at broadcast creation / completion timestamps (ISO-8601)

Each BroadcastMessage also records per-send timing as it is processed: started_at is stamped when the send job begins and delay_applied_seconds holds the throttle delay that was actually applied.


5.6 Chats — interactive 1-on-1

A chat binds a device to a WhatsApp contact (by phone number) and collects message history + delivery receipts. The find-or-create lookup key is device_id + normalized contact_phone.

GET /api/v1/chats — list chats — devices:read

Newest activity first. Each chat includes its device and latest_message (null for empty chats).

curl -sS "http://localhost/api/v1/chats?per_page=20" \
  -H "Authorization: Bearer $TOKEN"

Response 200 (paginated envelope; data[] sample:)

{
  "id": 4,
  "tenant_id": 1,
  "device_id": 1,
  "contact_phone": "6285179909424",
  "contact_jid": "32732398801055@lid",
  "contact_name": "Test Contact",
  "display_name": "Test Contact",
  "last_message_at": "2026-09-22T15:31:00.000000Z",
  "unread_count": 2,
  "created_at": "2026-09-22T15:30:00.000000Z",
  "updated_at": "2026-09-22T15:31:00.000000Z",
  "device": { "id": 1, "name": "Sales Bot", "status": "connected" },
  "latest_message": { "id": 12, "direction": "incoming", "body": "…" }
}
Field Notes
contact_phone Normalized digits — or the raw @lid JID when only reachable via a LID
contact_jid Routable JID (@s.whatsapp.net or @lid) when known
contact_name WhatsApp display name (pushName)
display_name contact_name if set, else contact_phone
unread_count Incoming messages not yet marked read

POST /api/v1/chats — create / find a chat — messages:send

curl -sS http://localhost/api/v1/chats \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{ "phone_number": "+6285179909424", "device_id": 1 }'
Field Type Req Notes
phone_number string ✅ Max 20 chars; non-digits stripped
device_id int ✅ Device must be connected (else 422)

Response 201 → { "chat": { …chat…, "device": {…} } } (find-or-create).

GET /api/v1/chats/{chat} — show chat + messages — devices:read

{
  "chat": { "…": "…", "device": { "…": "…" } },
  "messages": {
    "current_page": 1,
    "data": [ "…chronological page of ChatMessage…" ],
    "…": "…pagination envelope…"
  }
}

GET /api/v1/chats/{chat}/messages — list chat messages — devices:read

Paginated history; page reversed into chronological order (?per_page= default 50).

POST /api/v1/chats/{chat}/messages — send in a chat — messages:send

Creates an outgoing ChatMessage (status: pending), bumps last_message_at, and dispatches SendChatMessageJob which sends immediately (no jitter).

curl -sS http://localhost/api/v1/chats/4/messages \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{ "text": "Halo, ini pesan dari bot." }'

Response 201

{
  "message": {
    "id": 12,
    "tenant_id": 1,
    "chat_id": 4,
    "direction": "outgoing",
    "message_type": "text",
    "body": "Halo, ini pesan dari bot.",
    "media_mime": null,
    "media_size": null,
    "media_name": null,
    "media_url": null,
    "status": "pending",
    "wa_message_id": null,
    "error_reason": null,
    "created_at": "2026-09-22T15:31:00.000000Z",
    "updated_at": "2026-09-22T15:31:00.000000Z",
    "chat": { "id": 4, "device": { "id": 1, "name": "Sales Bot" } }
  }
}

422 if the chat's device is not connected: {"error":"Device is not connected."}

POST /api/v1/chats/{chat}/read — mark chat read — devices:read

Marks all incoming messages read and resets unread_count to 0.

curl -sS -X POST http://localhost/api/v1/chats/4/read \
  -H "Authorization: Bearer $TOKEN"

Response 200 → { "status": "ok" }

ChatMessage object

Field Type Notes
direction enum incoming · outgoing
message_type enum text image video audio document sticker location contact reaction unknown
status enum pending → sent → delivered → read, or failed
body string|null Text body / caption / location lat, lng / contact name
media_* *|null Metadata only in v1 (media_url always null)
wa_message_id string|null WhatsApp message id; receipts map by this

5.7 Contacts — devices:read

Manage synchronized WhatsApp address book contacts and phonebook entries stored in the database.

GET /api/v1/contacts — list contacts

curl -sS "http://localhost/api/v1/contacts?search=Budi&per_page=20" \
  -H "Authorization: Bearer $TOKEN"

Response 200

{
  "current_page": 1,
  "data": [
    {
      "id": 1,
      "tenant_id": 1,
      "device_id": 1,
      "phone_number": "6281234567890",
      "jid": "6281234567890@s.whatsapp.net",
      "name": "Budi Santoso",
      "push_name": "Budi",
      "avatar_url": null,
      "is_business": false,
      "last_active_at": "2026-09-23T14:10:00.000000Z",
      "created_at": "2026-09-23T14:10:00.000000Z",
      "updated_at": "2026-09-23T14:10:00.000000Z",
      "device": {
        "id": 1,
        "name": "Sales Bot"
      }
    }
  ],
  "total": 1
}

Query parameters:

  • search: Filter by name, push_name, or phone number.
  • device_id: Filter contacts associated with a specific device.
  • per_page: Number of results per page (default 20, max 100).

POST /api/v1/contacts/sync — fetch contacts from connected WhatsApp device

Fetches contacts discovered from the specified device's WhatsApp socket and merges them with existing chat records into the database. Supports quick in-memory sync or deep chat history sync.

curl -sS -X POST http://localhost/api/v1/contacts/sync \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"device_id": 1, "sync_type": "deep", "per_chat": 100}'
Field Type Default Notes
device_id int ✅ Target device ID (must be connected)
sync_type string "quick" "quick" for in-memory & active chats; "groups" to extract all members across participating groups (ideal for Android); "deep" to paginate older 1:1 history
per_chat int 50 Number of messages per conversation to query when sync_type="deep" (10–500)

Response 200

{
  "status": "ok",
  "device_id": 1,
  "sync_type": "deep",
  "deep_sync_triggered": true,
  "total": 24,
  "created": 18,
  "updated": 6
}

Errors: 422 if device is not connected to WhatsApp · 502 if worker is unreachable.

POST /api/v1/contacts/import — import contacts from file or raw text

Bulk import contacts from an uploaded file (CSV, vCard .vcf, or text) or raw pasted text. Optionally verifies phone numbers with WhatsApp in real-time before saving.

Example 1: Upload CSV or vCard (multipart/form-data)

curl -sS -X POST http://localhost/api/v1/contacts/import \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@contacts.csv" \
  -F "device_id=1" \
  -F "verify_whatsapp=1"

Example 2: Paste Raw Text (application/json)

curl -sS -X POST http://localhost/api/v1/contacts/import \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "raw_text": "Alice Smith, +6281234567890\nBob Johnson, +12025550143\n+628198765432",
    "device_id": 1,
    "verify_whatsapp": true
  }'
Field Type Req Notes
file file – Uploaded .csv, .vcf (vCard), or .txt file (max 5 MB)
raw_text string – Raw text containing contacts, one per line (max 50 KB)
device_id int – Optional device to associate with imported contacts
verify_whatsapp bool – Verify numbers via WhatsApp live check (sock.onWhatsApp). Skips non-existent numbers.

At least one of file or raw_text must be provided.

Response 200

{
  "status": "ok",
  "total": 3,
  "created": 2,
  "updated": 1,
  "invalid": 0,
  "verified": 3
}

POST /api/v1/contacts — create contact manually

curl -sS -X POST http://localhost/api/v1/contacts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+6281234567890", "name": "Budi Santoso", "device_id": 1}'

Response 201 — the created contact record (or 200 if updating existing phone).

GET /api/v1/contacts/{contact} — show contact

Response 200 — contact object.

DELETE /api/v1/contacts/{contact} — delete contact

Response 204 No Content.


5.8 Groups — devices:read

WhatsApp group snapshots fetched from linked devices and persisted to the groups table (upserted per device + group JID). These power group broadcast targets and the web Groups page. Announce-only groups and community-announce channels are included and can be broadcast to without the device being an admin.

GET /api/v1/groups — list groups

curl -sS "http://localhost/api/v1/groups?search=Pengumuman&device_id=1&per_page=20" \
  -H "Authorization: Bearer $TOKEN"
Query param Notes
search Filter by group name or group JID
device_id Filter groups associated with a specific device
per_page Page size (default 20, max 100)

Response 200 — paginated envelope; each item:

{
  "id": 12,
  "tenant_id": 1,
  "device_id": 1,
  "group_jid": "1203630000000000006@g.us",
  "name": "Pengumuman Warga",
  "display_name": "Pengumuman Warga",
  "description": "Informasi resmi",
  "member_count": 140,
  "is_admin": false,
  "admin_role": null,
  "is_community": false,
  "is_community_announce": true,
  "linked_parent": null,
  "announce_only": true,
  "invite_code": null,
  "last_synced_at": "2026-09-24T09:00:00.000000Z",
  "created_at": "2026-09-24T09:00:00.000000Z",
  "updated_at": "2026-09-24T09:00:00.000000Z",
  "device": { "id": 1, "name": "Sales Bot", "status": "connected" }
}
Field Notes
group_jid @g.us JID — the send target for group broadcasts
is_admin true when the linked device's number is a group admin
admin_role "superadmin" / "admin" when admin, else null
is_community / is_community_announce Community membership / announcement-channel flags
announce_only Only admins or the community can post; broadcasting here works regardless of admin access
last_synced_at When this snapshot was last fetched from WhatsApp

POST /api/v1/groups/sync — fetch groups from a connected device

Asks the worker to enumerate every group the linked number participates in (Baileys groupFetchAllParticipating) and upserts a fresh snapshot into the groups table. Run this after pairing (or after the admin-detection fix) to refresh roles — stored is_admin / admin_role only change on re-sync.

curl -sS -X POST http://localhost/api/v1/groups/sync \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{ "device_id": 1 }'
Field Type Req Notes
device_id int ✅ Must belong to the tenant and be connected (else 422)

Response 200

{
  "status": "ok",
  "device_id": 1,
  "total": 9,
  "created": 3,
  "updated": 6,
  "groups_count": 9
}

Errors: 422 if the device is not connected · 502 if the worker is unreachable or fails to enumerate groups.


6. Webhooks — worker → Laravel

POST /api/webhooks/baileys (Header <webhook_header>). The tenant and device are resolved server-side from sessionId — never trust client-supplied tenant.

Envelope: { "event": "<name>", "sessionId": "<session_xxx>", "data": {…} }

event Purpose data fields
qr QR refresh qr (base64 PNG)
pairing_code 8-character pairing code refresh pairing_code, phone_number?
connection.update Connection state changes status (connected/connecting/disconnected), reason?, qr?, phone_number? (phone number is preserved across transient disconnects)
message.incoming Inbound message see below
message.receipt Delivery/read receipt to, status, message_id, participant?
message.history Chat history batch (push sync + on-demand pagination) see below
import.progress Manual import round counters for the realtime progress bar see below

Tip: The shared secret can be viewed and regenerated on the Settings page (/settings). See § 14.

Realtime broadcast: Receipt of qr, pairing_code, or connection.update immediately updates the device record and dispatches a DeviceStatusUpdated event on the private Reverb channel private-devices.{tenantId}.

pairing_code

{
  "event": "pairing_code",
  "sessionId": "session_q51yK1GwUehsU6NF",
  "data": {
    "pairing_code": "ABC12345",
    "phone_number": "6281293599424"
  }
}

On arrival, Laravel stores pairing_code (and normalized phone number) on the device, sets status: pairing, and fires DeviceStatusUpdated to subscribers.

message.incoming

{
  "event": "message.incoming",
  "sessionId": "session_q51yK1GwUehsU6NF",
  "data": {
    "message_id": "3EB0F7A...",
    "from": "32732398801055@lid",
    "jid": "6285179909424@s.whatsapp.net",
    "participant": null,
    "timestamp": 1758624600,
    "type": "text",
    "text": "Halo!",
    "media": null,
    "push_name": "Test Contact"
  }
}
Field Notes
from Raw sender JID — may be @lid or @s.whatsapp.net
jid Resolved phone JID when from is a LID and the worker has the mapping; equals from otherwise
participant Set only for group messages → skipped in v1 ({status:"ok","skipped":"group_message"})
type Worker-normalized (text, image, …) or raw Baileys name
media {mime, size, caption?, seconds?, name?} for media messages

On arrival Laravel: finds/creates the chat (by phone, then by contact_jid), backfills contact_jid/contact_name, merges stray @lid duplicates into the phone chat, dedupes by wa_message_id, stores the message as direction:incoming, status:delivered, and fires the Reverb chat.message.created event.

message.receipt

{
  "event": "message.receipt",
  "sessionId": "session_q51yK1GwUehsU6NF",
  "data": {
    "to": "6285179909424@s.whatsapp.net",
    "status": "read",
    "message_id": "3EB0F7A...",
    "participant": null
  }
}

status values (worker-converted from Baileys' numeric enum): failed · pending · sent · delivered · read (played→read) · unknown. Matches by wa_message_id and also updates broadcast_messages.

message.history

Pushed for (a) automatic history syncs performed on link/reconnect and (b) the on-demand pagination triggered by POST /devices/{device}/history. Only 1-on-1 conversations are forwarded (@g.us, @newsletter, status@broadcast are dropped worker-side); @lid senders are resolved to phone JIDs first. Large batches are chunked (≤ 250 messages) by the worker — Laravel treats every chunk as an independent, idempotent import.

{
  "event": "message.history",
  "sessionId": "session_q51yK1GwUehsU6NF",
  "data": {
    "chats": [ { "jid": "6285179909424@s.whatsapp.net", "name": "Test Contact" } ],
    "messages": [
      {
        "message_id": "3EB0F7A...",
        "from": "32732398801055@lid",
        "jid": "6285179909424@s.whatsapp.net",
        "participant": null,
        "fromMe": false,
        "timestamp": 1758624600,
        "type": "text",
        "text": "Halo!",
        "media": null,
        "push_name": "Test Contact"
      }
    ],
    "isLatest": true,
    "unread": null
  }
}

unread, when set (true/false), carries the caller's per-run choice from a manual history sync; when absent, Laravel falls back to the device's history_unread default.

Laravel merges the batch through ChatHistoryImporter:

  • Merge by phone — an existing chat for the same device + contact_phone is reused (never duplicated); contact_jid/contact_name are backfilled when the sync reveals them. Rooms keyed by an @lid with no phone match get their phone backfilled too.
  • Dedup — messages are skipped when wa_message_id already exists for the tenant, so re-syncs/reconnects are idempotent.
  • Timestamps — the real WhatsApp timestamp drives created_at (and thus last_message_at).
  • Unread — imported history does not bump unread_count by default; imported incoming messages are marked read. When unread is requested, they are marked delivered and unread_count grows by the number of imported incoming messages. Outgoing imported messages are always sent.
  • No realtime events are fired for bulk imports (unlike message.incoming), so imported history doesn't trigger a flood of chat.message.created events.

Response 200 — { "status": "ok", chats_created, chats_merged, messages_imported, messages_skipped, groups_skipped, unread_added } (statistics for this batch).

What WhatsApp grants is capped by the phone's privacy setting (Linked devices → Share chats: none/30 days/all). The importer ingests exactly what the phone shares; if you want older history, have the user change that setting on their phone.

import.progress

Pushed by the worker while a manual history import runs (i.e. while the /sessions/history-sync round is paginating). Laravel caches the latest payload per session (10 min TTL) so the device UI can render a live progress bar via GET /devices/{device}/import-progress. Not emitted for automatic push syncs.

{
  "event": "import.progress",
  "sessionId": "session_q51yK1GwUehsU6NF",
  "data": {
    "roundId": "session_q51yK1GwUehsU6NF:1758624600000",
    "chatsTotal": 8,
    "chatsDone": 3,
    "messagesImported": 42,
    "startedAt": 1758624600000,
    "done": false,
    "note": null
  }
}
Field Notes
roundId unique per import round ({sessionId}:{epoch ms})
chatsTotal 1:1 chats the round will request (≤ MAX_REQUESTS)
chatsDone incremented as each fetchMessageHistory call completes
messagesImported count of message.history messages forwarded during the round
done true when the round fully finished (emitted ~2 s after the last fetch so in-flight batches are counted)
note non-null when the round found no 1:1 chats to paginate — actionable guidance for the user

Response 200 — { "status": "ok" }.


7. Realtime events (Reverb / Echo)

Subscribe to private tenant channels via Laravel Echo and Reverb. Channel authorization verifies that the authenticated user's tenant_id matches {tenantId}.

// Laravel Echo + Reverb (browser)
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Echo = new Echo({
  broadcaster: 'reverb',
  key: import.meta.env.VITE_REVERB_APP_KEY,
  wsHost: import.meta.env.VITE_REVERB_HOST,
  wsPort: import.meta.env.VITE_REVERB_PORT,
  wssPort: import.meta.env.VITE_REVERB_PORT,
  forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
  enabledTransports: ['ws', 'wss'],
});

// Reconnection resilience: auto-reconnect when tab is foregrounded or network restores
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible' && window.Echo) {
    window.Echo.connector?.pusher?.connection?.connect();
  }
});
window.addEventListener('online', () => {
  if (window.Echo) {
    window.Echo.connector?.pusher?.connection?.connect();
  }
});

7.1 Chats channel — private-chats.{tenantId}

const chatChannel = Echo.private(`chats.${tenantId}`);

chatChannel.listen('.chat.message.created', (e) => { /* new message */ });
chatChannel.listen('.chat.message.status_updated', (e) => { /* status change */ });
chatChannel.listen('.chat.updated', (e) => { /* unread / last_message_at change */ });
Event Payload
chat.message.created { message: { id, chat_id, direction, message_type, body, media_mime, media_size, media_name, status, created_at, contact_name, contact_phone } }
chat.message.status_updated { message_id, status, chat_id }
chat.updated { chat: { id, contact_name, contact_phone, contact_jid, unread_count, last_message_at, device: { id, name, phone_number } }, reason }

reason is one of new_message / read / unread_increment.

7.2 Devices channel — private-devices.{tenantId}

Provides instant real-time synchronization for device status transitions (disconnected → pairing → connected), QR code refreshes, and 8-digit pairing code delivery without needing client-side HTTP polling.

const deviceChannel = Echo.private(`devices.${tenantId}`);

deviceChannel.listen('.device.status_updated', (e) => {
  console.log('Device update received:', e.device.id, e.device.status);
  // Update badge, QR code image, pairing code, or connected phone number
});
Event Payload
device.status_updated { device: { id, tenant_id, name, phone_number, session_id, status, qr_code, pairing_code, import_history, history_unread, created_at, updated_at } }

7.3 Broadcasts channel — private-broadcasts.{tenantId}

Pushes a live progress snapshot for every in-flight broadcast: sending progress, success rate, actual throttle applied, and the sending queue — powering the Broadcast Monitor page. Polling fallback is GET /api/v1/broadcasts/{id}/progress (same payload).

const broadcastChannel = Echo.private(`broadcasts.${tenantId}`);

broadcastChannel.listen('.broadcast.progress_updated', (e) => {
  // e = snapshot (see the /progress endpoint contract above)
  // e.broadcast_id, e.status, e.progress_percent, e.success_rate,
  // e.current_recipient, e.next_up, e.queue_count, e.throttle, ...
});
Event Payload
broadcast.progress_updated full snapshot object — { broadcast_id, status, total, sent, failed, pending, success_rate, progress_percent, contacts, groups, current_recipient, queue_count, next_up, last_message, throttle { … }, started_at, finished_at }

The snapshot is emitted on every send completion, every receipt-driven status flip, the completion job, and when a broadcast is accepted for dispatch (initial snapshot). The monitor re-renders row badges, progress bars, counts, throttle readouts and the feed's most recent row from each event.

Event names broadcast via broadcastAs() so listeners use the dot-prefixed form as above. Realtime is the primary transport; polling is the documented fallback when websockets are unavailable. When a broadcast is small (≤ 300 recipients) the monitor auto-loads the full feed; larger broadcasts load the first 100 and offer "Load more".


8. Internal API (worker <-> Laravel, loopback)

/api/internal/v1/* is authenticated with a shared secret header and is consumed only by the Node worker over loopback (not a public API). Endpoint details are maintained in the internal OpenAPI spec; they are intentionally omitted from this public reference.

10. Environment variables

Laravel .env

Variable Notes
WHATSAPP_WORKER_URL Worker base URL, default http://<worker_url>
BAILEYS_WORKER_URL Legacy fallback for the worker URL
<webhook_secret_env> == worker WEBHOOK_SECRET; sent as <webhook_header>
<internal_secret_env> == worker <internal_secret_env>; sent as <internal_header> (strong random, e.g. <redacted>)
<internal_api_ips> Optional CSV allowlist for the internal API
BROADCAST_CONNECTION reverb
REVERB_APP_ID / REVERB_APP_KEY / REVERB_APP_SECRET Reverb app identity
REVERB_HOST / REVERB_PORT / REVERB_SCHEME Reverb listener (dev http 8080)
VITE_REVERB_APP_KEY / VITE_REVERB_HOST / VITE_REVERB_PORT / VITE_REVERB_SCHEME Client-side (public) Reverb values for Echo

Worker .env (app/Services/WhatsApp/Worker/.env)

Variable Notes
PORT 3000
LARAVEL_URL e.g. http://<api_url> — webhook + internal base
<internal_auth_url> Internal API base (scheme+host; path appended)
<internal_secret_env> Must be byte-identical to Laravel's
WEBHOOK_SECRET Must equal Laravel <webhook_secret_env>
WHATSAPP_AUTH_DRIVER database (default) · file (emergency rollback)
LOG_LEVEL info / debug

11. LID (WhatsApp Linked ID) handling

WhatsApp increasingly routes messages by LID (123456789012345@lid) rather than phone-number JIDs. The platform resolves between the two automatically:

  1. Send time — the worker preserves any JID containing @ (so @lid targets work verbatim) and resolves phone → LID via onWhatsApp (fire-and-forget, 5 s timeout).
  2. Inbound — the worker keeps a per-session lidToJid map (built from contacts.upsert/contacts.update, reused across reconnects) and sends both from (raw JID) and jid (resolved phone JID) in the incoming webhook.
  3. Storage — Laravel stores the chat under the phone number and keeps the LID in contact_jid. If a stray @lid chat was auto-created earlier, it is merged into the phone chat on the next incoming message (messages moved, unread counts and last_message_at combined, stray deleted).

For a brand-new contact with no contacts-sync data yet, an inbound may legitimately land with contact_phone = the raw @lid string. Sends to such a chat still work (contact_jid is routable; the worker preserves the @lid).


12. Status lifecycles (summary)

Device:        disconnected → pairing → connected
                              (QR)      (scan)      → disconnected (logout/error)

Operator actions on a device:
  connect → restart socket with stored creds (disconnected → connected)
  pair    → logout old link + purge creds → fresh QR (disconnected → pairing)
  unpair  → logout + purge creds (connected → disconnected, phone/qr cleared)
  delete  → logout + purge creds + remove record

ChatMessage:   pending → sent → delivered → read
                           ↘ failed (send error / device disconnected)

BroadcastMessage: pending → sent (started_at stamped at send start, sent_at + wa_message_id set on success)
                          ↘ failed (started_at + error_reason set)

Broadcast:     queued → processing         (draft reserved for future drafts)

Recipients of message status can rely on both webhooks (receipts) and Reverb events (chat.message.status_updated) to converge on the same status; REST GET /chats/{chat}/messages always reflects the latest.


13. Enums reference

DeviceStatus:     [disconnected, pairing, connected]
ChatMessageStatus:[pending, sent, delivered, read, failed]
ChatMessageDirection: [incoming, outgoing]
ChatMessageType:  [text, image, video, audio, document, sticker, location, contact, reaction, unknown]
BroadcastStatus:  [draft, queued, processing, completed]
BroadcastRecipientType: [contact, group]
MessageStatus:    [pending, sent, failed]     # broadcast per-recipient

Generated from the live main implementation (chat feature round verified end-to-end). Spec source of truth: openapi.yaml.


14. Settings UI (Web)

The Laravel web UI provides a settings page at /settings (requires authentication) for managing webhooks and API tokens. The page is accessible via the "Documentation" → "Settings" navigation link, or directly at http://app.libericano.test/settings.

The documentation viewer at /docs (or http://docs.libericano.test/docs) is publicly accessible — no authentication required. It renders the Markdown files in the project's docs/ directory with a sidebar navigation.

14.1 Webhook management

<redacted management endpoint> — Generates a new webhook shared secret and writes it to .env (key: <webhook_secret_env>). The runtime config is updated immediately so the new secret is enforced right away. The Baileys worker must be restarted to pick up the new WEBHOOK_SECRET.

The current webhook endpoint and secret are displayed on the settings page:

POST http://<APP_URL>/api/webhooks/baileys
Header: <webhook_header>: <current secret>

14.2 API token management

Sanctum personal-access tokens can be created and revoked via the web UI:

Method Route Description
GET /settings Settings page (webhook URL, secret, token list)
POST /settings/api-tokens Create a new API token (name, abilities[])
DELETE /settings/api-tokens/{token} Revoke (delete) an API token

The plain-text token is shown once after creation — copy it before navigating away. Abilities:

Ability Grants
messages:send Send chat messages, send-single, create broadcasts
devices:read Read/manage devices, templates, broadcasts, chats, contacts, groups; mark chat read