OneBloc Dispatch OneBlocDispatch Open your portal

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.

Base URL https://dispatch.onebloc.ioAuth: x-api-keyJSON over HTTPS

Getting 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.

Your number must be connectedThe key authenticates you, but messages only send while your WhatsApp shows Connected in the portal. If it's disconnected, sends fail with “No active WhatsApp session”.

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.

POST/send-messageQueue a WhatsApp message
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

FieldRequiredDescription
recipientPhonerequiredWho to message. Any format — see phone formats.
messagerequiredThe text. Supports emoji, line breaks, and WhatsApp formatting: *bold*, _italic_.
messageTypeoptionaltext (default), image, document.
referenceIdoptionalYour 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
}
Check 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..."
}
“Queued” — not instant, and that's deliberateMessages go through an anti-ban queue with a small random gap between sends, so WhatsApp sees human-like pacing instead of a burst. Normal delivery is a few seconds.

Send a photo

Receipts, site photos, product shots, payment slips.

POST/send-messagemessageType: image

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"
}
The URL must be publicly reachableOur server fetches it. If it's behind a login, on localhost, or the link is wrong, the send fails with a 404 — and that failure is about your URL, not WhatsApp. When in doubt, use base64.

Send a PDF or file

Invoices, statements, tenancy agreements, quotations.

POST/send-messagemessageType: document
{
  "recipientPhone": "60123456789",
  "messageType": "document",
  "fileData": "JVBERi0xLjQKJeLjz9MKM...",
  "fileName": "Invoice-INV-3312.pdf",
  "mimetype": "application/pdf",
  "caption": "Hi! Here's your invoice for August. Thank you 🙏"
}
FieldRequiredDescription
fileDataone ofBase64 of the file. Or put a public file URL in message.
fileNameoptionalWhat the recipient sees. Default document.pdf. Use a real name — it's the first thing they read.
mimetypeoptionalDefault application/pdf. Use application/vnd.openxmlformats-officedocument.spreadsheetml.sheet for .xlsx, etc.
captionoptionalMessage shown with the file.

Send a job to a worker

For field teams — dispatch a task and track its status as the worker replies.

POST/send-jobCreate + send a job
{
  "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 repliesJob becomes
STARTIn progress
ARRIVEDIn progress (arrived on site)
DONECompleted
ISSUEFlagged — they'll be asked to describe the problem
DELAY 30Delayed by 30 minutes

Phone number formats

Send whatever your database has — we normalise it.

You sendWe dial
012345678960123456789 ✅
+60 12-345 678960123456789 ✅
6012345678960123456789 ✅
012-345 678960123456789 ✅
+65 9123 45676591234567 ✅ Singapore
447911123456447911123456 ✅ UK, no + needed
171927742189577@lidThat contact, as-is ✅ a sender whose number WhatsApp hides. Reply to their senderJid
120363041234567890@g.usThat 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.

Replying to someone who messaged you?Use their 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.

A 200 does not mean "delivered"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.

GET/check-number?phone=60123456789Up to 50, comma separated
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);
ResponseMeaningWhat to do
200 onWhatsApp: trueReal WhatsApp accountSend via Dispatch
200 onWhatsApp: falseNo WhatsApp on that numberUse SMS for this customer
503 VERIFY_UNAVAILABLEYour WhatsApp number is disconnected, so we cannot askDon't store a false — retry later
Re-check occasionallyPeople do install WhatsApp later. Re-checking a 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" }
Want proof it arrived?Delivery and read receipts are pushed to your webhook — see Delivery receipts below.

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 }
ResponseMeaning
200Queued to the group. One post costs one token, however many people are in the group.
422 NOT_GROUP_MEMBERYour number isn't in that group. Nothing is sent and no token is used.
422 GROUP_POST_NOT_ALLOWEDOnly 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_UNAVAILABLEWe couldn't check the group just now. Retry shortly.
Always send a referenceIdPut your own ID for the job or booking in referenceId. When someone replies to your post, the reply carries that ID back to you. See Group messages.
A group is the safer way to reach many peopleOne post to a 50-person group counts as one send. Fifty separate messages to people who never messaged you first is exactly the pattern that gets numbers banned. Use a group people chose to join.

Receiving replies (webhooks)

Get every incoming WhatsApp message POSTed to your server in real time.

POST/tenant/webhookRegister your endpoint
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! ✅" }
Why senderJid, and not just the phone?WhatsApp is rolling out privacy IDs. For some senders the real phone number is hidden and 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.

Reply fast, work laterReturn 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 getMeans
200 from /send-messageAccepted into the queue. Not sent yet.
status: "delivered"On the recipient's device (double tick ✓✓)
status: "read"They opened it (blue ticks)
No receipt is not proof of failurePhones deliver receipts when they come online, so a delivered event can arrive minutes or hours later. And if the recipient has read receipts switched off, you will see 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);
});
Save photos when they arrivemediaUrl 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.
What isn't forwardedReactions, stickers, polls, and edited or deleted messages are skipped. Videos and documents come through with their caption or file name, but without a download link.

Status & usage

GET/tenant/statusIs WhatsApp connected?
GET/tenant/usageTokens used today
GET/messages/statusRecent delivery results
POST/messages/retryRe-queue failed messages
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 typeTokens
Text1
Photo2
Voice note2
Document / PDF1

Check what's left any time with GET /tenant/usage, or read tokensRemaining on every send response. Counters reset daily.

“Limit reached” with tokens apparently left?A photo needs 2 tokens. At 49/50 you have room for a text but not a photo — so the photo is refused even though the count isn't at the cap yet.

Errors

CodeMeaningWhat to do
400Bad request — a required field is missing or malformedRead the error text; it names the field.
401Invalid API keyRe-copy it from the portal. Header is x-api-key.
403Account deactivatedContact us.
429Daily token limit reachedResponse shows used/limit. Wait for reset or ask for a higher limit.
500Send 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 fast

FAQ

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.

Need a hand? Message us from the Dispatch home page or ask your OneBloc contact.
OneBloc Dispatch — WhatsApp automation for real businesses.