BHUTAN SMSC
Developers

Bhutan SMSC API

One REST endpoint for B-Mobile and TashiCell. JSON in, a message id out, delivery reported separately. Everything below can be run from this page.

Quickstart

Create an account and you are issued a sandbox key immediately. Sandbox sends are free, simulated end to end, and never reach a handset — so this works before you have paid anything.

# 1. price it — free, and no account needed at all
curl -X POST https://smsc.bt/api/quote \
  -d 'to=17123456' -d 'body=Your code is 481920'

# 2. send it
curl -X POST https://smsc.bt/api/v1/messages \
  -H "Authorization: Bearer bsk_test_…" \
  -H "Content-Type: application/json" \
  -d '{"to":"17123456","body":"Your code is 481920"}'

# 202 Accepted
{
  "accepted": 1,
  "rejected": 0,
  "cost_uusd": 100000,
  "hold_seconds": 15,
  "undo_url": "/api/v1/batches/xk29fj…",
  "results": [{ "to": "97517123456", "accepted": true,
                "carrier": "btl", "segments": 1 }]
}

Try it live

This fires real HTTP requests from your browser at this deployment, and writes the matching client code as you type. It is the same request either way — the code below is what the button above it sends, not a rendering of what it might send.

Get one free

Held in this tab only — never stored, and never sent anywhere but this API. Reloading the page clears it.

No request sent yet.
Pick an endpoint and press Send request.
Pick an endpoint above.

The samples read the key from the environment and never contain yours — generated code gets pasted into repositories, and a snippet that carries a live credential is a leak with a copy button on it.

Authentication

Send your key as a bearer token. X-Api-Key is accepted identically if that suits your client better.

Authorization: Bearer bsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-Api-Key: bsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys carry scopes, and the endpoint decides which one it needs — you never request a scope per call.

ScopeOpens
sendSending, cancelling a message, undoing a batch. Anything that spends or refunds.
readListing and reading messages and batches, and /validate.
balanceBalance and usage. Deliberately separate, so a reporting script can read your traffic without being told what you spend.

A reporting script does not need send, and a key issued without it cannot spend your credit even if it leaks. Keys can also be pinned to an IP allowlist. A revoked key stops working on its very next call — there is no cache to wait out.

Sandbox vs live

The mode is a property of the key, not of the request. A bsk_test_ key always simulates: nothing reaches a carrier, nothing is charged, and the full API — receipts, status transitions, error codes — behaves exactly as it does live. A bsk_live_ key sends to real handsets and spends real credit.

This is deliberate: there is no request flag that can be forgotten and accidentally bill you.

Status

GET /api/v1/health no key needed Try it →

Is the gateway accepting, and is anything being delivered?

Accepting and delivering are two different facts. `accepting` says a send would be taken and billed; `carrier_delivery` says a gateway exists to hand it to. A build with no gateway configured reports `accepting: true` and `carrier_delivery: false`, and adds a `note` saying so — nothing is lost and nothing needs resending.

Returns HTTP 200

FieldTypeNotes
status string Always "ok" when this responds at all.
api string Always "v1".
time string Server time, ISO-8601. Useful for checking clock skew.
accepting boolean Whether sends are being taken.
carrier_delivery boolean Whether any SMS gateway is configured.
gateways array One `{ name, queued }` per configured gateway.
note string Present only when `carrier_delivery` is false. Explains what that means for messages already accepted.

Errors

method_not_allowed 405 

GET /api/v1/pricing no key needed Try it →

The live price list and every plan's limits.

The authoritative source for `max_tps` — read it here rather than hard-coding a number, because an account can be granted a ceiling above its plan and because there are no rate-limit response headers to discover it from.

Returns HTTP 200

FieldTypeNotes
currency string Always "USD". Ngultrum is a settlement currency, not a pricing one.
micro_usd_per_usd integer 1000000. Every money field on this API is an integer count of these.
plans array Active plans only, in display order.
plans[].key string Stable identifier — branch on this, not on `name`.
plans[].price_uusd_per_segment integer Standard rate, per segment.
plans[].priority_uusd_per_segment integer Priority rate. Charged instead of the standard rate, not on top of it.
plans[].max_tps integer Requests per second this plan allows.
plans[].max_recipients_per_request integer Ceiling per request, not per second.
plans[].max_segments_per_message integer Longer messages are refused, never truncated.
plans[].features object `unicode`, `custom_sender_id`, `scheduling`, `webhooks`, `delivery_receipts`, `priority_send`, `priority_routing` — all booleans.

