API reference
One key, one endpoint, one signed webhook.
Everything /v1 serves today, taken from the code that serves it. What is not built yet is listed at the bottom rather than left for you to discover.
Read the last section first if you are evaluating us. Pairing a real number, sending, and receiving all run in production — a message reaches a handset and a reply comes back. What is still missing is listed at the bottom, and it is specific: media, the Cloud API, and billing. The home page draws the same line.
Base URL
https://sendbu.com/v1. There is no api. subdomain: /v1 is served by the same app that serves this page. Every request and response body is JSON.
Authentication
Every /v1 request carries an API key as a bearer token:
Authorization: Bearer sk_live_...You mint keys yourself in the panel, under Keys. The secret is shown once, at creation; we store only a hash of it, so a lost key is replaced rather than recovered. Revoking a key takes effect on the next request.
A request with no key, a malformed header, an unknown key, or a revoked one answers 401 with unauthorized. A valid key belonging to a suspended account answers 403 with account_suspended — which is a different thing and asks you for a different repair; see the error table.
The key carries a scopes field. Nothing enforces it today: a valid, unrevoked key can reach every endpoint on this page for its own organization and for no other. When scopes become real they become real for all of /v1 at once, and this page will say so.
Sending a message
POST/v1/messages
Takes channel_id, to (E.164, e.g. +15551234567), type — "text" is the only value today — and text.body, 1 to 4096 characters. Unknown fields are rejected rather than ignored.
Requires an Idempotency-Key header. Answers 202 with id, status and channel_id.
curl https://sendbu.com/v1/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Idempotency-Key: order-8842-shipped" \
-H "Content-Type: application/json" \
-d '{ "channel_id": "6b1f0c4e-9a2d-4f77-bd31-0c5a7e2f9a10",
"to": "+15551234567",
"type": "text",
"text": { "body": "Your order has shipped." } }'
< 202 Accepted
{ "id": "0f9c7a55-3d84-4b1e-9a60-27ce1f0b4d93",
"status": "queued",
"channel_id": "6b1f0c4e-9a2d-4f77-bd31-0c5a7e2f9a10" }202, not 200
200 would claim the message was delivered. It has not been: it has been accepted and queued, and the channel may still be reconnecting. The true outcome arrives later, as a webhook. A message's status is one of queued, sending, sent, delivered, read, failed, received.
A channel that is disconnected or logged_out is refused up front with 409 channel_not_connected rather than accepted — a message that can never be sent should not be queued. A channel that is pairing or reconnecting is accepted, and the message waits for the socket.
Idempotency-Key
Required, not optional. A request without one answers 400 invalid_request.
The key is unique per organization. A retry carrying the same key returns the existing message — the same id, and whatever status it has reached — and sends nothing further. So a timeout on your side never becomes two messages on your customer's phone. Keys are trimmed, so a stray newline in a header does not create a second message.
Reading messages
The pull side of what webhooks push. Every endpoint here is a read: nothing is queued and nothing is changed. A message id belonging to another organization answers exactly like an id that never existed — 404 — because telling you a message exists but is not yours is telling you a message exists.
GET/v1/messages
One page of this organization's messages, newest first. Filters: channel_id, status (one of queued, sending, sent, delivered, read, failed, received), direction (inbound or outbound) and contact — the other party in E.164, which is what turns this endpoint into "read one conversation".
limit is 1 to 100 and defaults to 25. A value that is not a whole number in that range is refused rather than rounded or defaulted, because quietly serving 25 rows hides the bug until somebody wonders why their pagination stopped early.
GET/v1/messages/:id
One message. Answers 404 for an id this organization does not own.
The wrong method
A verb a path does not serve answers 405 with the JSON envelope like everything else, and an Allow header listing what would work — so a client that guessed can read the answer rather than parse an empty body. It used to answer 405 with no body, no content type and no Allow at all, which broke both the promise above and the one HTTP makes.
The code is invalid_request rather than one of its own: a wrong method is a malformed request, and the part worth branching on is the Allow header, which HTTP already defines for exactly this.
Why a message failed
Every message carries failure_code and failure_message, both null until a send actually fails. The message is a sentence for a person to read; the code is the part to branch on, and these are all of them. They were returned by the API before they were written down here, which is a field with undocumented values — not useful to anybody.
| code | what happened, and whether sending again helps |
|---|---|
channel_reconnecting | The channel dropped and was still reconnecting two minutes later. Nothing was delivered, so sending again is safe — and worth checking the handset for, because a channel down that long usually means the phone is offline or the session was linked somewhere else. |
retries_exhausted | A connected channel refused the send on every attempt. Nothing was delivered, so sending again is safe, but expect the same answer until whatever refused it changes. |
delivery_unconfirmed | We called WhatsApp and never recorded what came back, so we cannot tell you whether it arrived. Deliberately not retried: a duplicate is a spam signal and spam signals get numbers banned. This is the one code where sending again is a decision for a person, not a client. |
account_suspended | The organization was stopped before this message went out. Queued messages are failed rather than held, so restoring the account does not fire a burst of hours-old messages nobody re-approved. |
not_found | The channel was deleted between accepting the message and sending it. |
Anything else in this field is the error code from the provider call itself — the same vocabulary as the table further down this page.
GET/v1/conversations
One row per contact this organization has exchanged a message with, newest first, each carrying the last message and the unread count. Filters on channel_id. There is no cursor here: the row count is contacts, not messages, and /v1/messages is where the volume is.
Paging
A list answers data, has_more and next_cursor. Pass next_cursor back as starting_after for the next page, and stop when has_more is false.
It is a cursor and not an offset, and the difference matters here more than usual: this list is ordered newest first and is written to constantly — every send, every status change, every message that arrives. Under OFFSET, a row arriving while you page pushes everything below it down one, so page two re-reads a row page one already gave you. The cursor is <timestamp>|<id> in the open; there is nothing secret in it, and a cursor we cannot parse is refused rather than treated as the first page — silently restarting you at the top is an infinite loop that looks like working software.
Channels
A channel is one WhatsApp number. Creating one takes no options — see POST /v1/channels below — and what it can do afterwards is on the channel itself, as capabilities.
A channel's status is one of disconnected, pairing, connected, reconnecting, logged_out, and its capabilities are drawn from text.send, text.receive, media.send, groups.send, contacts.read, templates.send, polls.send.
GET/v1/channels
Lists this organization's channels. Answers 200 with { "data": [...] }.
POST/v1/channels
Takes an empty body — {} — and answers 201 with the channel, whose status begins as disconnected. A body carrying any field is refused rather than ignored, so a request that believes it is choosing something finds out that it is not.
There is nothing to choose. This used to take a provider naming the implementation behind the channel. It was removed: the value is ours rather than yours, and there has only ever been one to pick. What a channel can actually do is on the channel itself, as capabilities — branch on that, because it describes behaviour we promise rather than a component we intend to replace. When a second kind of channel exists you will choose between them in words that mean something to you, and this page will say so.
GET/v1/channels/:id
Answers 200 with the channel, or 404 not_found. A channel belonging to another organization answers 404 too.
DELETE/v1/channels/:id
Answers 204 with no body. A channel that still has message history is refused rather than deleted.
POST/v1/channels/:id/connect
Starts a pairing attempt. Answers 202: nothing has connected yet, and the QR code comes from a worker's socket a moment later. Poll the challenge endpoint below for it.
GET/v1/channels/:id/challenge
Answers 200 with { "status": ..., "challenge": ... }. The challenge is null before the first QR, between one code expiring and the next, and after pairing ends — that is not a failure signal. Only status is terminal: connected means done, logged_out or disconnected mean the attempt failed.
A challenge carries type (qr or pairing_code), value, sequence and expires_at. The sequence increments on each refresh and restarts at 1 on every connect, so forget the sequence you remembered whenever you call connect. Impose your own polling timeout: a channel stuck at pairing with no challenge will not time itself out.
POST/v1/channels/:id/disconnect
Closes the socket and leaves the channel registered. Answers 200 with the channel.
Webhooks
We POST JSON to an endpoint you register. The events that exist today are message.sent, message.failed, message.received, channel.status_changed — a subscription naming anything else is refused at registration rather than stored and silently never delivered.
GET/v1/webhooks
Lists your endpoints, newest first, as { "data": [...] }. Never includes a secret.
POST/v1/webhooks
Takes url (https, publicly resolvable) and events, a non-empty array drawn from the list above. Answers 201.
This is the only response that ever carries the signing secret, as secret (whsec_…). Store it then; no later read returns it. An organization may hold at most 20 endpoints.
DELETE/v1/webhooks/:id
Answers 204. The endpoint's delivery history goes with it.
What we send
POST https://your-app.example.com/hooks/sendbu
Content-Type: application/json
Sendbu-Signature: t=1757913600,v1=5f2b...
{ "event": "message.sent",
"created_at": "2026-09-15T08:00:00.000Z",
"data": { "id": "0f9c7a55-3d84-4b1e-9a60-27ce1f0b4d93" } }The body is always event, created_at and data. For message.sent and message.failed, data is { "id": "<message id>" }; for channel.status_changed it is { "id": "<channel id>", "status": "..." }.
message.received carries the message itself rather than only its id — { "id": "...", "channel_id": "...", "from": "+15551234567", "text": { "body": "..." }, "sent_at": "..." }. The others answer something you just asked for and already hold the id of; nobody asked for this one, so a receiver holding only an id would have to call back before it could decide whether the message was interesting. sent_at is WhatsApp's own timestamp, which is how you tell a message that just arrived from one replayed after an outage.
Answer 2xx and we consider it delivered. A 5xx, a timeout (we wait 10 seconds) or a refused connection is retried with backoff. A 4xx is not retried — you rejected it, and retrying will not change that. A 3xx is not followed at all: a redirect could bounce a signed payload to a host you never registered. Delivery is at-least-once, so make your handler idempotent.
Verifying the signature
Every delivery carries a Sendbu-Signature header: t=<unix seconds>,v1=<hex>. The v1 value is HMAC-SHA256 over the exact string `${t}.${rawBody}`, keyed with your endpoint's secret.
Reject anything whose t is more than 300 seconds away from your clock in either direction — that is the replay window we verify against — and compare the digests in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'
// `body` must be the RAW request body, byte for byte. Parsing it to JSON and
// re-serialising changes the bytes and every signature then fails.
function verify(secret, body, header) {
const parts = new Map(header.split(',').map((p) => p.split('=')))
const t = Number(parts.get('t'))
if (!Number.isFinite(t)) return false
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false // replay window
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
const a = Buffer.from(parts.get('v1') ?? '', 'hex')
const b = Buffer.from(expected, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}Errors
Every refusal has the same shape:
{
"error": {
"code": "channel_not_connected",
"message": "Channel 6b1f0c4e... is logged_out and cannot send. Connect it first.",
"retryable": false,
"channel_id": "6b1f0c4e-9a2d-4f77-bd31-0c5a7e2f9a10",
"status": "logged_out"
}
}code is the field to branch on — the message is written for a human and will be reworded. retryable says whether trying the identical request again could ever succeed. Some errors add fields beside those three, as channel_not_connected adds channel_id and status above; the three contract fields are never overwritten by them.
| code | HTTP | What it means |
|---|---|---|
| invalid_request | 400 | The body, a field in it, or a required header is wrong. The message names what, and a field name is included where there is one. |
| unauthorized | 401 | No API key, or one that is wrong, revoked, or unknown. The repair is to check or rotate the key. |
| account_suspended | 403 | The key is fine and the account is stopped. Nothing to rotate — it starts working again the moment the account does. |
| forbidden | 403 | The credential is fine and this caller may not do this. Only a member with a higher role can, so rotating anything is the wrong move. |
| not_found | 404 | No such resource for this organization. Another tenant’s id answers exactly the same way as an id that never existed. |
| channel_not_connected | 409 | The channel is disconnected or logged out, so it cannot send. Connect it first; this never fixes itself. |
| capability_unsupported | 422 | The channel’s provider cannot do what the request asked of it. |
| rate_limited | 429 | Too many requests. Back off and retry. |
| quota_exceeded | 402 | This month’s message allowance for the plan is spent. Retrying will not help — it resets at the start of next month, or upgrade the plan. A retry carrying an idempotency key you have already used is never refused here, because it costs nothing new. |
| capacity_unavailable | 503 | We could not take the request right now. Retry. |
| internal_error | 500 | A bug on our side. The detail is in our logs, not in the response. |
What is not built yet
The same list the home page publishes, because a reference that implied otherwise would be the more expensive lie.
- Receiving anything that is not text. A photo, a voice note, a document or a location sent to a paired number is dropped. It does not reach the Inbox and fires no webhook. We will not announce a caption as though it were the message — that hands you "look at this" with nothing to look at — and we have nowhere to put the file yet. Group messages and status updates are dropped for the same reason: they have no single sender the
fromfield can name. - Delivery and read receipts as events. A message can reach
deliveredorreadas a status; the only events that exist aremessage.sent,message.failed,message.received,channel.status_changed. Subscribing tomessage.deliveredis refused. - Media, groups, templates and polls.
typeaccepts"text"and nothing else. - Scopes and billing. Keys carry a
scopesfield nothing enforces, and there is nothing to pay — no card, no price list, no invoice. - A monthly allowance is enforced, and it is not billing. Past a plan’s allowance
POST /v1/messagesanswers402withquota_exceeded, which is not retryable — waiting until next month or changing plan is the only way through. It counts messages ACCEPTED, so one that later failed at WhatsApp still counted. The Usage screen in the panel shows where you stand.