SMS API Manual
Send SMS to NTC, Ncell and Smart Telecom numbers in Nepal from your own application. Token authentication, JSON responses, delivery reports and signed webhooks.
Overview
| Base URL | https://kushasms.com |
| Auth | auth-token header (v4) or auth_token parameter (v3/v1) |
| Format | JSON responses; JSON or form-encoded requests |
| Wallet | API sends draw from your API SMS credit, not the web wallet |
| Machine-readable | /openapi.json · /llms-full.txt · /llms.txt |
Create a token in the portal under Developers → API Tokens (protected by a PIN you set once). The raw token is shown only when it is created — it is stored hashed, so a lost token has to be replaced rather than recovered.
Authentication
Every request carries the token one of two ways. v4 endpoints read the header; v3 and v1 also accept it as a parameter, so a browser or a shell one-liner works:
# v4 — header curl -X POST https://kushasms.com/sms/v4/send-user \ -H "auth-token: <YOUR_TOKEN>" \ -H "Content-Type: application/json" \ -d '{"to":["98XXXXXXXX"],"text":["Hello from Kusha SMS"]}' # v3 — parameter curl -X POST https://kushasms.com/sms/v3/send \ -d "auth_token=<YOUR_TOKEN>" \ -d "to=98XXXXXXXX" \ -d "text=Hello from Kusha SMS"
Response conventions
Two things surprise people, so they are worth stating plainly:
- The HTTP status is always 200, including for a bad token, a missing field or an empty wallet. Decide success from the JSON body, never from the status code.
- Partial success is normal. A send returns
valid(queued and charged) andinvalid(aborted, not charged) side by side. An unparseable number, an empty message or a blocklisted number lands ininvalid.
v3/v1 use a flat envelope (error, message, data);
v4 wraps successes in responses[] and failures in errors[].
For AI agents
This manual has machine-readable twins, kept in step with the code:
/openapi.json— OpenAPI 3.1 description of every endpoint; load it as a tool definition./llms-full.txt— this entire manual as plain Markdown, in one request./llms.txt— short index of the site.
Before sending anything, read the
testing checklist. There is no sandbox: every accepted
message is delivered to a real handset and charged. Verify with
GET /sms/v4/available-credit first, then send a single message to a number
you control.
Send · v4
Arrays in, per-recipient result out. One text broadcasts to every
number; N texts pair index-for-index with N numbers (extra entries on either side are
dropped). Headers: auth-token, optional Idempotency-Key.
// request { "to": ["98XXXXXXXX", "98YYYYYYYY"], "text": ["Hello world"] } // response — 200 OK { "responses": [{ "error": false, "message": "2 messages have been queued for delivery.", "data": { "valid": [{ "id": "12_3456", // "<tenant>_<message>" "mobile": "97798XXXXXXXX", "text": "Hello world", "credit": 1, "network": "ntc", "status": "queued", "shortcode": "KUSHA" }], "invalid": [] } }], "errors": [] }
If no recipient survives validation, or the wallet
can't fund the whole batch, the reply is {"error": true, "message": "All messages
encountered errors.", "errors": [...]} and nothing is charged.
Send · v3
One text to a comma-separated list. Parameters:
auth_token, to, text.
POST /sms/v3/send auth_token=…&to=98XXXXXXXX,98YYYYYYYY&text=Hello // response — 200 OK { "error": false, "message": "2 messages has been queued for delivery.", "data": { "valid": [{ "id": 3456, "mobile": "97798XXXXXXXX", "text": "Hello", "credit": 1, "network": "ncell", "status": "queued" }], "invalid": [] } }
Credit
The cheapest pre-flight check — use it before every batch.
{ "available_credit": 750.0, "response_code": 200 }
{
"available_credit": 750.0,
"total_sms_sent": 1240,
"last_transaction_date": "2026-08-07 14:05:11",
"last_transaction_date_sms_sent": 0,
"response_code": 202
}
Legacy shape: available_credit, total_sms_sent,
response_code. Authenticates with auth_token.
available_credit means. It is what you can
actually send right now. If your account is supplied by a reseller, it is the smaller of your
own allowance and your provider's remaining credit — so it can fall without you sending
anything. Size batches against this number.Reports
Filters: start_date, end_date (YYYY-MM-DD),
network (ntc, ncell, smarttel),
mobile (substring). Returns up to 500 rows, newest first.
{
"error": false,
"total": 128,
"credits": 131.0, // net charged — refunded messages count 0
"data": [{ "id": 3456, "mobile": "98XXXXXXXX", "text": "Hello", "credit": 1.0,
"network": "ntc", "status": "delivered",
"sent_on": "2026-08-07 14:05:11" }],
"response_code": 200
}
Paged log: send page (1-based, 50 per page). Returns
error, page, per_page, total,
data.
Idempotency
Network timeouts are the one way to accidentally send twice. Put a unique
Idempotency-Key header on a v4 send and a retry with the same key returns the
first response instead of sending again:
curl -X POST https://kushasms.com/sms/v4/send-user \ -H "auth-token: <YOUR_TOKEN>" \ -H "Idempotency-Key: order-4821-otp" \ -H "Content-Type: application/json" \ -d '{"to":["98XXXXXXXX"],"text":["Your code is 4821"]}'
The key is claimed before the send, so two concurrent requests can never both dispatch. If the first is still in flight, the second returns "A request with this Idempotency-Key is already being processed." — retry shortly. Keys are scoped to your account; use one per business event.
Rate limits
60 requests per minute per token by default, in fixed one-minute windows.
Your plan may set a different figure. Over the limit you get
{"error": true, "message": "Rate limit exceeded. Try again shortly.", "data": []}
— still HTTP 200. Batch recipients into one v4 call rather than one call per number.
Numbers & credits
Numbers are canonicalised to the 10-digit Nepali form. +977 98-1234567,
97798XXXXXXXX and 98XXXXXXXX are the same number; responses echo it
with the 977 country code. Anything that isn't a 10-digit number starting with
9 is aborted, not charged.
Credit is one per segment. Plain English is GSM-7; a single Devanagari character (or any non-GSM-7 character) switches the whole message to UCS-2:
| Encoding | Single message | Per part when longer |
|---|---|---|
| GSM-7 | 160 characters | 153 |
| UCS-2 (Nepali, emoji) | 70 characters | 67 |
The characters ^ { } [ ] ~ | \ € cost two
GSM-7 characters each. To check a message before you send it, paste it into the
SMS character counter — it applies these exact
rules and explains how Nepali text is counted.
Delivery statuses
A send returns queued; the status moves as the operator takes the message.
Poll a report endpoint or take the webhook.
| Status | Meaning | Charged |
|---|---|---|
queued | Accepted, awaiting dispatch | Reserved |
submitted | Handed to the operator | Yes |
sent | Operator accepted it | Yes |
delivered | Confirmed on the handset | Yes |
failed | Never reached an operator | Refunded |
cancelled | Cancelled before dispatch | Refunded |
aborted | Never accepted (bad/blocked number, empty text) | No |
submitted is terminal for billing. Once an operator has taken a message we
are charged for it, so it is charged to you — it stays submitted even if the
operator later reports it undelivered, and it is never refunded from that point. Only a
message no operator ever took comes back as failed, and only those are
refunded and retryable.
Webhooks
Enable a callback URL under Developers → Webhooks and every delivery receipt is POSTed to it as JSON:
POST https://your-app.example/kusha-hook
X-Kusha-Event: dlr
X-Kusha-Signature: <hex HMAC-SHA256 of the raw body>
{
"message_id": 3456,
"to": "98XXXXXXXX",
"network": "ntc",
"status": "delivered",
"smpp_message_id": "0f1a2b",
"delivered_at": "2026-08-07T14:05:11+05:45"
}
Verify by recomputing HMAC-SHA256(secret, raw_body) and comparing with
X-Kusha-Signature — compare in constant time, and use the raw bytes, not
re-serialised JSON. Events are dlr, inbound and batch.
Delivery is retried up to five times; respond 2xx quickly and do the work
afterwards. The URL must be public — private and loopback addresses are refused.
Errors
| Message | Cause |
|---|---|
The auth token field is required. | No auth-token header and no auth_token parameter |
The provided Auth Token is not valid. | Unknown or revoked token |
The to field is required. | Missing recipients |
The text field is required. | Missing message text |
Not enough balance. | The API wallet can't fund the whole batch — it is all-or-nothing |
Rate limit exceeded. Try again shortly. | Over your per-minute budget |
All messages encountered errors. | v4: no recipient survived validation, or funding failed |
Testing checklist
- Check the token.
GET /sms/v4/available-credit— a valid token returns a number; an invalid one returns{"error": true, "message": "The provided Auth Token is not valid."}. - Check the failure paths without spending anything. Send with a
deliberately malformed number (e.g.
"12345"): it comes back underinvalidwithstatus: "aborted"andcredit: 0. - Send one real message to your own number, with an
Idempotency-Key. Confirmvalid[0].status == "queued". - Replay the same request with the same key and confirm you get the same response and only one message on the handset.
- Confirm delivery via
POST /sms/v4/api-reportwith today's date, or your webhook. - Reconcile. Call
available-creditagain — it should have dropped by exactly the credits reported for the send.
Need help? info@sahasratech.com.np