API documentation

REST API for sending messages, reading the inbox, and receiving webhooks. Every example can be tried right away with your API key.

Quick start

  1. Sign up and verify your email.
  2. Devices menu → Add device → scan the QR from WhatsApp on your phone.
  3. API Keys menu → Create key. Save it, the key is only shown once.
  4. Send a message:
curl -X POST https://www.watsapbro.com/api/v1/messages/text \
  -H "Authorization: Bearer wsb_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"to":"08123456789","message":"Halo dari WatsapBro"}'

Base URL: https://www.watsapbro.com/api/v1. All requests and responses use JSON.

Authentication

Send the API key in the header Authorization: Bearer <API_KEY> (or X-Api-Key: <API_KEY>). Rate limit: 60 requests/minute per API key.

Responses always look like:

{ "success": true, "message": "...", "data": { ... } }

Language of message: Indonesian by default. Add ?lang=en to the URL for English messages, or switch the account language in the dashboard (ID / EN button) so every API response for that account uses it.

Recipient number format (to)

InputNotes
08123456789Automatically becomes 628123456789
628123456789International format without +
120363xxxx@g.usWhatsApp group ID

POST/messages/text

FieldTypeRequiredNotes
tostringRecipient number
messagestringMax 4096 characters. Supports *bold* _italic_ ~strike~
device_idintSending device. Empty = first connected device
device_external_idstringChoose the sending device by your external_id (alternative to device_id)
typing_timeint (ms)Show "typing…" before sending, 0–10000
{
  "to": "08123456789",
  "message": "*Pembayaran diterima*\nInvoice INV-001 sebesar Rp150.000 sudah lunas.",
  "typing_time": 1500
}

Response 200:

{
  "success": true,
  "message": "Pesan terkirim.",
  "quota_remaining": 1874,
  "data": { "log_id": 1024, "message_id": "3EB0B430A2B52B67D0", "device_id": 3, "to": "08123456789" }
}

quota_remaining = remaining quota this month, null if the plan is unlimited.

POST/messages/image

FieldTypeRequiredNotes
tostring
image_urlurlPublic JPG/PNG URL
captionstringMax 1024 characters
device_id, typing_timeSame as text
{ "to": "08123456789", "image_url": "https://cdn.toko.com/promo.jpg", "caption": "Promo minggu ini" }

POST/messages/document

FieldTypeRequiredNotes
tostring
document_urlurlPublic file URL
filenamestringFile name shown in WhatsApp
mimetypestringDefault application/pdf
captionstring
{
  "to": "08123456789",
  "document_url": "https://app.toko.com/invoice/INV-001.pdf",
  "filename": "INV-001.pdf",
  "caption": "Invoice Oktober"
}

POST/numbers/check

Check whether a number is registered on WhatsApp before sending.

{ "phone": "08123456789", "device_id": 3 }

Devices & QR scan from your app

Your platform can create devices, show the QR, and know when a number connects without your users ever opening the WatsapBro dashboard. Ideal for SaaS where every customer uses their own WhatsApp number.

Account & plan. Customers on your platform do not need a WatsapBro account. All their numbers become devices under your account and use your plan's device limit and message quota (monitor via GET /me). For hundreds of numbers, contact us for an integration plan.

On every device endpoint, {id} can be a numeric id or ext:<external_id>, e.g. /devices/ext:rt-12/qr.

Recommended flow

  1. POST /devices with your own external_id (e.g. a customer id). Safe to call repeatedly: if it already exists, the same device is returned.
  2. Show the QR in one of these ways: open the scan link (connect_link: true), embed that link as an iframe, or fetch the QR image yourself from GET /devices/{id}/qr every 3 seconds.
  3. Once scanned, a device.status webhook with status: "connected" is sent to webhook_url, and the scan link redirects to return_url.
  4. Send messages with device_external_id so you don't need to store our device id.

POST/devices

