API v2 (beta)
Messages
Send a message now or at a set time, to a phone number, a WhatsApp group, a saved contact or a Telegram username. Send the same message to up to 1,000 recipients in one request, read the history, follow a message's status, and cancel it while it waits.
| Operation | Endpoint | Scope |
|---|---|---|
| Send a message | POST /api/v2/messages | messages:send |
| Send to many recipients | POST /api/v2/messages/batch | messages:send |
| List messages | GET /api/v2/messages | messages:read |
| Get a message | GET /api/v2/messages/{id} | messages:read |
| Cancel a message | DELETE /api/v2/messages/{id} | messages:send |
Keys, errors, Idempotency-Key and limits work as described in the API v2 overview. To hear when a message is sent or fails without polling, add a webhook endpoint.
Send a message#
https://wbiztool.com/api/v2/messagesBody: JSON (application/json)
The message is queued and the answer comes before it goes out, with HTTP 202 and the Message in status queued (or scheduled with a future send_at). Follow it with Get a message or a webhook.
curl -X POST https://wbiztool.com/api/v2/messages \
-H "Authorization: Bearer $WBIZTOOL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-ready" \
-d '{
"from": 101,
"to": { "phone": "+919876543210" },
"text": "Hi Asha, your order #1042 is ready."
}'import os
import requests
response = requests.post(
"https://wbiztool.com/api/v2/messages",
headers={
"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}",
"Idempotency-Key": "order-1042-ready",
},
json={
"from": 101,
"to": {"phone": "+919876543210"},
"text": "Hi Asha, your order #1042 is ready.",
},
timeout=30,
)
body = response.json()
if response.status_code == 202:
print("Queued:", body["id"], body["status"])
else:
print("Refused:", body["error"]["code"], body["error"]["message"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const response = await fetch("https://wbiztool.com/api/v2/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-1042-ready",
},
body: JSON.stringify({
from: 101,
to: { phone: "+919876543210" },
text: "Hi Asha, your order #1042 is ready.",
}),
});
const body = await response.json();
if (response.status === 202) {
console.log("Queued:", body.id, body.status);
} else {
console.error("Refused:", body.error.code, body.error.message);
}<?php
$ch = curl_init('https://wbiztool.com/api/v2/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-1042-ready',
],
CURLOPT_POSTFIELDS => json_encode([
'from' => 101,
'to' => ['phone' => '+919876543210'],
'text' => 'Hi Asha, your order #1042 is ready.',
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 202) {
echo 'Queued: ' . $body['id'] . ' ' . $body['status'];
} else {
echo 'Refused: ' . $body['error']['code'] . ' ' . $body['error']['message'];
}Answer (202):
{
"id": 9817263,
"status": "queued",
"from": { "id": 101, "phone": "919800000000", "platform": "whatsapp" },
"to": { "phone": "+919876543210", "contact_id": 991 },
"text": "Hi Asha, your order #1042 is ready.",
"media": null,
"send_at": null,
"expires_at": null,
"created_at": "2026-10-18T10:15:02.311Z",
"sent_at": null,
"error": null
}
Body
toobjectrequiredWho gets it: exactly one of
phone,group,contact_idorusername. See Recipients.frominteger or nullRequired unless the workspace has one connected numberThe
idof the number that sends it, from List numbers. Left out, the only connected number sends it: one connected WhatsApp number is used even if a Telegram account is also connected, and a Telegram account only when no WhatsApp number is connected. A number that is logged out is accepted: its messages wait until it reconnects.textstringRequired unless media is sentThe message, up to 3,000 characters. With
media, it's the caption and can be left out. WhatsApp formatting works:*bold*,_italic_,~strikethrough~. In JSON, write new lines as\n.mediaobject or nulloptionalAn image or file to send. See Media.
send_atstring or nulloptionalSend at this time instead of now: ISO 8601 with its offset, such as
2026-10-20T09:30:00+05:30. It must be in the future. See Scheduling and expiry.expires_atstring or nulloptionalDon't send after this time: the message becomes
expiredinstead. ISO 8601 with its offset, in the future and aftersend_at.
Headers
AuthorizationheaderrequiredBearer wbz_live_…, a key with themessages:sendscope.Idempotency-KeyheaderoptionalAny unique string, so a retry never sends a second message. See Idempotency-Key.
Errors when sending#
| HTTP | Code | When |
|---|---|---|
400 | validation_failed | A field is missing, has the wrong type or isn't known; to has none or several of its fields; media has both or neither of url and media_id; expires_at isn't after send_at |
400 | text_required | No text and no media |
400 | text_too_long | text is over 3,000 characters |
400 | invalid_datetime | send_at or expires_at isn't ISO 8601 with an offset |
402 | no_credits | The workspace has no messages left on its plan |
403 | number_not_allowed | The key is limited to other numbers |
403 | account_disabled | The workspace owner's account is disabled |
422 | invalid_phone | to.phone isn't a phone number in international format with + |
422 | invalid_username | to.username isn't a Telegram username, link or tg: id |
422 | username_needs_telegram | to.username with a WhatsApp number as from |
422 | contact_not_found | No contact with that to.contact_id |
422 | media_not_found | No file with that media.media_id |
422 | invalid_media_url | media.url isn't a public http(s) link |
422 | time_in_past | send_at or expires_at has already passed |
422 | number_not_found | from isn't a number of this workspace |
422 | from_required | No from, and the workspace has no connected number or several |
422 | message_rejected | The message couldn't be queued; message says why |
429 | number_daily_cap_reached | The number has sent 1,000 messages in the last 24 hours |
A refused send leaves nothing behind: no message is stored, so there's nothing to cancel. Errors that can happen on any request (keys, scopes, rate limits, Idempotency-Key) are on the errors page.
Send to many recipients#
https://wbiztool.com/api/v2/messages/batchBody: JSON (application/json)
Sends the same message to up to 1,000 recipients. The body is the same as Send a message, except that to is a list of recipients.
- All or nothing. Every recipient is checked before anything is queued, and the whole list must fit in the plan's messages left and in the sending number's room for the next 24 hours. If one recipient is wrong, nothing is queued, and
fieldnames it, such asto[3].phone. - The same recipient twice gets one message.
- The answer is
202withdata: one Message per recipient, in the order ofto.
curl -X POST https://wbiztool.com/api/v2/messages/batch \
-H "Authorization: Bearer $WBIZTOOL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: opening-hours-2026-10-20" \
-d '{
"from": 101,
"to": [
{ "phone": "+919876543210" },
{ "phone": "+447700900123" },
{ "group": "Bakery staff" }
],
"text": "We open at 10 tomorrow."
}'import os
import requests
recipients = ["+919876543210", "+447700900123"]
response = requests.post(
"https://wbiztool.com/api/v2/messages/batch",
headers={
"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}",
"Idempotency-Key": "opening-hours-2026-10-20",
},
json={
"from": 101,
"to": [{"phone": p} for p in recipients] + [{"group": "Bakery staff"}],
"text": "We open at 10 tomorrow.",
},
timeout=60,
)
body = response.json()
if response.status_code == 202:
for message in body["data"]:
print(message["id"], message["to"], message["status"])
else:
print("Refused:", body["error"]["code"], body["error"]["field"], body["error"]["message"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const recipients = ["+919876543210", "+447700900123"];
const response = await fetch("https://wbiztool.com/api/v2/messages/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "opening-hours-2026-10-20",
},
body: JSON.stringify({
from: 101,
to: [...recipients.map((phone) => ({ phone })), { group: "Bakery staff" }],
text: "We open at 10 tomorrow.",
}),
});
const body = await response.json();
if (response.status === 202) {
for (const message of body.data) console.log(message.id, message.to, message.status);
} else {
console.error("Refused:", body.error.code, body.error.field, body.error.message);
}<?php
$recipients = ['+919876543210', '+447700900123'];
$to = array_map(fn ($phone) => ['phone' => $phone], $recipients);
$to[] = ['group' => 'Bakery staff'];
$ch = curl_init('https://wbiztool.com/api/v2/messages/batch');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: opening-hours-2026-10-20',
],
CURLOPT_POSTFIELDS => json_encode(['from' => 101, 'to' => $to, 'text' => 'We open at 10 tomorrow.']),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 202) {
foreach ($body['data'] as $message) {
echo $message['id'] . ' ' . $message['status'] . "\n";
}
} else {
echo 'Refused: ' . $body['error']['code'] . ' ' . $body['error']['field'];
}Answer (202), one Message per recipient; the second and third look like the first, with to set to {"phone": "+447700900123", …} and {"group": "Bakery staff", …}:
{
"data": [
{
"id": 9817264,
"status": "queued",
"from": { "id": 101, "phone": "919800000000", "platform": "whatsapp" },
"to": { "phone": "+919876543210", "contact_id": 991 },
"text": "We open at 10 tomorrow.",
"media": null,
"send_at": null,
"expires_at": null,
"created_at": "2026-10-19T16:02:40.118Z",
"sent_at": null,
"error": null
}
]
}
Errors when sending a batch#
The same as when sending one message, with the recipient's place in field (to[0].phone), and also:
| HTTP | Code | When |
|---|---|---|
400 | too_many_recipients | to has more than 1,000 entries |
400 | validation_failed | to is empty or not a list |
402 | no_credits | The list doesn't fit in the messages left: requested and remaining say by how much |
429 | number_daily_cap_reached | The list doesn't fit in the number's room for the next 24 hours: requested and room say how many |
List messages#
https://wbiztool.com/api/v2/messagesThe workspace's messages of the last 90 days, newest first, in pages. This includes messages sent from the dashboard, campaigns, reminders, integrations and API v1, not only API v2's. A key limited to some numbers sees only their messages.
Query parameters
numberintegeroptionalOnly messages from this number
id.statusstringoptionalOnly messages with this status:
queued,scheduled,sent,delivered,read,failed,cancelledorexpired.sincestringoptionalOnly messages created at or after this time (ISO 8601 with offset).
untilstringoptionalOnly messages created before this time (ISO 8601 with offset).
limitintegeroptionalMessages per page, 1 to 100 (default 25).
cursorstringoptionalThe last page's
next_cursor, with the same filters.
Filters combine. In a query string, write the + of an offset as %2B (a plain + arrives as a space, which is read as + too), or use Z.
curl -G https://wbiztool.com/api/v2/messages \
-H "Authorization: Bearer $WBIZTOOL_API_KEY" \
--data-urlencode "status=failed" \
--data-urlencode "since=2026-10-01T00:00:00+05:30" \
--data-urlencode "limit=100"import os
import requests
headers = {"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}"}
params = {"status": "failed", "since": "2026-10-01T00:00:00+05:30", "limit": 100}
failed = []
while True:
page = requests.get("https://wbiztool.com/api/v2/messages",
headers=headers, params=params, timeout=30).json()
failed += page["data"]
if not page["next_cursor"]:
break
params["cursor"] = page["next_cursor"] # same filters, next page
for message in failed:
print(message["id"], message["to"], message["error"]["message"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const params = new URLSearchParams({ status: "failed", since: "2026-10-01T00:00:00+05:30", limit: "100" });
const failed = [];
while (true) {
const response = await fetch(`https://wbiztool.com/api/v2/messages?${params}`, {
headers: { Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}` },
});
const page = await response.json();
failed.push(...page.data);
if (!page.next_cursor) break;
params.set("cursor", page.next_cursor); // same filters, next page
}
for (const message of failed) console.log(message.id, message.to, message.error.message);<?php
$params = ['status' => 'failed', 'since' => '2026-10-01T00:00:00+05:30', 'limit' => 100];
$failed = [];
do {
$ch = curl_init('https://wbiztool.com/api/v2/messages?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY')],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$page = json_decode(curl_exec($ch), true);
curl_close($ch);
$failed = array_merge($failed, $page['data']);
$params['cursor'] = $page['next_cursor']; // same filters, next page
} while ($page['next_cursor']);
foreach ($failed as $message) {
echo $message['id'] . ' ' . $message['error']['message'] . "\n";
}The answer is {"data": [Message, …], "next_cursor": "…" or null}.
Errors when listing#
| HTTP | Code | When |
|---|---|---|
400 | validation_failed | number isn't a number id, status isn't one of the statuses, or limit isn't 1 to 100 |
400 | invalid_datetime | since or until isn't ISO 8601 with an offset |
400 | invalid_cursor | The cursor is damaged or was made with other filters |
Get a message#
https://wbiztool.com/api/v2/messages/{id}One message and its status now.
curl https://wbiztool.com/api/v2/messages/9817263 \
-H "Authorization: Bearer $WBIZTOOL_API_KEY"import os
import requests
response = requests.get(
"https://wbiztool.com/api/v2/messages/9817263",
headers={"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}"},
timeout=30,
)
message = response.json()
print(message["status"], message["sent_at"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const response = await fetch("https://wbiztool.com/api/v2/messages/9817263", {
headers: { Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}` },
});
const message = await response.json();
console.log(message.status, message.sent_at);<?php
$ch = curl_init('https://wbiztool.com/api/v2/messages/9817263');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY')],
CURLOPT_RETURNTRANSFER => true,
]);
$message = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $message['status'];Answer (200), a message that was delivered:
{
"id": 9817263,
"status": "delivered",
"from": { "id": 101, "phone": "919800000000", "platform": "whatsapp" },
"to": { "phone": "+919876543210", "contact_id": 991 },
"text": "Hi Asha, your order #1042 is ready.",
"media": null,
"send_at": null,
"expires_at": null,
"created_at": "2026-10-18T10:15:02.311Z",
"sent_at": "2026-10-18T10:15:09.870Z",
"error": null
}
A message that doesn't exist, belongs to another workspace, or (for a key limited to some numbers) was sent from another number answers 404 message_not_found.
Cancel a message#
https://wbiztool.com/api/v2/messages/{id}Cancels a message that is still waiting (queued or scheduled), whoever made it. The answer is 200 with the Message, now in status cancelled. A webhook endpoint gets message.cancelled.
A message that has gone out can't be recalled. A message the sending number is picking up at that moment may still go out, so check its status afterwards if it matters.
curl -X DELETE https://wbiztool.com/api/v2/messages/9817263 \
-H "Authorization: Bearer $WBIZTOOL_API_KEY"import os
import requests
response = requests.delete(
"https://wbiztool.com/api/v2/messages/9817263",
headers={"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}"},
timeout=30,
)
body = response.json()
if response.status_code == 200:
print("Cancelled")
elif body["error"]["code"] == "not_cancellable":
print("Too late, it is", body["error"]["status"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const response = await fetch("https://wbiztool.com/api/v2/messages/9817263", {
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}` },
});
const body = await response.json();
if (response.ok) console.log("Cancelled");
else if (body.error.code === "not_cancellable") console.log("Too late, it is", body.error.status);<?php
$ch = curl_init('https://wbiztool.com/api/v2/messages/9817263');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY')],
CURLOPT_RETURNTRANSFER => true,
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo $status === 200 ? 'Cancelled' : $body['error']['code'];Errors when cancelling#
| HTTP | Code | When |
|---|---|---|
404 | message_not_found | No such message for this key |
409 | not_cancellable | It isn't waiting any more; status in the error says what it is now, such as sent |
The Message object#
Every operation on this page answers with Message objects, and webhook events carry one in data.message.
| Field | Type | Description |
|---|---|---|
id | integer | The message's id. Use it to get or cancel the message. |
status | string | Where it is now. See Statuses. |
from | object | The sending number: id, phone (its digits with the country code, no +) and platform (whatsapp or telegram). |
to | object | The recipient: phone (with +), group or username, and contact_id, the saved contact it went to. A phone number or group sent to for the first time is saved as a contact. (contact_id is null only for a test message to a number that isn't saved.) |
text | string | The text or caption; "" when there's none. |
media | object or null | type (image or file), url and file_name (null for images). The url may be a link that works for a limited time; don't store it. |
send_at | string or null | The time it's scheduled for, or null for "now". |
expires_at | string or null | The time after which it's not sent, or null. |
created_at | string | When it was made. |
sent_at | string or null | When it went out, once it has (sent, delivered or read). |
error | object or null | For a failed or expired message, message says why, such as File exceeds WhatsApp size limit (16MB max). null otherwise. |
Statuses#
| Status | Meaning |
|---|---|
queued | Waiting to go out. |
scheduled | Waiting for its send_at. |
sent | Sent from your number (one tick in WhatsApp). |
delivered | Delivered to the recipient's phone (two ticks). |
read | Read by the recipient (blue ticks). |
failed | It couldn't be sent; error.message says why. |
cancelled | Cancelled before it went out: with Cancel a message, from the dashboard, or because its number or campaign was removed. |
expired | Not sent before its expires_at. |
queued and scheduled can be cancelled. sent can still become delivered and then read. failed, cancelled, expired and read are final. Webhooks report sent, failed, cancelled and expired; to see delivered and read, get the message.
Recipients#
to takes exactly one of these fields:
| Field | Example | Who gets it |
|---|---|---|
phone | {"phone": "+919876543210"} | A phone number in international format with + (E.164). Spaces and dashes are allowed. |
group | {"group": "Bakery staff"} | A WhatsApp group the sending number is in, by its exact name (up to 200 characters). |
contact_id | {"contact_id": 991} | A contact saved in the workspace, by its id (the contact_id of an earlier message, say). It goes to that contact's number or group. |
username | {"username": "@asha_bakes"} | A Telegram user: @name, a t.me/name link, or tg: and a user id. Only from a Telegram account, so give one as from (or have exactly one connected). |
Group and Telegram sends need the sending number to be in that group or able to reach that user; problems there show up as a failed message, not as an error when you send.
Media#
media sends an image or a file, with text as its caption. Give exactly one of url or media_id:
media.urlstringEither url or media_idA public
httporhttpslink to the file, up to 1,000 characters. Links to private or local addresses are refused withinvalid_media_url.media.media_idintegerEither url or media_idA file in the workspace's Media directory, from Upload a file. Upload a file once and send it as often as you like.
media.typestringoptionalimageorfile. Left out, a link ending in.jpg,.jpeg,.png,.gifor.webpis sent as an image and anything else as a file; amedia_iduses the type the file was uploaded with.media.file_namestringoptionalFor a
file: the name the recipient sees, such asinvoice-1042.pdf(up to 100 characters). Left out, the name comes from the link or the uploaded file. Its extension decides how the file is sent, so include one. Ignored for images.
{
"to": { "phone": "+919876543210" },
"text": "Your invoice for order #1042",
"media": { "url": "https://example.com/invoices/1042.pdf", "type": "file", "file_name": "invoice-1042.pdf" }
}
The file is fetched when the message is sent, not when you call the API, so a broken link or a file that's too big shows up as a failed message with the reason in error.message. The size and type rules are those of v1: see Sending images and files.
Scheduling and expiry#
send_atschedules the message. It staysscheduleduntil then and can be cancelled until it goes out. Write the time with its offset, so2026-10-20T09:30:00+05:30means 09:30 in India. For messages that repeat, use reminders.expires_atis a deadline: if the message hasn't gone out by then (the number was logged out, say), it becomesexpiredinstead of going out late. Use it for one-time codes, delivery times and offers. A background job marks it expired at least 30 seconds after the deadline, so don't rely on deadlines shorter than a minute.- Both must be in the future, and
expires_atmust be aftersend_at.
