Quick start
- Sign up and verify your email.
- Devices menu → Add device → scan the QR from WhatsApp on your phone.
- API Keys menu → Create key. Save it, the key is only shown once.
- 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)
| Input | Notes |
|---|---|
08123456789 | Automatically becomes 628123456789 |
628123456789 | International format without + |
120363xxxx@g.us | WhatsApp group ID |
POST/messages/text
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | Recipient number | |
message | string | Max 4096 characters. Supports *bold* _italic_ ~strike~ | |
device_id | int | Sending device. Empty = first connected device | |
device_external_id | string | Choose the sending device by your external_id (alternative to device_id) | |
typing_time | int (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
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | ||
image_url | url | Public JPG/PNG URL | |
caption | string | Max 1024 characters | |
device_id, typing_time | Same as text |
{ "to": "08123456789", "image_url": "https://cdn.toko.com/promo.jpg", "caption": "Promo minggu ini" }
POST/messages/document
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | ||
document_url | url | Public file URL | |
filename | string | File name shown in WhatsApp | |
mimetype | string | Default application/pdf | |
caption | string |
{
"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
POST /deviceswith your ownexternal_id(e.g. a customer id). Safe to call repeatedly: if it already exists, the same device is returned.- 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 fromGET /devices/{id}/qrevery 3 seconds. - Once scanned, a
device.statuswebhook withstatus: "connected"is sent towebhook_url, and the scan link redirects toreturn_url. - Send messages with
device_external_idso you don't need to store our device id.
POST/devices
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Device name, max 60 characters |
external_id | string | The ID in your system, unique per account. Letters, numbers, . _ : -, max 100 | |
metadata | object | Your own free-form data, max 4 KB | |
webhook_url | url | Receives device.status & message events. The secret is returned in the response | |
connect_link | bool | Also create a scan link | |
return_url | url | Destination after a successful scan. Appended with status, device_id, external_id | |
link_expires_in | int | Scan 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
| Endpoint | Function |
|---|---|
GET/devices | List devices. Filter ?external_id= and ?status= |
GET/devices/{id} | Single device details |
PATCH/devices/{id} | Change name, external_id, metadata |
POST/devices/{id}/connect | Restart session (new QR) |
POST/devices/{id}/logout | Log 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/test | Send a test event to the webhook |
DELETE/devices/{id} | Log out WhatsApp & delete device |
| status | Meaning |
|---|---|
connected | Ready to send |
connecting | Reconnecting, try again in a few seconds |
qr_ready | Waiting for QR scan |
disconnected | Disconnected; 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.
| Header | Content |
|---|---|
X-Webhook-Event | message, device.status, message.transcript, payment.proof, or test |
X-Webhook-Delivery | A unique UUID per event. Store it to avoid double processing on retries. |
X-Webhook-Signature | sha256= + 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.transcriptevent:{"message_id": "…", "chat_id": "…", "from": "62812…", "transcript": "…"}. GET /conversations/{id}/messagesreturns atranscriptfield.- 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:
| Check | Result 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 future | Suspicious |
| Image and reference number not used in any other proof | Suspicious |
| Transaction status successful | Suspicious |
| AI saw signs of an edited image, or the data could not be read | Needs 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" }
| Endpoint | Function |
|---|---|
GET/payments/expected | List of expected payments. Filter ?status=pending|matched|cancelled, ?reference= |
DELETE/payments/expected/{id} | Cancel invoice |
GET/payment-proofs | List 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
| HTTP | Meaning | Retry? |
|---|---|---|
| 401 | API key missing / invalid | No |
| 402 | Plan inactive or devices exceed the plan limit | No, renew the plan |
| 403 | Account email not verified | No |
| 404 | Device not found / no connected device | No |
| 409 | Device not connected | Yes, after the device is connected |
| 422 | Validation failed or invalid number | No |
| 429 | Rate 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 / 503 | WhatsApp gateway is currently unavailable | Yes, 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.