FieldTypeRequiredNotes
namestringYesDevice name, max 60 characters
external_idstringThe ID in your system, unique per account. Letters, numbers, . _ : -, max 100
metadataobjectYour own free-form data, max 4 KB
webhook_urlurlReceives device.status & message events. The secret is returned in the response
connect_linkboolAlso create a scan link
return_urlurlDestination after a successful scan. Appended with status, device_id, external_id
link_expires_inintScan link validity in minutes, 5–1440 (default 30)
curl -X POST https://www.watsapbro.com/api/v1/devices \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"RT 05 Melati","external_id":"rt-5","webhook_url":"https://app-anda.com/webhooks/wa",
       "connect_link":true,"return_url":"https://app-anda.com/pengaturan/whatsapp"}'
HTTP 201
{
  "success": true,
  "created": true,
  "data": {
    "id": 41, "name": "RT 05 Melati", "external_id": "rt-5", "status": "connecting",
    "phone": null, "webhook_url": "https://app-anda.com/webhooks/wa",
    "webhook_secret": "whsec_…",
    "qr_url": "https://www.watsapbro.com/api/v1/devices/41/qr",
    "connect_link": { "url": "https://www.watsapbro.com/connect/wcl_…", "expires_at": "2026-10-23T10:30:00+07:00" }
  }
}

With an existing external_id, the response is 200 with "created": false. If the plan's device limit is reached, the response is 402.

GET/devices/{id}/qr

Latest status + QR (PNG base64). Disconnected devices are reconnected automatically, so a new QR appears on its own. Call every refresh_in seconds until status = connected; the QR changes every ±20 seconds. Add ?format=png to get the image directly.

{
  "success": true,
  "data": {
    "device_id": 41, "external_id": "rt-5", "status": "qr_ready", "label": "Perlu scan QR",
    "phone": null, "qr": "data:image/png;base64,iVBORw0…", "qr_base64": "iVBORw0…", "refresh_in": 3
  }
}

Scan link (no login)

POST/devices/{id}/connect-link with optional return_url and expires_in (minutes) returns a branded scan page URL. Open it directly, redirect to it, or embed it with ?embed=1:

<iframe src="https://…/connect/wcl_…?embed=1" style="width:100%;height:460px;border:0"></iframe>
<script>
window.addEventListener('message', function (e) {
  if (e.data && e.data.source === 'watsapbro' && e.data.status === 'connected') {
    location.reload(); // nomor sudah terhubung
  }
});
</script>

A new link invalidates the old one, and the link stops working 5 minutes after the number connects. DELETE/devices/{id}/connect-link to cancel it early. Do not share the link with unauthorized people, since anyone holding it can link their number to this device.

Other device endpoints

EndpointFunction
GET/devicesList devices. Filter ?external_id= and ?status=
GET/devices/{id}Single device details
PATCH/devices/{id}Change name, external_id, metadata
POST/devices/{id}/connectRestart session (new QR)
POST/devices/{id}/logoutLog WhatsApp out of the device; the device stays so another number can scan it
PUT/devices/{id}/webhook{"url": "...", "rotate_secret": true}, or {"url": null} to turn it off. Returns webhook_secret
POST/devices/{id}/webhook/testSend a test event to the webhook
DELETE/devices/{id}Log out WhatsApp & delete device
statusMeaning
connectedReady to send
connectingReconnecting, try again in a few seconds
qr_readyWaiting for QR scan
disconnectedDisconnected; the system reconnects automatically, or rescan if WhatsApp on the phone was logged out

GET/me

Account info: plan, end date, device limit, and quota usage this month. Quota counts messages that were sent successfully (API, broadcast, and test messages) and resets on the 1st of every month.

{
  "success": true,
  "data": {
    "name": "Toko Budi",
    "email": "budi@toko.com",
    "plan": "Starter",
    "plan_expires_at": "2026-11-17T10:00:00+07:00",
    "device_limit": 1,
    "devices": 1,
    "can_add_device": false,
    "quota_monthly": 2000,
    "quota_used": 126,
    "quota_remaining": 1874
  }
}

Incoming message webhook

Set the webhook URL on the device detail page (Devices menu). WatsapBro sends a JSON POST to that URL for every incoming message to that number, and when the device status changes. Messages you send yourself are not forwarded.

HeaderContent
X-Webhook-Eventmessage, device.status, message.transcript, payment.proof, or test
X-Webhook-DeliveryA unique UUID per event. Store it to avoid double processing on retries.
X-Webhook-Signaturesha256= + HMAC-SHA256 of the raw body using the device signing secret

