Skip to content

Account and billing

Billing

OmniMessage is prepaid. Outbound messages are paid from message packages or a wallet at the moment they are accepted, and refunded automatically if they fail.

How billing works#

  • You pay per accepted outbound message. The currency is USD.
  • Funds come from two prepaid sources: message packages (credits) and the wallet (money).
  • A message is charged when the API accepts it, before delivery starts. There is no invoice at the end of the month and no credit line.
  • A message that fails after acceptance is refunded automatically.
  • Inbound messages and test-mode messages are free.

Wallet#

The wallet is a USD balance that you top up in the console by card. It is expressed in micro-USD: one dollar is 1000000, so a wallet_micros of 48250000 is $48.25. When a message is paid from the wallet, the per-message price for its channel type is debited.

Message packages#

A package is a block of prepaid message credits, bought in the console. One credit pays for one outbound message, whatever the wallet price of the channel would have been.

  • A package has a quota (credits bought), a remaining count and an expires_at date. Unused credits are lost when the package expires.
  • A package may be limited to certain channel types through channel_types. null means it applies to every channel type.
  • You can hold several packages at once.

Consumption order#

For each message the API decides how to pay in this order, inside one transaction:

  1. Among your packages that have credits left, have not expired and cover the channel type of the message, take the one that expires first and consume one credit.
  2. If no package applies, look up the per-message price for the channel type. If the wallet holds at least that amount, debit it.
  3. Otherwise reject the request with 402 insufficient_balance. No message is created.

The outcome is recorded on the message under billing:

billing.sourceMeaningOther fields
packageOne package credit was consumed.package_grant_id is the package; amount_micros is 0.
walletThe per-message price was debited from the wallet.amount_micros is the amount; package_grant_id is null.
noneNot billed: a test-mode or inbound message.amount_micros is 0.

Check your balance#

GET /v1/balance returns the wallet and every active package. It requires the billing:read scope. The balance belongs to the account, so live and test keys return the same figures.

curl https://api.omnimessage.co/v1/balance \
  -H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx"
200 OK
{
  "object": "balance",
  "currency": "USD",
  "wallet_micros": 48250000,
  "packages": [
    {
      "id": "grant_5tGh2Kp9LmQ4xW7nB1cD",
      "name": "100K messages",
      "quota": 100000,
      "remaining": 81234,
      "channel_types": null,
      "expires_at": "2027-10-05T00:00:00.000Z"
    }
  ],
  "credits_remaining": 81234
}

credits_remaining is the sum of remaining over all packages. Packages are listed with the earliest expiry first, which is the order in which they are consumed.

Per-message prices#

The wallet price depends on the channel type. GET /v1/pricing returns the prices that apply to your account, including any that were agreed individually. Always read prices from this endpoint rather than hard-coding them.

curl https://api.omnimessage.co/v1/pricing \
  -H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx"
200 OK
{
  "object": "pricing",
  "currency": "USD",
  "data": [
    {
      "channel_type": "whatsapp",
      "unit_price_micros": 1000
    },
    {
      "channel_type": "telegram",
      "unit_price_micros": 300
    },
    {
      "channel_type": "sms",
      "unit_price_micros": 500
    },
    {
      "channel_type": "sms_otp",
      "unit_price_micros": 500
    },
    {
      "channel_type": "messenger",
      "unit_price_micros": 500
    },
    {
      "channel_type": "instagram",
      "unit_price_micros": 500
    },
    {
      "channel_type": "tiktok",
      "unit_price_micros": 500
    }
  ]
}

The default prices at the time of writing are listed below. They are defaults, not a guarantee: your account may differ, and the endpoint is authoritative.

ChannelTypeDefault unit_price_microsIn USD
WhatsApp Businesswhatsapp1000$0.001
Telegramtelegram300$0.0003
SMSsms500$0.0005
SMS OTPsms_otp500$0.0005
Messengermessenger500$0.0005
Instagraminstagram500$0.0005
TikToktiktok500$0.0005

Automatic refunds#

If a message is accepted and then ends in status failed, the charge is reversed without any action from you:

  • A package credit goes back to the package it came from, provided that package has not expired in the meantime.
  • A wallet debit is credited back to the wallet.
  • The message shows billing.refunded: true, and the message.failed event already carries that value.

If the delivery layer rejects a message immediately, the API returns an error (for example 422 channel_not_connected), the charge is reversed and no message is created. Messages that reach sent but are never confirmed as delivered are not refunded.

