# Send a message

> Send a message to a recipient via SMS or WhatsApp.

`POST https://api.zavu.dev/v1/messages`

Send a message to a recipient via SMS or WhatsApp.

**Channel selection:**
- If `channel` is omitted and `messageType` is `text`, defaults to SMS
- If `messageType` is anything other than `text`, WhatsApp is used automatically

**WhatsApp 24-hour window:**
- Free-form messages (non-template) require an open 24h window
- Window opens when the user messages you first
- Use template messages to initiate conversations outside the window

**Plan allowances and email billing:**
- WhatsApp, Telegram, Instagram and Messenger share an allowance of 2,000 messages per month on Free. **It counts messages in both directions**: a message a contact sends you consumes one unit exactly as a message you send them does, so a project that has sent 300 and received 1,700 has used the whole allowance. Messages you send from the WhatsApp Business App on your own phone under coexistence are mirrored into your inbox but never counted, and neither are failed sends. **Pay as you go:** a Free team that has added funds to its balance is not stopped at the allowance. Past it, each started block of 1,000 messages is prepaid from the balance at $4 ($0.004 a message, either direction); only when the balance cannot cover the next block does the rule below apply. Otherwise, over the allowance, sends return 429 with code `a2p_limit_exceeded` and upgrade details, **and inbound messages on those channels are held**: kept, but not shown in the inbox and no `message.inbound` webhook until the team adds funds or moves to a paid plan (or, for a pay-as-you-go team stopped by its spend cap, raises the cap). Held messages are then delivered in arrival order with their original `providerTimestamp`, counted like any other, and an AI agent does not reply to them. Up to 1,000 are held per team, each for 30 days; past either limit they are refused and not delivered later. The month resetting does not release them. The counter resets on the 1st of each month. Paid plans have no message caps
- Email is billed from your prepaid balance in 1,000-message blocks: $0.40 per 1,000 transactional emails, $0.80 per 1,000 marketing (broadcast) emails. A block is charged when your monthly count crosses each 1,000 boundary, and at zero balance email sends return 402 with code `insufficient_balance`. Free teams start with $2 of credit and additionally cap at 3,000 emails/month (lifted once the team has added funds: past it, email keeps billing from the balance in the same blocks) and 100/day. Email broadcasts (marketing email) need a paid plan: Free cannot send them, see `POST /v1/broadcasts/{broadcastId}/send`. Teams on earlier plans keep their original email quotas instead
- SMS and voice are billed per message from your balance on every plan

**Daily limits:**
- An account sends on every channel from its first minute, to any destination. Verification is not a permission to send: identity verification and business verification (KYB) raise the ceilings below. That includes `sms_oneway`: it sends without verification, within the SMS ceiling, and every message passes the risk review below. KYB is still required to register a 10DLC brand and campaign, which every US and Canadian (+1) SMS destination needs — a carrier rule, answered separately with `403 ten_dlc_required`
- Daily ceilings apply per channel group and rise with verification. An account that has verified nothing: 25/day across `sms` + `sms_oneway`, 5/day for `voice`, 100/day across WhatsApp, Telegram, Instagram and Messenger combined. Past that floor: 200/day for SMS, or 10,000/day once identity or business verification is approved (or a higher limit agreed for your account); 50/day voice and 250/day conversational on Free. **Paid plans have no voice or conversational daily ceiling**, and neither does a Free team that has added funds on conversational (voice keeps 50/day). Over a ceiling, sends return `429` with code `daily_limit_exceeded` and `details.limit`; the count resets at 00:00 UTC
- The daily ceiling never reduces the monthly allowance: 100/day on the conversational group still reaches the 2,000 monthly A2P messages Free includes
- Email: a sender with a verified domain sends from day one, within the plan quota (100/day and 3,000/month on Free). Over the daily quota it returns `429` with code `daily_limit_exceeded`
- Every plan, paid ones included, also has a rolling email ceiling by account age: 200 per hour and 1,000 per 24 hours for the first 7 days, 1,000 per hour and 10,000 per 24 hours until day 30, then 5,000 per hour and 50,000 per 24 hours. Past it the request is accepted and the message then fails with `errorCode` `EMAIL_RATE_LIMIT_HOURLY` or `EMAIL_RATE_LIMIT_DAILY`, visible on `GET /v1/messages/{messageId}` and the `message.failed` webhook. It does not reset at 00:00 UTC: it counts the last hour and the last 24 hours
- Full reference: https://www.zavu.dev/docs/concepts/sending-limits