Sample message event body:

{
  "event": "message",
  "delivery_id": "6f1c2a0e-6c1e-4b0a-9a51-2d0f0b8e7c11",
  "device": { "id": 3, "external_id": "rt-5", "name": "CS Toko", "phone": "628123456789", "status": "connected" },
  "data": {
    "id": "3EB0C767D26A8B4E1F",
    "from": "6281298765432@c.us",
    "text": "Halo, pesanan saya sudah dikirim?",
    "timestamp": 1760668200
  },
  "timestamp": "2026-10-17T10:30:00+07:00"
}

The device.status event contains data.status, data.previous, and data.phone (status values match the device status table above). The webhook can be set in the dashboard or via the API (webhook_url when creating a device / PUT /devices/{id}/webhook).

Reply with HTTP 2xx within 10 seconds. If your URL times out, returns 5xx, or 429, the event is retried up to 4 times (10 seconds, 1 minute, 5 minutes, 15 minutes). Other 4xx responses are treated as rejected and are not retried. Move heavy processing into a queue in your app.

Verifying the signature

// Laravel
$raw = $request->getContent();
$expected = 'sha256=' . hash_hmac('sha256', $raw, env('WEBHOOK_SECRET'));
abort_unless(hash_equals($expected, (string) $request->header('X-Webhook-Signature')), 401);

$event = $request->json('event');
if ($event === 'message') {
    $from = $request->json('data.from');   // 6281298765432@c.us
    $text = $request->json('data.text');
}
// Go
func verify(body []byte, sig, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(sig))
}
// Node.js (Express: pakai express.raw({ type: 'application/json' }))
const crypto = require('crypto');
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.WEBHOOK_SECRET).update(req.body).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get('X-Webhook-Signature') || ''));

GET/conversations

List of inbox conversations (newest first). Optional query: device_id, unread=1, needs_human=1, per_page (max 100), page.

{
  "success": true,
  "data": [
    { "id": 41, "device_id": 3, "chat_id": "6281298765432@s.whatsapp.net", "name": "Sari", "phone": "6281298765432",
      "is_group": false, "unread": 2, "needs_human": false, "ai": "on",
      "last_message": "Masih ada stok ukuran M?", "last_message_at": "2026-10-18T09:12:00+07:00" }
  ],
  "meta": { "page": 1, "per_page": 30, "total": 128 }
}

GET/conversations/{id}/messages

Messages in a conversation, oldest to newest. Query: limit (max 100), before_id for the previous page. The source field: contact (from the customer), ai, autoreply, api, broadcast, dashboard, phone.

AI & auto reply

Auto replies are configured from the dashboard, no code needed: Auto Reply (keywords) is checked first, then the AI Agent attached to the device. AI uses your own API key (OpenAI, Anthropic, Gemini, Groq, DeepSeek, OpenRouter, or an OpenAI-compatible endpoint) and reads your Knowledge Base. Auto replies are recorded in the inbox (readable via /conversations) and count toward the message quota.

Automation pauses for a chat when you reply manually from the inbox or your phone, and when the customer asks for a human agent (handover).

AI reads voice notes

Turn on Read voice notes in the AI agent. Customer voice messages are transcribed with OpenAI Whisper, Groq Whisper, or Gemini, then answered by AI and checked against keyword rules like regular text messages. The transcript also appears in the inbox.

  • The webhook receives a message.transcript event: {"message_id": "…", "chat_id": "…", "from": "62812…", "transcript": "…"}.
  • GET /conversations/{id}/messages returns a transcript field.
  • Main provider doesn't support audio (e.g. Claude)? Fill in Dedicated AI for voice & images in the agent, e.g. the cheap Groq.

Check transfer proofs

Turn on Check transfer proofs in the AI agent and enter your receiving accounts. Every photo a customer sends is read by AI. If it is a transfer receipt, the system checks:

CheckResult if it doesn't match
The recipient is one of your accounts (masked numbers like ****7890 are still recognized)Suspicious
Amount matches the invoice (from POST /payments/expected, or the last "Total Rp…" you sent in the chat)Suspicious
Transaction date is not older than the limit (default 72 hours) and not in the futureSuspicious
Image and reference number not used in any other proofSuspicious
Transaction status successfulSuspicious
AI saw signs of an edited image, or the data could not be readNeeds review