Errors

method_not_allowed 405 

POST /api/quote no key needed Try it →

Price a message with no account at all.

The only write-shaped endpoint that needs no key. It exists so a pricing page can be honest before anybody signs up, and it quotes at the entry rate. Once you hold a key, use `POST /api/v1/validate` instead — it quotes at YOUR rate and reports per-recipient outcomes.

Rate limit: 120 requests per minute, per IP address.

Parameters

NameInTypeNotes
body body string The message text. Empty is allowed and quotes zero.
to body string Optional recipient. Adds carrier routing to the answer.

Returns HTTP 200

FieldTypeNotes
encoding string "gsm7" or "ucs2".
segments integer How many segments this text needs.
cost_uusd integer Total, at the entry rate.
carrier object `{ code, name, e164 }`, or null when `to` was empty or unroutable.
carrier_error string Why `carrier` is null. A human string — there is no machine code here; use /api/v1/validate if you need one.

Errors

method_not_allowed 405 rate_limited 429 

Sending

POST /api/v1/validate read scope Try it →

Price and check recipients without creating anything.

Free, creates no message, and charges nothing. It runs the same segmenting and routing code the accept path bills with, so its answer is the answer — not an estimate. A bad recipient comes back as `valid: false` inside a 200, not as an HTTP error, because one bad number in fifty is not a failed request.

Parameters

NameInTypeNotes
torequired body string | array One number or many, in any format.
body body string The message text. Empty quotes zero segments.
priority body boolean Quote at the priority rate instead of the standard one.

Returns HTTP 200

FieldTypeNotes
message.encoding string "gsm7" or "ucs2".
message.segments integer Segments per recipient.
message.needs_unicode boolean Whether the text forced UCS-2.
message.exceeds_plan_limit boolean Whether sending this would be refused.
recipients integer How many recipients were VALID. Not how many you sent.
total_cost_uusd integer Cost across valid recipients only.
results array One entry per input, in order. Valid entries carry `carrier` and `cost_uusd`; invalid ones carry `error` and `message`.

Errors

validation_failed 400 key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 ip_not_allowed 403 scope_denied 403 tenant_suspended 403 method_not_allowed 405 rate_limited 429 internal_error 500 

POST /api/v1/messages send scope Try it →

Send to one recipient or many.

Returns **202 Accepted**, not 200 — the message is billed and queued, which is not the same as delivered. Recipients are accepted or rejected individually and you are charged only for the accepted ones.

Headers

HeaderNotes
Idempotency-KeyRetry safely. A repeat returns the original batch with `replayed: true` instead of sending again. Scoped to your account.

Parameters

NameInTypeNotes
torequired body string | array One number or many, in any format.
bodyrequired body string The text. Never truncated — too long is refused.
sender body string An approved sender ID on your account. Omit for the platform default.
priority body boolean Overtake ordinary traffic, at the priority rate.
scheduled_at body string ISO-8601. Held until due.
hold_seconds body integer Your undo window, 0–300. 0 sends immediately.
client_ref body string Your own reference, returned on every read. Truncated at 120 characters.
callback_url body string Per-message receipt callback. See Webhooks for what does and does not fire today.
idempotency_key body string Same as the header. The header wins if both are sent.

Returns HTTP 202

FieldTypeNotes
batch object `{ id, requested, accepted, rejected, segments, cost_uusd, client_ref, mode, created }`.
accepted integer How many recipients were taken.
rejected integer How many were refused. Never charged.
cost_uusd integer What this request cost in total.
replayed boolean True when an Idempotency-Key matched an earlier request. See the note below — the `results` shape differs.
hold_until string When the undo window closes, or null.
undo_url string DELETE this to withdraw the whole send. Null when `hold_seconds` was 0.
results array Per recipient. Accepted: `{ to, accepted: true, carrier, segments }`. Rejected: `{ to, accepted: false, error, message }`.

Errors

