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.

codewhat happened, and whether sending again helps
channel_reconnectingThe 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_exhaustedA 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_unconfirmedWe 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_suspendedThe 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_foundThe 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.

codeHTTPWhat it means
invalid_request400The 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.
unauthorized401No API key, or one that is wrong, revoked, or unknown. The repair is to check or rotate the key.
account_suspended403The key is fine and the account is stopped. Nothing to rotate — it starts working again the moment the account does.
forbidden403The 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_found404No such resource for this organization. Another tenant’s id answers exactly the same way as an id that never existed.
channel_not_connected409The channel is disconnected or logged out, so it cannot send. Connect it first; this never fixes itself.
capability_unsupported422The channel’s provider cannot do what the request asked of it.
rate_limited429Too many requests. Back off and retry.
quota_exceeded402This 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_unavailable503We could not take the request right now. Retry.
internal_error500A 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 from field can name.
  • Delivery and read receipts as events. A message can reach delivered or read as a status; the only events that exist are message.sent, message.failed, message.received, channel.status_changed. Subscribing to message.delivered is refused.
  • Media, groups, templates and polls. type accepts "text" and nothing else.
  • Scopes and billing. Keys carry a scopes field 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/messages answers 402 with quota_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.