Dispatch API guide
Send WhatsApp messages, photos and PDFs from your own software — and receive replies straight into your system. One API key, one HTTP call.
https://dispatch.onebloc.ioAuth: x-api-keyJSON over HTTPSGetting started
Three steps, about five minutes.
Sign in to your portal
Go to dispatch.onebloc.io/tenant-portal and sign in with OneBloc Connect. Your workspace is created automatically.
Connect your WhatsApp number
Open WhatsApp Connect and either enter a pairing code (WhatsApp → Settings → Linked Devices → Link with phone number) or scan the QR. When the dot turns green, you're live.
Copy your API key and send
Open Business Setup, reveal your key with the 👁 button, hit Copy, and run the call below.
Get your API key
It lives in your portal — you never need to ask us for it.
Portal → Business Setup → Your API key → click 👁 to reveal, then Copy.
Treat it like a password: it can send WhatsApp messages from your number. Keep it on your server, never in a browser page or mobile app. If it leaks, click regenerate in the portal — the old key stops working immediately.
Authentication
Every request carries one header.
x-api-key: your-api-key-here
Content-Type: application/json
Send a text message
The workhorse. Order confirmations, reminders, OTPs, notifications.
curl -X POST https://dispatch.onebloc.io/send-message \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipientPhone": "60123456789",
"message": "Hi Sarah! Your booking BK-2291 is confirmed ✅"
}'using var http = new HttpClient();
var req = new HttpRequestMessage(HttpMethod.Post,
"https://dispatch.onebloc.io/send-message");
req.Headers.Add("x-api-key", apiKey);
req.Content = JsonContent.Create(new {
recipientPhone = "60123456789",
message = "Hi Sarah! Your booking BK-2291 is confirmed ✅"
});
var resp = await http.SendAsync(req);
if (!resp.IsSuccessStatusCode)
logger.LogWarning("WhatsApp send failed: {Status}", resp.StatusCode);const res = await fetch('https://dispatch.onebloc.io/send-message', {
method: 'POST',
headers: {
'x-api-key': process.env.DISPATCH_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipientPhone: '60123456789',
message: 'Hi Sarah! Your booking BK-2291 is confirmed ✅'
})
});
const data = await res.json();
console.log(data.conversationId, data.tokensRemaining);$ch = curl_init('https://dispatch.onebloc.io/send-message');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['x-api-key: ' . $apiKey, 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'recipientPhone' => '60123456789',
'message' => 'Hi Sarah! Your booking BK-2291 is confirmed ✅'
])
]);
$out = json_decode(curl_exec($ch), true);import requests
r = requests.post(
"https://dispatch.onebloc.io/send-message",
headers={"x-api-key": API_KEY},
json={
"recipientPhone": "60123456789",
"message": "Hi Sarah! Your booking BK-2291 is confirmed ✅",
},
timeout=15,
)
r.raise_for_status()Body fields
| Field | Required | Description |
|---|---|---|
recipientPhone | required | Who to message. Any format — see phone formats. |
message | required | The text. Supports emoji, line breaks, and WhatsApp formatting: *bold*, _italic_. |
messageType | optional | text (default), image, document. |
referenceId | optional | Your own ID (invoice no, booking ref). Comes back on replies so you can match them up. |
Response
{
"success": true,
"message": "Message queued for delivery",
"conversationId": "CVS-9A3F-2B71-C044",
"tokensRemaining": 487,
"whatsAppStatus": "connected", // connected | connecting | disconnected | logged_out
"willDeliver": true // false = accepted but HELD, going nowhere yet
}
willDeliver, not just the status codeA 200 means the message was accepted into the queue. If the sending number is disconnected or logged out we still return 200 and hold the message — willDeliver is then false and a warning field explains why. Nothing is lost: held messages send themselves the moment the number reconnects. Alert on willDeliver === false if you need to know straight away.// held because the sending number is logged out
{
"success": true,
"message": "Message accepted but HELD — WhatsApp is not connected",
"whatsAppStatus": "logged_out",
"willDeliver": false,
"warning": "This WhatsApp number is logged out and must be re-linked in the portal..."
}
Send a photo
Receipts, site photos, product shots, payment slips.
Two ways to supply the picture. Base64 is the reliable one — the bytes go straight to WhatsApp and there's no URL that can break.
Option A — upload the file itself (recommended)
{
"recipientPhone": "60123456789",
"messageType": "image",
"fileData": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...",
"caption": "Payment received ✅ Thank you!"
}
fileData accepts a data: URI or plain base64. In C#: Convert.ToBase64String(bytes). In Node: fs.readFileSync(p).toString('base64').
Option B — a public image URL
{
"recipientPhone": "60123456789",
"messageType": "image",
"message": "https://yoursite.com/photos/unit-a1203.jpg",
"caption": "Unit A-12-03 after cleaning"
}
Send a PDF or file
Invoices, statements, tenancy agreements, quotations.
{
"recipientPhone": "60123456789",
"messageType": "document",
"fileData": "JVBERi0xLjQKJeLjz9MKM...",
"fileName": "Invoice-INV-3312.pdf",
"mimetype": "application/pdf",
"caption": "Hi! Here's your invoice for August. Thank you 🙏"
}
| Field | Required | Description |
|---|---|---|
fileData | one of | Base64 of the file. Or put a public file URL in message. |
fileName | optional | What the recipient sees. Default document.pdf. Use a real name — it's the first thing they read. |
mimetype | optional | Default application/pdf. Use application/vnd.openxmlformats-officedocument.spreadsheetml.sheet for .xlsx, etc. |
caption | optional | Message shown with the file. |
Send a job to a worker
For field teams — dispatch a task and track its status as the worker replies.
{
"workerPhone": "601120438890",
"clientName": "Vista Genting",
"location": "Unit A-12-03",
"task": "Deep clean before 11am checkout. Key at reception.",
"mapLink": "https://maps.google.com/?q=3.4231,101.7935"
}
The worker gets a formatted job card and can reply with a keyword to update it — no app required:
| Worker replies | Job becomes |
|---|---|
START | In progress |
ARRIVED | In progress (arrived on site) |
DONE | Completed |
ISSUE | Flagged — they'll be asked to describe the problem |
DELAY 30 | Delayed by 30 minutes |
Phone number formats
Send whatever your database has — we normalise it.
| You send | We dial |
|---|---|
0123456789 | 60123456789 ✅ |
+60 12-345 6789 | 60123456789 ✅ |
60123456789 | 60123456789 ✅ |
012-345 6789 | 60123456789 ✅ |
+65 9123 4567 | 6591234567 ✅ Singapore |
447911123456 | 447911123456 ✅ UK, no + needed |
171927742189577@lid | That contact, as-is ✅ a sender whose number WhatsApp hides. Reply to their senderJid |
120363041234567890@g.us | That group ✅ see Post to a group |
A number without a country code is treated as Malaysian. For any other country, include its country code, with or without a +: 6591234567, +1 415 555 0123.
senderJid from the webhook as recipientPhone. It is the exact address WhatsApp gave us, so it always reaches them, including international customers and hidden numbers.Check if a number is on WhatsApp
Essential for OTP, password resets, and anything that must reach every customer.
POST /send-message answers 200 as soon as the message is accepted into the queue. On its own it does not tell you whether the recipient even has WhatsApp — a message to someone without it is accepted and then quietly goes nowhere. If your flow must reach everyone, check first.Recommended: check once, store the answer
Ask when you first capture the number — at sign-up, or when a customer record is created — and save the result against the customer. Every later message just reads your own flag, so you never sprinkle fallback logic through the rest of your app.
curl "https://dispatch.onebloc.io/check-number?phone=60123456789,60199999999" -H "x-api-key: YOUR_API_KEY"
{ "success": true, "data": [
{ "phone": "60123456789", "onWhatsApp": true },
{ "phone": "60199999999", "onWhatsApp": false } ] }
The pattern
// 1. ONCE — when the customer gives you their number
const res = await fetch(`${DISPATCH}/check-number?phone=${phone}`, { headers: { 'x-api-key': KEY } });
const info = (await res.json()).data[0];
await db.saveCustomer({ phone, hasWhatsApp: info.onWhatsApp });
// 2. EVERYWHERE ELSE — no error handling, just read your own flag
if (customer.hasWhatsApp) await dispatch.send(customer.phone, text);
else await sms.send(customer.phone, text);
| Response | Meaning | What to do |
|---|---|---|
200 onWhatsApp: true | Real WhatsApp account | Send via Dispatch |
200 onWhatsApp: false | No WhatsApp on that number | Use SMS for this customer |
503 VERIFY_UNAVAILABLE | Your WhatsApp number is disconnected, so we cannot ask | Don't store a false — retry later |
false every few months (or when a message bounces back to you) keeps your data honest. Results are cached for 24 hours, so repeat calls in a batch are cheap.Safety net: verify at send time
If you'd rather not store anything, add "requireWhatsApp": true to a send. The message is refused with 422 when the recipient has no WhatsApp, and no token is charged — useful as a backstop even when you do pre-check.
{
"recipientPhone": "60123456789",
"message": "Your reset code is 481920",
"requireWhatsApp": true
}
// 422 — not on WhatsApp. Nothing sent, no token used.
{ "error": "Recipient is not on WhatsApp", "code": "NOT_ON_WHATSAPP" }
Post to a WhatsApp group
Reach a whole team in one message, e.g. a job notice to your cleaners' group.
Add your number to the group
From a phone, add the WhatsApp number that's linked to Dispatch. Dispatch never creates groups or adds anyone to them, because WhatsApp bans numbers that add people without their consent.
Copy the group ID
In your portal, open Groups. It lists every group your number is in, with each group's ID (it looks like 120363041234567890@g.us) and whether you're allowed to post there.
Send to the group ID
Use the same /send-message call as a normal message, with the group ID as the recipient.
{
"recipientPhone": "120363041234567890@g.us",
"message": "🧹 Unit A-12-3 checks out at 11am. Reply to this message when it's cleaned.",
"referenceId": "CLEAN-12345"
}
// 200
{ "success": true, "groupJid": "120363041234567890@g.us", "groupName": "KL Cleaners",
"referenceId": "CLEAN-12345", "willDeliver": true, "tokensRemaining": 486 }
| Response | Meaning |
|---|---|
| 200 | Queued to the group. One post costs one token, however many people are in the group. |
422 NOT_GROUP_MEMBER | Your number isn't in that group. Nothing is sent and no token is used. |
422 GROUP_POST_NOT_ALLOWED | Only admins can post in that group and your number isn't an admin, or the ID is a community rather than a group. |
503 VERIFY_UNAVAILABLE | We couldn't check the group just now. Retry shortly. |
referenceId. When someone replies to your post, the reply carries that ID back to you. See Group messages.Receiving replies (webhooks)
Get every incoming WhatsApp message POSTed to your server in real time.
curl -X POST https://dispatch.onebloc.io/tenant/webhook \
-H "x-api-key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"webhookUrl": "https://yourapp.com/hooks/whatsapp"}'
From then on, every inbound message is POSTed to you:
{
"event": "message.received",
"tenantId": "FD152A71-...",
"timestamp": "2026-08-08T09:14:22.117Z",
"data": {
"senderPhone": "60123456789",
"senderJid": "60123456789@s.whatsapp.net",
"isLid": false,
"message": "Paid already, slip attached",
"messageType": "image",
"mediaUrl": "https://dispatch.onebloc.io/tenant/inbox/media/...?token=...",
"whatsAppMsgId": "3EB0C767D...",
"referenceId": "INV-3312"
}
}
Replying to the sender
Take data.senderJid and use it as recipientPhone to reply. It works for every sender, always:
{ "recipientPhone": "60123456789@s.whatsapp.net", "message": "Got it, thank you! ✅" }
senderPhone holds an internal identifier instead — you'll see "isLid": true. That value is not dialable, so if you store it as a phone number your follow-up goes nowhere. Always reply to senderJid, and only treat senderPhone as a real number when isLid is false. (When we can't see a number, Dispatch automatically asks that customer — once — to share it, so future messages resolve normally.)Downloading the photo or file
mediaUrl is a signed link, ready to GET with no API key, valid for about an hour. Fetch and store it on your side when the webhook arrives.
200 straight away and queue the real work in the background. Slow endpoints get retried and can back things up.Delivery receipts
Know when a message actually reached the phone — and when it was read.
If you've registered a webhook, we push a message.status event each time WhatsApp reports progress on something you sent. No extra setup.
{
"event": "message.status",
"data": {
"status": "delivered", // "delivered" then later "read"
"whatsAppMsgId": "3EB0C767D...",
"senderPhone": "60123456789", // who you sent it to
"senderJid": "60123456789@s.whatsapp.net"
}
}
| What you get | Means |
|---|---|
200 from /send-message | Accepted into the queue. Not sent yet. |
status: "delivered" | On the recipient's device (double tick ✓✓) |
status: "read" | They opened it (blue ticks) |
delivered but never read — that is their privacy setting, not a fault. Treat a missing read as "unknown", never as "not read".Receipts also show in your Team Inbox and in the admin console's Messages list, so you can eyeball them without writing code.
Group messages
Get your team's replies, like "done" or a photo of the finished unit, at your webhook.
Groups start switched off, so a busy group can't flood your webhook. Switch on the ones you want in your portal under Groups. Their messages then arrive at your webhook as their own event type, so you'll never mix them up with direct messages:
{
"event": "group.message.received",
"data": {
"groupJid": "120363041234567890@g.us",
"groupName": "KL Cleaners",
"senderPhone": "60198887702", // the member; null when WhatsApp hides their number
"senderJid": "60198887702@s.whatsapp.net",
"pushName": "Siti",
"message": "Done, unit A-12-3 is clean",
"messageType": "image", // text | image | voice | video | document | location
"mediaUrl": "https://…/tenant/inbox/media/…?token=…", // photos + voice notes
"quotedMessageId": "3EB0C767D…", // set when they replied to a specific message
"referenceId": "CLEAN-12345", // YOUR id, when they replied to your post
"mentionsMe": false // true when they @mention your number
}
}
Which job is this reply about?
A group has many posts going at once, so a bare "done" doesn't tell you much. Ask members to swipe (or long-press) your post and reply to it. That reply comes back with your referenceId, so you can close the right job:
app.post("/hooks/whatsapp", async (req, res) => {
const { event, data } = req.body;
if (event === "group.message.received" && data.referenceId) {
await markCleaningDone(data.referenceId, { by: data.senderPhone, photo: data.mediaUrl });
}
res.sendStatus(200);
});
mediaUrl is a signed link that expires after about an hour. If you need to keep a photo, such as proof a unit was cleaned, download a copy when the webhook arrives.Status & usage
curl https://dispatch.onebloc.io/tenant/status -H "x-api-key: YOUR_API_KEY"
# { "success": true, "data": { "status": "connected", "phoneNumber": "60123456789" } }
Worth calling /tenant/status from your own health check — if it ever reads disconnected, someone needs to re-link the number in the portal.
Token costs
Your daily allowance is counted in tokens, not messages.
| Message type | Tokens |
|---|---|
| Text | 1 |
| Photo | 2 |
| Voice note | 2 |
| Document / PDF | 1 |
Check what's left any time with GET /tenant/usage, or read tokensRemaining on every send response. Counters reset daily.
Errors
| Code | Meaning | What to do |
|---|---|---|
| 400 | Bad request — a required field is missing or malformed | Read the error text; it names the field. |
| 401 | Invalid API key | Re-copy it from the portal. Header is x-api-key. |
| 403 | Account deactivated | Contact us. |
| 429 | Daily token limit reached | Response shows used/limit. Wait for reset or ask for a higher limit. |
| 500 | Send failed — often “No active WhatsApp session” | Check the portal: the number is probably disconnected and needs re-linking. |
Errors always come back as JSON: { "error": "Daily message limit reached", "usage": { "used": 50, "limit": 50 } }
A complete integration
Send a message, handle the failure cases, and receive replies — the whole loop.
// appsettings.json → "Dispatch": { "Url": "...", "ApiKey": "..." }
public sealed class DispatchClient(HttpClient http, IConfiguration cfg, ILogger<DispatchClient> log)
{
private Uri Endpoint => new($"{cfg["Dispatch:Url"]}/send-message");
public async Task<bool> SendTextAsync(string phone, string text, CancellationToken ct = default)
=> await PostAsync(new { recipientPhone = phone, message = text }, ct);
public async Task<bool> SendPdfAsync(string phone, byte[] pdf, string fileName, string caption, CancellationToken ct = default)
=> await PostAsync(new {
recipientPhone = phone, messageType = "document",
fileData = Convert.ToBase64String(pdf), fileName, mimetype = "application/pdf", caption
}, ct);
private async Task<bool> PostAsync(object body, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Post, Endpoint) { Content = JsonContent.Create(body) };
req.Headers.Add("x-api-key", cfg["Dispatch:ApiKey"]);
using var resp = await http.SendAsync(req, ct);
if (resp.IsSuccessStatusCode) return true;
var detail = await resp.Content.ReadAsStringAsync(ct);
log.LogWarning("Dispatch send failed ({Status}): {Detail}", (int)resp.StatusCode, detail);
return false; // 429 = out of tokens, 500 = WhatsApp not connected
}
}const DISPATCH = 'https://dispatch.onebloc.io';
async function send(body) {
const r = await fetch(`${DISPATCH}/send-message`, {
method: 'POST',
headers: { 'x-api-key': process.env.DISPATCH_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
// receive replies
app.post('/hooks/whatsapp', (req, res) => {
res.sendStatus(200); // ack immediately
const { senderJid, isLid, senderPhone, message, mediaUrl } = req.body.data;
queue.push(async () => {
if (mediaUrl) await downloadAndStore(mediaUrl); // signed, ~1h
const phone = isLid ? null : senderPhone; // only real when isLid=false
await recordReply({ phone, jid: senderJid, message });
await send({ recipientPhone: senderJid, message: 'Got it, thank you! ✅' });
});
});import os, requests
from flask import Flask, request
DISPATCH = "https://dispatch.onebloc.io"
KEY = os.environ["DISPATCH_KEY"]
def send(**body):
r = requests.post(f"{DISPATCH}/send-message", headers={"x-api-key": KEY}, json=body, timeout=15)
if r.status_code == 429:
raise RuntimeError("out of tokens today")
r.raise_for_status()
return r.json()
app = Flask(__name__)
@app.post("/hooks/whatsapp")
def inbound():
d = request.json["data"]
phone = None if d["isLid"] else d["senderPhone"] # may be hidden
enqueue(save_reply, phone=phone, jid=d["senderJid"], text=d["message"], media=d.get("mediaUrl"))
return "", 200 # ack fastFAQ
Do I need WhatsApp Business API or Meta approval?
No. Dispatch links your existing WhatsApp number, so there's no Meta application, no template approval, and no per-message fee.
Will my number get banned?
Every message goes through a paced queue that spaces sends out like a human. Keep to people who expect to hear from you and avoid cold blasts, and you're doing what the pacing is designed to protect.
Can I use one number for several systems?
Yes — the same API key works from your CRM, website and internal tools at once. They all share the one queue and daily allowance.
Why is a message “queued” but not delivered?
Check GET /messages/status. The usual causes are the number being disconnected (re-link in the portal) or a bad recipient number. POST /messages/retry re-queues failures.
Can I see conversations without writing code?
Yes — Team Inbox in your portal shows every chat and lets your team reply directly, no integration needed.
OneBloc Dispatch — WhatsApp automation for real businesses.