**Risk review:**
Every outbound `sms`, `sms_oneway`, `email` and `voice` message is read before it is sent — the content, and how this account has been sending. What is checked is the message, not who you are.
- A message can be **held** for a short review. It stays `queued` while it waits: no new status exists for this, and `MessageStatus` is unchanged. When it is approved it sends normally.
- A message that is not approved moves to `failed` and fires `message.failed`. `errorCode` says which happened: `RISK_REJECTED` (a reviewer refused it), `RISK_REVIEW_EXPIRED` (the review window closed first — it is a couple of hours, because a code that arrives late is worse than one that does not arrive), or `RISK_BLOCKED` (refused outright, without a hold). An SMS that fails this way is not charged; the prepaid amount is returned.
- A call is never held. `POST /v1/calls` fails a call the review stops rather than placing it hours late.
- A message whose content cannot be read — the check is briefly unavailable — is held rather than sent. An account with an approved business verification is unaffected, and so is one that has verified something, already sends real traffic, and has a clean recent record.
- Repeated refusals suspend an account's sending. While it is suspended every send is refused with `403` and code `sending_suspended`, `details.dashboardUrl` points at support, and a message already queued fails with `errorCode` `SENDING_SUSPENDED`.
- A broadcast is read once, on the broadcast itself, rather than per recipient — see `POST /v1/broadcasts/{broadcastId}/send`.

**Email recipient pre-flight:**
Email messages are validated automatically before dispatch. Sends that would be a guaranteed hard bounce are failed instead of sent, protecting your bounce rate: the message transitions to `failed` (visible via `GET /v1/messages/{messageId}` and the `message.failed` webhook) with `errorCode` set to `EMAIL_INVALID_RECIPIENT` (malformed address), `EMAIL_DOMAIN_NOT_FOUND` (recipient domain has no MX or A records), or `EMAIL_RECIPIENT_SUPPRESSED` (address is on your suppression list after a previous bounce or complaint). Advisory signals (role addresses, disposable domains) do not block sends — check them beforehand with `POST /v1/introspect/email`.

## Parameters

- `Zavu-Sender` (header, string): Optional sender profile ID. If omitted, the project's default sender will be used.

## Body

