# Send a message

> Send one message. The body is the /v1 message body plus from and conversationId. There is no Zavu-Sender header.

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

Send one message. The body is the /v1 message body plus `from` and `conversationId`. There is no `Zavu-Sender` header.

**Where it leaves from:** `conversationId` if given, else `from` (an address), else the project default. See `from` for ambiguous addresses.

**Which transport:** decided by the first branch that applies, and reported in `routing.reason`: a test-mode key uses the WhatsApp sandbox; an email-address `to` uses email; a WhatsApp-only `messageType` uses WhatsApp; an explicit `channel` is used as given; otherwise the fixed order over the sender's ACTIVE transports (for a phone number: WhatsApp, then SMS, then one-way SMS). The order ignores price and the 24-hour window. A `channel` that contradicts the recipient (an email `to` on anything but `email`, or `channel: "email"` with a phone number or chat id) or a WhatsApp-only `messageType` is refused with `400`; /v1 overrides the first and last silently.

**When nothing can send** the answer is always `409 no_channel`, whatever the reason: no channel at all, an explicit `channel` that is not connected, an email `to` without an email channel, a WhatsApp-only `messageType` without WhatsApp. `details.connect` lists what this project can connect to reach `to`, with the exact call for each (`POST /v2/channels`, `POST /v2/numbers`), and the message never names an address that carries nothing.

**Fallback** from WhatsApp to SMS, when enabled, happens after this call returned, once the WhatsApp send fails. It is not visible in `routing`; read `attemptedChannels` on the message or wait for the webhooks. Before falling back to SMS, a message sent through /v2 is checked for an approved 10DLC campaign (+1 numbers) and an active connection; when either is missing it stays `failed` with `errorCode` `TEN_DLC_REQUIRED` or `CHANNEL_INACTIVE`.

Limits, risk review, pricing and verification are exactly /v1's: see `POST /v1/messages`.

## Body

