mcpField
S
send-message

send-message

github.com/zavudevProductivity1351 installsUpdated 2d ago
Repository

About send-message

send-message is a skill for developers building messaging with the Zavu API. It explains channel routing, recipient formats, message types, and options for handling replies and errors.

Key features

  1. 01Choose channels based on recipients
  2. 02Support multiple recipient identifier formats
  3. 03Send text and WhatsApp rich messages
  4. 04Configure quoted replies and senders
  5. 05Send messages with idempotency keys

Use cases

  1. 01Send SMS or email from an application
  2. 02Send media or interactive messages to WhatsApp contacts
  3. 03Reply to conversations on Telegram or social channels

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/zavudev/zavu-skills --skill send-message

After installing, type/send-messagein Claude Code to confirm it loaded.

Details

From the project README

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 Zavu to pick the channel?
  -> YES: channel = "auto" (first channel the sender has configured
     that the recipient's address can receive; not cost-based)
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).

FormatExampleNotes
E.164 phone+14155551234SMS, WhatsApp, Voice, Telegram.
Email address[email protected]Defaults to email.
WhatsApp BSUIDUS.13491208655302741918Business-scoped user ID. Routed to WhatsApp; use to message a contact who adopted a username and hid their phone number.
Numeric chat ID123456789Telegram, 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!",
});
Email
const result = await zavu.messages.send({
  to: "[email protected]",
  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: "[email protected]",
});
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). On WhatsApp, body and header values may not contain line breaks, tabs or more than 4 consecutive spaces: the send is rejected with 400 invalid_request naming the value in details.field.

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",
});

Quoted Reply (WhatsApp and Telegram)

Set content.replyToMessageId to the Zavu ID of the message you are answering, such as the data.messageId of a message.inbound webhook. The recipient sees that message quoted above yours.

const telegram = event.data.channel === "telegram";
await zavu.messages.send({
  // Telegram's `data.from` is "telegram:<chatId>"; send to the bare chat id.
  to: telegram ? event.data.telegramChatId : event.data.from,
  channel: telegram ? "telegram" : "whatsapp",
  text: "Your order ships tomorrow.",
  content: { replyToMessageId: event.data.messageId },
  'Zavu-Sender': event.senderId, // the sender that received it
});
  • WhatsApp: works on every message type except reaction. Telegram: works on every message type; a button tap (data.isCallbackQuery: true on the inbound) is not a message and cannot be quoted.
  • The quoted message must belong to the same conversation as to, and must have been sent or received by the sender this message goes out from. Pass that sender in Zavu-Sender.
  • Set channel explicitly. With auto, a phone number can route to SMS, which cannot quote.
  • A quote that cannot be delivered is refused, never sent unquoted: 404 when the message is not in this project, 400 for another channel, another conversation, another sender, a reaction, a Telegram button tap, a failed message, or a quoted message with no providerMessageId yet.
  • Only replyToMessageId is read from the request. Zavu fills replyToProviderMessageId, replyToFrom, replyToText and replyToMessageType on the stored message.

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",
});

A retry with a key already used in the project sends nothing. It returns 409 whose body is { message }: the message the first request created, in its current state, so read its id and status from there. The replay is answered before any rate, daily or balance limit, so a retry inside the one-SMS-per- recipient-per-minute window still gets the 409 replay, not a 429. The SDK raises that 409 as a ConflictError (it does not retry it); the replayed message is on the error's error property:

import Zavudev from "@zavudev/sdk";

try {
  await zavu.messages.send({ to: "+14155551234", text: "Payment confirmed", idempotencyKey: key });
} catch (err) {
  if (err instanceof Zavudev.ConflictError) {
    const { message } = err.error as { message: { id: string; status: string } };
    // Already sent: message.id and message.status describe the first attempt.
  } else {
    throw err;
  }
}

Automatic Fallback to SMS

A failed WhatsApp text or template message is retried over SMS when the sender also has SMS. Whether that happens depends on how the channel was chosen:

  • channel omitted or "auto": fallback is on. Turn it off with fallbackEnabled: false.
  • channel set explicitly (e.g. "whatsapp"): fallback is off. Turn it on with fallbackEnabled: true.
// Explicit WhatsApp, with the SMS safety net
await zavu.messages.send({
  to: "+14155551234",
  channel: "whatsapp",
  text: "Your order has shipped.",
  fallbackEnabled: true,
});

