Messaging API
One endpoint.Every channel.
POST /v1/messages takes a phone number in E.164, an email address, a numeric chat ID or a WhatsApp business-scoped user ID. Zavu picks the channel, records the delivery, and tells your endpoint what happened.
- Channels
- 9 behind one body
- Sends
- Idempotent by key
- Webhooks
- HMAC-signed, v2
- Reads
- Cursor-paginated
Request builder
Pick a channel. Read the body.
The same shape covers SMS, WhatsApp, Telegram, email and voice. Change the channel and the message type below — the request and the 202 response are what you would actually send and receive.
- Authorization:
- Bearer zv_live_...
- Content-Type:
- application/json
- Zavu-Sender:
- sender_12345
{
"to": "+14155551234",
"channel": "whatsapp",
"messageType": "buttons",
"text": "How would you rate your experience?",
"content": {
"buttons": [
{
"id": "great",
"title": "Great"
},
{
"id": "okay",
"title": "It was okay"
},
{
"id": "poor",
"title": "Not good"
}
]
},
"idempotencyKey": "msg_01HZY4ZP7VQY2J3BRW7Z6G0QGE"
}{
"message": {
"id": "jd7x2k3m4n5p6q7r8s9t0",
"to": "+14155551234",
"from": "+13125551212",
"senderId": "sender_12345",
"channel": "whatsapp",
"messageType": "buttons",
"status": "queued",
"text": "How would you rate your experience?",
"conversationId": "js723987cyghwqxxaxcf590qd18axd95",
"createdAt": "2026-02-11T09:24:18.412Z"
}
}whatsapp · buttons
Free-form text needs an open 24-hour window. Outside it, send a template — anything else returns 400 whatsapp_window_closed.
Three at most. The tapped `id` comes back on the inbound webhook.
SDKs
Send it from your stack.
TypeScript, Python and curl on every operation the SDK generates. Conversations and webhook verification are shown as REST, because the SDK does not cover them yet.
import Zavudev from "@zavudev/sdk"
const zavu = new Zavudev({ apiKey: process.env.ZAVUDEV_API_KEY })
const { message } = await zavu.messages.send({
to: "+14155551234",
text: "Your order ORD-12345 has shipped.",
// channel defaults to "auto"; omit it and Zavu routes the message
idempotencyKey: "msg_01HZY4ZP7VQY2J3BRW7Z6G0QGE",
"Zavu-Sender": "sender_12345",
})
console.log(message.id, message.status) // -> "queued"Official SDKs: TypeScript, Python, Ruby, Go, PHP. Auth is a Bearer token; the sender override is a header.
Delivery
Every status is a webhook.
A message moves through queued, sent and delivered — and, on WhatsApp, read. Click a step to see the exact event your endpoint receives.
{
"id": "evt_1786113454000_a1b2c3",
"type": "message.queued",
"timestamp": 1786113454000,
"senderId": "sender_12345",
"projectId": "prj_7a1e08d4",
"data": {
"messageId": "jx7744x3wckc9cwy0j9c8g6v0h7z7shs",
"to": "+14155551234",
"channel": "whatsapp",
"status": "queued"
}
}Created and queued. Nothing has left Zavu yet.
Signatures
Verify before you trust.
Every webhook carries X-Zavu-Signature. Which scheme a sender uses is a setting, so you can move a receiver without dropping an event.
v1=HMAC_SHA256(secret, body)The scheme used before this was configurable. Existing webhooks stay on it until you move them.
both, sharing one tThe migration setting. A receiver reading either one works, so you can deploy and confirm the new verifier first.
v2=HMAC_SHA256(secret, "{t}.{body}")The current scheme and the default for new senders. It signs the timestamp together with the body.
Moving from v1 straight to v2 returns 400. Set v1+v2 first.
Refusals
What the API refuses.
Each of these is a documented response, not a silent drop. Read the code and you know what to do next.
Free-form WhatsApp outside the 24-hour window. Send a template, or wait for the contact to write first.
An SMS or email carrying a URL nobody submitted. Pre-verify it with POST /v1/urls.
bit.ly, t.co and the rest hide their destination. Use the full URL.
SMS and voice bill per message; email bills in blocks. At zero balance the send is rejected rather than queued.
The Free plan's shared allowance is spent. It resets on the 1st; paid plans have no message cap.
That idempotencyKey already sent a message. Retry as loudly as you like.
Email is checked before it is sent
A send that would be a guaranteed hard bounce is failed instead of dispatched, so your bounce rate survives a bad list. The message goes to `failed` with one of these codes, visible on the message and on message.failed.
- EMAIL_INVALID_RECIPIENT
- The address is malformed.
- EMAIL_DOMAIN_NOT_FOUND
- The domain has no MX or A records.
- EMAIL_RECIPIENT_SUPPRESSED
- It is on your suppression list after a bounce or a complaint.
Allowances
What a send costs you.
Two different meters. WhatsApp, Telegram, Instagram and Messenger share a monthly allowance; SMS, voice and email come out of your prepaid balance.
Email is charged in 1,000-message blocks as your monthly count crosses each boundary. Teams on earlier plans keep their original email quotas.
/pricingEndpoints
The surface, in full.
Sending is one call. Everything around it — reading a thread, reacting, showing that you are typing, pulling an attachment — is one more.
/v1/messagesSend. Returns 202 with the queued message.
/v1/messagesList, filtered by status, recipient or channel. Cursor-paginated.
/v1/messages/{messageId}One message, with its cost and conversationId.
/v1/messages/{messageId}/reactionsReact with an emoji. WhatsApp only.
/v1/messages/{messageId}/typingMark read and show a typing indicator for up to 25 seconds.
/v1/messages/{messageId}/attachmentsSigned, short-lived download URLs for email attachments.
/v1/conversationsInbox threads, most recently active first.
/v1/conversations/{id}/messagesOne thread, across every channel it has carried.
/v1/conversations/{id}/readReset the thread's unread count. Your inbox only.
Send the first one.
A key, a phone number, one POST. The rest of the surface is there when you need it.