Docs
Send your first SMS in a few minutes.
Quickstart
Use your test key
sk_test_YOUR_TEST_KEY with a key from your dashboard. Sign up and the dashboard docs show these snippets with your real test key filled in.One request sends an SMS to any Mongolian number; the operator is picked for you. The example sends to the magic number 00000000, which is accepted and then delivered about 2 seconds later, without reaching a real phone.
curl https://web-production-c8403.up.railway.app/v1/messages \
-H "Authorization: Bearer sk_test_YOUR_TEST_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{"to": "00000000", "body": "Таны захиалга баталгаажлаа"}'// Node.js 18+ (global fetch)
const res = await fetch('https://web-production-c8403.up.railway.app/v1/messages', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_YOUR_TEST_KEY',
'Content-Type': 'application/json',
'Idempotency-Key': 'order-1042', // safe to retry: never sends twice
},
body: JSON.stringify({ to: '00000000', body: 'Таны захиалга баталгаажлаа' }),
});
const message = await res.json();
if (!res.ok) throw new Error(`${message.error.code}: ${message.error.message}`);
console.log(message.id, message.status); // msg_… queued// npm install @smsmulti/sdk
import { SmsMulti, SmsMultiError } from '@smsmulti/sdk';
const sms = new SmsMulti({ apiKey: 'sk_test_YOUR_TEST_KEY', baseUrl: 'https://web-production-c8403.up.railway.app' });
try {
const message = await sms.messages.send({ to: '00000000', body: 'Таны захиалга баталгаажлаа' });
console.log(message.id, message.status);
} catch (err) {
if (err instanceof SmsMultiError) console.error(err.code, err.message, err.docUrl);
else throw err;
}# pip install requests
import requests
res = requests.post(
"https://web-production-c8403.up.railway.app/v1/messages",
headers={
"Authorization": "Bearer sk_test_YOUR_TEST_KEY",
"Idempotency-Key": "order-1042",
},
json={"to": "00000000", "body": "Таны захиалга баталгаажлаа"},
timeout=30,
)
message = res.json()
if not res.ok:
raise RuntimeError(f"{message['error']['code']}: {message['error']['message']}")
print(message["id"], message["status"])The response is 202 with the message object (status: "queued"). Send the same Idempotency-Key again and you get the stored response back instead of a second SMS. Mongolian Cyrillic switches the message to UCS-2 (70 characters per segment instead of 160); pass "transliterate": true to send it as Latin and pay for fewer segments.
Check a message
curl https://web-production-c8403.up.railway.app/v1/messages/msg_YOUR_MESSAGE_ID \
-H "Authorization: Bearer sk_test_YOUR_TEST_KEY"Statuses are honest. Most operators send no delivery report, so their final status is accepted (the operator took it). G-Mobile reports delivered or undelivered. unknown means the request may have reached the operator; it is never retried automatically, so nobody gets the SMS twice. failed, expired and canceled messages are refunded.
Test mode and magic numbers
Test keys (sk_test_…) go through the same queue, worker, statuses and webhooks as live keys, but never reach an operator and cost nothing. A normal Mongolian number is routed (so you see the operator) and then accepted without being sent. These numbers simulate every outcome:
| Number | What happens | Final status |
|---|---|---|
| 00000000 | accepted, then delivered ~2s later | delivered |
| 00000001 | failed / operator_rejected | failed / operator_rejected |
| 00000002 | unknown / operator_timeout | unknown / operator_timeout |
| 00000003 | accepted after a 30s delay (slow operator) | accepted |
| 00000004 | rejected at the API with recipient_opted_out (no message created) | HTTP 400 recipient_opted_out |
| 00000005 | retryable twice, then accepted (attempts: 3) | accepted |
| 00000006 | accepted, then undelivered ~2s later | undelivered |
| 0000xxxx | Any other number starting with 0000 | HTTP 400 invalid_phone |
Live keys reject magic numbers with invalid_phone.
One-time passwords
POST /v1/otp generates and sends a code (4 to 10 digits, valid 60 to 900 seconds). We store only a hash. POST /v1/otp/check verifies it: 5 wrong tries fail the verification, a correct code works once. Per number: one send per 30 seconds, 5 per 10 minutes, 20 per day. In test mode the response includes test_code.
# 1. Send a code (test mode answers with "test_code")
curl https://web-production-c8403.up.railway.app/v1/otp \
-H "Authorization: Bearer sk_test_YOUR_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "00000000", "app_name": "MyShop"}'
# -> {"id": "ver_…", "status": "pending", "expires_at": "…", "test_code": "123456"}
# 2. Check what the user typed
curl https://web-production-c8403.up.railway.app/v1/otp/check \
-H "Authorization: Bearer sk_test_YOUR_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "ver_…", "code": "123456"}'
# -> {"id": "ver_…", "status": "approved", "valid": true}import { SmsMulti } from '@smsmulti/sdk';
const sms = new SmsMulti({ apiKey: 'sk_test_YOUR_TEST_KEY', baseUrl: 'https://web-production-c8403.up.railway.app' });
const otp = await sms.otp.send({ to: '00000000', app_name: 'MyShop' });
// …later, with the code the user typed:
const { valid } = await sms.otp.check({ id: otp.id, code: userInput });
if (!valid) throw new Error('Wrong or expired code');Webhooks
Add an endpoint in the dashboard (or POST /v1/webhooks) to get message.*, otp.approved, batch.completed, inbound.received and balance.low events. Each delivery is signed with Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature). Answer with any 2xx within 15 seconds; failures are retried after 5s, 5m, 30m, 2h, 5h, 10h and 10h. Live endpoints must use https:// and a public host.
import express from 'express';
import { verifyWebhook } from '@smsmulti/sdk';
const app = express();
// Keep the raw body: re-serialized JSON will not verify.
app.post('/webhooks/sms', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhook({
payload: req.body,
headers: req.headers,
secret: process.env.SMSMULTI_WEBHOOK_SECRET, // whsec_…
});
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString('utf8'));
if (event.type === 'message.delivered') {
// event.data is the message object
}
res.status(200).end(); // any 2xx; we retry anything else
});import { createHmac, timingSafeEqual } from 'node:crypto';
/** Standard Webhooks: v1,<base64 HMAC-SHA256(secret, id.timestamp.body)>. */
export function verify(rawBody, headers, secret, toleranceSeconds = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
// During a secret rotation two signatures are sent, space-separated.
return signatures.split(' ').some((part) => {
const [version, sig] = part.split(',');
if (version !== 'v1' || !sig) return false;
const given = Buffer.from(sig, 'base64');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Local development
smsmulti listen streams your project's events and forwards each one to a local URL, signed exactly like a production delivery, so you can build your webhook handler without a public URL.
npm install -g @smsmulti/cli
export SMSMULTI_API_KEY=sk_test_YOUR_TEST_KEY
export SMSMULTI_BASE_URL=https://web-production-c8403.up.railway.app
# Streams your events and POSTs each one, signed like a real webhook, to your local server
smsmulti listen --forward-to http://localhost:4000/webhooks/sms
# Send from the terminal too
smsmulti send --to 00000000 --body "Hello from the CLI"Reference
- OpenAPI 3.1 document
/v1/openapi.json - Every error code (each API error links to its entry through
doc_url)
Pricing