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.
Held in this tab only — never stored, and never sent anywhere but this API. Reloading the page clears it.
That is a live key. Sending from here will deliver
real messages and spend real credit. Use a bsk_test_
key to try things out.
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.
| Scope | Opens |
|---|---|
| send | Sending, cancelling a message, undoing a batch. Anything that spends or refunds. |
| read | Listing and reading messages and batches, and /validate. |
| balance | Balance 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
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
| Field | Type | Notes |
|---|---|---|
| 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
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
| Field | Type | Notes |
|---|---|---|
| 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
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
| Name | In | Type | Notes |
|---|---|---|---|
| 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
| Field | Type | Notes |
|---|---|---|
| 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
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
| Name | In | Type | Notes |
|---|---|---|---|
| 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
| Field | Type | Notes |
|---|---|---|
| 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
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
| Header | Notes |
|---|---|
| Idempotency-Key | Retry safely. A repeat returns the original batch with `replayed: true` instead of sending again. Scoped to your account. |
Parameters
| Name | In | Type | Notes |
|---|---|---|---|
| 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
| Field | Type | Notes |
|---|---|---|
| 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
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
| Name | In | Type | Notes |
|---|---|---|---|
| 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
| Field | Type | Notes |
|---|---|---|
| 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
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
| Name | In | Type | Notes |
|---|---|---|---|
| idrequired | path | string | The message id. |
Returns HTTP 200
| Field | Type | Notes |
|---|---|---|
| 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
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
| Name | In | Type | Notes |
|---|---|---|---|
| idrequired | path | string | The message id. |
Returns HTTP 200
| Field | Type | Notes |
|---|---|---|
| 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
One send, and how its messages are doing.
Parameters
| Name | In | Type | Notes |
|---|---|---|---|
| idrequired | path | string | The batch id, from a send response. |
Returns HTTP 200
| Field | Type | Notes |
|---|---|---|
| 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
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
| Name | In | Type | Notes |
|---|---|---|---|
| idrequired | path | string | The batch id. |
Returns HTTP 200
| Field | Type | Notes |
|---|---|---|
| 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
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
| Field | Type | Notes |
|---|---|---|
| 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
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
| Name | In | Type | Notes |
|---|---|---|---|
| from | query | string | YYYY-MM-DD, inclusive. Default 29 days ago. |
| until | query | string | YYYY-MM-DD, inclusive. Default today. |
Returns HTTP 200
| Field | Type | Notes |
|---|---|---|
| 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.
| Encoding | Single | Per segment when longer |
|---|---|---|
| GSM-7 (plain Latin text) | 160 | 153 |
| UCS-2 (Dzongkha, emoji, curly quotes) | 70 | 67 |
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.
| Status | Means |
|---|---|
| queued | Accepted and billed. Inside its undo window, or waiting for a gateway. |
| sending | Handed over, not yet acknowledged. |
| submitted | A carrier accepted it. Not proof of delivery. |
| delivered | A carrier receipt confirmed the handset. |
| failed | Permanently failed. Refunded automatically. |
| expired | Outlived its usefulness before delivery. Refunded. |
| cancelled | Withdrawn during the undo window. Refunded. |
| rejected | Refused 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.
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 →
| Code | HTTP | Retry? | 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.