invalid_number 400 message_too_long 400 scheduling_not_allowed 400 too_many_recipients 400 unicode_not_allowed 400 unroutable_number 400 validation_failed 400 key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 insufficient_credit 402 ip_not_allowed 403 priority_not_allowed 403 scope_denied 403 sender_not_allowed 403 sender_not_approved 403 tenant_suspended 403 method_not_allowed 405 rate_limited 429 internal_error 500 

Reading

GET /api/v1/messages read scope Try it →

List your messages, newest first.

Filters combine with AND. `charging` rows are never listed — they are the inert moment between insert and payment, not messages you sent.

Parameters

NameInTypeNotes
status query string Exact match on one status.
batch query string Everything accepted in one send.
to query string Recipient, in any format — it is normalised the same way sending is.
client_ref query string Your own reference, exact match.
since query string ISO-8601 lower bound on `created`, inclusive.
until query string ISO-8601 upper bound on `created`, inclusive.
page query integer 1-based. Default 1.
per_page query integer Clamped to 200. Default 50.

Returns HTTP 200

FieldTypeNotes
items array Message objects — the same shape a single read returns, without `receipts`.
page integer The page you got.
per_page integer After clamping.
total integer Matching messages, not messages on this page.
total_pages integer Total ÷ per_page, rounded up.

Errors

key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 ip_not_allowed 403 scope_denied 403 tenant_suspended 403 method_not_allowed 405 rate_limited 429 internal_error 500 

GET /api/v1/messages/{id} read scope Try it →

One message, with its carrier receipts.

Another account's message reads as `not_found`, never as forbidden — a 403 would confirm the id exists.

Parameters

NameInTypeNotes
idrequired path string The message id.

Returns HTTP 200

FieldTypeNotes
id string The message id.
to string Recipient in E.164, as stored.
status string See the status table.
segments integer What you were billed for.
price_uusd integer What it cost.
carrier string "btl" or "tashicell", or null before routing.
can_cancel boolean Whether DELETE would work right now.
undo_seconds_remaining integer Seconds left in the undo window. 0 once it has closed.
receipts array Carrier receipts, oldest first. Empty until a carrier reports.

Errors

key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 ip_not_allowed 403 scope_denied 403 tenant_suspended 403 not_found 404 method_not_allowed 405 rate_limited 429 internal_error 500 

DELETE /api/v1/messages/{id} send scope Try it →

Withdraw one message inside its undo window.

Refunds what the message cost. Anything past `queued` returns `not_cancellable` rather than pretending — once a carrier has it, neither we nor SMPP can take it back.

Parameters

NameInTypeNotes
idrequired path string The message id.

Returns HTTP 200

FieldTypeNotes
message object The message, re-read. `status` is now "cancelled".
refunded_uusd integer Credited back to your balance.

Errors

key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 ip_not_allowed 403 scope_denied 403 tenant_suspended 403 not_found 404 method_not_allowed 405 not_cancellable 409 rate_limited 429 internal_error 500 

GET /api/v1/batches/{id} read scope Try it →

One send, and how its messages are doing.

Parameters

NameInTypeNotes
idrequired path string The batch id, from a send response.

Returns HTTP 200

FieldTypeNotes
batch object Counts and cost for the send as a whole.
status_counts object Keyed by status. A status with no messages is ABSENT, not zero.
messages_url string A ready-made link to this batch's messages.

Errors

key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 ip_not_allowed 403 scope_denied 403 tenant_suspended 403 not_found 404 method_not_allowed 405 rate_limited 429 internal_error 500 

DELETE /api/v1/batches/{id} send scope Try it →

Withdraw a whole send.

Partial success is a 200, not an error: `cancelled` and `already_sent` tell you exactly how much you caught. `complete` is true only when nothing had left already.

Parameters

NameInTypeNotes
idrequired path string The batch id.

Returns HTTP 200

FieldTypeNotes
cancelled integer Messages actually withdrawn.
already_sent integer Messages that had left the queue. Not refunded.
refunded_uusd integer Total credited back.
complete boolean Whether the whole send was caught.

Errors

key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 ip_not_allowed 403 scope_denied 403 tenant_suspended 403 not_found 404 method_not_allowed 405 rate_limited 429 internal_error 500 

Account

GET /api/v1/balance balance scope Try it →

Credit, and what it actually buys.

Needs the `balance` scope — a `read` key is refused here, so a reporting script can be given message access without being told what you spend.

Returns HTTP 200