- `to` (string): 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 phone number written with spaces, dashes, dots or parentheses after its `+` (`+56 9 1234 5678`) is read as the E.164 number; bare digits are not (see `400` on this operation). 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. Required unless `conversationId` is given. With `conversationId` it may be omitted (it is the conversation's contact: its email for `channel: "email"`, else the identifier it is filed under); if given it must be one of that conversation's identifiers (its key, email or BSUID, in any formatting), else `400` naming `to` and `conversationId`.
- `channel` (string): `auto`, or a transport. `auto` applies the fixed order described on `POST /v2/messages`. 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 address this message goes out from (`from`), 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.
    - `{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.
    - `{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`: `details.field` is `content.templateHeaderMediaUrl` and `details.errors` repeats the message, 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`, `details.field` set to `content.templateHeaderFilename` and `details.errors` repeating the message, 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 to enable automatic fallback to SMS if WhatsApp fails. Defaults to true.
- `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">`.
- `from` (string): Which of your addresses sends (see `GET /v2/channels`): an E.164 number in any common formatting, an email address, `@botname`, `@username`, a Page id. Resolved inside your project only. Omitted: the project default, the same one /v1 uses when no `Zavu-Sender` header is sent. Ignored when `conversationId` is set, and with a test-mode key, which always sends from the WhatsApp sandbox. An address that no entry has returns `404 not_found`. An address two of your senders share (a number whose WhatsApp was connected on one sender and SMS on another) resolves when `channel` names the transport; otherwise it returns `409 ambiguous_from` with `details.candidates[{address, type, v1SenderId}]`, and in that case, and only then, `from` also accepts one of those `v1SenderId` values.
- `conversationId` (string): Reply inside an existing conversation: the message leaves from the sender that last handled it (`from` on the Conversation), and goes to whoever the conversation is with, so `to` may be omitted. A `to` that is not the conversation's contact is refused with `400` (`details.errors` on `to` and `conversationId`, `details.conversationTo`) instead of silently opening another conversation. Takes precedence over `from`. When `channel` is omitted, `routing.reason` is then `conversation`. A conversation of another project returns `404`; one with no sender recorded returns `409 no_channel`. The 202's `message.conversationId` is set whenever the conversation the message is filed in already exists; it is absent only for a recipient's first message, whose conversation is created after the response. A reply to a conversation that an inbound email opened reaches the right person but is filed in a separate conversation keyed by the email address.

## Responses

### 202

Accepted. `message.status` is `queued`.

- `message` (object, required)
  - `id` (string, required)
  - `to` (string, required)
  - `from` (string): Where the message came from: for an outbound message, your address (as `GET /v2/channels` lists it for `channel`, except WhatsApp, where it is the sender's own number field); for an inbound one, the contact.
  - `channel` (string, required): A transport a message travels on. One of: `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. Every type except `text` and `template` is sent on WhatsApp only: the send routes them there whatever `channel` says, and in v2 an explicit `channel` other than `whatsapp` is refused with `400` naming `messageType` and `channel`. `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 address this message goes out from (`from`), 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.
      - `{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.
      - `{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`: `details.field` is `content.templateHeaderMediaUrl` and `details.errors` repeats the message, 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`, `details.field` set to `content.templateHeaderFilename` and `details.errors` repeating the message, 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): Why a failed message failed, as a Zavu code: `RISK_REJECTED`, `DAILY_LIMIT_EXCEEDED`, `TEN_DLC_REQUIRED`, `SMS_NOT_DELIVERED`, `WHATSAPP_WINDOW_CLOSED`... A delivery provider's own code is translated where its meaning is known and kept in `errorDetails.providerCode`; one with no known meaning is `SMS_DELIVERY_FAILED`, `WHATSAPP_REJECTED`, `VOICE_DELIVERY_FAILED`, `EMAIL_DELIVERY_FAILED` or `DELIVERY_FAILED`. (/v1 returns the provider's raw code here.)
  - `errorMessage` (string, nullable)
  - `cost` (number, nullable): Zavu's own per-message platform charge, in USD (6 decimals). Messaging is billed against the plan's allowance, so it is often 0.
  - `costProvider` (number, nullable): What delivering the message cost Zavu (carrier cost), in USD. Filled in when the delivery report brings it, so null until then.
  - `costTotal` (number, nullable): `cost` + `costProvider`. For SMS this is BEFORE Zavu's markup, so it is not what your balance was charged: read `charged` for that. For voice it includes the markup.
  - `metadata` (object)
    - `{key}` (string)
  - `createdAt` (string<date-time>, required)
  - `updatedAt` (string<date-time>)
  - `attemptedChannels` (string[]): Transports tried before the current `channel`, oldest first, when an automatic fallback moved the message (WhatsApp to SMS). Empty when no fallback happened. Fallback runs after the send returned, so this can change after `POST /v2/messages` answered. One of: `sms`, `sms_oneway`, `whatsapp`, `telegram`, `email`, `instagram`, `messenger`, `voice`.
  - `riskHold` (string, nullable): `held` while the message waits for risk review (it stays `queued`), `released` once a reviewer let it go, null when it was never held. One of: `held`, `released`, `null`.
  - `errorDetails` (object, nullable): Present when `errorCode` translates a delivery provider's code.
    - `providerCode` (string): The provider's own code, unchanged.
  - `charged` (number, nullable): What this message has taken from your prepaid balance, in USD. SMS and one-way SMS only: charged per segment when the message is accepted (`routing.estimatedCost`), raised if the delivery report brings a higher real cost, and refunded (0) when the message fails AND the carrier charged nothing. A failed SMS the carrier did charge for (it attempted delivery) stays charged. Null on other channels, which are not billed per message here.
- `routing` (object, required): What the engine decided at send time. Reports only decisions the engine makes; it does not rank channels by price or by WhatsApp window state.
  - `channel` (string, required): A transport a message travels on. One of: `sms`, `sms_oneway`, `whatsapp`, `telegram`, `email`, `instagram`, `messenger`, `voice`.
  - `from` (string, required): The address the message leaves from, as `GET /v2/channels` lists it for `channel`. With a test-mode key, the WhatsApp sandbox number.
  - `reason` (string, required): Why this transport was chosen. Closed list: - `explicit`: `channel` was given. - `auto_order`: `channel` omitted or `auto`; the first ACTIVE transport of the sender in a fixed order (for a phone number: WhatsApp, then SMS, then one-way SMS). A connected transport that is switched off is skipped, not chosen and then refused. It does not compare price and does not check the WhatsApp 24-hour window; a closed window fails later as `message.failed`. - `conversation`: the sender came from `conversationId` (the sender, not the transport; the transport is then chosen as for `auto_order` unless `channel` is given). - `recipient_type`: `to` is an email address, so email. - `message_type`: `messageType` is WhatsApp-only (media, interactive, template...), so WhatsApp. - `sandbox`: test-mode key, which always uses the WhatsApp sandbox. An automatic fallback is never a send-time reason: it happens later, see `Message.attemptedChannels`. One of: `explicit`, `auto_order`, `conversation`, `recipient_type`, `message_type`, `sandbox`.
  - `estimatedCost` (number, required, nullable): USD this call took from the balance when it was accepted (rounded to 6 decimals). An estimate: for SMS the delivery report can raise it to the real cost, and `message.charged` shows where it ended up. Present for `sms` and `sms_oneway`. Null otherwise — WhatsApp is billed by Meta, email in 1,000-message blocks, voice per minute — and also null while a message waits for URL verification, which is charged when it is released. Null means "not known at send time", not "free".

### 400

Invalid request, always with `details.errors[{path, message}]` naming the field. Checked in this order: a `channel` that contradicts the recipient (an email `to` on anything but `email`; `channel: "email"` with a `to` that is not an email address, `details.errors` on `channel` and `to`) comes before any missing field; then the body (`subject` for email, `text` or `content` by `messageType`, a bad `to`); then a `channel` that contradicts a WhatsApp-only `messageType`. A `to` of 8-15 bare digits is a chat id: when nothing in the request can deliver to a chat id (the channel is a phone channel, or `auto` from an address with no Telegram/Instagram/Messenger) it is refused with `details.suggestion` holding it with a `+`. With `conversationId`, a `to` that is not the conversation's contact (`details.errors` on `to` and `conversationId`). A WhatsApp-only `messageType` on another explicit `channel` names both `messageType` and `channel`.

- `code` (string, required)
- `message` (string, required)
- `details` (object): Machine-readable context. Keys that mean the same thing everywhere: - `errors`: present on every `400 invalid_request` for an invalid body or query parameter, `[{path, message, ...}]`, `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`). - `recommendation`: one sentence saying what to do next. - `dashboardUrl`: the dashboard screen that fixes it. - `connect`: on `409 no_channel`, the channels this project can connect to reach the recipient, each `{type, request, note, available, dashboardUrl?}`; `request` is the exact call (`POST /v2/channels` with its body, or `POST /v2/numbers`), or null for a channel connected from the dashboard (WhatsApp, Instagram, Messenger). `available: false` says why in `note` (one-way SMS closed until business verification is approved). - `address` / `from`: the address the error is about, under the name the request used for it.
  - `errors` (object[])
    - `path` (object[], required)
    - `message` (string, required)
    - `code` (string)

### 401

Unauthorized.

- `code` (string, required)
- `message` (string, required)
- `details` (object): Machine-readable context. Keys that mean the same thing everywhere: - `errors`: present on every `400 invalid_request` for an invalid body or query parameter, `[{path, message, ...}]`, `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`). - `recommendation`: one sentence saying what to do next. - `dashboardUrl`: the dashboard screen that fixes it. - `connect`: on `409 no_channel`, the channels this project can connect to reach the recipient, each `{type, request, note, available, dashboardUrl?}`; `request` is the exact call (`POST /v2/channels` with its body, or `POST /v2/numbers`), or null for a channel connected from the dashboard (WhatsApp, Instagram, Messenger). `available: false` says why in `note` (one-way SMS closed until business verification is approved). - `address` / `from`: the address the error is about, under the name the request used for it.
  - `errors` (object[])
    - `path` (object[], required)
    - `message` (string, required)
    - `code` (string)

