Reference

API documentation

Send messages, check numbers and the connection, and receive incoming messages.

This API uses WhatsApp's linked-device protocol, not Meta's official Business API. Send transactional messages only (OTPs, order updates, reminders) to people who expect them. Marketing blasts get numbers banned.

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
FieldDescription
to requiredRecipient's full number with country code, digits only, e.g. 923001234567. The recipient must have WhatsApp.
textMessage text, up to 4,096 characters. When sending media it becomes the caption.
imagePublic URL of a JPG, PNG or WebP image.
documentPublic URL of a file (PDF, DOCX, XLSX, ZIP…).
filenameFile name shown to the recipient for a document, e.g. invoice-1042.pdf.
audioPublic URL of an audio file (MP3, M4A, WAV, OGG…).
captionOptional 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:

401Missing or invalid token
409Number isn't connected. Link it in the dashboard.
413Request body too large
422Invalid input, or the recipient isn't on WhatsApp
429Too many requests per minute, or the number's daily send limit was reached
500Sending 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.