SelanimDocs

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.

bash
Authorization: Bearer sbs_live_7f4c9a2e1d8b45f0a3c6e9d2b7f1a4c8

If 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 typeTypeDescription
sbs_live_…stringSends real messages and debits your wallet.
sbs_test_…stringRuns 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 numberTypeDescription
255740000001stringAlways reports delivered.
255740000002stringAlways reports failed.
255740000003stringAlways reports expired.

Everything else is deterministic too

Any other number gets a fixed outcome derived from the number itself — roughly nine in ten delivered — so the same test suite gives the same answer twice. Timings vary slightly, which means your handler gets rehearsed against events arriving out of order, exactly as they do in production.

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.

ScopeTypeDescription
sms:sendstringPOST /v1/sms/send.
sms:readstringThe message log and single-message status.
wallet:readstringBalance and ledger.
sender_ids:readstringList the names you may send under.
sender_ids:writestringRequest a new sender ID.
contacts:readstringRead contacts and groups.
contacts:writestringCreate, edit, import and delete contacts and groups.
webhooks:readstringRead your webhook configuration.
webhooks:writestringChange 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

A key can send messages and spend money. Never ship one in a mobile app, a browser bundle, or a public repository — anything that reaches a user’s device is public, however compiled it looks. Route calls through your own backend.

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.

POSThttps://bulksmsapi.selanim.com/v1/keys/rotate
bash
curl -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.

bash
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

A key derived from what you are sending — an order id, an invoice number — is self-documenting and naturally unique. A random UUID works too, as long as you keep it across the retry: generating a fresh one on each attempt gives you no protection at all.