### 402

`insufficient_balance`: the balance cannot cover the SMS or email.

- `code` (string, required)
- `message` (string, required)
- `details` (object): Machine-readable context. Keys that mean the same thing everywhere: - `errors`: present on every `400 invalid_request` for an invalid body or query parameter, `[{path, message, ...}]`, `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`). - `recommendation`: one sentence saying what to do next. - `dashboardUrl`: the dashboard screen that fixes it. - `connect`: on `409 no_channel`, the channels this project can connect to reach the recipient, each `{type, request, note, available, dashboardUrl?}`; `request` is the exact call (`POST /v2/channels` with its body, or `POST /v2/numbers`), or null for a channel connected from the dashboard (WhatsApp, Instagram, Messenger). `available: false` says why in `note` (one-way SMS closed until business verification is approved). - `address` / `from`: the address the error is about, under the name the request used for it.
  - `errors` (object[])
    - `path` (object[], required)
    - `message` (string, required)
    - `code` (string)

### 403

Refused before sending. `ten_dlc_required` (+1 SMS or one-way SMS without an approved 10DLC campaign in this project: `details.connect` lists the registration as calls, in order, on /v1's 10DLC endpoints — for `sms`: brand, submit brand, campaign, submit campaign, assign the number you send from; for `sms_oneway`: the same four, then buy a US number (`POST /v2/numbers`) and assign it, because one-way SMS leaves from shared numbers that cannot be assigned to your campaign. The first four are what lift the refusal for one-way SMS; the messages still leave from numbers outside the campaign, which `details.oneway` and `message` say plainly; `details.alternatives` lists WhatsApp, Telegram and email, which need no 10DLC, each with `connected` for the sending address and how to connect it; `details.dashboardUrl` is `/10dlc/campaigns`), `sending_suspended`, `kyb_required` (one-way SMS without an approved business verification, including `channel: "sms_oneway"` when it is not connected; `details.dashboardUrl` is `/kyb`), `destination_not_verified`, `channel_inactive` (an explicitly chosen transport is connected but switched off; `details.channel`), `recipient_opted_out`, `url_not_verified`, `url_shortener_blocked`.

- `code` (string, required)
- `message` (string, required)
- `details` (object): Machine-readable context. Keys that mean the same thing everywhere: - `errors`: present on every `400 invalid_request` for an invalid body or query parameter, `[{path, message, ...}]`, `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`). - `recommendation`: one sentence saying what to do next. - `dashboardUrl`: the dashboard screen that fixes it. - `connect`: on `409 no_channel`, the channels this project can connect to reach the recipient, each `{type, request, note, available, dashboardUrl?}`; `request` is the exact call (`POST /v2/channels` with its body, or `POST /v2/numbers`), or null for a channel connected from the dashboard (WhatsApp, Instagram, Messenger). `available: false` says why in `note` (one-way SMS closed until business verification is approved). - `address` / `from`: the address the error is about, under the name the request used for it.
  - `errors` (object[])
    - `path` (object[], required)
    - `message` (string, required)
    - `code` (string)