Media, buttons, lists and other WhatsApp-only types never fall back.

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 CodeMeaningFix
whatsapp_window_closed24h window not openUse template message instead
a2p_limit_exceededHTTP 429: the Free plan's monthly allowance is spent. WhatsApp, Telegram, Instagram and Messenger share 2,000 messages/month, counted in both directions — a message a contact sends you consumes a unit exactly like one you send, so a project that sent 300 and received 1,700 is at the cap. Messages sent from the WhatsApp Business App under coexistence do not count, nor do failed sends. At the cap inbound is refused too: not stored, not in the inbox, no message.inbound webhook. Separate from the daily ceiling below, which never prevents reaching this monthly figureUpgrade to a paid plan (no caps) or wait for the monthly reset on the 1st
insufficient_balanceHTTP 402, { code, message } at the top level like every other error (details carries requiredCents and availableCents when known): 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 messageAdd funds from the dashboard, then retry
sms_compliance_blockedHTTP 422, SMS and one-way SMS only: the content check refused the text (details.complianceFailures, details.requiredFixes, and a suggestedCompliantVersion when there is one), or the check could not run and the message says so. Nothing is sent or charged. One-time codes always passApply requiredFixes (often: name your business, say why you are writing) and send again; if the check could not run, retry in a few minutes
url_not_verifiedMessage has unverified URLsSubmit URLs via /v1/urls first
url_shortener_blockedURL shortener detectedUse full destination URL
ten_dlc_requiredHTTP 403: the destination is a US or Canadian (+1) number and the project has no approved 10DLC campaign. A carrier requirement, not a Zavu one; registering a brand and campaign needs an approved business verificationRegister a 10DLC brand and campaign in the dashboard, or send to that contact on another channel
sending_suspendedHTTP 403: this account's sending was suspended after Zavu's automated review refused several of its messages. Every channel is refused while it lasts, and details.dashboardUrl points at support. Same code on POST /v1/callsContact support; waiting does not clear it
RISK_BLOCKED / RISK_REJECTED / RISK_REVIEW_EXPIREDOn the failed message (async), not on the response: the automated review refused the message outright, a reviewer did not approve a held one, or the review window closed first. A failed SMS is not chargedRewrite the message, or contact support with the message ID if the refusal looks wrong
SENDING_SUSPENDEDOn the failed message (async): the account's sending was suspended while this message was queuedContact support
daily_limit_exceededHTTP 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. Separately, every plan has a rolling email ceiling by account age (1,000/24 h and 200/h for the first 7 days, 10,000 and 1,000/h until day 30, then 50,000 and 5,000/h): past it the message is accepted and fails with EMAIL_RATE_LIMIT_DAILY or EMAIL_RATE_LIMIT_HOURLY, not with this 429Wait for the reset, or verify identity / add a payment method to raise it (upgrade the plan for email)
EMAIL_INVALID_RECIPIENTMalformed email address (async, on the failed message)Fix the address; pre-check lists with POST /v1/introspect/email
EMAIL_DOMAIN_NOT_FOUNDRecipient domain has no MX or A records (async)Remove the address; the send would hard bounce
EMAIL_RECIPIENT_SUPPRESSEDAddress bounced or complained before (async)Remove it from your lists

What verification changes, and what it does not. An account sends on every channel, to any destination, from its first minute. No channel asks for identity or business verification in order to send. What verification changes is the daily ceiling: identity verification (KYC), a saved payment method, a settled deposit or a paid plan each lift an account off the lowest rung, and identity or business verification is what takes SMS to 10,000 a day. Business verification (KYB) is still required to register a 10DLC brand and campaign, which every +1 SMS destination needs. 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

Every sms, sms_oneway, email and voice message is read before it is sent — its content, and the pattern of sends around it. Three outcomes. It is sent, which is the normal one. It is held for a short review: the message stays queued (no new status, MessageStatus is unchanged), and it sends normally once approved; the window is a couple of hours. Or it is refused: the message fails with RISK_BLOCKED, RISK_REJECTED or RISK_REVIEW_EXPIRED in errorCode and fires message.failed, and a refused SMS is not charged. A call is never held — a call the review stops is failed rather than placed late. Repeated refusals suspend the account's sending (403 sending_suspended). A broadcast is read once, on its own text, not per recipient. Do not build retry logic that re-sends a message that failed with one of these codes: the content is what was refused, so an identical retry is refused identically.

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 content object, 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

Comments

Markdown supported