FieldTypeNotes
balance_uusd integer Credit in micro-USD.
balance_display string Formatted, e.g. "$4.20".
messages_remaining integer At your live standard rate. Null when the rate is 0.
low_balance boolean True once you are at or under your own threshold.
rate_uusd_per_segment integer Your LIVE standard rate — never the sandbox 0, never the priority rate.
month_to_date object `{ from, spent_uusd, spent_display, segments }`.

Errors

key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 ip_not_allowed 403 scope_denied 403 tenant_suspended 403 method_not_allowed 405 rate_limited 429 internal_error 500 

GET /api/v1/usage balance scope Try it →

A daily series of what you sent and spent.

Days with no traffic are ABSENT from `days`, not present as zero. A day that was not measured and a day with no messages are different facts and this API keeps them apart — plot the gaps as gaps.

Parameters

NameInTypeNotes
from query string YYYY-MM-DD, inclusive. Default 29 days ago.
until query string YYYY-MM-DD, inclusive. Default today.

Returns HTTP 200

FieldTypeNotes
days array Ascending. Each `{ day, accepted, submitted, delivered, failed, expired, segments, cost_uusd, refunded_uusd, by_carrier }`.
totals object The same counters summed, plus `cost_display`.
note string The sparse-days caveat, restated in the payload.

Errors

key_expired 401 key_invalid 401 key_revoked 401 unauthorized 401 ip_not_allowed 403 scope_denied 403 tenant_suspended 403 method_not_allowed 405 rate_limited 429 internal_error 500 

Numbers and routing

We accept a Bhutanese number in any form you have it and normalise before routing, so all of these are the same recipient:

17123456        # bare national
+975 17 12 34 56
0097517123456
97517123456     # canonical, and what we store

The prefix decides the network: 16 and 17 are B-Mobile, 77 is TashiCell. Anything else is refused at the boundary with unroutable_number and is not charged. A foreign number returns unroutable_number rather than invalid_number — it is a real phone number, just not one we carry.

Filtering with ?to= normalises the same way, so the string you sent finds the message you sent with it.

Segments and encoding

You are billed per segment, not per message. This is the one thing worth understanding before you go live.

EncodingSinglePer segment when longer
GSM-7 (plain Latin text)160153
UCS-2 (Dzongkha, emoji, curly quotes)7067

A single character outside the GSM alphabet moves the whole message to UCS-2. A word processor's curly apostrophe is the usual culprit, and it can turn a one-segment message into two.

POST /api/v1/validate returns the exact count and cost before you commit, from the same code that does the charging. Use it — it is free and it does not create a message.

The undo window

Every send is held for a few seconds before it is handed to a carrier, and can be withdrawn in full during that window. The response tells you how long you have:

{ "hold_seconds": 15, "undo_url": "/api/v1/batches/xk29fj…" }

# withdraw the whole send
curl -X DELETE https://smsc.bt/api/v1/batches/xk29fj… \
  -H "Authorization: Bearer bsk_live_…"

{ "cancelled": 3, "already_sent": 0, "complete": true,
  "refunded_uusd": 300000 }

Once the window closes there is no recall — the message is on a carrier's dispatcher and neither we nor SMPP can take it back. A cancel attempt after that returns not_cancellable rather than pretending. Set hold_seconds: 0 to skip the window.

Status and receipts

Submission and delivery are two different facts and we never merge them. submitted means a carrier accepted the message. Only delivered — which requires a carrier receipt — means a handset got it.

StatusMeans
queuedAccepted and billed. Inside its undo window, or waiting for a gateway.
sendingHanded over, not yet acknowledged.
submittedA carrier accepted it. Not proof of delivery.
deliveredA carrier receipt confirmed the handset.
failedPermanently failed. Refunded automatically.
expiredOutlived its usefulness before delivery. Refunded.
cancelledWithdrawn during the undo window. Refunded.
rejectedRefused at the boundary. Never charged.

Webhooks

You can register a receipt endpoint at Settings on a plan that includes them, and the registration is stored with its own signing secret.

Not firing yet

Nothing is dispatched to those endpoints today. Carrier delivery is not live in this build, so there are no message.delivered or message.failed events to report — and an endpoint that has never been called looks exactly like one that is quietly broken. Better to say so here than let you build a receiver and spend a day wondering why it is silent.