### 404

`from` names no address of this project, `conversationId` is not a conversation of this project, `content.templateId` not found, or `content.replyToMessageId` is not a message of this project.

- `code` (string, required)
- `message` (string, required)
- `details` (object): Machine-readable context. Keys that mean the same thing everywhere: - `errors`: present on every `400 invalid_request` for an invalid body or query parameter, `[{path, message, ...}]`, `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`). - `recommendation`: one sentence saying what to do next. - `dashboardUrl`: the dashboard screen that fixes it. - `connect`: on `409 no_channel`, the channels this project can connect to reach the recipient, each `{type, request, note, available, dashboardUrl?}`; `request` is the exact call (`POST /v2/channels` with its body, or `POST /v2/numbers`), or null for a channel connected from the dashboard (WhatsApp, Instagram, Messenger). `available: false` says why in `note` (one-way SMS closed until business verification is approved). - `address` / `from`: the address the error is about, under the name the request used for it.
  - `errors` (object[])
    - `path` (object[], required)
    - `message` (string, required)
    - `code` (string)

### 409

`no_channel`: nothing this call could use can reach `to`. The project has no channel and no `from` was given; the conversation has no sender; the sender has no ACTIVE transport that can reach `to`; an explicit `channel` is not connected (`details.channel`); an email `to` has no email channel (a phone `to` with `channel: "email"` is a `400`, not this); a WhatsApp-only `messageType` has no WhatsApp. `details.recommendation` says what to do, `details.connect` lists what can be connected with the call for each (never a type the address already has; for an explicit `channel`, that channel; `details.connected` lists what it has, and `sms` vs `sms_oneway` is explained when one was asked for and the other is there), and `details.inactive` lists a transport that is connected but off (activate it instead). `ambiguous_from`: two of your senders share the `from` address; `details.candidates[{address, type, v1SenderId}]`. `conflict`: a repeated `idempotencyKey`. Nothing is sent again; the message the key first created is in `details.message`, so a retry after a timeout can read the outcome from the 409 without a second call.