- `to` (string, required): Recipient phone number in E.164 format, email address, WhatsApp business-scoped user ID (BSUID, e.g. `US.13491208655302741918`), or numeric chat ID (for Telegram/Instagram/Messenger). A BSUID is routed to WhatsApp and sent via the `recipient` field; use it to message a contact who adopted a username and whose phone number is hidden.
- `channel` (string): Delivery channel. Use 'auto' for intelligent routing. If omitted, channel is auto-selected based on sender capabilities and recipient type. For email recipients, defaults to 'email'. One of: `auto`, `sms`, `sms_oneway`, `whatsapp`, `telegram`, `email`, `instagram`, `messenger`, `voice`.
- `messageType` (string): Type of message. Defaults to 'text'. One of: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `buttons`, `list`, `cta_url`, `request_contact_info`, `location_request`, `reaction`, `template`.
- `text` (string): Text body for text messages or caption for media messages.
- `content` (object): Additional content for non-text message types.
  - `mediaUrl` (string): URL of the media file (for image, video, audio, document, sticker).
  - `mediaId` (string): WhatsApp media ID if already uploaded.
  - `mimeType` (string): MIME type of the media.
  - `filename` (string): Filename for documents.
  - `latitude` (number): Latitude for location messages.
  - `longitude` (number): Longitude for location messages.
  - `locationName` (string): Name of the location.
  - `locationAddress` (string): Address of the location.
  - `contacts` (object[]): Contact cards for contact messages.
    - `name` (string)
    - `phones` (string[])
  - `buttons` (object[]): Interactive buttons (max 3).
    - `id` (string, required)
    - `title` (string, required)
  - `listButton` (string): Button text for list messages.
  - `sections` (object[]): Sections for list messages.
    - `title` (string, required)
    - `rows` (object[], required)
      - `id` (string, required)
      - `title` (string, required)
      - `description` (string)
  - `ctaDisplayText` (string): Button label for cta_url messages.
  - `ctaUrl` (string<uri>): Destination URL opened in the device's default browser when the button is tapped. Used with messageType=cta_url. WhatsApp requires HTTPS in production.
  - `ctaHeaderType` (string): Optional header type for cta_url messages. One of: `text`, `image`, `video`, `document`.
  - `ctaHeaderText` (string): Header text when ctaHeaderType is 'text'.
  - `ctaHeaderMediaUrl` (string<uri>): Public HTTPS URL of the header media when ctaHeaderType is 'image', 'video', or 'document'. WhatsApp fetches this URL — it must be publicly reachable and return the declared content type.
  - `footerText` (string): Optional footer text for cta_url messages.
  - `emoji` (string): Emoji for reaction messages.
  - `reactToMessageId` (string): Message ID to react to.
  - `replyToMessageId` (string): Zavu message ID of the quoted message this message replies to. **When sending:** set it to make the message a quoted reply to an earlier message in the same conversation, such as the `data.messageId` of a `message.inbound` webhook. Supported on WhatsApp (every message type except `reaction`) and Telegram. The quoted message must belong to this project (otherwise `404`), must have gone out on the same channel as this send, must belong to the conversation `to` addresses, must have been sent or received by the sender this message goes out from (pass it in `Zavu-Sender`), and must have a `providerMessageId`. A failed message and a Telegram button tap cannot be quoted. Any other case returns `400` instead of sending the message unquoted. The stored message then carries the rest of the `replyTo*` fields. **When receiving:** present on inbound messages that quote an earlier message. Omitted when the quoted message is not found in Zavu (e.g. an old or unknown message) — use replyToProviderMessageId in that case.
  - `replyToProviderMessageId` (string): Provider message ID (WhatsApp WAMID, or Telegram message ID) of the quoted message. Present whenever an inbound WhatsApp message is a reply, even if the quoted message is not stored in Zavu, and on outbound quoted replies. Read-only: to quote when sending, set replyToMessageId.
  - `replyToFrom` (string): Sender of the quoted message (phone number in E.164 format, or the Telegram chat ID). Read-only.
  - `replyToText` (string): Truncated snippet (up to 200 characters) of the quoted message's text, for display. Empty when the quoted message has no text (e.g. media). Read-only.
  - `replyToMessageType` (string): Type of the quoted message (text, image, video, etc.). Read-only.
  - `templateId` (string): Template ID for template messages.
  - `templateVariables` (object): Variables for body placeholders. Key them to match the template body: by position (`1`, `2`, ...) for positional templates, or by name (e.g. `customer_name`) for named templates. Zavu detects the template's format and sends the correct payload to Meta. Named keys also resolve a named text-header variable. Do not mix positional and named keys in the same request. On WhatsApp, a value may not contain line breaks, tabs or more than 4 consecutive spaces: Meta refuses those at delivery, so the send is rejected with `400 invalid_request` instead (`details.field` names the value).
    - `{key}` (string)
  - `templateButtonVariables` (object): Variables for dynamic button placeholders (URL buttons and OTP buttons). Keys are the button index (0, 1, 2) in the template's `buttons` array — not the placeholder name. Values substitute the `{{1}}` placeholder inside that button's URL. **WhatsApp constraints:** - URL buttons only accept `{{1}}` — positional, numeric, no whitespace, no name. Named placeholders like `{{token}}` are stored as literal URL text by Meta and cannot be substituted. - At most one placeholder per URL button. - A template may have at most three buttons. - Static URL buttons (no placeholder) and `quick_reply` buttons are not included here.
    - `{key}` (string)
  - `templateHeaderVariables` (object): Value for a text-header variable, keyed by `1` (WhatsApp text headers allow at most one variable). Optional override. If omitted, Zavu resolves the header from `templateVariables` using the header placeholder's name (e.g. `novios`). Static text headers need no value. The same WhatsApp rule as `templateVariables` applies: no line breaks, tabs or more than 4 consecutive spaces.
    - `{key}` (string)
  - `templateHeaderMediaUrl` (string<uri>): HTTPS URL of the media for this send, for a WhatsApp template whose header is `image`, `video` or `document`. It replaces the file stored on the template for this message only; the template is not changed. WhatsApp fetches the URL when the message is sent, so it must be publicly reachable at that moment (a short-lived signed link works if it outlives the send). Without it, the template's stored header file is sent. A template whose header is `text`, or that has no header, is refused with `400 invalid_request` and `details.field` set to `content.templateHeaderMediaUrl`, before anything is sent or charged. A URL that is not HTTPS is refused with `400 invalid_request` and a `details.errors` entry whose `path` is `["content", "templateHeaderMediaUrl"]`. Ignored on channels other than WhatsApp.
  - `templateHeaderFilename` (string): File name WhatsApp shows the recipient for a `document` header, e.g. `invoice-1042.pdf`. Works with `templateHeaderMediaUrl`, or on its own to name the template's stored file. Only valid when the template's header is `document`: any other header is refused with `400 invalid_request` and `details.field` set to `content.templateHeaderFilename`, before anything is sent or charged. Ignored on channels other than WhatsApp.
  - `referral` (object): Click-to-WhatsApp (CTWA) ad attribution: where an inbound conversation came from. WhatsApp only. Present on the **first inbound message** of a conversation opened from a Meta ad or post, and on no message after it — so store it when it arrives rather than expecting it again. Organic conversations never carry it. Field names are camelCased to match the rest of this API; Meta sends them as snake_case (`ctwa_clid`, `source_id`, ...). Fields that do not apply are omitted: a `post` source has no click id, and an image ad has no `videoUrl`.
    - `sourceType` (string): Where the click came from. One of: `ad`, `post`.
    - `sourceId` (string): Identifier of the ad or post that produced the click.
    - `sourceUrl` (string<uri>): Meta permalink to the ad or post.
    - `headline` (string): Headline of the ad or post.
    - `body` (string): Body copy of the ad or post.
    - `mediaType` (string): Type of media on the ad, when it had any. One of: `image`, `video`.
    - `imageUrl` (string<uri>): Image of the ad. Present when `mediaType` is `image`.
    - `videoUrl` (string<uri>): Video of the ad. Present when `mediaType` is `video`.
    - `thumbnailUrl` (string<uri>): Thumbnail of the ad media.
    - `ctwaClid` (string): Click-to-WhatsApp click identifier. This is the value Meta's Conversions API needs to credit a conversion back to the ad that produced the conversation. Present on `ad` sources; a `post` source has none.