Handle 402 responses#

402 insufficient_balance means no applicable package has credits and the wallet cannot cover the price. The request had no effect: nothing was sent and nothing was charged.

402 Payment Required
{
  "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"
  }
}
  • Do not retry immediately. The request will keep failing until funds are added.
  • Pause your outbound queue, keep the unsent messages and alert whoever is responsible for billing.
  • After a top-up or package purchase, resume. Reusing the original Idempotency-Key is safe: a 402 response was never an accepted message.
  • In a batch, each item is billed on its own, so a batch can be partly accepted. Rejected items carry status: 402.
async function send(params, idempotencyKey) {
  const response = await fetch('https://api.omnimessage.co/v1/messages', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OMNIMESSAGE_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(params),
  });
  const data = await response.json();

  if (response.status === 402) {
    // Nothing was created or charged. Stop the queue instead of retrying in a loop.
    await pauseOutboundQueue({ reason: data.error.code });
    await notifyBillingOwner(data.error.message);
    return null;
  }
  if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
  return data;
}

pauseOutboundQueue and notifyBillingOwner stand for your own code.

Low balance and package events#

Three webhook events let you act before sending stops. Subscribe a webhook endpoint to them.

EventSent whendata.object
balance.lowThe wallet falls below the low-balance threshold set in the console and no package credits remain. At most once every 24 hours. The account owner is also emailed.Balance
package.exhaustedThe last credit of a package was consumed.Package
package.expiringA package that still has credits expires within 7 days.Package
balance.low
{
  "id": "evt_6hJ9kL2zX5cV8bN1mQ4w",
  "object": "event",
  "type": "balance.low",
  "mode": "live",
  "created_at": "2026-10-05T09:30:02.900Z",
  "data": {
    "object": {
      "object": "balance",
      "currency": "USD",
      "wallet_micros": 1870000,
      "packages": [],
      "credits_remaining": 0
    }
  }
}

Auto-recharge#

Auto-recharge tops up the wallet for you. It is optional and configured in the console under Billing.

  • You choose a threshold and a recharge amount, and save a card during a top-up.
  • When the wallet drops below the threshold, the saved card is charged the recharge amount and the wallet is credited.
  • At most one recharge attempt is made per hour. If the card is declined, sending continues until the balance runs out, so keep balance.low handling in place.

Usage#

GET /v1/usage reports outbound volume and spend for a date range, in UTC days. Group by day for one row per day and channel type, or by channel_type for one row per channel type over the whole range.

curl "https://api.omnimessage.co/v1/usage?from=2026-10-01&to=2026-10-05&group_by=day" \
  -H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx"
200 OK
{
  "object": "usage",
  "from": "2026-10-01",
  "to": "2026-10-05",
  "group_by": "day",
  "data": [
    {
      "period": "2026-10-01",
      "channel_type": "whatsapp",
      "messages": 1200,
      "package_credits": 1000,
      "wallet_micros": 200000,
      "failed": 12,
      "refunded_micros": 2000
    },
    {
      "period": "2026-10-01",
      "channel_type": "telegram",
      "messages": 310,
      "package_credits": 310,
      "wallet_micros": 0,
      "failed": 0,
      "refunded_micros": 0
    },
    {
      "period": "2026-10-02",
      "channel_type": "whatsapp",
      "messages": 980,
      "package_credits": 980,
      "wallet_micros": 0,
      "failed": 4,
      "refunded_micros": 0
    }
  ],
  "totals": {
    "messages": 2490,
    "package_credits": 2290,
    "wallet_micros": 200000,
    "failed": 16,
    "refunded_micros": 2000
  }
}
FieldMeaning
messagesOutbound messages accepted in the period.
package_creditsHow many of them were paid with package credits.
wallet_microsTotal debited from the wallet, in micro-USD.
failedHow many ended in failed. Their charges were refunded, except a package credit whose package had already expired.
refunded_microsWallet amount returned for failed messages in the period.

Working with micro-USD#

Why integers#

Per-message prices are fractions of a cent. Integer micro-USD keeps every amount exact, with no floating-point rounding. Do arithmetic on the integers and convert only for display.

Formatting
const usd = (micros) => (micros / 1_000_000).toFixed(micros % 10_000 === 0 ? 2 : 4);

usd(48250000); // "48.25"
usd(1000);     // "0.0010"

    Loading