Poll instead, for now. GET /api/v1/messages/{id} carries status and a receipts array, and GET /api/v1/batches/{id} gives status_counts for a whole send in one call — which is the cheaper thing to poll after sending in bulk. Check GET /api/v1/health first: while carrier_delivery is false, messages stay queued and no amount of polling will move them.

When receipts do ship, the signature scheme will be documented here before the first call goes out — not discovered from a payload.

Errors

Every failure returns the same shape. Branch on error; it is a stable string and will not be renamed under you.

{ "error": "insufficient_credit",
  "message": "…",
  "docs_url": "/docs/errors#insufficient_credit" }

The docs_url in a failure is a real link — follow it and you land on that code, with what to do about it. All 25 codes →

CodeHTTPRetry?Meaning
insufficient_credit 402 no Not enough credit to accept this request. Top up and send it again — nothing was charged.
internal_error 500 yes Something went wrong on our side. Nothing was charged.
invalid_number 400 no That is not a phone number.
ip_not_allowed 403 no This API key is restricted to an IP allowlist that does not include this address.
key_expired 401 no That API key has expired. Issue a new one from your dashboard.
key_invalid 401 no That API key is not valid.
key_revoked 401 no That API key has been revoked. Issue a new one from your dashboard.
message_too_long 400 no This message needs more segments than your plan permits. Shorten it or upgrade — it was not truncated and not charged.
method_not_allowed 405 no That HTTP method is not supported on this endpoint.
not_cancellable 409 no This message has left the queue and can no longer be cancelled.
not_found 404 no No such resource.
priority_not_allowed 403 no Priority sending is not included in your plan. It moves a message ahead of ordinary traffic and is charged at a higher per-segment rate.
rate_limited 429 yes You are sending faster than your plan allows. Slow down or upgrade for a higher rate.
scheduling_not_allowed 400 no Scheduled sending is not included in your plan.
scope_denied 403 no This API key does not carry the scope required for this endpoint.
sender_blocked 400 no That sender ID is reserved and cannot be registered.
sender_invalid 400 no That sender ID is not a valid format.
sender_not_allowed 403 no Your plan sends under the platform sender ID and cannot set its own.
sender_not_approved 403 no That sender ID is not approved for your account.
tenant_suspended 403 no This account is suspended. Contact support to restore service.
too_many_recipients 400 no More recipients than your plan permits in one request. Split it into smaller batches.
unauthorized 401 no No API key was supplied. Send it as `Authorization: Bearer <key>` or `X-Api-Key: <key>`.
unicode_not_allowed 400 no This message needs UCS-2 (it contains a character outside the GSM alphabet), which your plan does not include.
unroutable_number 400 no This gateway carries Bhutanese numbers only (+975), on a prefix an operator claims.
validation_failed 400 no The request body is missing a required field or has one of the wrong type.

Codes marked "no" will never succeed by repeating the identical request — retrying one is how a clear refusal becomes a rate-limit ban.

Rate limits

Throughput is per account, not per key, so issuing more keys does not buy more capacity. Exceeding it returns rate_limited with HTTP 429 — back off and retry.

There are no rate-limit response headers. No Retry-After, no X-RateLimit-*. Do not write a client that waits for one. The window is one second, so a short pause clears it, and your ceiling is readable at any time from GET /api/v1/pricing as max_tps — which is also where to read it if your account holds an agreed rate above its plan.

A key may be throttled tighter than your account, never looser. See pricing for the rate on each tier.

OpenAPI and generated clients

The whole API is described at /docs/openapi.json — OpenAPI 3.0.3, generated from the same tables the routes are built from, so it cannot describe an endpoint this build does not serve. Point a generator at it and get a typed client instead of hand-writing one:

# a typed client, in whatever you write in
npx @openapitools/openapi-generator-cli generate \
  -i https://smsc.bt/docs/openapi.json \
  -g typescript-fetch -o ./smsc-client

# or python, php, go, dart, java, csharp, …
  -g python -o ./smsc_client

The document names its own server, so a spec fetched from a staging box generates a client pointed at that box rather than at production.

Ready to try it?

A sandbox key works immediately and costs nothing.

Get a free sandbox key
UNLICENSED PLATFORM — no valid license for this deployment. Contact the vendor.