- `subject` (string): Email subject line. Required when channel is 'email' or recipient is an email address.
- `htmlBody` (string): HTML body for email messages. If provided, email will be sent as multipart with both text and HTML.
- `replyTo` (string<email>): Reply-To email address for email messages.
- `idempotencyKey` (string): Optional idempotency key to avoid duplicate sends.
- `metadata` (object): Arbitrary metadata to associate with the message.
  - `{key}` (string)
- `fallbackEnabled` (boolean): Whether a failed WhatsApp text or template message is retried over SMS, when the sender also has SMS. When omitted, it is on if `channel` is omitted or `auto`, and off if a channel is set explicitly: a send with `channel: "whatsapp"` does not fall back unless this is `true`. Media, buttons, lists and other WhatsApp-only types never fall back.
- `voiceLanguage` (string): Language code for voice text-to-speech (e.g., 'en-US', 'es-ES', 'pt-BR'). If omitted, language is auto-detected from recipient's country code.
- `attachments` (object[]): Email attachments. Only supported when channel is 'email'. Maximum 40MB total size.
  - `filename` (string, required): Name of the attached file.
  - `content` (string): Content of the attached file as a Base64-encoded string.
  - `path` (string<uri>): URL where the attachment file is hosted. The server will fetch the file.
  - `content_type` (string): MIME type of the attachment. If not set, will be derived from the filename.
  - `content_id` (string): Content ID for inline images. Reference in HTML as `<img src="cid:your_content_id">`.