Customers get an automatic reply "we've received your proof and are checking it". Suspicious chats or ones that need checking are marked Needs support. Final statuses: valid (Match), review (Needs review), suspicious (Suspicious), then confirmed / rejected after you decide. Screenshots can be faked, so a "Match" result is no substitute for checking your bank statement.

POST/payments/expected

Register an expected payment so transfer proofs from that number are matched against its amount. Calling again with the same reference updates a payment that is still pending.

{ "phone": "08123456789", "amount": 475000, "reference": "INV-2291", "description": "2 kain batik", "expires_in_days": 7, "device_external_id": "toko-1" }
EndpointFunction
GET/payments/expectedList of expected payments. Filter ?status=pending|matched|cancelled, ?reference=
DELETE/payments/expected/{id}Cancel invoice
GET/payment-proofsList of proofs. Filter ?status=open|valid|review|suspicious|confirmed|rejected, ?phone=, ?since=2026-10-01
GET/payment-proofs/{id}Proof details including the result of every check
POST/payment-proofs/{id}/review{"decision": "confirmed" | "rejected", "note": "…"} after you check your bank statement

The webhook receives a payment.proof event when a new proof arrives and when it is decided:

{
  "event": "payment.proof",
  "data": {
    "id": 88, "status": "suspicious", "label": "Mencurigakan", "phone": "6281234567890",
    "bank": "BCA", "amount": 200000, "expected_amount": 250000, "reference": "REF006",
    "recipient_name": "TOKO LARAS", "recipient_account": "1234567890", "paid_at": "2026-10-24T18:02:00+07:00",
    "checks": [ { "code": "amount", "ok": false, "label": "Nominal Rp200.000, seharusnya Rp250.000" }, … ],
    "expected_payment": { "id": 12, "reference": "INV-77", "amount": 250000 }
  }
}

Error codes

HTTPMeaningRetry?
401API key missing / invalidNo
402Plan inactive or devices exceed the plan limitNo, renew the plan
403Account email not verifiedNo
404Device not found / no connected deviceNo
409Device not connectedYes, after the device is connected
422Validation failed or invalid numberNo
429Rate limit exceeded, or this month's message quota is used up (see message)Rate limit: yes, with a delay. Quota: no, upgrade your plan
502 / 503WhatsApp gateway is currently unavailableYes, backoff 1s, 4s, 9s

Code examples

Laravel

use Illuminate\Support\Facades\Http;

$res = Http::withToken(env('API_KEY'))
    ->retry(3, 1000, fn ($e) => $e instanceof \Illuminate\Http\Client\ConnectionException
        || in_array(optional($e->response)->status(), [409, 429, 502, 503]))
    ->post('https://www.watsapbro.com/api/v1/messages/text', [
        'to' => '08123456789',
        'message' => 'Kode OTP Anda: 482910',
    ]);

if (! $res->json('success')) {
    logger()->error('WA gagal', $res->json());
}

Go

body, _ := json.Marshal(map[string]any{"to": "08123456789", "message": "Halo dari Go"})
req, _ := http.NewRequest("POST", "https://www.watsapbro.com/api/v1/messages/text", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)

Node.js

const res = await fetch("https://www.watsapbro.com/api/v1/messages/text", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ to: "08123456789", message: "Halo dari Node" }),
});
const json = await res.json();

PHP native

$ch = curl_init('https://www.watsapbro.com/api/v1/messages/text');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('API_KEY'), 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode(['to' => '08123456789', 'message' => 'Halo']),
]);
$result = json_decode(curl_exec($ch), true);

Tips to keep your number from being banned

  • For bulk sending, use the Broadcast menu (CSV upload, automatic random delays) rather than an API loop with no delay.
  • Use a dedicated number (preferably WhatsApp Business), not a personal one.
  • Send to people who actually know you: customers, members, app users.
  • For many recipients, wait at least 3–5 seconds between messages and use typing_time.
  • Avoid identical messages to hundreds of numbers at once; personalize with names.
  • Open WhatsApp on the main phone at least once every 2 weeks.
  • Send through a queue in your app, not directly within a user request.