Messaging
Sending messages
One endpoint sends every message type on every channel. This guide covers the request, the status lifecycle, replies, your own references and batches.
Anatomy of a message#
A send request names a channel, a recipient and a content type, and carries the content in an object under the key named by type. Everything else is optional.
curl https://api.omnimessage.co/v1/messages \
-H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a0e-3b7d-4c59-9f0a-2d8e5b1c7a43" \
-d '{
"channel": "ch_7Hq2mN5vB8cX1zL0pK3j",
"to": "+971501234567",
"type": "text",
"text": {
"body": "Your code is 482910"
},
"reference": "order-1042",
"metadata": {
"user_id": "u_17"
},
"reply_to": "msg_4fD8sA1gH6jK9lZ3xC5v"
}'| Field | Required | Description |
|---|---|---|
channel | Yes | ID of the channel to send from. Its type decides which message types and recipient formats are valid. In test mode, a sandbox channel such as ch_test_whatsapp. |
to | Yes | Recipient identifier in the format of the channel: an E.164 phone number for WhatsApp and SMS, a chat ID for Telegram, a page-scoped user ID for Messenger and Instagram. |
type | Yes | Content type, for example text or template. Must be listed in the capabilities of the channel. |
<type> | Yes | The content, under a key equal to type: "type": "text" requires a text object. See Message types. |
reply_to | No | ID of a message in the same conversation to quote. |
reference | No | Your own identifier, up to 255 characters. Stored on the message and usable as a list filter. |
metadata | No | Up to 20 string keys with string values of up to 500 characters. Returned on the message and in every webhook event about it. |
The response is 202 Accepted with the message object in status queued. At that point the message has been validated, charged and stored; delivery happens afterwards.
{
"id": "msg_2b1Xw9aQ3rT8yU0pL4kZ",
"object": "message",
"mode": "live",
"channel_id": "ch_7Hq2mN5vB8cX1zL0pK3j",
"channel_type": "whatsapp",
"direction": "outbound",
"to": "+971501234567",
"from": "+971800123456",
"type": "text",
"content": {
"text": {
"body": "Your code is 482910"
}
},
"status": "queued",
"error": null,
"reference": "order-1042",
"metadata": {
"user_id": "u_17"
},
"billing": {
"source": "package",
"amount_micros": 0,
"package_grant_id": "grant_5tGh2Kp9LmQ4xW7nB1cD",
"refunded": false
},
"sender": null,
"contact_id": null,
"created_at": "2026-10-05T09:30:00.000Z",
"updated_at": "2026-10-05T09:30:02.871Z",
"sent_at": null,
"delivered_at": null,
"read_at": null,
"failed_at": null
}Message lifecycle#
An outbound message moves forward through a fixed sequence of statuses. Each change is recorded with a timestamp on the message and, if you subscribed, announced by a webhook event.
- 1
queuedAccepted and charged - 2
sendingHanded to the channel - 3
sentAccepted by the provider - 4
deliveredReached the device - 5
readOpened by the recipient - or
failedFrom queued, sending or sent. Refunded automatically.
sent or delivered.| Status | Meaning | Timestamp | Event |
|---|---|---|---|
queued | Accepted, charged and waiting to be handed to the channel. | created_at | None |
sending | Handed to the channel; the provider has not confirmed it yet. | None | None |
sent | The provider accepted the message. | sent_at | message.sent |
delivered | The message reached the recipient device. | delivered_at | message.delivered |
read | The recipient opened the message. | read_at | message.read |
failed | The message could not be delivered. error says why and the charge was refunded. | failed_at | message.failed |
- A status never moves backwards. If events arrive out of order, keep the furthest status you have seen.
- How far a message gets depends on the channel. WhatsApp reports delivery and read receipts; SMS routes may stop at
sent; read receipts require the recipient to have them enabled. failedis final. A message can fail fromqueued,sendingorsent, but not after it was delivered.
Track the outcome#
Webhooks#
Subscribe a webhook endpoint to message.sent, message.delivered, message.read and message.failed. Each event carries the full message object, including your reference and metadata, so the handler rarely needs to call the API back.
Polling#
GET /v1/messages/{id} returns the current state. GET /v1/messages/{id}/events returns the status history in order, which helps when you debug timing.
curl https://api.omnimessage.co/v1/messages/msg_2b1Xw9aQ3rT8yU0pL4kZ/events \
-H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx"{
"object": "list",
"data": [
{
"status": "queued",
"description": "Accepted and charged.",
"occurred_at": "2026-10-05T09:30:00.000Z"
},
{
"status": "sending",
"description": "Handed to the channel.",
"occurred_at": "2026-10-05T09:30:00.412Z"
},
{
"status": "sent",
"description": "Accepted by the provider.",
"occurred_at": "2026-10-05T09:30:01.210Z"
},
{
"status": "delivered",
"description": "Delivered to the recipient device.",
"occurred_at": "2026-10-05T09:30:02.871Z"
}
],
"has_more": false,
"next_cursor": null
}Two kinds of failure#
A send can fail at two moments, and your code sees them differently.
| When | How you learn about it | Charged | Examples |
|---|---|---|---|
| At the request | A 4xx or 5xx response. No message is created. | No | parameter_invalid, unsupported_message_type, insufficient_balance, channel_not_connected |
| After acceptance | The message moves to failed; a message.failed event is sent. | Charged, then refunded automatically | provider_error with the provider code in error.provider_code |
{
"id": "msg_9cV4nH7jK2mP5qR8sT1w",
"object": "message",
"mode": "live",
"channel_id": "ch_7Hq2mN5vB8cX1zL0pK3j",
"channel_type": "whatsapp",
"direction": "outbound",
"to": "+971501234567",
"from": "+971800123456",
"type": "text",
"content": {
"text": {
"body": "Your code is 482910"
}
},
"status": "failed",
"error": {
"code": "provider_error",
"message": "Message undeliverable.",
"provider_code": "131026"
},
"reference": "order-1042",
"metadata": {
"user_id": "u_17"
},
"billing": {
"source": "wallet",
"amount_micros": 1000,
"package_grant_id": null,
"refunded": true
},
"sender": null,
"contact_id": null,
"created_at": "2026-10-05T09:30:00.000Z",
"updated_at": "2026-10-05T09:30:02.871Z",
"sent_at": null,
"delivered_at": null,
"read_at": null,
"failed_at": "2026-10-05T09:30:03.118Z"
}error.provider_code is the code of the channel provider, passed through unchanged. Use it when you contact the provider or look up its documentation. billing.refunded confirms the charge was returned. The errors guide lists every request-time error code.
Reply to a message#
Set reply_to to the ID of a message in the same conversation to send a quoted reply, on channels that display quotes. It is most often the ID of an inbound message you received through message.received.
curl https://api.omnimessage.co/v1/messages \
-H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "ch_7Hq2mN5vB8cX1zL0pK3j",
"to": "+971501234567",
"type": "text",
"text": {
"body": "It left the warehouse this morning."
},
"reply_to": "msg_4fD8sA1gH6jK9lZ3xC5v"
}'The referenced message must exist on the same channel; otherwise the request fails with 404 resource_missing.
Reference and metadata#
Use reference for the one identifier you will search by, such as an order number, and metadata for additional context your webhook handler needs.
referenceis a string of up to 255 characters. It need not be unique.GET /v1/messages?reference=order-1042returns every message that carries it.metadataholds up to 20 keys. Keys and values are strings; values are limited to 500 characters. It cannot be used as a filter.- Neither field is shown to the recipient or passed to the channel provider. Do not store secrets or sensitive personal data in them.
curl "https://api.omnimessage.co/v1/messages?reference=order-1042&limit=10" \
-H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx"Send a batch#
POST /v1/messages/batch accepts up to 100 messages in one request. Each item has the same shape as a single send and may use a different channel, recipient and type.
curl https://api.omnimessage.co/v1/messages/batch \
-H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: shipping-run-2026-10-05-a" \
-d '{
"messages": [
{
"channel": "ch_7Hq2mN5vB8cX1zL0pK3j",
"to": "+971501234567",
"type": "text",
"text": {
"body": "Your order has shipped."
},
"reference": "order-1042"
},
{
"channel": "ch_7Hq2mN5vB8cX1zL0pK3j",
"to": "+971509876543",
"type": "text",
"text": {
"body": "Your order has shipped."
},
"reference": "order-1043"
}
]
}'The response status is 207 whenever the batch itself is well-formed. Items are validated, billed and accepted independently, so one batch can contain both accepted and rejected messages. Always read the per-item status.
{
"object": "batch",
"data": [
{
"index": 0,
"status": 202,
"message": {
"id": "msg_2b1Xw9aQ3rT8yU0pL4kZ",
"object": "message",
"mode": "live",
"channel_id": "ch_7Hq2mN5vB8cX1zL0pK3j",
"channel_type": "whatsapp",
"direction": "outbound",
"to": "+971501234567",
"from": "+971800123456",
"type": "text",
"content": {
"text": {
"body": "Your code is 482910"
}
},
"status": "queued",
"error": null,
"reference": "order-1042",
"metadata": {},
"billing": {
"source": "package",
"amount_micros": 0,
"package_grant_id": "grant_5tGh2Kp9LmQ4xW7nB1cD",
"refunded": false
},
"sender": null,
"contact_id": null,
"created_at": "2026-10-05T09:30:00.000Z",
"updated_at": "2026-10-05T09:30:02.871Z",
"sent_at": null,
"delivered_at": null,
"read_at": null,
"failed_at": null
}
},
{
"index": 1,
"status": 402,
"error": {
"type": "billing_error",
"code": "insufficient_balance",
"message": "No package credits remain and the wallet balance is below the message price.",
"request_id": "req_0aB3cD6eF9gH2iJ5kL8m",
"doc_url": "https://omnimessage.co/docs/errors#insufficient_balance"
}
}
],
"accepted": 1,
"rejected": 1
}datahas one entry per submitted message, in request order.indexis the position in yourmessagesarray.- An accepted item has
status: 202and amessage. A rejected item has the HTTP status it would have received from the single-send endpoint and anerrorobject. - An
Idempotency-Keycovers the whole batch. Retrying with the same key and body returns the stored result and sends nothing twice. - A malformed envelope (no
messagesarray, an empty array or more than 100 items) is rejected as a whole with400. - To resend only the rejected items, build a new batch from them and use a new idempotency key.
Before you send at volume#
- Send an
Idempotency-Keywith every request and reuse it when you retry. See Idempotency. - Treat
402 insufficient_balanceas a signal to pause the queue, not to retry in a loop. See Billing. - Respect
Retry-Afteron429. See Rate limits. - Read
capabilitiesfrom the channel rather than hard-coding which types a channel supports.