External App Integration (bursaku-api)
External App Integration (e.g. bursaku-api)
Companion to api.md. How an external application (your service
sending via a linked WhatsApp device) should call the public REST API to
dispatch messages. Not a replacement for api.md; read that for full
field/response detail.
TL;DR — the single thing that breaks most external integrators:
/api/webhooks/baileysis an inbound-only worker→server webhook that is not reachable from outside. Sending a message isPOST https://<host>/api/v1/broadcasts(or/api/v1/messages/send-single) with a Sanctum bearer token.
1. What an external app actually does
- Have a tenant account that an administrator has approved (§3.1) and
issue a bearer token with the
messages:sendability (plusdevices:readto read device and broadcast status). - Ensure the target device is
connected(GET /api/v1/devices/{id}). - POST a message to a send endpoint under
/api/v1(see §2). - For broadcasts, poll
GET /api/v1/broadcasts/{id}/progressuntilstatusreachescompleted; eachBroadcastMessage.statusmovespending → sent → delivered → read(orfailed).
Results come back asynchronously. Either poll the API (§6) or register a
signed outbound webhook under Settings → Webhooks and let Libericano call
your app (§9). A 201 from /broadcasts means "accepted + queued," not
"delivered."
2. The send endpoints (correct)
Both live under the authenticated v1 API and require the messages:send
ability.
| Broadcast | One-off | |
|---|---|---|
| Endpoint | POST /api/v1/broadcasts |
POST /api/v1/messages/send-single |
| Recipients | Many (pasted numbers, saved contacts, groups) | One |
| Pacing | Throttled (default random 3–7 s between messages) | None, sent immediately |
| Returns | 201 once queued; result per recipient is read later |
200 once the worker accepted the send, 422 if it failed |
| Rate limit | 10 requests / minute per tenant | 60 requests / minute per tenant |
Broadcast — many recipients, paced (POST /api/v1/broadcasts → 201)
Best for: one message to a list of pasted numbers, saved contacts or groups.
# Replace $TOKEN with your Sanctum bearer token, $DEVICE_ID with your device id.
TOKEN="<your bearer token>"
curl -sS https://libericano.cloud/api/v1/broadcasts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "order-confirmation", # ✅ required label
"device_id": 4, # ✅ required, must be connected
"recipients": "+6281200000001\n+6281200000002,John", # one number/line; ",Name" optional
"message": "Your order {{1}} shipped.", # ✅ required (unless template_id), max 4096 chars
"template_tags": { "1": "ORD-12345" }, # optional {{1}}..{{n}} subs
"throttle_enabled": true, # default true (3–7s random)
"throttle_min_seconds": 3,
"throttle_max_seconds": 7
}'
Minimal viable body (one recipient):
{"name":"ping","device_id":4,"recipients":"+6281200000001","message":"hi"}
Notes:
recipientsmay also be a JSON array of numbers (one entry per recipient). Instead of, or in addition to,recipientsyou can passcontact_ids[]andgroup_ids[]of saved contacts and groups.- The body field is
message. Validation fails with422("Provide a custom message or select a template") unlessmessageis non-empty or atemplate_idis given.text,body,contentandpayloadare not accepted in place ofmessagehere. throttle_max_secondsmust be ≥throttle_min_seconds; both are 0–3600.media[](multipart/form-data) attaches up to 10 files, 20 MB each.- Response:
{"broadcast": {...}, "total_recipients": 1, "contacts": ..., "groups": ...}.
One-off — single recipient, instant (POST /api/v1/messages/send-single)
Best for: transactional one-to-one sends. Not recorded in chat history. The request waits until the worker has sent the message, so it returns the real WhatsApp message id.
TOKEN="<your bearer token>"
curl -sS https://libericano.cloud/api/v1/messages/send-single \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"device_id": 4, "to": "+6281200000001", "text": "Halo from bursaku"}'
Field contract (see SingleMessageSendRequest + §5.4 of api.md):
| Field | Req | Notes |
|---|---|---|
device_id |
✅ | Must be one of your tenant's devices and connected (422 otherwise) |
to |
✅ | 7–15 digits with an optional + (e.g. +6281200000001), or a full JID |
text |
✅* | Body — required with no media[] (aliases message/body are accepted on this endpoint) |
media[] |
✅* | One+ files; max 10 × 20 MB (multipart) |
* At least one of text or media[].
Responses:
// 200 — the worker accepted the send
{ "status": "sent", "device_id": 4, "to": "6281200000001@s.whatsapp.net",
"text": "Halo from bursaku", "message_id": "<whatsapp message id>", "media": 0 }
// 422 — device not connected, worker session not active, or the send failed
{ "error": "Device is not connected.", "status": "disconnected" }
{ "status": "failed", "device_id": 4, "to": "+6281200000001", "error": "<reason>" }
With media, text is sent first, then one WhatsApp message per file;
message_id is the id of the last message sent.
Composed per recipient — let Libericano do the pacing
If your app writes every message itself, do not loop send-single with your own
timer: that interval is only the gap between your API calls. Send the whole run in one
POST /broadcasts with messages[] and set throttle_min_seconds / throttle_max_seconds;
Libericano then queues each message with the gap, in order.
curl -sS https://libericano.cloud/api/v1/broadcasts \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"order-updates","device_id":4,"throttle_min_seconds":5,"throttle_max_seconds":12,
"messages":[
{"to":"+6281200000001","text":"Order 1 shipped","reference":"order-1"},
{"to":"+6281200000002","text":"Order 2 shipped","reference":"order-2"}]}'
referenceis your own id. It comes back onGET /broadcasts/{id}(messages[].reference) and in thebroadcast.messagewebhook event (§9), which carries the realsent_at.- Images and documents: use
multipart/form-data. Upload a shared file once inmedia[]and pick it per message withmessages[N][attachments][]=<index>, or upload a file for one message withmessages[N][media][](for example one invoice PDF per recipient). With an attachment,textis optional and becomes the caption of the first image or video. Details and limits:api.md§5.5. - A composed message only gets the attachments it names.
- Check actual timing afterwards: each message has
started_at,sent_atanddelay_applied_seconds.
3. Authentication — what the request looks like
Authorization: Bearer <token>
Content-Type: application/json
LIBERICANO_API_KEY is a Sanctum personal-access token (the 2|… form is
<token_id>|<plaintext>, exactly what Sanctum expects as a bearer). A token
that carries ["devices:read","messages:send"] has the right abilities for
both send endpoints above plus reading status.
The token is scoped to its tenant, so you don't pass the tenant in the URL —
it is resolved from the token. LIBERICANO_TENANT_ID is only needed when you
issue the token (§3.1).
3.1 Getting a token
Prerequisite — tenant approval. A newly registered tenant is pending:
it cannot sign in or get tokens until a platform administrator approves it at
/admin/tenants. Once approved, either:
- create a token in the web UI under Settings → API tokens, or
- request one with the tenant's login:
curl -sS https://libericano.cloud/api/v1/auth/tokens \
-H "Content-Type: application/json" \
-d '{
"tenant_id": 1,
"email": "owner@example.com",
"password": "<account password>",
"name": "bursaku-api",
"abilities": ["messages:send", "devices:read"],
"expires_in_days": 365
}'
# → 201 {"token":"2|...","abilities":[...],"expires_at":"...","tenant_id":1,"user":{...}}
- The plain-text token is shown once; store it in a secret manager.
- Tokens expire.
expires_in_daysis30,90or365(default365). Issue a replacement beforeexpires_at; an expired token returns401. - Revoke a token with
DELETE /api/v1/auth/tokens/current(bearer = that token). - Only failed token requests are rate limited (5 per minute per email, tenant and IP).
3.2 Error responses you should handle
| Status | Meaning | What to do |
|---|---|---|
401 |
Missing, invalid or expired token | Re-issue the token (§3.1) |
403 Insufficient token abilities. |
Token lacks messages:send / devices:read |
Issue a token with the right abilities |
403 Your account … |
Tenant is pending approval or suspended | Contact the platform administrator; retrying will not help |
404 |
Device or broadcast does not exist or belongs to another tenant | Check the id and the token's tenant |
422 |
Validation failed, or the device is not connected | Read errors / error; fix the request or reconnect the device |
429 |
Rate limit exceeded | Wait for the Retry-After header, then retry |
Rate limits (per minute, adjustable by the operator): sends 60 per tenant, broadcasts 10 per tenant, the rest of the API 120 per user.
4. ❌ DO NOT send via /api/webhooks/baileys
POST /api/webhooks/baileys is the inbound webhook the Baileys Node worker
posts to Laravel with events (qr, connection.update, message.incoming,
message.receipt, message.history, import.progress, pairing_code).
- It is not a send endpoint; it has no outbound-send code path.
- It is authenticated by a platform-level shared secret (
X-Webhook-Secretheader) known only to the operator, not by a bearer token. - It is not reachable on the public host (the public vhost returns
404for/api/webhooks/and/api/internal/); only the worker, on the same server, can call it. - It is not the webhook your app receives. To get callbacks you register your own https endpoint in Settings → Webhooks (§9); it has its own per-tenant secret and signature.
If your LIBERICANO_BASE_URL is https://.../api/webhooks/baileys, every
send fails and "not even sent" — this is the exact failure this guide
exists to prevent.
5. Correct .env for bursaku-api
# ✅ Send endpoint base — the public v1 API (NOT the webhook path)
LIBERICANO_BASE_URL=https://libericano.cloud/api/v1
# ✅ Sanctum bearer token with messages:send (+ devices:read) — pass this value
# in the Authorization header on every request. Expires: see §3.1.
LIBERICANO_API_KEY=2|<redacted>
# Tenant the token was issued for; only needed when requesting a token (§3.1).
LIBERICANO_TENANT_ID=1
# Per-request HTTP timeout (seconds). send-single waits for the worker, so keep
# this at 15 or more.
LIBERICANO_TIMEOUT=15
WEBHOOK_SECRET / BAILEYS_WEBHOOK_SECRET belong to the operator's worker and
must never be copied into an external app. If you use outbound webhooks (§9),
your app needs one more value, the endpoint's own signing secret:
# Shown once in Settings → Webhooks when you add the endpoint (starts with whsec_).
LIBERICANO_WEBHOOK_SECRET=whsec_<redacted>
In code, send to URLjoin(LIBERICANO_BASE_URL, '/broadcasts')
(i.e. https://libericano.cloud/api/v1/broadcasts) with the bearer header and
the broadcast body from §2.
6. Status lifecycle & how to know it landed
Broadcast send (per recipient):
pending → sent (wa_message_id + sent_at set) → delivered → read
or → failed (error_reason set).
| What you see | Where to read it |
|---|---|
| Overall broadcast status | GET /api/v1/broadcasts/{id}/progress (status, sent/failed/pending, success_rate) |
| Recipient rows | messages array on GET /api/v1/broadcasts/{id} (each has status, wa_message_id, sent_at, error_reason) |
TOKEN="<your bearer token>"
curl -sS https://libericano.cloud/api/v1/broadcasts/8/progress \
-H "Authorization: Bearer $TOKEN"
Both reads need the devices:read ability. Poll every few seconds, not in a
tight loop (the general API limit is 120 requests per minute).
The realtime Reverb channel private-broadcasts.{tenantId} is for the web UI:
it authenticates with a browser session, so an external app with a bearer
token cannot subscribe — poll /progress or use outbound webhooks (§9).
Receipts (
delivered/read) are posted by the worker to Libericano and updatebroadcast_messagesautomatically. If a row stayspendinglong after the broadcast finished, check thatwa_message_idis set — that proves WhatsApp accepted the send; the receipt may simply not have arrived yet.send-singlehas no receipts: its200+message_idis the final result.
7. Cross-check checklist for the bursaku codebase
- The tenant is approved (not pending/suspended) and the token has not expired.
-
LIBERICANO_BASE_URLends in/api/v1(NOT/api/webhooks/baileys). - Send requests go to
/broadcastsor/messages/send-single(under/api/v1). - Requests send
Authorization: Bearer <token>(your Sanctum2|…token). - The message text is sent under
message(broadcast) ortext(send-single) — not posted as a bare webhookevent/sessionId/dataenvelope. -
device_idis a connected device (GET /api/v1/devices/{id}→status:connected). - Recipients use international digits with
+(e.g.+628…); the device's own WhatsApp number can't receive a message from itself (Baileys rejects self-addressed sends) — send to a real other contact to prove delivery. -
429and5xxare retried with a delay (honourRetry-After);401,403and422are not retried blindly. - If you use webhooks: the endpoint verifies
X-Libericano-Signatureover the raw body, checks the timestamp, de-duplicates onX-Libericano-Deliveryand answers2xxwithin 10 seconds (§9). - Browser-only frontends never see the bearer token or the signing secret; they talk to your own backend, which calls Libericano.
8. Smoke test
With your token and a connected device (here device_id 4) — send to a real
number that is not the device's own:
# Expected: 201 with a real message body.
curl -sS https://libericano.cloud/api/v1/broadcasts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"ping-test","device_id":4,"recipients":"+6281200000001","message":"ping-test"}'
# → HTTP 201, {"broadcast":{"id":8,...},"total_recipients":1,...}
# broadcast_messages.body = "ping-test" (real body, NOT null)
Then confirm it landed:
curl -sS https://libericano.cloud/api/v1/broadcasts/8/progress \
-H "Authorization: Bearer $TOKEN"
# → status moves to "completed"; the recipient shows sent / delivered
If LIBERICANO_BASE_URL still points at /api/webhooks/baileys, the same
payload returns 404 and no broadcast_message row is ever created.
9. Receiving webhooks (events pushed to your app)
Full event list, envelope and retry rules: api.md §9. Setup:
- Expose an https URL on port 443 in your backend (public host, no credentials in the URL).
- In Libericano: Settings → Webhooks → Add endpoint, pick the events, copy the
whsec_…secret (shown once) intoLIBERICANO_WEBHOOK_SECRET. - Press Send test; the delivery log under the endpoint shows the HTTP status.
Events you can subscribe to: device.status_updated, device.pairing (QR / pairing
code for showing the link screen in your own app), broadcast.progress,
broadcast.completed, contacts.synced, groups.synced (communities included).
Contact and group events carry counts only; read the data with GET /contacts and
GET /groups?is_community=1.
Always verify against the raw body. The signature is
sha256= + HMAC-SHA256 of "{timestamp}.{raw body}" with your secret.
PHP / Laravel
// routes/api.php — exclude this route from CSRF (api routes already are)
Route::post('/libericano/webhook', function (Request $request) {
$body = $request->getContent(); // raw body
$timestamp = (string) $request->header('X-Libericano-Timestamp');
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, config('services.libericano.webhook_secret'));
abort_unless(
hash_equals($expected, (string) $request->header('X-Libericano-Signature'))
&& abs(time() - (int) $timestamp) <= 300,
401,
);
$event = $request->json()->all();
// De-duplicate on $event['id'] (== X-Libericano-Delivery), then dispatch a job.
ProcessLibericanoEvent::dispatch($event);
return response()->noContent();
});
Node.js (Next.js route handler)
// app/api/libericano/webhook/route.js
import crypto from 'node:crypto';
export async function POST(request) {
const body = await request.text(); // raw body
const timestamp = request.headers.get('x-libericano-timestamp') ?? '';
const received = request.headers.get('x-libericano-signature') ?? '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.LIBERICANO_WEBHOOK_SECRET)
.update(`${timestamp}.${body}`)
.digest('hex');
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!valid) return new Response('invalid signature', { status: 401 });
const event = JSON.parse(body);
// De-duplicate on event.id, enqueue the work, answer fast.
return new Response(null, { status: 204 });
}
Python (Flask; Django works the same with request.body)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/libericano/webhook")
def libericano_webhook():
body = request.get_data() # raw bytes
timestamp = request.headers.get("X-Libericano-Timestamp", "")
received = request.headers.get("X-Libericano-Signature", "")
expected = "sha256=" + hmac.new(
os.environ["LIBERICANO_WEBHOOK_SECRET"].encode(),
timestamp.encode() + b"." + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(received, expected) or abs(time.time() - int(timestamp or 0)) > 300:
abort(401)
event = request.get_json()
# De-duplicate on event["id"], enqueue the work, answer fast.
return "", 204
Browser-only apps (SPA without a backend)
/api/* answers CORS preflights from any origin, so a browser can call the API,
but then the bearer token (and any webhook secret) would be visible to every visitor
and a leaked token can send WhatsApp messages as your tenant. Do not do that: call
Libericano from your own server (a Next.js route, a Laravel controller, a Flask view)
and let the browser talk to that. Webhooks can only be received by a server anyway.