Skip to content

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"
  }'
FieldRequiredDescription
channelYesID 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.
toYesRecipient 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.
typeYesContent type, for example text or template. Must be listed in the capabilities of the channel.
<type>YesThe content, under a key equal to type: "type": "text" requires a text object. See Message types.
reply_toNoID of a message in the same conversation to quote.
referenceNoYour own identifier, up to 255 characters. Stored on the message and usable as a list filter.
metadataNoUp 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.

202 Accepted
{
  "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. 1queuedAccepted and charged
  2. 2sendingHanded to the channel
  3. 3sentAccepted by the provider
  4. 4deliveredReached the device
  5. 5readOpened by the recipient
  6. orfailedFrom queued, sending or sent. Refunded automatically.
Lifecycle of an outbound message. Not every channel reports every stage: some stop at sent or delivered.
StatusMeaningTimestampEvent
queuedAccepted, charged and waiting to be handed to the channel.created_atNone
sendingHanded to the channel; the provider has not confirmed it yet.NoneNone
sentThe provider accepted the message.sent_atmessage.sent
deliveredThe message reached the recipient device.delivered_atmessage.delivered
readThe recipient opened the message.read_atmessage.read
failedThe message could not be delivered. error says why and the charge was refunded.failed_atmessage.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.
  • failed is final. A message can fail from queued, sending or sent, 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"
200 OK
{
  "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.

WhenHow you learn about itChargedExamples
At the requestA 4xx or 5xx response. No message is created.Noparameter_invalid, unsupported_message_type, insufficient_balance, channel_not_connected
After acceptanceThe message moves to failed; a message.failed event is sent.Charged, then refunded automaticallyprovider_error with the provider code in error.provider_code
A failed message
{
  "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.

  • reference is a string of up to 255 characters. It need not be unique. GET /v1/messages?reference=order-1042 returns every message that carries it.
  • metadata holds 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.

207 Multi-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
}
  • data has one entry per submitted message, in request order. index is the position in your messages array.
  • An accepted item has status: 202 and a message. A rejected item has the HTTP status it would have received from the single-send endpoint and an error object.
  • An Idempotency-Key covers the whole batch. Retrying with the same key and body returns the stored result and sends nothing twice.
  • A malformed envelope (no messages array, an empty array or more than 100 items) is rejected as a whole with 400.
  • 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-Key with every request and reuse it when you retry. See Idempotency.
  • Treat 402 insufficient_balance as a signal to pause the queue, not to retry in a loop. See Billing.
  • Respect Retry-After on 429. See Rate limits.
  • Read capabilities from the channel rather than hard-coding which types a channel supports.

    Loading