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 (
participantset ⇒ ignored) even if they arrive. - Media is metadata-only. Inbound media stores MIME/size/caption/name;
media_urlis alwaysnullin v1 (no blob download). - History = messages since rollout.
syncFullHistory: falseon 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
422with 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": "« 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,
422with{"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 returnsstatus: "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:
422ifphone_numberis missing or invalid.502if 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 toprivate-devices.{tenantId}.GET /api/v1/devices/{id}returnsstatus: "pairing"+qr_codeas 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 toprivate-devices.{tenantId}. On link completion, the worker pushesconnection.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;
urlis the worker's internal download endpoint (needs the<internal_header>header — not usable from a browser). The web UI streams previews through the authenticatedtemplates/{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_idormessage) are required;contact_ids/group_idsmust 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
fileorraw_textmust 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, orconnection.updateimmediately updates the device record and dispatches aDeviceStatusUpdatedevent on the private Reverb channelprivate-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_phoneis reused (never duplicated);contact_jid/contact_nameare backfilled when the sync reveals them. Rooms keyed by an@lidwith no phone match get their phone backfilled too. - Dedup — messages are skipped when
wa_message_idalready exists for the tenant, so re-syncs/reconnects are idempotent. - Timestamps — the real WhatsApp
timestampdrivescreated_at(and thuslast_message_at). - Unread — imported history does not bump
unread_countby default; imported incoming messages are markedread. When unread is requested, they are markeddeliveredandunread_countgrows by the number of imported incoming messages. Outgoing imported messages are alwayssent. - No realtime events are fired for bulk imports (unlike
message.incoming), so imported history doesn't trigger a flood ofchat.message.createdevents.
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:
- Send time — the worker preserves any JID containing
@(so@lidtargets work verbatim) and resolves phone → LID viaonWhatsApp(fire-and-forget, 5 s timeout). - Inbound — the worker keeps a per-session
lidToJidmap (built fromcontacts.upsert/contacts.update, reused across reconnects) and sends bothfrom(raw JID) andjid(resolved phone JID) in the incoming webhook. - Storage — Laravel stores the chat under the phone number and keeps the
LID in
contact_jid. If a stray@lidchat was auto-created earlier, it is merged into the phone chat on the next incoming message (messages moved, unread counts andlast_message_atcombined, 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 |