## Responses

### 202

Message accepted for delivery.

- `message` (object, required)
  - `id` (string, required)
  - `to` (string, required)
  - `from` (string)
  - `senderId` (string)
  - `channel` (string, required): Delivery channel. Use 'auto' for intelligent routing. One of: `auto`, `sms`, `sms_oneway`, `whatsapp`, `telegram`, `email`, `instagram`, `messenger`, `voice`.
  - `direction` (string, required): Who sent the message. Needed to render a thread: `status` cannot tell the two apart, because an inbound message is also stored as `delivered`. One of: `inbound`, `outbound`.
  - `messageType` (string, required): Type of message. Non-text types are supported by WhatsApp and Telegram (varies by type). `location_request` asks the recipient to share their location and is WhatsApp-only. It takes no `content` object — the prompt goes in `text` (max 1024 characters) and the button label is fixed by WhatsApp. The recipient's answer arrives as an inbound `location` message whose `content.replyToMessageId` is the ID of the request. `request_contact_info` asks the recipient to share their phone number and is WhatsApp-only. Like `location_request` it takes no `content` object — the prompt goes in `text` (max 1024 characters) and WhatsApp renders a fixed **Share Contact Info** button. The answer arrives as an inbound `contact` message. Use it to recover the phone number of a contact who adopted a WhatsApp username and is only known by their business-scoped user ID (BSUID); when they share it, Zavu automatically links the phone number to that contact. One of: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `buttons`, `list`, `cta_url`, `request_contact_info`, `location_request`, `reaction`, `template`.
  - `status` (string, required) One of: `queued`, `sending`, `sent`, `delivered`, `read`, `failed`, `received`, `pending_url_verification`.
  - `text` (string): Text content or caption.
  - `content` (object): Content for non-text message types (WhatsApp and Telegram).
    - `mediaUrl` (string): URL of the media file (for image, video, audio, document, sticker).
    - `mediaId` (string): WhatsApp media ID if already uploaded.
    - `mimeType` (string): MIME type of the media.
    - `filename` (string): Filename for documents.
    - `latitude` (number): Latitude for location messages.
    - `longitude` (number): Longitude for location messages.
    - `locationName` (string): Name of the location.
    - `locationAddress` (string): Address of the location.
    - `contacts` (object[]): Contact cards for contact messages.
      - `name` (string)
      - `phones` (string[])
    - `buttons` (object[]): Interactive buttons (max 3).
      - `id` (string, required)
      - `title` (string, required)
    - `listButton` (string): Button text for list messages.
    - `sections` (object[]): Sections for list messages.
      - `title` (string, required)
      - `rows` (object[], required)
        - `id` (string, required)
        - `title` (string, required)
        - `description` (string)
    - `ctaDisplayText` (string): Button label for cta_url messages.
    - `ctaUrl` (string<uri>): Destination URL opened in the device's default browser when the button is tapped. Used with messageType=cta_url. WhatsApp requires HTTPS in production.
    - `ctaHeaderType` (string): Optional header type for cta_url messages. One of: `text`, `image`, `video`, `document`.
    - `ctaHeaderText` (string): Header text when ctaHeaderType is 'text'.
    - `ctaHeaderMediaUrl` (string<uri>): Public HTTPS URL of the header media when ctaHeaderType is 'image', 'video', or 'document'. WhatsApp fetches this URL — it must be publicly reachable and return the declared content type.
    - `footerText` (string): Optional footer text for cta_url messages.
    - `emoji` (string): Emoji for reaction messages.
    - `reactToMessageId` (string): Message ID to react to.
    - `replyToMessageId` (string): Zavu message ID of the quoted message this message replies to. **When sending:** set it to make the message a quoted reply to an earlier message in the same conversation, such as the `data.messageId` of a `message.inbound` webhook. Supported on WhatsApp (every message type except `reaction`) and Telegram. The quoted message must belong to this project (otherwise `404`), must have gone out on the same channel as this send, must belong to the conversation `to` addresses, must have been sent or received by the sender this message goes out from (pass it in `Zavu-Sender`), and must have a `providerMessageId`. A failed message and a Telegram button tap cannot be quoted. Any other case returns `400` instead of sending the message unquoted. The stored message then carries the rest of the `replyTo*` fields. **When receiving:** present on inbound messages that quote an earlier message. Omitted when the quoted message is not found in Zavu (e.g. an old or unknown message) — use replyToProviderMessageId in that case.
    - `replyToProviderMessageId` (string): Provider message ID (WhatsApp WAMID, or Telegram message ID) of the quoted message. Present whenever an inbound WhatsApp message is a reply, even if the quoted message is not stored in Zavu, and on outbound quoted replies. Read-only: to quote when sending, set replyToMessageId.
    - `replyToFrom` (string): Sender of the quoted message (phone number in E.164 format, or the Telegram chat ID). Read-only.
    - `replyToText` (string): Truncated snippet (up to 200 characters) of the quoted message's text, for display. Empty when the quoted message has no text (e.g. media). Read-only.
    - `replyToMessageType` (string): Type of the quoted message (text, image, video, etc.). Read-only.
    - `templateId` (string): Template ID for template messages.
    - `templateVariables` (object): Variables for body placeholders. Key them to match the template body: by position (`1`, `2`, ...) for positional templates, or by name (e.g. `customer_name`) for named templates. Zavu detects the template's format and sends the correct payload to Meta. Named keys also resolve a named text-header variable. Do not mix positional and named keys in the same request. On WhatsApp, a value may not contain line breaks, tabs or more than 4 consecutive spaces: Meta refuses those at delivery, so the send is rejected with `400 invalid_request` instead (`details.field` names the value).
      - `{key}` (string)
    - `templateButtonVariables` (object): Variables for dynamic button placeholders (URL buttons and OTP buttons). Keys are the button index (0, 1, 2) in the template's `buttons` array — not the placeholder name. Values substitute the `{{1}}` placeholder inside that button's URL. **WhatsApp constraints:** - URL buttons only accept `{{1}}` — positional, numeric, no whitespace, no name. Named placeholders like `{{token}}` are stored as literal URL text by Meta and cannot be substituted. - At most one placeholder per URL button. - A template may have at most three buttons. - Static URL buttons (no placeholder) and `quick_reply` buttons are not included here.
      - `{key}` (string)
    - `templateHeaderVariables` (object): Value for a text-header variable, keyed by `1` (WhatsApp text headers allow at most one variable). Optional override. If omitted, Zavu resolves the header from `templateVariables` using the header placeholder's name (e.g. `novios`). Static text headers need no value. The same WhatsApp rule as `templateVariables` applies: no line breaks, tabs or more than 4 consecutive spaces.
      - `{key}` (string)
    - `templateHeaderMediaUrl` (string<uri>): HTTPS URL of the media for this send, for a WhatsApp template whose header is `image`, `video` or `document`. It replaces the file stored on the template for this message only; the template is not changed. WhatsApp fetches the URL when the message is sent, so it must be publicly reachable at that moment (a short-lived signed link works if it outlives the send). Without it, the template's stored header file is sent. A template whose header is `text`, or that has no header, is refused with `400 invalid_request` and `details.field` set to `content.templateHeaderMediaUrl`, before anything is sent or charged. A URL that is not HTTPS is refused with `400 invalid_request` and a `details.errors` entry whose `path` is `["content", "templateHeaderMediaUrl"]`. Ignored on channels other than WhatsApp.
    - `templateHeaderFilename` (string): File name WhatsApp shows the recipient for a `document` header, e.g. `invoice-1042.pdf`. Works with `templateHeaderMediaUrl`, or on its own to name the template's stored file. Only valid when the template's header is `document`: any other header is refused with `400 invalid_request` and `details.field` set to `content.templateHeaderFilename`, before anything is sent or charged. Ignored on channels other than WhatsApp.
    - `referral` (object): Click-to-WhatsApp (CTWA) ad attribution: where an inbound conversation came from. WhatsApp only. Present on the **first inbound message** of a conversation opened from a Meta ad or post, and on no message after it — so store it when it arrives rather than expecting it again. Organic conversations never carry it. Field names are camelCased to match the rest of this API; Meta sends them as snake_case (`ctwa_clid`, `source_id`, ...). Fields that do not apply are omitted: a `post` source has no click id, and an image ad has no `videoUrl`.
      - `sourceType` (string): Where the click came from. One of: `ad`, `post`.
      - `sourceId` (string): Identifier of the ad or post that produced the click.
      - `sourceUrl` (string<uri>): Meta permalink to the ad or post.
      - `headline` (string): Headline of the ad or post.
      - `body` (string): Body copy of the ad or post.
      - `mediaType` (string): Type of media on the ad, when it had any. One of: `image`, `video`.
      - `imageUrl` (string<uri>): Image of the ad. Present when `mediaType` is `image`.
      - `videoUrl` (string<uri>): Video of the ad. Present when `mediaType` is `video`.
      - `thumbnailUrl` (string<uri>): Thumbnail of the ad media.
      - `ctwaClid` (string): Click-to-WhatsApp click identifier. This is the value Meta's Conversions API needs to credit a conversion back to the ad that produced the conversation. Present on `ad` sources; a `post` source has none.
  - `conversationId` (string): ID of the conversation (inbox thread) this message belongs to. Use it to build a direct dashboard link: `https://dashboard.zavu.dev/{locale}/inbox?conv={conversationId}`. Omitted only on legacy messages created before conversation threading.
  - `providerMessageId` (string): Message ID from the delivery provider.
  - `errorCode` (string, nullable)
  - `errorMessage` (string, nullable)
  - `cost` (number, nullable): Zavu platform charge in USD for this message. Messaging is billed against your plan's monthly limits plus usage-based overage.
  - `costProvider` (number, nullable): Carrier and delivery cost in USD.
  - `costTotal` (number, nullable): Total cost in USD (platform charge + delivery cost).
  - `metadata` (object)
    - `{key}` (string)
  - `createdAt` (string<date-time>, required)
  - `updatedAt` (string<date-time>)

