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.

Authorization header
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.

Keep your API key secret. Never expose it in client-side code or public repositories.
POST /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

FieldTypeDescription
instance_idintegerThe connected WhatsApp number to send from — the id from List instances. Required.
tostringRecipient in international format, no + or leading 0 (e.g. 201001234567). Required.
typestringtext (default), image, or document. Added later, same request shape: video, audio, sticker, location, contact — see more message types.
textstringMessage body for text type.
contentobjectFor 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_refstringOptional 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.

Example request
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 👋"
  }'
Response · 202 Accepted
{
  "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.

Video · shown inline with a caption
{
  "instance_id": 1,
  "to": "201001234567",
  "type": "video",
  "content": { "url": "https://example.com/clip.mp4", "caption": "Here is the demo" }
}
Voice note · ptt: true plays as a voice message; any audio format is converted
{
  "instance_id": 1,
  "to": "201001234567",
  "type": "audio",
  "content": { "url": "https://example.com/note.mp3", "ptt": true }
}
Sticker · a WebP image
{
  "instance_id": 1,
  "to": "201001234567",
  "type": "sticker",
  "content": { "url": "https://example.com/sticker.webp" }
}
Location
{
  "instance_id": 1,
  "to": "201001234567",
  "type": "location",
  "content": { "latitude": 30.0444, "longitude": 31.2357, "name": "Our showroom", "address": "Tahrir Square, Cairo" }
}
Contact card
{
  "instance_id": 1,
  "to": "201001234567",
  "type": "contact",
  "content": { "name": "Sales team", "phone": "201000000000" }
}
POST /api/v1/messages/send-bulk

Send bulk messages

Queue up to 100 messages in one request. Each is processed individually with rate limiting.

Example request
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" }
    ]
  }'
Response · 202 Accepted
{
  "queued": 2,
  "messages": [
    { "id": 124, "to": "201001234567", "status": "queued" },
    { "id": 125, "to": "201009999999", "status": "queued" }
  ]
}
GET /api/v1/messages/{id}

Get message status

Retrieve the current status of a message you previously sent.

Example request
curl https://api.khabeersoft.cloud/api/v1/messages/123 \
  -H "x-api-key: YOUR_API_KEY"
A 202 from the send endpoint means queued, not delivered. Poll this endpoint, or subscribe to the webhook and let us tell you.
Example request
curl https://api.khabeersoft.cloud/api/v1/messages/123 \
  -H "x-api-key: YOUR_API_KEY"
Response · 200 OK
{
  "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_at if sequence matters to you.
Envelope · every event has this shape
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.

Delivered or read
{
  "event": "message.status",
  "data": {
    "message_id": 123,
    "instance_id": 5,
    "wa_message_id": "3EB044D81C8441910EFE9F",
    "status": "delivered"
  },
  "sent_at": "2026-08-21T03:55:40.512Z"
}
Failed · the reason is in error
{
  "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.

Incoming message
{
  "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.

Connection changed
{
  "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.

message.reaction · an empty emoji means the reaction was removed
{
  "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"
}
message.edited · the new text of a message the customer edited
{
  "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"
}
message.deleted · the customer deleted a message for everyone
{
  "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.

Hash the raw body exactly as received. Parsing the JSON and re-encoding it changes the bytes, and the signature will never match. Compare with a constant-time function, not ==.
PHP
$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']
Node.js · Express
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
});
POST /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.

Example request
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" }'
Response · 201 Created
{
  "id": 5,
  "status": "waiting_scan",
  "qr_url": "/api/v1/instances/5/qr"
}
GET /api/v1/instances · /api/v1/instances/{id}

List & get instances

List all your instances, or fetch a single one by id.

Response · 200 OK
[
  {
    "id": 1,
    "name": "Main Store Number",
    "phone": "201012345678",
    "status": "connected",
    "created_at": "2026-06-20T10:00:00.000Z"
  }
]
GET /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.

Response while waiting
{
  "qr_base64": "data:image/png;base64,iVBORw0...",
  "status": "waiting_scan"
}
Response once linked
{
  "status": "connected",
  "phone": "201012345678"
}
DELETE /api/v1/instances/{id}

Delete an instance

Disconnects the WhatsApp session and removes it.

Response · 200 OK
{
  "status": "disconnected"
}
GET /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.

Response · 200 OK
{
  "monthly": { "used": 320, "limit": 1000, "remaining": 680, "percentage": 32 },
  "daily":   { "used": 45,  "limit": 500,  "remaining": 455, "percentage": 9, "sent": 43 }
}

Fields

FieldTypeDescription
usedintegerMessages sent in the current window.
limitintegerYour plan's allowance for that window.
remainingintegerlimit − used, never below 0.
percentageintegerShare of the allowance used, rounded. Clamp it before drawing a progress bar — moving to a smaller plan mid-month can put it above 100.
sentintegerOf the used figure, how many have actually gone out. Present in daily only.
planobject{ name, is_trial } — the plan you are on.
periodobject{ 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_atstring|nullLast 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.

Response · 429 Too Many Requests
{
  "error": "Daily message limit reached",
  "used": 500,
  "limit": 500
}

Message statuses

An outgoing message moves forward through these and never backwards.

queuedAccepted and waiting its turn. See pacing for why that wait exists.
sendingBeing handed to WhatsApp right now.
sentWhatsApp accepted it. Not proof it reached the recipient — no receipt has come back yet.
deliveredIt arrived on the recipient's device. delivered_at is set.
readThe recipient opened it. Only if they have read receipts on.
receivedAn incoming message — something sent to your number.
failedGave up — the reason is in the 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.

Response · 202 Accepted
{
  "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

CodeMeaning
200 / 201Done.
202Queued — accepted, not yet sent. Track it by status or webhook.
400Invalid phone number, missing field, or the number is not connected.
401Missing or invalid API key.
403Key disabled, account suspended, subscription expired, or your plan's number limit reached.
404Resource not found.
429Too 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.

errorWhat 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