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/baileys is an inbound-only worker→server webhook that is not reachable from outside. Sending a message is POST https://<host>/api/v1/broadcasts (or /api/v1/messages/send-single) with a Sanctum bearer token.


1. What an external app actually does

  1. Have a tenant account that an administrator has approved (§3.1) and issue a bearer token with the messages:send ability (plus devices:read to read device and broadcast status).
  2. Ensure the target device is connected (GET /api/v1/devices/{id}).
  3. POST a message to a send endpoint under /api/v1 (see §2).
  4. For broadcasts, poll GET /api/v1/broadcasts/{id}/progress until status reaches completed; each BroadcastMessage.status moves pending → sent → delivered → read (or failed).

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:

  • recipients may also be a JSON array of numbers (one entry per recipient). Instead of, or in addition to, recipients you can pass contact_ids[] and group_ids[] of saved contacts and groups.
  • The body field is message. Validation fails with 422 ("Provide a custom message or select a template") unless message is non-empty or a template_id is given. text, body, content and payload are not accepted in place of message here.
  • throttle_max_seconds must 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"}]}'
  • reference is your own id. It comes back on GET /broadcasts/{id} (messages[].reference) and in the broadcast.message webhook event (§9), which carries the real sent_at.
  • Images and documents: use multipart/form-data. Upload a shared file once in media[] and pick it per message with messages[N][attachments][]=<index>, or upload a file for one message with messages[N][media][] (for example one invoice PDF per recipient). With an attachment, text is 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_at and delay_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_days is 30, 90 or 365 (default 365). Issue a replacement before expires_at; an expired token returns 401.
  • 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-Secret header) known only to the operator, not by a bearer token.
  • It is not reachable on the public host (the public vhost returns 404 for /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 update broadcast_messages automatically. If a row stays pending long after the broadcast finished, check that wa_message_id is set — that proves WhatsApp accepted the send; the receipt may simply not have arrived yet. send-single has no receipts: its 200 + message_id is 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_URL ends in /api/v1 (NOT /api/webhooks/baileys).
  • Send requests go to /broadcasts or /messages/send-single (under /api/v1).
  • Requests send Authorization: Bearer <token> (your Sanctum 2|… token).
  • The message text is sent under message (broadcast) or text (send-single) — not posted as a bare webhook event/sessionId/data envelope.
  • device_id is 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.
  • 429 and 5xx are retried with a delay (honour Retry-After); 401, 403 and 422 are not retried blindly.
  • If you use webhooks: the endpoint verifies X-Libericano-Signature over the raw body, checks the timestamp, de-duplicates on X-Libericano-Delivery and answers 2xx within 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:

  1. Expose an https URL on port 443 in your backend (public host, no credentials in the URL).
  2. In Libericano: Settings → Webhooks → Add endpoint, pick the events, copy the whsec_… secret (shown once) into LIBERICANO_WEBHOOK_SECRET.
  3. 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.