- `code` (string, required)
- `message` (string, required)
- `details` (object): Machine-readable context. Keys that mean the same thing everywhere: - `errors`: present on every `400 invalid_request` for an invalid body or query parameter, `[{path, message, ...}]`, `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`). - `recommendation`: one sentence saying what to do next. - `dashboardUrl`: the dashboard screen that fixes it. - `connect`: on `409 no_channel`, the channels this project can connect to reach the recipient, each `{type, request, note, available, dashboardUrl?}`; `request` is the exact call (`POST /v2/channels` with its body, or `POST /v2/numbers`), or null for a channel connected from the dashboard (WhatsApp, Instagram, Messenger). `available: false` says why in `note` (one-way SMS closed until business verification is approved). - `address` / `from`: the address the error is about, under the name the request used for it.
  - `errors` (object[])
    - `path` (object[], required)
    - `message` (string, required)
    - `code` (string)

### 422

`sms_compliance_blocked`: an 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): Machine-readable context. Keys that mean the same thing everywhere: - `errors`: present on every `400 invalid_request` for an invalid body or query parameter, `[{path, message, ...}]`, `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`). - `recommendation`: one sentence saying what to do next. - `dashboardUrl`: the dashboard screen that fixes it. - `connect`: on `409 no_channel`, the channels this project can connect to reach the recipient, each `{type, request, note, available, dashboardUrl?}`; `request` is the exact call (`POST /v2/channels` with its body, or `POST /v2/numbers`), or null for a channel connected from the dashboard (WhatsApp, Instagram, Messenger). `available: false` says why in `note` (one-way SMS closed until business verification is approved). - `address` / `from`: the address the error is about, under the name the request used for it.
  - `errors` (object[])
    - `path` (object[], required)
    - `message` (string, required)
    - `code` (string)

### 429

`daily_limit_exceeded` (`details.limit`), `a2p_limit_exceeded` (Free-plan monthly allowance on WhatsApp/Telegram/Instagram/Messenger; a Free team that added funds keeps sending at $4 per 1,000 and gets it only when the balance cannot cover the next block; while incoming messages are on hold, `details.heldInbound` carries `count`, `max` and `nextExpiresAt`), `rate_limit_exceeded` (one SMS per recipient per minute), `email_limit_exceeded`.

- `code` (string, required)
- `message` (string, required)
- `details` (object): Machine-readable context. Keys that mean the same thing everywhere: - `errors`: present on every `400 invalid_request` for an invalid body or query parameter, `[{path, message, ...}]`, `path` naming the field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`). - `recommendation`: one sentence saying what to do next. - `dashboardUrl`: the dashboard screen that fixes it. - `connect`: on `409 no_channel`, the channels this project can connect to reach the recipient, each `{type, request, note, available, dashboardUrl?}`; `request` is the exact call (`POST /v2/channels` with its body, or `POST /v2/numbers`), or null for a channel connected from the dashboard (WhatsApp, Instagram, Messenger). `available: false` says why in `note` (one-way SMS closed until business verification is approved). - `address` / `from`: the address the error is about, under the name the request used for it.
  - `errors` (object[])
    - `path` (object[], required)
    - `message` (string, required)
    - `code` (string)

## Example

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

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

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

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

console.log(result);
```
