SendHub API documentation Dashboard

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

  1. 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.
  2. 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
  1. 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": "…"}}
  1. 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
KeyStarts withWhat it does
Livewag_Really sends. Counts against your plan, and needs API access in it.
Testwag_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 inWhat happens
0000Not on WhatsApp: WhatsApp fails (an SMS fallback then goes through)
9999Fails on every channel
1111Fails once, goes through on the next try
2222Sent, never delivered
3333Delivered, never read
anything elseSent → 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
toRequired. The phone number.
textThe message (up to 4096 characters). Or use template instead.
template{"name": "fee_reminder", "params": ["Ali", "5000"]}. See Templates.
number_idThe 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_refYour 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.
fallbackUp 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/templatesYour templates
POST /v1/templates{"name": "fee_reminder", "body": "Dear {{1}}, …"}
PATCH /v1/templates/:id, DELETE /v1/templates/:idChange 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/:idOne message
GET /v1/messagesNewest 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/cancelCancel a waiting message
POST /v1/messages/:id/resendSend 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/statsCounts by status and the last 14 days
GET /v1/stats/summary?days=30The 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.

EventWhendata
message.sentA message was sentThe message
message.deliveredIt reached the phoneThe message
message.readIt was read (WhatsApp)The message
message.failedIt failed for goodThe message, with error (and retried_as if a fallback took over)
message.receivedSomeone wrote to your number{number_id, from, text, wa_id, at}
number.statusA number's status changed{number_id, status, phone}
campaign.doneA campaign's run finished{campaign, run, run_counts}
ussd.done, ussd.failedA USSD code got its answer, or failedThe 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/numbersYour numbers, their status (connected, not_linked, …) and sending rules
GET /v1/numbers/:id/statusStatus 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/:idSending 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, /unlinkStart 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/groupsYour 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/contactsThe 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/:idRename; 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
nameRequired; for you.
number_idsThe numbers it sends from (up to 20).
send_modeWith 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, recipientsWho gets it: groups, and/or [{"to", "name", "values"}]. Someone in two groups gets one message.
text or templateThe message. A template's params can use {name}, {1}…; without params, each person's values are used.
send_at, repeatWhen (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/:idWith 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/:idChange a scheduled campaign
POST /v1/campaigns/:id/startRun a scheduled one now
POST /v1/campaigns/:id/cancelStop it; its waiting messages are cancelled
POST /v1/campaigns/:id/resend-failedIts failed messages go again
DELETE /v1/campaigns/:idDelete 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/ussdSeveral codes on several phones: {"number_ids": [7, 10], "codes": ["*444#", "*111#"]}
GET /v1/ussd/:idstatus: pending → running → done (with response) or failed (with error)
GET /v1/ussdHistory (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/billingThe 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/:idInvoices; status: unpaid → submitted → paid (with period_from, period_to), or rejected (with reject_reason: upload again), or cancelled
POST /v1/invoices/:id/cancelCancel an unpaid invoice
GET /v1/invoices/quote?plan_id=3&months=1What buying it would mean now, before making the invoice: change, credit, extra_days, starts_at, tax, total

Changing plan

Every invoice has a change:

newNo plan running: it starts when paid
renewThe same plan: the months are added to its end
upgradeA 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
downgradeA 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"}
CodeMeaning
400Something in the request is wrong (the sentence says what)
401Missing or wrong API key
402Your plan's limit is reached, or the plan has ended
403Not allowed: the account is suspended, the plan doesn't include this, or only the owner may do it
404Not found (or not yours)
409Not now: e.g. cancelling a message that was already sent
429Too many at once (USSD codes waiting for a phone, phone pairing attempts)
5xxA 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/meThe account and mode (live / sandbox)
GET /v1/planPlan, usage, allowances
GET /v1/billing, GET/POST /v1/invoices, GET /v1/invoices/:id, POST …/proof, …/cancelBilling and invoices
Messages
POST /v1/messagesSend one, or up to 1000
GET /v1/messages, GET /v1/messages/:idThe log; one message
POST /v1/messages/:id/resend, /cancel, POST /v1/messages/bulkSend again, cancel, many at once
GET /v1/statsCounts and the last 14 days
GET /v1/stats/summaryDashboard figures for 7, 30 or 90 days
GET /v1/invoices/quoteWhat buying a plan would mean now
Templates
GET/POST /v1/templates, PATCH/DELETE /v1/templates/:idYour templates
GET /v1/numbers/:id/templatesMeta's approved templates (Cloud API numbers)
Numbers
GET/POST /v1/numbers, GET/PATCH/DELETE /v1/numbers/:idNumbers and their rules
GET /v1/numbers/:id/status, POST …/link, …/unlink, …/testStatus, linking, a test message
Groups and campaigns
GET/POST /v1/groups, GET/PATCH/DELETE /v1/groups/:idGroups
GET/POST /v1/groups/:id/contacts, POST …/contacts/removePeople in a group
GET/POST /v1/campaigns, GET/PATCH/DELETE /v1/campaigns/:idCampaigns and schedules
POST /v1/campaigns/:id/start, …/cancel, …/resend-failedRun now, stop, resend failed
USSD
POST /v1/numbers/:id/ussd, POST /v1/ussd, GET /v1/ussd, GET /v1/ussd/:idRun codes, read answers
Webhooks (owner)
GET/POST /v1/webhooks, DELETE /v1/webhooks/:id, GET /v1/webhooks/:id/deliveriesWebhooks and their calls

SendHub · Dashboard