### 400

Invalid request, WhatsApp 24h window not open, a WhatsApp template parameter Meta would refuse (line break, tab or more than 4 consecutive spaces in `content.templateVariables` or `content.templateHeaderVariables`), or a quoted reply (`content.replyToMessageId`) that cannot be delivered as one: wrong channel, a reaction, a button tap, a failed message, a message from another conversation or sender, or one with no provider message ID yet.

- `code` (string, required)
- `message` (string, required)
- `details` (object)

### 401

Unauthorized.

- `code` (string, required)
- `message` (string, required)
- `details` (object)

### 402

Insufficient balance (`insufficient_balance`), or the team's monthly spend cap is reached (`spend_cap_reached`). Email is billed from your prepaid balance in 1,000-message blocks; SMS and voice are billed per message. When the balance cannot cover the send, the message is rejected until you add funds. The spend cap is set by the team in the dashboard's Billing: it limits what usage may take from the balance in a calendar month, never phone number or other renewals. Reached, SMS, voice, email and AI replies answer `402 spend_cap_reached` with `details.capCents`, `details.spentCents` and `details.dashboardUrl` until the 1st or until the cap is raised; a message that fails on it carries `errorCode` `SPEND_CAP_REACHED`. App messages (WhatsApp, Telegram, Instagram, Messenger) past the allowance on pay as you go answer `429 a2p_limit_exceeded` instead, with `details.payAsYouGo.reason` `spend_cap_reached`.

