Authentication
Every request needs your number's API token from the dashboard:
Authorization: Bearer YOUR_TOKEN
Each linked number has its own token. Keep it secret: it grants full sending access as that number.
Requests may be JSON (Content-Type: application/json) or form-encoded.
Send a message
POST https://YOUR-WAAPI-HOST/api/send
| Field | Description |
|---|---|
to required | Recipient's full number with country code, digits only, e.g. 923001234567. The recipient must have WhatsApp. |
text | Message text, up to 4,096 characters. When sending media it becomes the caption. |
image | Public URL of a JPG, PNG or WebP image. |
document | Public URL of a file (PDF, DOCX, XLSX, ZIP…). |
filename | File name shown to the recipient for a document, e.g. invoice-1042.pdf. |
audio | Public URL of an audio file (MP3, M4A, WAV, OGG…). |
caption | Optional caption for media, overriding text. |
Send one of image, document or audio per request. Files are downloaded from your URL (max 16 MB), sent, and not stored.
cURL
curl -X POST https://YOUR-WAAPI-HOST/api/send \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"to":"923001234567","text":"Your OTP is 482913. It expires in 5 minutes."}' # PDF with a caption curl -X POST https://YOUR-WAAPI-HOST/api/send \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"to":"923001234567","document":"https://example.com/invoice.pdf","filename":"invoice-1042.pdf","text":"Your invoice"}'
PHP
$ch = curl_init('https://YOUR-WAAPI-HOST/api/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer YOUR_TOKEN', 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'to' => '923001234567',
'text' => 'Your booking for 20 Oct is confirmed.',
]),
]);
$response = json_decode(curl_exec($ch), true);
Laravel
Http::withToken(config('services.waapi.token'))
->post('https://YOUR-WAAPI-HOST/api/send', [
'to' => '923001234567',
'text' => 'Your plan expires on 20 Oct. Renew at https://example.com/billing',
])->throw()->json();
Response
{ "ok": true, "id": "3EB0C767D26A1D8B2F4E", "to": "923001234567", "type": "text" }
Messages from one number are queued and sent with a short random gap (a few seconds), so a request may take a moment while earlier messages go out.
Check a number
Find out whether a number has WhatsApp before sending, e.g. when a user enters it in a form. Nothing is sent and it doesn't count towards the daily send limit.
GET https://YOUR-WAAPI-HOST/api/check?to=923001234567
{ "ok": true, "to": "923001234567", "exists": true, "jid": "923001234567@s.whatsapp.net" }
{ "ok": true, "to": "923001112222", "exists": false, "jid": null }
A number without WhatsApp is a normal 200 response with "exists": false.
Check numbers one at a time as people enter them. Checking long lists looks like scraping and can get your number banned.
Labels (WhatsApp Business)
Create labels and put them on chats. They show as filters above the chat list on the phone and in WhatsApp Web, so your app can sort customers into groups such as "Paid" or "Trial". Business numbers only.
POST https://YOUR-WAAPI-HOST/api/labels
{ "id": "9001", "name": "Monthly", "color": 3 }
{ "ok": true, "id": "9001" }
You choose the id (digits). Send the same id again to rename or recolour the label, or "deleted": true to remove it. color is 0–19, WhatsApp's 20 label colours. A Business account holds at most 20 labels.
POST https://YOUR-WAAPI-HOST/api/labels/9001/chats
{ "to": "923001234567", "action": "add" }
{ "ok": true, "label_id": "9001", "to": "923001234567", "action": "add", "jids": ["923001234567@s.whatsapp.net"] }
action is add or remove. Label changes from one number are applied one at a time with a short gap.
GET /api/labels lists the labels and labelled chats this server has seen: its own changes, plus anything changed on the phone since the number was linked.
Chats
GET https://YOUR-WAAPI-HOST/api/chats
{ "ok": true, "chats": [{ "phone": "923001234567", "last_message_at": 1760000000000 }] }
One-to-one chats that had a message since the number was linked to this server, newest first (at most 5000, or ?limit=). Useful for working out which chats to label.
Connection status
GET https://YOUR-WAAPI-HOST/api/status
{ "ok": true, "status": "connected", "phone": "923001234567" }
status is one of connected, starting, qr (waiting to be linked) or disconnected.
Check it from a cron job and alert yourself when it isn't connected.
Webhook for incoming messages
Set it in the dashboard, or with the API:
POST https://YOUR-WAAPI-HOST/api/webhook
{ "url": "https://your-site.com/whatsapp-webhook" }
{ "ok": true, "url": "https://your-site.com/whatsapp-webhook", "secret": "whsec_..." }
Send {"url": ""} to remove it. The response includes the signing secret.
Each incoming message is POSTed to your URL as JSON:
{
"event": "message",
"instance_phone": "923001234567",
"id": "3A9F1C0B5E7D2A4C",
"timestamp": 1760790000,
"from": "923339876543",
"from_jid": "923339876543@s.whatsapp.net",
"push_name": "Ali",
"is_group": false,
"group_jid": null,
"type": "text",
"text": "Yes, please confirm my booking",
"reply_to": { "id": "3EB0C767D26A1D8B2F4E", "text": "Reply YES to confirm", "type": "text" }
}
type can be text, image, video, audio, voice, document,
sticker, location, contact, reaction, button_reply, list_reply or another WhatsApp type.
Media messages include a media object (mimetype, size, filename, seconds)
plus media.url, a signed download link that's valid for 7 days (the file is kept for MEDIA_RETENTION_DAYS, 30 by default).
If the download from WhatsApp failed, url is missing. Location messages include location with latitude, longitude, name and address.
from can be null when WhatsApp hides the sender's number behind a privacy ID (LID). from_jid is always present and stable.
Everything also appears in the portal's Inbox, where you can read conversations and reply, with or without a webhook.
Reply with any 2xx status within 10 seconds. Failed deliveries are retried twice (after 5 s and 30 s). Redirects are not followed.
Verifying webhook signatures
Every webhook carries these headers:
X-WaApi-Timestamp: 1760790001
X-WaApi-Signature: sha256=<hex HMAC-SHA256 of "{timestamp}.{raw body}" using your secret>
// PHP
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_WAAPI_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WAAPI_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, 'whsec_YOUR_SECRET');
if (!hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$message = json_decode($body, true);
// Node.js (use the raw body, before JSON parsing)
const crypto = require('crypto');
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${ts}.${rawBody}`).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
Errors & limits
Errors return {"ok": false, "error": "..."} with:
401 | Missing or invalid token |
409 | Number isn't connected. Link it in the dashboard. |
413 | Request body too large |
422 | Invalid input, or the recipient isn't on WhatsApp |
429 | Too many requests per minute, or the number's daily send limit was reached |
500 | Sending failed. Safe to retry later. |
502 | /check couldn't reach WhatsApp. Safe to retry later. |
Limits: 16 MB per file, 4,096 characters per text, a per-token request rate limit and a per-number daily send cap set by the server admin.