SendHub API
SendHub sends WhatsApp messages and SMS for your business. Your system calls a simple JSON API; SendHub queues each message, sends it from your numbers with their limits and pauses, and tells you what happened (sent, delivered, read or failed) by webhook.
- WhatsApp numbers are linked by scanning a QR code, or connected to Meta's official Cloud API.
- SMS numbers are Android phones with a SIM, running the SendHub Sender app.
The API's address (every path below goes after it):
https://your-sendhub/
Requests and answers are JSON (Content-Type: application/json). Times are in UTC (ISO 8601). Phone numbers can be written like 03001234567, +92 300 1234567 or 923001234567; SendHub answers with the international digits (923001234567).
Quick start
- In the dashboard, open API keys and create a Test key. It starts with
wag_test_. Nothing sent with it is really sent, so you can try anything. - Send a message:
curl -X POST https://your-sendhub/v1/messages \
-H "Authorization: Bearer wag_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "03001234567", "text": "Hello from SendHub!"}'
$ch = curl_init('https://your-sendhub/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer wag_test_YOUR_KEY', 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['to' => '03001234567', 'text' => 'Hello from SendHub!']),
]);
$answer = json_decode(curl_exec($ch), true);
echo $answer['message']['id'], ' ', $answer['message']['status']; // 42 pending
const res = await fetch('https://your-sendhub/v1/messages', {
method: 'POST',
headers: { Authorization: 'Bearer wag_test_YOUR_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({ to: '03001234567', text: 'Hello from SendHub!' }),
});
const { message } = await res.json();
console.log(message.id, message.status); // 42 pending
- The answer (
202) is the queued message. Ask for it again to see how it went, or add a webhook to be told:
curl https://your-sendhub/v1/messages/42 -H "Authorization: Bearer wag_test_YOUR_KEY"
{"message": {"id": 42, "number_id": 1, "channel": "whatsapp", "sandbox": true, "to": "923001234567",
"text": "Hello from SendHub!", "status": "read", "sent_at": "2026-11-05T09:01:02Z", "delivered_at": "…", "read_at": "…"}}
- When it works, create a Live key and use it instead. Nothing else changes.
Keys: live and test
Every request sends a key in the Authorization header:
Authorization: Bearer wag_YOUR_KEY
| Key | Starts with | What it does |
|---|---|---|
| Live | wag_ | Really sends. Counts against your plan, and needs API access in it. |
| Test | wag_test_ | Uses the sandbox: everything is checked, nothing is sent, nothing counts. |
Keys are made, and revoked, by the account owner on the dashboard's API keys page. A key is shown once; SendHub keeps only a hash of it. Keep live keys on your server, never in a web page or app. GET /v1/me says which account and mode a key is for.
Sandbox
With a test key (or the dashboard switched to Sandbox, top left), the same calls work and are checked the same way, but a simulator plays each message's journey instead of sending it. Your webhooks get the same events, with "sandbox": true in their data. Sandbox messages, campaigns and USSD codes are kept apart from live ones, never reach WhatsApp or a phone, and don't count against your plan.
Choose what happens with the last four digits of the number:
| Number ends in | What happens |
|---|---|
0000 | Not on WhatsApp: WhatsApp fails (an SMS fallback then goes through) |
9999 | Fails on every channel |
1111 | Fails once, goes through on the next try |
2222 | Sent, never delivered |
3333 | Delivered, never read |
| anything else | Sent → delivered → read (SMS: sent → delivered) |
USSD *000# | Fails; any other code gets a pretend answer |
Sending messages
POST /v1/messages with one message, or up to 1000 in {"messages": [ … ]}.
| Field | |
|---|---|
to | Required. The phone number. |
text | The message (up to 4096 characters). Or use template instead. |
template | {"name": "fee_reminder", "params": ["Ali", "5000"]}. See Templates. |
number_id | The number to send from. Needed when the account has several numbers (see GET /v1/numbers). |
channel | "whatsapp" or "sms": with no number_id, the account's only number of that kind. |
client_ref | Your own id for the message (up to 100 characters). Sending the same client_ref again returns the first message (200, "duplicate": true) instead of sending twice, so retrying is always safe. |
fallback | Up to 3 number ids to try next if it fails for good. See Fallback. |
One message answers 202 with {"message": {…}}. A batch answers 202 with {"results": [ … ]}, one per message in the same order: {"message": {…}} or {"error": "…"} (the others still go).
curl -X POST https://your-sendhub/v1/messages \
-H "Authorization: Bearer wag_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"messages": [
{"number_id": 1, "to": "03001234567", "text": "Dear Ali, the fee of Rs 5000 is due.", "client_ref": "fee-2026-11-17"},
{"number_id": 1, "to": "03007654321", "text": "Dear Sara, the fee of Rs 4500 is due.", "client_ref": "fee-2026-11-18"}
]}'
$messages = [];
foreach ($students as $s) {
$messages[] = ['number_id' => 1, 'to' => $s['phone'], 'client_ref' => 'fee-2026-11-' . $s['id'],
'text' => "Dear {$s['name']}, the fee of Rs {$s['fee']} is due."];
}
$ch = curl_init('https://your-sendhub/v1/messages');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer wag_YOUR_KEY', 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['messages' => $messages])]);
$answer = json_decode(curl_exec($ch), true);
foreach ($answer['results'] as $i => $r) {
if (isset($r['error'])) { echo $messages[$i]['to'], ': ', $r['error'], "\n"; }
}
const messages = students.map((s) => ({
number_id: 1, to: s.phone, client_ref: 'fee-2026-11-' + s.id,
text: `Dear ${s.name}, the fee of Rs ${s.fee} is due.`,
}));
const res = await fetch('https://your-sendhub/v1/messages', {
method: 'POST',
headers: { Authorization: 'Bearer wag_YOUR_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
});
const { results } = await res.json();
results.forEach((r, i) => r.error && console.log(messages[i].to, r.error));
Messages go out from each number one at a time, with its own pauses, per-minute and daily limits, and quiet hours (set on the number in the dashboard). Sending a thousand messages queues them all at once; they are then sent over time. SMS longer than one part are split: 160 English characters (153 per part when longer), or 70 Urdu / emoji characters (67 per part); the message's parts says how many.
Templates
Save messages you send often on the dashboard's Templates page (or with POST /v1/templates), with {{1}}, {{2}}… for the parts that change, and send them by name:
{"to": "03001234567", "template": {"name": "fee_reminder", "params": ["Ali", "5000", "10 Nov"]}}
The template Dear {{1}}, the fee of Rs {{2}} is due on {{3}}. becomes Dear Ali, the fee of Rs 5000 is due on 10 Nov. This works for SMS and QR-linked WhatsApp numbers. Cloud API numbers use templates approved by Meta instead (GET /v1/numbers/:id/templates), with "language": {"name": "fee_reminder", "language": "en", "params": ["Ali"]}.
GET /v1/templates | Your templates |
POST /v1/templates | {"name": "fee_reminder", "body": "Dear {{1}}, …"} |
PATCH /v1/templates/:id, DELETE /v1/templates/:id | Change or delete one |
Several numbers and fallback
Each message names its number (number_id). To share many messages across several numbers so they go out faster, give each message a different number_id in turn.
Fallback: add "fallback": [5] to try number 5 if the message fails for good on its own number, for example a WhatsApp number first and an SMS number if the person is not on WhatsApp. The same text is sent once more; nothing is sent twice when the first one works.
{"number_id": 1, "fallback": [7], "to": "03001234567", "text": "Your fee is due on 10 Nov."}
The failed message stays failed with retried_as (the new message's id); the new message has fallback_of. A Meta-approved template can only fall back to Cloud API numbers (an SMS has nothing to fill it with); use text or your own template to fall back to SMS.
Campaigns do all this for you with number_ids and send_mode.
Status and the message log
A message's status moves through pending → sending → sent → delivered → read (SMS stops at delivered), or ends as failed (with error) or cancelled. A message that fails for a passing reason is tried again, up to the number's "tries" setting, before it is failed.
GET /v1/messages/:id | One message |
GET /v1/messages | Newest first, 50 at a time. Filters: status, number_id, campaign_id, channel, to, client_ref, q (number, text or ref), limit (up to 200). Next page: before_id= the next_before_id you got. |
POST /v1/messages/:id/cancel | Cancel a waiting message |
POST /v1/messages/:id/resend | Send a failed or cancelled message again |
POST /v1/messages/bulk | {"action": "resend" | "cancel", "ids": [1, 2, 3]}, or {"action": "resend", "all_failed": true} |
GET /v1/stats | Counts by status and the last 14 days |
GET /v1/stats/summary?days=30 | The dashboard's figures for 7, 30 or 90 days: totals and previous (the period before), daily by channel, by_number, failures, by_hour, campaigns |
Webhooks
A webhook is your URL that SendHub calls when something happens, so you don't have to keep asking. Add it on the dashboard's Webhooks page or with the API (account owner, and a plan with webhooks):
POST /v1/webhooks
{"url": "https://your-site.com/sendhub-webhook", "events": ["message.delivered", "message.failed"]}
Leave out events to get all of them. The answer contains the webhook's secret, shown only this once: keep it to check signatures.
| Event | When | data |
|---|---|---|
message.sent | A message was sent | The message |
message.delivered | It reached the phone | The message |
message.read | It was read (WhatsApp) | The message |
message.failed | It failed for good | The message, with error (and retried_as if a fallback took over) |
message.received | Someone wrote to your number | {number_id, from, text, wa_id, at} |
number.status | A number's status changed | {number_id, status, phone} |
campaign.done | A campaign's run finished | {campaign, run, run_counts} |
ussd.done, ussd.failed | A USSD code got its answer, or failed | The USSD request |
What SendHub sends:
POST https://your-site.com/sendhub-webhook
Content-Type: application/json
X-Webhook-Id: 1234
X-Webhook-Event: message.delivered
X-Signature: t=1762333262,v1=5f8e1c…
{"id": 1234, "event": "message.delivered", "created_at": "2026-11-05T09:01:02Z",
"data": {"id": 42, "to": "923001234567", "status": "delivered", "client_ref": "fee-2026-11-17", …}}
Answer with any 2xx within 15 seconds. Otherwise SendHub tries again after 10 s, 1 min, 5 min, 30 min, 2 h and 6 h, then gives up (the dashboard shows each call). A retry has the same X-Webhook-Id: use it to ignore duplicates. Events can arrive out of order; trust the times in the message, or ask GET /v1/messages/:id.
Checking a webhook's signature
To be sure a call really comes from SendHub, check its signature: an HMAC-SHA256 of "<t>.<raw body>" with your webhook's secret, where t is the time in the X-Signature header. Use the raw body exactly as received, before parsing it, and refuse calls older than 5 minutes.
$secret = 'YOUR_WEBHOOK_SECRET';
$body = file_get_contents('php://input');
preg_match('/t=(\d+),v1=([a-f0-9]+)/', $_SERVER['HTTP_X_SIGNATURE'] ?? '', $m);
$expected = hash_hmac('sha256', ($m[1] ?? '') . '.' . $body, $secret);
if (!$m || !hash_equals($expected, $m[2]) || abs(time() - (int) $m[1]) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
if ($event['event'] === 'message.failed') {
// e.g. mark $event['data']['client_ref'] as not sent
}
http_response_code(200);
// Node.js with Express: keep the raw body for the signature
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/sendhub-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const [, t, sig] = (req.get('X-Signature') || '').match(/t=(\d+),v1=([a-f0-9]+)/) || [];
const expected = crypto.createHmac('sha256', process.env.SENDHUB_WEBHOOK_SECRET).update(t + '.' + req.body).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!sig || !fresh || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
console.log(event.event, event.data.id, event.data.status);
res.sendStatus(200);
});
# Signatures are checked on your server; to look at the calls SendHub made:
curl https://your-sendhub/v1/webhooks/1/deliveries -H "Authorization: Bearer wag_YOUR_KEY"
Numbers
Numbers are usually added and linked in the dashboard (scan the QR code, enter Cloud API details, or pair an SMS phone). The API can do it too:
GET /v1/numbers | Your numbers, their status (connected, not_linked, …) and sending rules |
GET /v1/numbers/:id/status | Status now, the QR code while linking, how many messages wait, and paused_because when sending is paused (quiet hours, limits, plan…) |
POST /v1/numbers | {"name": "Main office", "engine": "qr" | "cloud" | "android"} |
PATCH /v1/numbers/:id | Sending rules: daily_limit, per_minute, delay_min/delay_max (seconds), quiet_start/quiet_end ("22:00"), timezone, max_attempts, enabled |
POST /v1/numbers/:id/link, /unlink | Start linking (QR code / pairing code), or unlink |
POST /v1/numbers/:id/test | {"to": "03001234567", "text": "Test"}: send one message now, skipping the queue |
Groups
Lists of people for campaigns: a number, a name, and values for {1}, {2}… in the message.
GET /v1/groups | Your groups and how many people each has (?q= searches names) |
POST /v1/groups | {"name": "Class 5 parents"} |
POST /v1/groups/:id/contacts | {"contacts": [{"phone": "03001234567", "name": "Ali", "values": ["5000", "10 Nov"]}]}, up to 5000 at a time. A number already in the group gets the new name and values. Answers {added, updated, errors} |
GET /v1/groups/:id/contacts | The people, 50 at a time (q, limit, before_id) |
POST /v1/groups/:id/contacts/remove | {"ids": [1, 2]} or {"all": true} |
PATCH /v1/groups/:id, DELETE /v1/groups/:id | Rename; delete with everyone in it |
Campaigns and schedules
A campaign sends one message to groups and/or a list of numbers, now, at a set time, or again every day, week or month. Each person's message is personalised: {name} is their name, {1}, {2}… their values.
curl -X POST https://your-sendhub/v1/campaigns \
-H "Authorization: Bearer wag_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"name": "November fee reminder", "number_ids": [1, 7], "send_mode": "fallback", "first_channel": "whatsapp",
"group_ids": [3], "text": "Dear {name}, the fee of Rs {1} is due on {2}.",
"send_at": "2026-11-20T09:00:00+05:00"}'
$campaign = ['name' => 'November fee reminder', 'number_ids' => [1, 7], 'send_mode' => 'fallback', 'first_channel' => 'whatsapp',
'group_ids' => [3], 'text' => 'Dear {name}, the fee of Rs {1} is due on {2}.', 'send_at' => '2026-11-20T09:00:00+05:00'];
$ch = curl_init('https://your-sendhub/v1/campaigns');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer wag_YOUR_KEY', 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($campaign)]);
$c = json_decode(curl_exec($ch), true)['campaign'];
echo $c['id'], ' ', $c['status']; // 12 scheduled
const res = await fetch('https://your-sendhub/v1/campaigns', {
method: 'POST',
headers: { Authorization: 'Bearer wag_YOUR_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'November fee reminder', number_ids: [1, 7], send_mode: 'fallback', first_channel: 'whatsapp',
group_ids: [3], text: 'Dear {name}, the fee of Rs {1} is due on {2}.', send_at: '2026-11-20T09:00:00+05:00',
}),
});
const { campaign } = await res.json();
| Field | |
|---|---|
name | Required; for you. |
number_ids | The numbers it sends from (up to 20). |
send_mode | With several numbers: "split" (default): each person gets one message, the numbers take turns. "all": one from every number. "fallback" (WhatsApp and SMS numbers): one message on first_channel, and only failed ones go again on the other channel. |
group_ids, recipients | Who gets it: groups, and/or [{"to", "name", "values"}]. Someone in two groups gets one message. |
text or template | The message. A template's params can use {name}, {1}…; without params, each person's values are used. |
send_at, repeat | When (ISO time; leave out for now), and "none", "daily", "weekly" or "monthly". Campaigns start within 30 seconds of their time. |
GET /v1/campaigns, GET /v1/campaigns/:id | With counts by message status (and moved: failed and sent again by fallback). Filters: status, q, repeat=once|repeating. Its messages: GET /v1/messages?campaign_id= |
PATCH /v1/campaigns/:id | Change a scheduled campaign |
POST /v1/campaigns/:id/start | Run a scheduled one now |
POST /v1/campaigns/:id/cancel | Stop it; its waiting messages are cancelled |
POST /v1/campaigns/:id/resend-failed | Its failed messages go again |
DELETE /v1/campaigns/:id | Delete one that isn't sending |
USSD
Run a code like *444# (balance, package status) on your SMS phones and get the network's answer. Only single-step codes: Android gives an app the first answer but no way to choose from a menu.
POST /v1/numbers/:id/ussd | {"code": "*444#"} → 202 with the request |
POST /v1/ussd | Several codes on several phones: {"number_ids": [7, 10], "codes": ["*444#", "*111#"]} |
GET /v1/ussd/:id | status: pending → running → done (with response) or failed (with error) |
GET /v1/ussd | History (number_id, status, q, ids, paging) |
Or get the answer by webhook: ussd.done / ussd.failed.
Your plan and limits
GET /v1/plan shows your plan, its end date, its allowances, your usage in the current plan cycle (period, usage), this calendar month's messages for reference (month), and the plans on offer (to buy or renew one, see Billing). Allowances count a month at a time from the day your plan started: a plan started on 18 November counts 18 November – 17 December, and so on; a new plan starts a new cycle at zero. When the allowance for a cycle is used up, depending on the plan, new messages are either refused with 402 or accepted and held until the next cycle. Adding more numbers, phones or team members than the plan allows also answers 402; a feature the plan doesn't include answers 403. Test keys are never limited.
Billing and invoices
Plans are paid for outside SendHub (bank transfer, JazzCash, Easypaisa…). The account owner asks for an invoice for a plan and a number of months, pays it with one of the payment methods shown, then uploads the receipt. Once SendHub approves it, the plan starts, or, when it's the same plan, its end date moves on by those months. Most people do this on the dashboard's Plan & usage and Billing pages; the API is the same:
GET /v1/billing | The payment methods (name, details, logo), the business on invoices, the tax, and the month choices (1, 3, 6, 12) |
POST /v1/invoices | {"plan_id": 3, "months": 3} → 201 with the invoice: number, amount, tax, total, currency, status: "unpaid", due_at (account owner) |
POST /v1/invoices/:id/proof | {"method": "<method id>", "reference": "TXN 123456", "file": {"name": "receipt.jpg", "type": "image/jpeg", "data": "<base64>"}}: a JPG, PNG, WebP or PDF under 900 KB. The status becomes submitted |
GET /v1/invoices, GET /v1/invoices/:id | Invoices; status: unpaid → submitted → paid (with period_from, period_to), or rejected (with reject_reason: upload again), or cancelled |
POST /v1/invoices/:id/cancel | Cancel an unpaid invoice |
GET /v1/invoices/quote?plan_id=3&months=1 | What buying it would mean now, before making the invoice: change, credit, extra_days, starts_at, tax, total |
Changing plan
Every invoice has a change:
new | No plan running: it starts when paid |
renew | The same plan: the months are added to its end |
upgrade | A pricier plan: it starts when paid, and the unused days of the current plan are taken off the invoice as credit (at its daily price). Credit beyond the invoice becomes extra_days on the new plan, and there is nothing to pay |
downgrade | A cheaper plan: it starts on starts_at, when the current plan ends, so nothing paid for is lost. GET /v1/plan shows it under next |
Errors
Errors answer with a status code and a sentence you can show or log:
{"error": "to must be a phone number"}
| Code | Meaning |
|---|---|
400 | Something in the request is wrong (the sentence says what) |
401 | Missing or wrong API key |
402 | Your plan's limit is reached, or the plan has ended |
403 | Not allowed: the account is suspended, the plan doesn't include this, or only the owner may do it |
404 | Not found (or not yours) |
409 | Not now: e.g. cancelling a message that was already sent |
429 | Too many at once (USSD codes waiting for a phone, phone pairing attempts) |
5xx | A problem on SendHub's side: try again later (with the same client_ref, so nothing is sent twice) |
All endpoints
With a key (Authorization: Bearer …). Owner-only ones also work with a key.
| Account | |
|---|---|
GET /v1/me | The account and mode (live / sandbox) |
GET /v1/plan | Plan, usage, allowances |
GET /v1/billing, GET/POST /v1/invoices, GET /v1/invoices/:id, POST …/proof, …/cancel | Billing and invoices |
| Messages | |
POST /v1/messages | Send one, or up to 1000 |
GET /v1/messages, GET /v1/messages/:id | The log; one message |
POST /v1/messages/:id/resend, /cancel, POST /v1/messages/bulk | Send again, cancel, many at once |
GET /v1/stats | Counts and the last 14 days |
GET /v1/stats/summary | Dashboard figures for 7, 30 or 90 days |
GET /v1/invoices/quote | What buying a plan would mean now |
| Templates | |
GET/POST /v1/templates, PATCH/DELETE /v1/templates/:id | Your templates |
GET /v1/numbers/:id/templates | Meta's approved templates (Cloud API numbers) |
| Numbers | |
GET/POST /v1/numbers, GET/PATCH/DELETE /v1/numbers/:id | Numbers and their rules |
GET /v1/numbers/:id/status, POST …/link, …/unlink, …/test | Status, linking, a test message |
| Groups and campaigns | |
GET/POST /v1/groups, GET/PATCH/DELETE /v1/groups/:id | Groups |
GET/POST /v1/groups/:id/contacts, POST …/contacts/remove | People in a group |
GET/POST /v1/campaigns, GET/PATCH/DELETE /v1/campaigns/:id | Campaigns and schedules |
POST /v1/campaigns/:id/start, …/cancel, …/resend-failed | Run now, stop, resend failed |
| USSD | |
POST /v1/numbers/:id/ussd, POST /v1/ussd, GET /v1/ussd, GET /v1/ussd/:id | Run codes, read answers |
| Webhooks (owner) | |
GET/POST /v1/webhooks, DELETE /v1/webhooks/:id, GET /v1/webhooks/:id/deliveries | Webhooks and their calls |
SendHub · Dashboard