- `code` (string, required)
- `message` (string, required)
- `details` (object)

### 403

Message blocked. `sending_suspended`: this account's sending is suspended after Zavu's automated review refused several of its messages, and `details.dashboardUrl` points at support. `url_not_verified` / `url_shortener_blocked`: the message contains URLs that are not allowed.

- `code` (string, required)
- `message` (string, required)
- `details` (object)

### 404

Template, sender, or quoted message (`content.replyToMessageId`) not found.

- `code` (string, required)
- `message` (string, required)
- `details` (object)

### 409

Two cases, told apart by the body.

- **`channel_not_connected`** (an `Error`): the sender has no account for the channel asked for, or none at all for `auto`. `details.channel` names it. Connect it, then activate it with `POST /v1/senders/{senderId}/channels/{channel}/activate`. Same code and status as activation uses.
- **An idempotent replay** (a `MessageResponse`): the `idempotencyKey` was already used in this project. Nothing is sent again: the body replays the message the first request created, in its current state, so a retry can tell the first attempt landed and read its `id` and `status`. The replay is answered before any rate, daily or balance limit, since the original already consumed them.

### 422

`sms_compliance_blocked`: an SMS or one-way SMS was refused by the content check, or the check could not run (then the message says so, nothing was charged, and a retry in a few minutes goes through). `details` carries `riskScore`, `messageType`, `complianceFailures`, `requiredFixes` and, when there is one, `suggestedCompliantVersion`. One-time codes (OTP) always pass.

