> For the complete documentation index, see [llms.txt](https://api.bind.hr/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://api.bind.hr/channels/channels-api/send-a-message.md).

# Send a message

The `channels` list holds the channels to send on, in order. Each entry is self-contained: the channel, the sender profile it goes out from, the recipient and the content.

One entry sends on one channel and that is the whole request. Several entries are tried in order - the message goes out on the first, and moves to the next only if that one does not deliver within its `failover_after`. There is no failover flag; the number of entries is what decides it.

The request is all-or-nothing: if any entry is invalid or cannot be sent, the whole request is rejected and nothing goes out.

```json
{"openapi":"3.0.3","info":{"title":"Channels API","version":"1.0"},"servers":[{"url":"https://{host}","description":"Base URL for the API. Everything below is relative to it. `host` defaults to the production endpoint; we tell you if your account uses a different one.\n","variables":{"host":{"default":"channels.bind.hr"}}}],"security":[{"BearerAuth":[]}],"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"Your API key as `Authorization: Bearer <key>`. The `Bearer` prefix is case-sensitive.\n\nYour key can additionally be restricted to the addresses you send from. Give us the list as individual addresses or CIDR blocks - `203.0.113.4`, `198.51.100.0/24`, `2001:db8::/32` - and a request from anywhere else is rejected with `IP_NOT_ALLOWED`. IPv4 and IPv6 are listed separately: an IPv4 range does not cover you if you reach us over IPv6, so if you use both, send us both. Leave the restriction empty and we accept your key from any address.\n"}},"schemas":{"MessageRequest":{"type":"object","required":["channels"],"properties":{"channels":{"type":"array","minItems":1,"description":"The channels to send on, in order. At least one. Each entry carries everything that channel needs.\n","items":{"$ref":"#/components/schemas/ChannelEntry"}}}},"ChannelEntry":{"type":"object","required":["channel","profile_id","to","content"],"description":"One channel to send on. Everything the send needs is on the entry itself - a missing field is a `400` naming it.\n","properties":{"channel":{"type":"string","description":"Channel name, e.g. `viber` or `sms`."},"profile_id":{"type":"string","format":"uuid","description":"The sender profile this channel sends from, by the UUID we issued for it. We fill in the sender from it, and it is what the message is attributed to in your reporting. A profile belongs to one channel, so each entry has its own. A UUID that matches no active profile of yours is rejected with `PROFILE_NOT_FOUND`.\n"},"to":{"type":"string","description":"Recipient phone number in international format."},"content":{"$ref":"#/components/schemas/Content"},"failover_after":{"type":"integer","minimum":1,"description":"How many seconds to wait for delivery on this channel before moving to the next entry. Only meaningful when there is a next entry.\n"}}},"Content":{"description":"For `sms`, the content is a plain text string. For rich channels like `viber` it is an object. The two are interchangeable for plain text: a string sent to Viber goes out as a text message, and `{\"type\": \"text\", \"text\": \"...\"}` sent to SMS goes out as that text.\n","oneOf":[{"type":"string","description":"Plain text."},{"$ref":"#/components/schemas/ViberContent"}]},"ViberContent":{"type":"object","description":"Viber message content. Not every combination of fields is a valid Viber message, so the ones that work are listed here in full. Anything else is rejected with `UNSUPPORTED_CONTENT`. There is a ready-made example of each under **Request samples** on both send endpoints.\n\n**These work:**\n\n| Content | With text | With a button | |---|---|---| | `text` | - | yes | | `media_url` (image) | yes | yes | | `video` | yes | yes | | `file` | no | no | | `carousel` | yes | no | | `list` | yes | no |\n\n**These do not:** an image and a video (or a file) together; a carousel and a list together; a file with anything else at all; a button on its own, with no text, image or video to attach it to; a button with no `caption`; a completely empty content object. `ttl` may accompany any of the working combinations.\n\nOnly fields you actually fill in are sent on; empty strings, empty objects and empty lists count as not set. Keys are snake_case, and the camelCase spelling noted per field also works.\n","properties":{"text":{"type":"string"},"media_url":{"type":"string","format":"uri","description":"Image URL. Also accepted as `mediaUrl`."},"video":{"type":"object","description":"A video. This is an object, not a plain URL string.","properties":{"media_url":{"type":"string","format":"uri","description":"Video URL. Also accepted as `mediaUrl`."},"thumbnail":{"type":"string","format":"uri"}}},"file":{"type":"object","description":"A file. This is an object, not a plain URL string. A file must be sent on its own - no text, button, image or video alongside it.\n","properties":{"media_url":{"type":"string","format":"uri","description":"File URL. Also accepted as `mediaUrl`."},"file_name":{"type":"string","description":"Also accepted as `fileName`."}}},"button":{"type":"object","required":["caption"],"description":"A button needs text, an image or a video alongside it, and it must have a caption.\n","properties":{"caption":{"type":"string","description":"The button label. Required."},"action":{"type":"string","description":"What the button opens - an `https://` or `viber://` link. Note the key is `action`; a `url` key is not supported and is ignored.\n"}}},"carousel":{"type":"object","description":"A carousel of cards. Send it on its own or with text - not with an image, video, file, button or list.\n","properties":{"items":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"image_url":{"type":"string","format":"uri","description":"Also accepted as `imageUrl`."},"primary_button":{"$ref":"#/components/schemas/CarouselButton"},"secondary_button":{"$ref":"#/components/schemas/CarouselButton"}}}}}},"list":{"type":"object","description":"A survey list. Send it on its own or with text - not with an image, video, file, button or carousel. `survey.options` must have at least one option.\n","properties":{"survey":{"type":"object","properties":{"options":{"type":"array","minItems":1,"items":{"type":"string"}}}}}},"ttl":{"type":"integer","description":"How long the message stays valid, in seconds."},"type":{"type":"string","description":"Accepted and ignored on Viber. It only matters if you send the same object to the SMS channel, where `\"text\"` marks it as convertible to plain text.\n"},"message_type":{"type":"string","description":"Accepted and ignored. Also accepted as `messageType`."}}},"CarouselButton":{"type":"object","description":"A button on a carousel card. The containing keys are also accepted as `primaryButton` / `secondaryButton`.\n","properties":{"label":{"type":"string"},"action_url":{"type":"string","format":"uri","description":"Also accepted as `actionUrl`."}}},"Problem":{"type":"object","description":"Every error, in one shape - `application/problem+json` (RFC 9457). There is not a different body per kind of failure, so you only need one parser.\n","required":["title","status","code"],"properties":{"title":{"type":"string","description":"Short human-readable summary of what went wrong. Meant for your logs, not for branching on - the wording may change.\n"},"status":{"type":"integer","description":"The HTTP status, repeated here so the body stands on its own."},"code":{"type":"string","description":"Stable machine-readable code. **This is the one to branch on** - it never changes for a given condition, while the status and title may be refined.\n","enum":["VALIDATION_FAILED","UNAUTHORIZED","OUT_OF_BALANCE","CLIENT_PRICE_NOT_FOUND","IP_NOT_ALLOWED","DISABLED_CONNECTION","DISABLED_CLIENT","CLIENT_ARCHIVED","DISABLED_ACCOUNT","DISABLED_PRODUCT","DISABLED_CLIENT_PRODUCT","CHANNEL_DISABLED","INVALID_SENDER","DESTINATION_COUNTRY_FORBIDDEN","UNKNOWN_CHANNEL","CHANNEL_NOT_CONFIGURED","CLIENT_PRODUCT_DOES_NOT_EXIST","PROFILE_NOT_FOUND","UNKNOWN_BRAND","NO_DNS_CONFIGURED","NOT_FOUND","METHOD_NOT_ALLOWED","UNSUPPORTED_MEDIA_TYPE","PAYLOAD_TOO_LARGE","UNSUPPORTED_CONTENT","REJECTED","TOO_MANY_REQUESTS","NO_ROUTES","INTERNAL_ERROR"]},"errors":{"type":"array","description":"Only on a `400`: one entry per offending field, so you can point at the exact problem rather than re-reading the whole request.\n","items":{"$ref":"#/components/schemas/ErrorEntry"}}}},"ErrorEntry":{"type":"object","properties":{"property":{"type":"string","description":"The request field that is wrong."},"error":{"type":"string","description":"What is wrong with it."}}},"DeliveryReport":{"type":"object","description":"The delivery report we POST to your account's default webhook.","properties":{"type":{"type":"string","enum":["DLR"]},"message_id":{"type":"string","format":"uuid","description":"The `request_id` you got when the message was accepted."},"to":{"type":"string","description":"Recipient phone number."},"status":{"type":"string","enum":["DELIVERED","SEEN","UNDELIVERABLE"],"description":"`DELIVERED` and `UNDELIVERABLE` are final - the message is done, and every channel it could be tried on has been. `SEEN` is a read receipt and is not final; expect it in addition to, not instead of, a final report.\n"},"error":{"type":"string","description":"Result code - `000` for `DELIVERED` and `SEEN`, `001` for a generic failure, or a more specific code when the channel gives us one.\n"},"cost":{"type":"number","nullable":true,"description":"What this attempt actually charged, in your account currency - `0` if the message was never charged (e.g. an `UNDELIVERABLE` outcome on an account billed per delivery). May differ from the `cost` on the send response, which was only an indicative estimate for the first channel tried.\n"}}}},"responses":{"Accepted":{"description":"Accepted for delivery. This is a `202`, not a `200`, on purpose: we have queued the message, not delivered it. The outcome arrives later in the delivery report.\n","content":{"application/json":{"schema":{"type":"object","properties":{"request_id":{"type":"string","format":"uuid","description":"Id for this message. One id covers every channel it is tried on, and it comes back as `message_id` in the delivery report. Store it - it is what we need for any support question about the message.\n"},"cost":{"type":"number","nullable":true,"description":"Indicative price for the first channel tried, in your account currency. `null` when no price could be resolved. This is only an estimate: a failover may deliver on a different, differently-priced channel, and the delivery report's `cost` is what was actually charged - the two may differ.\n"}}}}}},"ValidationFailed":{"description":"Something in the request is wrong. `errors` names each offending field. Fix the request before retrying - repeating it unchanged will fail the same way.\n","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"Unauthorized":{"description":"Your API key was not accepted. This status means only that - every other kind of rejection has its own status, so a `401` is always worth checking your key over.\n","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"PaymentRequired":{"description":"Not enough balance to send. Top up and retry.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"Forbidden":{"description":"Your key is fine, but this send is not allowed. Retrying will not help - the `code` says what to fix, and most of these need us to change something on your account.\n","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"NotFound":{"description":"Something named in the request does not exist - the channel, the sender profile, or the URL itself.\n","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"MethodNotAllowed":{"description":"Right URL, wrong method. This endpoint is POST only.","headers":{"Allow":{"description":"The methods this endpoint accepts.","schema":{"type":"string"}}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"PayloadTooLarge":{"description":"The request body is larger than we accept. A send is a small JSON document - content plus a short list of failover channels, with media sent by URL rather than inline - so this normally means something unintended was posted.\n","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"UnsupportedMediaType":{"description":"The body must be JSON, sent with `Content-Type: application/json`. A form or plain-text body is not accepted.\n","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"Unprocessable":{"description":"The request was understood but cannot be sent as it stands. Retrying it unchanged will not help.\n","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"TooManyRequests":{"description":"You are sending faster than the rate agreed for your API key. The limit is a per-second one, so it clears almost immediately - wait out the `Retry-After` and send again. Nothing was sent, so the request is safe to repeat as-is.\n","headers":{"Retry-After":{"description":"Seconds to wait before retrying. Always `1`, because the limit is counted in one-second windows - your allowance is back at the next whole second.\n","schema":{"type":"integer"}}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"InternalError":{"description":"Something went wrong on our side. Safe to retry.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"ServiceUnavailable":{"description":"No delivery route to that number at the moment. This one is temporary and on our side, so the same request may well succeed later.\n","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"paths":{"/v1/messages":{"post":{"operationId":"createMessage","summary":"Send a message","description":"The `channels` list holds the channels to send on, in order. Each entry is self-contained: the channel, the sender profile it goes out from, the recipient and the content.\n\nOne entry sends on one channel and that is the whole request. Several entries are tried in order - the message goes out on the first, and moves to the next only if that one does not deliver within its `failover_after`. There is no failover flag; the number of entries is what decides it.\n\nThe request is all-or-nothing: if any entry is invalid or cannot be sent, the whole request is rejected and nothing goes out.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageRequest"}}}},"responses":{"202":{"$ref":"#/components/responses/Accepted"},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"405":{"$ref":"#/components/responses/MethodNotAllowed"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"415":{"$ref":"#/components/responses/UnsupportedMediaType"},"422":{"$ref":"#/components/responses/Unprocessable"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}},"callbacks":{"deliveryReport":{"https://{your-configured-webhook}":{"post":{"operationId":"deliveryReport","summary":"Delivery report for a message you sent","description":"Sent once per message, as soon as the outcome is known, to your account's default webhook. If no webhook is configured for your account you get no delivery report.\n\nIf a webhook secret is configured for your account, every call carries it in the `X-Webhook-Secret` header, so you can reject anything that does not present it. Compare the whole value; we send it unchanged on every call. Ask us if you need it in a differently named header.\n\nReply with any `2xx` to acknowledge. Anything else - or a connection failure - is retried with a growing delay for up to two days, after which we stop trying. That window also covers a secret change: while you deploy a new one, rejected reports are retried rather than lost.\n\nReports are paced at the rate agreed for your account, so expect them spread out rather than all at once during a large campaign.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryReport"}}}},"responses":{"2XX":{"description":"Report acknowledged - we will not retry it."}}}}}}}}}}
```
