Authentication
Every request is authenticated with a bearer API key scoped to one account.
API keys
Create a key in the dashboard under Developer API. It is shown once, at the moment it is created, and never again — we store a hash, not the key, so there is no endpoint that can give it back to you. Lose it and you issue a new one.
Pass it in the Authorization header. A request with no valid key returns 401 invalid_api_key; one whose key lacks the required scope returns 403 insufficient_scope.
Authorization: Bearer sbs_live_7f4c9a2e1d8b45f0a3c6e9d2b7f1a4c8If something between your code and us rewrites Authorization — some corporate proxies and shared PHP hosts do — send the key as X-API-Key instead. Both are accepted; the bearer header is the one we document because it is the one everything understands.
Live and test keys
| Key type | Type | Description |
|---|---|---|
sbs_live_… | string | Sends real messages and debits your wallet. |
sbs_test_… | string | Runs the whole path — validation, sender ID check, message log, delivery webhooks — but never reaches an operator and never bills you. |
The environment is in the key text on purpose. The most expensive mistake anyone makes with an SMS API is shipping a live key where they meant a test one, and the only reliable moment to catch it is while you are looking at the string.
Build against a test key first. It is free, it exercises your delivery-webhook handler for real, and three reserved numbers make the awkward branches easy to reach:
| Test number | Type | Description |
|---|---|---|
255740000001 | string | Always reports delivered. |
255740000002 | string | Always reports failed. |
255740000003 | string | Always reports expired. |
Everything else is deterministic too
Scopes
A key holds only the permissions you give it. The key on a website that sends order confirmations has no reason to be able to read your customer list — and if it leaks, it should not be able to.
| Scope | Type | Description |
|---|---|---|
sms:send | string | POST /v1/sms/send. |
sms:read | string | The message log and single-message status. |
wallet:read | string | Balance and ledger. |
sender_ids:read | string | List the names you may send under. |
sender_ids:write | string | Request a new sender ID. |
contacts:read | string | Read contacts and groups. |
contacts:write | string | Create, edit, import and delete contacts and groups. |
webhooks:read | string | Read your webhook configuration. |
webhooks:write | string | Change where events are delivered. |
A key created without naming any scopes gets the four that make “send a message and find out what happened to it” work: sms:send, sms:read, wallet:read and sender_ids:read. It gets no access to the address book.
Treat keys as passwords
Rotating a key
Rotation issues a replacement and revokes the current key immediately. There is no overlap window — that is deliberate, because the usual reason to rotate is that the old key is somewhere it should not be. Deploy the new value everywhere it is used before you rely on it.
The key being rotated is the one that authenticated the call, so there is no id in the path. That is what lets you replace a leaked key from the machine holding it, without signing into anything.
https://bulksmsapi.selanim.com/v1/keys/rotatecurl -X POST https://bulksmsapi.selanim.com/v1/keys/rotate \
-H "Authorization: Bearer $SBS_API_KEY"
# { "apiKey": "sbs_live_9d1e...", "rotatedAt": "2026-08-24T09:20:00Z" }IP allowlisting
A key can be pinned to the addresses your servers call from — individual addresses or CIDR ranges. Requests from anywhere else are refused with 403 ip_not_allowed even when the key itself is valid, which makes a leaked key useless off-premises.
A caller we cannot identify is refused rather than allowed. If you put a proxy in front of your own integration, make sure it is not the proxy’s address we end up seeing.
Idempotency
Send an Idempotency-Key header with any POST. Replay the same key with the same body within 24 hours and you get the original response back instead of a second send — the safe way to retry after a timeout, which is the one situation where you genuinely cannot tell whether the request arrived.
The same key with a different body is refused with 409 idempotency_key_reused. That is almost always a hardcoded key, and it is much better found here than after the second campaign goes out under the first one’s response.
curl -X POST https://bulksmsapi.selanim.com/v1/sms/send \
-H "Authorization: Bearer $SBS_API_KEY" \
-H "Idempotency-Key: order-4471-confirmation" \
-H "Content-Type: application/json" \
-d '{ "senderId": "SELANIM", "to": ["255712345678"], "message": "Order 4471 confirmed." }'Use something meaningful