- `code` (string, required)
- `message` (string, required)
- `details` (object)

### 429

A sending limit was reached. `daily_limit_exceeded` is the channel group's daily ceiling described above: `details.limit` is the ceiling that applied, and the count resets at 00:00 UTC. Verifying your identity or adding a payment method raises it; for email, upgrading the plan does. Two other limits also answer 429 on this endpoint, both wrapped in an `error` object rather than at the top level: `a2p_limit_exceeded`, the Free plan's monthly allowance on WhatsApp, Telegram, Instagram and Messenger (for a team that has added funds, the balance or its spend cap cannot cover the next $4 block of 1,000; `details.payAsYouGo` then carries `blockSize`, `blockPriceCents`, `dashboardUrl` and `reason`, `insufficient_balance` or `spend_cap_reached`). When incoming messages are on hold, `details.heldInbound` carries `count`, `max` (1,000) and `nextExpiresAt` (ISO 8601, when the oldest one expires), with no content and no sender. An integration that only replies to `message.inbound` never sends while messages are held, so it will not see this: it reads the same figures from `GET /v1/balance` (`heldInbound`, `appMessages.paused`), and the team is emailed the first time messages are held in a month, and `rate_limit_exceeded`, one SMS per recipient per minute.

- `code` (string)
- `message` (string)
- `details` (object)
- `error` (object)
  - `code` (string, required)
  - `message` (string, required)
  - `details` (object)

### 500

Server error.

- `code` (string, required)
- `message` (string, required)
- `details` (object)

## Example

```bash
curl -X POST "https://api.zavu.dev/v1/messages" \
  -H "Authorization: Bearer $ZAVUDEV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "to": "+56912345678",
  "text": "Your verification code is 123456"
}'
```

```typescript
import Zavudev from "@zavudev/sdk";

const zavu = new Zavudev({ apiKey: process.env.ZAVUDEV_API_KEY });

const result = await zavu.messages.send({
  to: "+56912345678",
  text: "Your verification code is 123456",
});

console.log(result);
```
