API Documentation
The KhabeerSoft WhatsApp REST API lets you connect WhatsApp numbers, send single and bulk messages, and follow each one through to delivery and read — all over plain HTTP requests that return JSON. Replies and status changes are pushed back to you as webhooks.
Base URL
https://api.khabeersoft.cloud/api/v1
All requests must be sent over HTTPS in production and include your API key. Request bodies are JSON
(Content-Type: application/json).
Authentication
Authenticate every request with your API key in the x-api-key header.
You can generate and revoke keys from your dashboard.
x-api-key: YOUR_API_KEY
A key belongs to your whole account, not to one WhatsApp number. If you have linked two numbers,
any key can send from either of them: each send request names the number in its
instance_id field.
List your instances to find the id of each number.
A key can only use numbers on its own account — another account's instance_id is refused.
The key is shown only once, right after you create it; if you lose it, create a new one and delete the old. Giving each website or system its own key lets you disable one without breaking the others.
/api/v1/messages/send
Send a message
Queues a single message for delivery. Returns immediately with a message id you can poll for status.
Body parameters
| Field | Type | Description |
|---|---|---|
| instance_id | integer | The connected WhatsApp number to send from — the id from List instances. Required. |
| to | string | Recipient in international format, no + or leading 0 (e.g. 201001234567). Required. |
| type | string | text (default), image, or document. Added later, same request shape: video, audio, sticker, location, contact — see more message types. |
| text | string | Message body for text type. |
| content | object | For media: { url, caption } (image) or { url, filename, mimetype, caption } (document — the caption is sent with the file). Instead of url you may send the file itself as base64 (at most 2 MB decoded); a larger or empty one is refused with 400 before any allowance is spent. |
| client_ref | string | Optional idempotency key, up to 64 characters (letters, digits, . _ : -). Send the same key again — say after a timeout — and you get the first message back with "duplicate": true instead of a second send. |
Send instance_id as a JSON number, not a string.
A quoted id — "instance_id": "5" — still comes back
202 Accepted, because the id matches numerically when we
look your number up. The send itself happens later and cannot find it, so every message fails with
Instance not connected while your number is plainly online.
This catches integrations that read the id out of a database column or a config file, where it arrives as
text. Cast it before you send: (int) $id in PHP,
int(id) in Python,
Number(id) in JavaScript. The same applies to
bulk sends.
curl -X POST https://api.khabeersoft.cloud/api/v1/messages/send \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"instance_id": 1,
"to": "201001234567",
"type": "text",
"text": "Hello from the API 👋"
}'
{
"id": 123,
"status": "queued"
}
More message types
These were added after text, image and
document, which behave exactly as before. Every type uses the same endpoint, the same
content object and the same 202 response. Media URLs must be public HTTPS links; files up to 2 MB may instead be sent inline as
content.base64.
{
"instance_id": 1,
"to": "201001234567",
"type": "video",
"content": { "url": "https://example.com/clip.mp4", "caption": "Here is the demo" }
}
{
"instance_id": 1,
"to": "201001234567",
"type": "audio",
"content": { "url": "https://example.com/note.mp3", "ptt": true }
}
{
"instance_id": 1,
"to": "201001234567",
"type": "sticker",
"content": { "url": "https://example.com/sticker.webp" }
}
{
"instance_id": 1,
"to": "201001234567",
"type": "location",
"content": { "latitude": 30.0444, "longitude": 31.2357, "name": "Our showroom", "address": "Tahrir Square, Cairo" }
}
{
"instance_id": 1,
"to": "201001234567",
"type": "contact",
"content": { "name": "Sales team", "phone": "201000000000" }
}
/api/v1/messages/send-bulk
Send bulk messages
Queue up to 100 messages in one request. Each is processed individually with rate limiting.
curl -X POST https://api.khabeersoft.cloud/api/v1/messages/send-bulk \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"instance_id": 1,
"messages": [
{ "to": "201001234567", "text": "Hi Ahmed" },
{ "to": "201009999999", "text": "Hi Sara" }
]
}'
{
"queued": 2,
"messages": [
{ "id": 124, "to": "201001234567", "status": "queued" },
{ "id": 125, "to": "201009999999", "status": "queued" }
]
}
/api/v1/messages/{id}
Get message status
Retrieve the current status of a message you previously sent.
curl https://api.khabeersoft.cloud/api/v1/messages/123 \
-H "x-api-key: YOUR_API_KEY"
202 from the send endpoint means queued, not delivered.
Poll this endpoint, or subscribe to the webhook and let us tell you.
curl https://api.khabeersoft.cloud/api/v1/messages/123 \
-H "x-api-key: YOUR_API_KEY"
{
"id": 123,
"direction": "outbound",
"from_phone": null,
"to_phone": "201001234567",
"type": "text",
"status": "read",
"error": null,
"wa_message_id": "3EB044D81C8441910EFE9F",
"queued_at": "2026-08-21T03:55:38.000Z",
"sent_at": "2026-08-21T03:55:39.000Z",
"delivered_at": "2026-08-21T03:55:40.000Z",
"read_at": "2026-08-21T03:56:47.000Z"
}
Webhooks
Rather than polling, let us call you. We send a POST with a
JSON body every time something happens to your messages or your number — including incoming replies,
which are only available this way.
Add an endpoint from the dashboard under Developers → Webhooks. You pick which events you want (choose none to receive the three standard events — see opt-in events), copy the signing secret from the webhook's edit screen, and send a test delivery to check your endpoint answers.
A webhook covers your whole account, not one number: events from every number you have linked
arrive at the same URL, and each one carries an instance_id
telling you which number it concerns. Messages you type yourself on the phone are not sent — only what customers
send you, and the status of what you send.
Requirements
- • Your URL must be publicly reachable. Private and internal addresses are rejected — and re-checked on every delivery, not only when you save it.
- • Answer with any
2xx. Answer first, then do your work: we time out after 10 seconds. - • Each delivery is tried up to 5 times in total, with exponential backoff between attempts. If all of them fail the delivery is dropped and the failure count shows on your dashboard.
- • Deliveries are not ordered. Use
sent_atif sequence matters to you.
POST https://your-server.example/webhooks/whatsapp
Content-Type: application/json
x-khabeersoft-event: message.status
x-khabeersoft-signature: sha256=9f86d081884c7d659a2f...
{
"event": "message.status",
"data": { },
"sent_at": "2026-08-21T03:55:40.512Z"
}
Events
message.status
A message you sent moved. message_id is the id we returned when you queued it,
so you can match the event to your own record without keeping a side table.
{
"event": "message.status",
"data": {
"message_id": 123,
"instance_id": 5,
"wa_message_id": "3EB044D81C8441910EFE9F",
"status": "delivered"
},
"sent_at": "2026-08-21T03:55:40.512Z"
}
{
"event": "message.status",
"data": {
"message_id": 124,
"instance_id": 5,
"status": "failed",
"error": "Number not registered on WhatsApp"
},
"sent_at": "2026-08-21T03:56:11.004Z"
}
message.received
Someone replied to your number. One-to-one chats only — group messages and status broadcasts are not forwarded.
text carries the body for text messages, and the caption for media.
{
"event": "message.received",
"data": {
"instance_id": 5,
"from": "201001234567",
"type": "text",
"text": "Yes, that works for me",
"wa_message_id": "3A54F8B21C9E77440A",
"message_id": 9182,
"push_name": "Ahmed",
"caption": null
},
"sent_at": "2026-08-21T04:10:02.771Z"
}
message_id (our id, usable with GET /messages/:id),
push_name (the sender's WhatsApp display name) and
caption (media caption) were added later and are always present; the original fields never change.
Inbound type is one of text, image, video, document, audio, sticker, location.
instance.status
Your WhatsApp number connected, or was logged out from the phone. Worth listening to: a logged-out number stops sending until someone scans the QR again.
{
"event": "instance.status",
"data": {
"instance_id": 5,
"status": "connected",
"phone": "201012345678"
},
"sent_at": "2026-08-21T03:50:11.290Z"
}
Opt-in events
Three newer events exist for reactions, edits and deletions. They are never sent unless you tick them in the webhook settings: a webhook with no events selected keeps receiving exactly the three events above and nothing else, so an integration written before these existed cannot be surprised by them. The envelope, headers and signature are the same.
{
"event": "message.reaction",
"data": {
"instance_id": 5,
"message_id": 9182,
"wa_message_id": "3A54F8B21C9E77440A",
"from": "201001234567",
"emoji": "👍"
},
"sent_at": "2026-08-21T04:12:40.101Z"
}
{
"event": "message.edited",
"data": {
"instance_id": 5,
"message_id": 9182,
"wa_message_id": "3A54F8B21C9E77440A",
"from": "201001234567",
"text": "Yes, that works for me — tomorrow at 10"
},
"sent_at": "2026-08-21T04:13:02.771Z"
}
{
"event": "message.deleted",
"data": {
"instance_id": 5,
"message_id": 9182,
"wa_message_id": "3A54F8B21C9E77440A",
"from": "201001234567"
},
"sent_at": "2026-08-21T04:15:30.004Z"
}
Verifying signatures
Every delivery is signed so you can prove it came from us and was not altered on the way. We send
x-khabeersoft-signature, which is
sha256= followed by the HMAC-SHA256 of the
raw request body keyed with your webhook secret.
==.
$secret = 'YOUR_WEBHOOK_SECRET';
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_KHABEERSOFT_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
if (! hash_equals($expected, $header)) {
http_response_code(401);
exit;
}
$event = json_decode($payload, true);
// $event['event'], $event['data'], $event['sent_at']
const crypto = require('crypto');
// Give this route the RAW body, not a parsed one.
app.post('/webhooks/whatsapp', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const header = req.get('x-khabeersoft-signature') || '';
const ok = expected.length === header.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
if (!ok) return res.sendStatus(401);
const { event, data } = JSON.parse(req.body);
res.sendStatus(200); // answer first, do the work after
});
/api/v1/instances
Create an instance
Creates a new WhatsApp session. After creating, fetch the QR code to link a phone. Subject to your plan's number limit.
curl -X POST https://api.khabeersoft.cloud/api/v1/instances \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Main Store Number" }'
{
"id": 5,
"status": "waiting_scan",
"qr_url": "/api/v1/instances/5/qr"
}
/api/v1/instances
·
/api/v1/instances/{id}
List & get instances
List all your instances, or fetch a single one by id.
[
{
"id": 1,
"name": "Main Store Number",
"phone": "201012345678",
"status": "connected",
"created_at": "2026-06-20T10:00:00.000Z"
}
]
/api/v1/instances/{id}/qr
Get QR code
Returns the QR code to link the WhatsApp number. Poll this endpoint every few seconds until status becomes
connected. Add ?format=image to get a PNG instead of JSON.
{
"qr_base64": "data:image/png;base64,iVBORw0...",
"status": "waiting_scan"
}
{
"status": "connected",
"phone": "201012345678"
}
/api/v1/instances/{id}
Delete an instance
Disconnects the WhatsApp session and removes it.
{
"status": "disconnected"
}
/api/v1/usage
Usage stats
Your message usage against your plan limits — enough to build a balance or quota screen without keeping a count of your own. The figures cover the whole account: every number you have linked and every API key you have issued, added together. There is no per-number breakdown.
{
"monthly": { "used": 320, "limit": 1000, "remaining": 680, "percentage": 32 },
"daily": { "used": 45, "limit": 500, "remaining": 455, "percentage": 9, "sent": 43 }
}
Fields
| Field | Type | Description |
|---|---|---|
| used | integer | Messages sent in the current window. |
| limit | integer | Your plan's allowance for that window. |
| remaining | integer | limit − used, never below 0. |
| percentage | integer | Share of the allowance used, rounded. Clamp it before drawing a progress bar — moving to a smaller plan mid-month can put it above 100. |
| sent | integer | Of the used figure, how many have actually gone out. Present in daily only. |
| plan | object | { name, is_trial } — the plan you are on. |
| period | object | { start, end } as YYYY-MM-DD: the current monthly window, where end is the day it renews. Both null on a trial, which never renews. |
| expires_at | string|null | Last paid day of your subscription (YYYY-MM-DD), or null if it does not expire. |
What the numbers mean
The monthly figure counts only messages that actually went out. A
message is counted the moment WhatsApp accepts it — when it reaches sent.
One still sitting in queued has not been counted yet, and one
that ends as failed never is. So right after a bulk send,
monthly.used lags the number of messages you submitted, and it
catches up as the queue drains. That lag is pacing, not an error.
The daily figure is the exception: it counts messages we accepted.
Your daily allowance is taken the moment a send is accepted rather than when it goes out, so that a burst cannot slip past the
limit while the queue is still draining — daily.used is
therefore the figure that produces the 429. If a message ends up
never going out, the allowance is given straight back. daily.sent
beside it is how many of them have really left.
The daily window is a calendar day, on our clock (UTC+03:00); it resets at midnight, not as a rolling 24 hours. A day that starts at 23:50 leaves you ten minutes of that day's allowance.
The monthly window runs from your subscription date. If you subscribed on the 17th, it resets on the 17th of each month, not on the 1st. When that day does not exist in a shorter month it falls to the last day (the 31st becomes the 28th in February) and returns to the 31st the next time the month is long enough. Accounts that were already running before this changed keep resetting on the 1st, because that is the date they were anchored to.
The hourly cap is not in here. Alongside your plan limits, each
number may send 100 messages an hour. That one is per number rather than per account, so it is not part of this
response — you meet it as a 429 on
send.
Cache the answer. These counters move only when you send, so calling this on every page load buys you nothing. Refreshing it once every minute or two is plenty.
When you run out
Sends stop with 429 as soon as either window is full, and the
body names the one you hit — so you can show the right message without calling this endpoint first.
{
"error": "Daily message limit reached",
"used": 500,
"limit": 500
}
Message statuses
An outgoing message moves forward through these and never backwards.
delivered_at is set.error field.
A message can sit at sent indefinitely: WhatsApp only sends a receipt
once the recipient's phone comes online. That is not a failure.
Throughput & pacing
WhatsApp bans numbers that fire messages in a fast, even rhythm. So we hold a randomized gap between consecutive sends from the same number — by default between 10 and 30 seconds. Three things about it surprise people:
The gap accumulates. Queue ten messages at once and the last one leaves after the sum of nine gaps, not after one. A batch of ten takes tens of seconds; a batch of a hundred takes minutes.
It is per sending number, not per recipient. Sending to ten different people is paced exactly like sending ten messages to one.
You are told the wait up front. Every send response carries
delay_ms — how long that specific message will be held before it goes out.
{
"id": 123,
"status": "queued",
"delay_ms": 2400
}
On top of the gap there is a cap of 100 messages per hour per number, plus the daily and monthly limits of your plan — those you can read at any time from usage stats. Messages your team types by hand in the dashboard's inbox count toward the monthly limit only, so they never use up the daily or hourly room your integration relies on. If your volume needs a different balance between speed and safety, ask us — the gap is set per account.
Errors & status codes
| Code | Meaning |
|---|---|
| 200 / 201 | Done. |
| 202 | Queued — accepted, not yet sent. Track it by status or webhook. |
| 400 | Invalid phone number, missing field, or the number is not connected. |
| 401 | Missing or invalid API key. |
| 403 | Key disabled, account suspended, subscription expired, or your plan's number limit reached. |
| 404 | Resource not found. |
| 429 | Too many requests, or your daily or monthly message limit is reached. |
Error responses always include a JSON error message describing what went wrong.
Accepted, then failed
A 202 only means the request was well-formed. The send happens
afterwards, so anything that goes wrong at that point never reaches you as an HTTP error — it lands on the
message as failed with a reason, and on your
message.status webhook.
| error | What it usually means |
|---|---|
| Instance not connected |
The number logged out or dropped mid-send — or you sent
instance_id as a string.
If the number shows connected in your dashboard,
it is the second one: see the note under
Send a message.
|
| Number not registered on WhatsApp | The recipient has no WhatsApp account on that number. |
| Invalid phone: use international format | A leading 0 or a missing country code. Send 201001234567, not 01001234567. |
Ready to build?
Contact Us