Send Message
When to Use
Use this skill when building code to send messages through the Zavu API. Covers channel selection, the recipient (to) formats, message types, and error handling.
Channels
auto, sms, sms_oneway, whatsapp, telegram, email, instagram, messenger, voice.
Senders vs accounts (one paragraph)
A Sender is the API handle you pass as Zavu-Sender; accounts (a WhatsApp Business Account, a Facebook Page, a Telegram bot, a phone number) are the connections it routes — and what bills. Senders are free. Connecting an account in the dashboard auto-creates its sender; find it with GET /v1/senders and trust its channels array for what it can send. See the channel-setup skill for the full model.
Channel Selection Decision Tree
Is recipient an email address?
-> YES: channel = "email" (the sender needs an email channel: a verified domain)
Is message type non-text (image, video, buttons, list, template, etc.)?
-> YES: channel = "whatsapp" (auto-selected)
Need voice call / TTS?
-> YES: channel = "voice"
Recipient is a numeric chat ID (Instagram / Messenger / Telegram)?
-> YES: channel = "instagram" | "messenger" | "telegram"
Need one-way SMS (no inbound replies)?
-> YES: channel = "sms_oneway"
Need guaranteed delivery to a specific channel?
-> YES: channel = "sms" | "whatsapp" | "telegram" | "instagram" | "messenger"
Want cost-optimized routing?
-> YES: channel = "auto" (ML-powered smart routing)
Default?
-> channel = "sms" (or omit for default)
Recipient (to) formats
The universal to field accepts several identifier formats. Routing follows channel (or is auto-selected from the identifier when channel is omitted).
| Format | Example | Notes |
|---|---|---|
| E.164 phone | +14155551234 |
SMS, WhatsApp, Voice, Telegram. |
| Email address | user@example.com |
Defaults to email. |
| WhatsApp BSUID | US.13491208655302741918 |
Business-scoped user ID. Routed to WhatsApp; use to message a contact who adopted a username and hid their phone number. |
| Numeric chat ID | 123456789 |
Telegram, Instagram, or Messenger chat/user ID. |
Basic Messages
SMS (default)
TypeScript:
const result = await zavu.messages.send({
to: "+14155551234",
text: "Your verification code is 123456",
});
console.log(result.message.id);
Python:
result = zavu.messages.send(
to="+14155551234",
text="Your verification code is 123456",
)
print(result.message.id)
Go:
result, err := client.Messages.Send(context.TODO(), zavudev.MessageSendParams{
To: zavudev.String("+14155551234"),
Text: zavudev.String("Your verification code is 123456"),
})
fmt.Println(result.Message.ID)
Ruby:
result = client.messages.send(to: "+14155551234", text: "Your verification code is 123456")
puts result.message.id
PHP:
$result = $client->messages->send([
'to' => '+14155551234',
'text' => 'Your verification code is 123456',
]);
echo $result->message->id;
WhatsApp Text
const result = await zavu.messages.send({
to: "+14155551234",
channel: "whatsapp",
text: "Hello from Zavu!",
});
const result = await zavu.messages.send({
to: "user@example.com",
channel: "email",
subject: "Your order has shipped",
text: "Hi John, your order #12345 has shipped.",
htmlBody: "<h1>Order Shipped</h1><p>Your order #12345 has shipped.</p>",
replyTo: "support@example.com",
});
Voice (Text-to-Speech)
const result = await zavu.messages.send({
to: "+14155551234",
channel: "voice",
text: "Your verification code is 1 2 3 4 5 6",
voiceLanguage: "en-US", // optional, auto-detected from country code
});
One-Way SMS
Use sms_oneway when replies are not expected (no inbound path back to you).
await zavu.messages.send({
to: "+14155551234",
channel: "sms_oneway",
text: "Your appointment is confirmed for 3pm.",
});
Instagram / Messenger
Target a numeric chat/user ID (from an inbound conversation).
// Instagram Direct
await zavu.messages.send({
to: "17841400000000000",
channel: "instagram",
text: "Hello from Zavu via Instagram!",
});
// Messenger (Facebook Page / Marketplace chat)
await zavu.messages.send({
to: "24025631120151183",
channel: "messenger",
text: "Hello from Zavu via Messenger!",
});
WhatsApp Rich Messages
Image
await zavu.messages.send({
to: "+14155551234",
messageType: "image",
text: "Check out this product!", // caption
content: { mediaUrl: "https://example.com/image.jpg" },
});
Document
await zavu.messages.send({
to: "+14155551234",
messageType: "document",
content: {
mediaUrl: "https://example.com/invoice.pdf",
filename: "invoice.pdf",
},
});
Video / Audio
// Video
await zavu.messages.send({
to: "+14155551234",
messageType: "video",
text: "Watch this!",
content: { mediaUrl: "https://example.com/video.mp4" },
});
// Audio
await zavu.messages.send({
to: "+14155551234",
messageType: "audio",
content: { mediaUrl: "https://example.com/audio.mp3" },
});
Sticker
await zavu.messages.send({
to: "+14155551234",
messageType: "sticker",
content: { mediaUrl: "https://example.com/sticker.webp" },
});
Location
await zavu.messages.send({
to: "+14155551234",
messageType: "location",
content: {
latitude: 37.7749,
longitude: -122.4194,
locationName: "San Francisco",
locationAddress: "123 Main St, San Francisco, CA",
},
});
Contact Card
await zavu.messages.send({
to: "+14155551234",
messageType: "contact",
content: {
contacts: [
{ name: "John Doe", phones: ["+14155551234", "+14155555678"] },
],
},
});
Interactive Buttons (max 3)
await zavu.messages.send({
to: "+14155551234",
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" },
],
},
});
List Message
await zavu.messages.send({
to: "+14155551234",
messageType: "list",
text: "Select an option:",
content: {
listButton: "View Options",
sections: [{
title: "Products",
rows: [
{ id: "prod_1", title: "Product A", description: "$10.00" },
{ id: "prod_2", title: "Product B", description: "$20.00" },
],
}],
},
});
Template Message
templateVariables fill body placeholders (keyed by position 1, 2, ... for positional templates, or by name for named ones — do not mix). templateButtonVariables fill dynamic URL/OTP button placeholders (keyed by button index 0, 1, 2). templateHeaderVariables set a text-header variable (keyed by 1).
await zavu.messages.send({
to: "+14155551234",
messageType: "template",
content: {
templateId: "tpl_abc123",
templateVariables: { "1": "John", "2": "ORD-12345" },
templateButtonVariables: { "0": "abc-report-token" }, // optional: dynamic URL/OTP buttons
},
});
CTA URL (Call-to-Action button)
await zavu.messages.send({
to: "+14155551234",
messageType: "cta_url",
text: "Check out our latest collection",
content: {
ctaDisplayText: "View Products", // max 20 chars
ctaUrl: "https://example.com/products",
ctaHeaderType: "image", // optional: text | image | video | document
ctaHeaderMediaUrl: "https://example.com/header.jpg",
footerText: "Limited time offer", // optional, max 60 chars
},
});
Location Request (ask the contact to share their location)
Sends a message with a fixed "Send location" button. WhatsApp-only. Takes no content object — the prompt goes in text (max 1024 chars).
Not yet generated in the SDK — use REST:
curl -X POST https://api.zavu.dev/v1/messages \
-H "Authorization: Bearer $ZAVU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"channel": "whatsapp",
"messageType": "location_request",
"text": "To finish your order, share the delivery address."
}'
The answer arrives as a normal inbound location message (not a new type) with
content.replyToMessageId set to the ID of the request. Match on that field to
correlate the coordinates with the order/ticket you asked about:
if (
event.type === "message.inbound" &&
event.data.messageType === "location" &&
event.data.content?.replyToMessageId === pendingRequestId
) {
const { latitude, longitude } = event.data.content;
}
content.name and content.address are optional — present only when the contact
picks a saved place instead of dropping a pin. Always rely on lat/lng.
Contact Info Request (ask the contact to share their phone number)
Sends a message with a fixed "Share Contact Info" button. WhatsApp-only. Takes no content object — the prompt goes in text (max 1024 chars). Essential for contacts who adopted a WhatsApp username and are only known by BSUID.
Not yet generated in the SDK — use REST:
curl -X POST https://api.zavu.dev/v1/messages \
-H "Authorization: Bearer $ZAVU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "US.13491208655302741918",
"channel": "whatsapp",
"messageType": "request_contact_info",
"text": "Share your phone number so our team can call you back."
}'
The answer arrives as a normal inbound contact message with the shared number in
content.contacts[0].phones. When the sender was only known by BSUID, Zavu links
the shared phone to that contact automatically — no manual update needed.
Note: authentication templates with one-tap/zero-tap/copy-code buttons cannot be
sent to a BSUID (WhatsApp error 131062) — recover the phone number first.
Broadcasts also reject BSUID/@username recipients.
Reaction
await zavu.messages.react({
messageId: "msg_abc123",
emoji: "\ud83d\udc4d",
});
Typing Indicator
Mark an inbound WhatsApp message as read and show a typing indicator while you prepare a reply (POST /v1/messages/{messageId}/typing). It clears automatically when you send a reply or after 25 seconds. Only valid for inbound WhatsApp messages. Use it when a reply takes more than a couple of seconds (LLM agent, tool call, lookup).
curl -X POST https://api.zavu.dev/v1/messages/MESSAGE_ID/typing \
-H "Authorization: Bearer $ZAVU_API_KEY" \
-H "Zavu-Sender: sender_12345"
Sender Override
await zavu.messages.send({
to: "+14155551234",
text: "Hello!",
'Zavu-Sender': "snd_abc123",
});
Idempotency
await zavu.messages.send({
to: "+14155551234",
text: "Payment confirmed",
idempotencyKey: "payment_confirm_order_123",
});
Disable Automatic Fallback
By default, WhatsApp messages auto-fallback to SMS on failure. To disable:
await zavu.messages.send({
to: "+14155551234",
channel: "whatsapp",
text: "WhatsApp only — no SMS fallback",
fallbackEnabled: false,
});
Get Status & List Messages
// Get single message
const msg = await zavu.messages.get({ messageId: "msg_abc123" });
console.log(msg.message.status); // queued | sending | sent | delivered | read | failed
// List with filters + pagination
let cursor: string | undefined;
do {
const result = await zavu.messages.list({ status: "delivered", limit: 50, cursor });
for (const message of result.items) {
console.log(message.id, message.status);
}
cursor = result.nextCursor ?? undefined;
} while (cursor);
Common Errors
| Error Code | Meaning | Fix |
|---|---|---|
whatsapp_window_closed |
24h window not open | Use template message instead |
a2p_limit_exceeded |
Free plan monthly allowance reached: WhatsApp, Telegram, Instagram and Messenger share 2,000 messages/month. Separate from the daily ceiling below, which never prevents reaching this monthly figure | Upgrade to a paid plan (no caps) or wait for the monthly reset on the 1st |
insufficient_balance |
HTTP 402: prepaid balance cannot cover the send. Email is billed from balance in 1,000-message blocks ($0.40/1k transactional, $0.80/1k marketing); SMS and voice are billed per message | Add funds from the dashboard, then retry |
url_not_verified |
Message has unverified URLs | Submit URLs via /v1/urls first |
url_shortener_blocked |
URL shortener detected | Use full destination URL |
destination_not_verified |
HTTP 403: the account has verified nothing yet, so sms, sms_oneway and voice reach only the phone numbers the project has verified. details.verifiedNumbers lists them. Same code on POST /v1/calls |
Send to a verified number, verify the number you meant (see below), or verify identity / add a payment method / settle a deposit / subscribe to reach any destination |
daily_limit_exceeded |
HTTP 429: the channel group's daily ceiling, details.limit says which. Verified nothing: 25/day across sms + sms_oneway, 5/day voice, 100/day across WhatsApp + Telegram + Instagram + Messenger. Past that floor: 200/day SMS, or 10,000 once identity or business verification is approved; on Free, 50/day voice and 250/day conversational. Paid plans have no voice or conversational ceiling. Email: the plan quota (100/day on Free). Counts reset at 00:00 UTC |
Wait for the reset, or verify identity / add a payment method to raise it (upgrade the plan for email) |
EMAIL_INVALID_RECIPIENT |
Malformed email address (async, on the failed message) | Fix the address; pre-check lists with POST /v1/introspect/email |
EMAIL_DOMAIN_NOT_FOUND |
Recipient domain has no MX or A records (async) | Remove the address; the send would hard bounce |
EMAIL_RECIPIENT_SUPPRESSED |
Address bounced or complained before (async) | Remove it from your lists |
How a number gets verified, and what lifts the restriction. A new account that has proven nothing reaches only its verified numbers on sms, sms_oneway and voice; every other channel is open from the start. The developer verifies a number from the dashboard's Sandbox screen: generate a code, open the WhatsApp link (or scan the QR) on the phone to verify, and send the pre-filled VERIFY- message to Zavu's sandbox number. The number is marked verified automatically; one verification covers WhatsApp, SMS and calls; up to 5 numbers per project; a code expires after 10 minutes. There is no API for this step. Any one of identity verification (KYC), a saved payment method, a settled deposit, or a paid plan opens every destination. Business verification (KYB) never gates sending; it gates 10DLC registration. Email has no verification gate: a sender with a verified domain sends from day one within the plan quota (100/day and 3,000/month on Free). Reference: https://docs.zavu.dev/concepts/sending-limits
Email sends are pre-validated automatically at dispatch: guaranteed hard bounces (bad syntax, dead domain, suppressed address) are failed with the codes above instead of being sent, so they never hurt your bounce rate. These surface asynchronously on the message (status: "failed" + errorCode) and in the message.failed webhook. Advisory signals (role addresses like info@, disposable domains) never block a send — check them upfront with POST /v1/introspect/email.
Constraints
- Button titles: max 20 chars, max 3 buttons
- List row titles: max 24 chars, descriptions: max 72 chars, max 10 rows per section
- Location request body: max 1024 chars, no
contentobject, WhatsApp only - Email subject: max 998 chars
- Voice language codes:
en-US,es-ES,pt-BR, etc. (auto-detected if omitted) - Media messages auto-select WhatsApp channel