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), aremainingcount and anexpires_atdate. Unused credits are lost when the package expires. - A package may be limited to certain channel types through
channel_types.nullmeans 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:
- 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.
- If no package applies, look up the per-message price for the channel type. If the wallet holds at least that amount, debit it.
- Otherwise reject the request with
402 insufficient_balance. No message is created.
The outcome is recorded on the message under billing:
billing.source | Meaning | Other fields |
|---|---|---|
package | One package credit was consumed. | package_grant_id is the package; amount_micros is 0. |
wallet | The per-message price was debited from the wallet. | amount_micros is the amount; package_grant_id is null. |
none | Not 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"{
"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"{
"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.
| Channel | Type | Default unit_price_micros | In USD |
|---|---|---|---|
| WhatsApp Business | whatsapp | 1000 | $0.001 |
| Telegram | telegram | 300 | $0.0003 |
| SMS | sms | 500 | $0.0005 |
| SMS OTP | sms_otp | 500 | $0.0005 |
| Messenger | messenger | 500 | $0.0005 |
instagram | 500 | $0.0005 | |
| TikTok | tiktok | 500 | $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 themessage.failedevent 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.
{
"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-Keyis safe: a402response 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.
| Event | Sent when | data.object |
|---|---|---|
balance.low | The 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.exhausted | The last credit of a package was consumed. | Package |
package.expiring | A package that still has credits expires within 7 days. | Package |
{
"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.lowhandling 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"{
"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
}
}| Field | Meaning |
|---|---|
messages | Outbound messages accepted in the period. |
package_credits | How many of them were paid with package credits. |
wallet_micros | Total debited from the wallet, in micro-USD. |
failed | How many ended in failed. Their charges were refunded, except a package credit whose package had already expired. |
refunded_micros | Wallet 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.
const usd = (micros) => (micros / 1_000_000).toFixed(micros % 10_000 === 0 ? 2 : 4);
usd(48250000); // "48.25"
usd(1000); // "0.0010"