Everything the dashboard does goes through a REST API, and that same API is open to your own software: a CRM that sends appointment reminders, a website that sends a confirmation code, a script that reads the replies. The API runs on your gateway, on your server. Your calls never go through us.
This page is for the developer who connects an application to the gateway. It covers what you need for a first working call and the rules every endpoint follows. The full list of endpoints, with every field, lives in the interactive reference built into your gateway.
Base URL
The API answers under /api/v1 on the address of your gateway:
| How the gateway was installed | Base URL |
|---|---|
| With a domain name (recommended) | https://sms.example.com/api/v1 |
| On a local network, without a domain | http://<machine>.local:8080/api/v1 |
| From the server itself | http://127.0.0.1:8080/api/v1 |
Requests and responses are JSON (Content-Type: application/json). Dates are in ISO 8601, in UTC
(2026-09-21T09:14:02Z). Phone numbers are always in international E.164 format: a +, the
country code, then the number, with no spaces (+261341234567).
A request body is capped at 2 MiB, which is far more than any send needs. Beyond that the answer is
413 PAYLOAD_TOO_LARGE.
Authentication: API keys
An application authenticates with an API key, sent in the X-API-KEY header of every call:
curl -H "X-API-KEY: sk_..." https://sms.example.com/api/v1/messages?limit=1
The key always travels in that header, never in the URL: a URL ends up in server logs, in browser history and in shared links, and a key written there should be considered leaked. A key in the query string is simply not read.
Keys are created on the API keys page of the dashboard, by a superadmin. Each key has:
| Setting | What it does |
|---|---|
| A scope | read, send or admin: what the key is allowed to do (below) |
| Allowed IP addresses | Optional. When set, calls from any other address are refused |
| Calls per minute | Optional. Protects against a burst from a runaway script |
| Calls per day | Optional. Caps the total volume, counted per UTC day |
The secret is shown once, when the key is created: the gateway only keeps a fingerprint of it. Creating, rotating and revoking keys is explained in API keys.
The three scopes
The scopes are ordered: send can do everything read can, and admin can do everything.
| Scope | What it opens |
|---|---|
read |
Every read: messages, conversations, contacts, groups, devices, campaigns, statistics, the activity log |
send |
Everything read allows, plus sending an SMS (POST /messages) and retrying a failed one (POST /messages/{id}/retry) |
admin |
The whole API: configuration, contacts, templates, rules, campaigns, webhooks, bulk exports, backups, the technical log and API keys themselves |
A few reads also require admin, because they hand over a large amount of data at once or describe
the inside of the installation: the bulk exports (/export/..., /contacts/export), backups
(/backups/...), the list of dashboard accounts (/users) and the technical log
(/logs/technical). Creating, editing or deleting dashboard accounts is refused to every key, even
admin: those actions need a person signed in to the dashboard.
Give each application the narrowest scope that does the job. A website that sends confirmation codes
needs send, not admin.
The dashboard does not use keys
The dashboard signs in with a session cookie, not with a key. That is why some endpoints you see in the reference (sign-in, password, first account) are of no use to an integration: they belong to the dashboard.
A first call: send an SMS and follow it
1. Send
curl -X POST https://sms.example.com/api/v1/messages \
-H "X-API-KEY: sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-10482-confirmation" \
-d '{"to": "+261341234567", "body": "Your order 10482 is ready."}'
The gateway answers 202 Accepted as soon as the message is queued, before it leaves the phone:
{
"accepted": [
{
"id": "0192f0c1-7a3e-7c55-9d1e-2b8f4a6c1d20",
"conversationId": "0192f0c1-7a3e-7c55-9d1e-2b8f4a6c1d21",
"direction": "out",
"phone": "+261341234567",
"body": "Your order 10482 is ready.",
"encoding": "gsm7",
"segments": 1,
"status": "queued",
"attempts": 0,
"createdAt": "2026-09-21T09:14:02Z"
}
]
}
Options of a send
Every field of the request:
| Field | Default | Allowed values | Effect |
|---|---|---|---|
to |
required | One E.164 number, or an array of 1 to 500 | One message is queued per number. Beyond 500, use a campaign, which paces the sending |
body |
required | Any non-empty text | No length limit: a long text is split into several SMS (segments). An accent or emoji outside the GSM alphabet switches the whole message to ucs2, which holds fewer characters per SMS |
deviceId |
none: the gateway chooses | The identifier of a paired phone | Forces that phone and bypasses the automatic choice and the prefix routing rules. If that phone cannot send, the message goes through the normal retries, then ends failed |
simSlot |
none: the gateway chooses | 0 or 1 |
Forces a SIM slot on the chosen phone |
scheduledAt |
none: send now | A future ISO 8601 date | The message stays in the queue until that time. A date in the past is refused with SCHEDULED_AT_IN_PAST |
shortenLinks |
false |
true or false |
true replaces every http:// or https:// address of body with a short link of your gateway that counts the clicks, before the message is queued. The same address twice gets one link, shared by all the recipients of the request; an address that is already one of your short links is left alone. The body returned, stored and sent carries the short links, so its length and segments change. GET /links returns shortUrlLength to compute that length in advance. See Short links |
Without deviceId and simSlot, the phone and SIM are chosen by your routing settings (routing
mode, prefix rules, SIM quotas): see Routing. The daily quota of each SIM
and the retries (Settings > Sending) apply to messages sent through the API exactly as to
messages sent from the dashboard. The send window and the night pause do not: they only hold
campaigns, so an API send leaves at any hour.
The text options of Settings > General, devices (Strip diacritics, Trim extra
whitespace, Uppercase everything) also apply to API sends. They are applied when the message
leaves for a phone, with that phone's settings: the answer to this request shows body,
encoding and segments as you sent them, and GET /messages/{id} shows them as they were sent
once the message has left. See Settings.
The reference also lists templateId and variables on this request. The current version of the
gateway does not apply them to a send through the API: send the final text in body. Templates
are available from the dashboard and in campaigns.
With shortenLinks, replaying a request with the same Idempotency-Key and the same original
body returns the message already queued, even though its stored body now carries short links: no
second link and no second message is created.
If one recipient is invalid, or has opted out, the whole request is refused and nothing is queued: you never have to guess which part of a request went through.
2. Follow its status
curl -H "X-API-KEY: sk_..." \
https://sms.example.com/api/v1/messages/0192f0c1-7a3e-7c55-9d1e-2b8f4a6c1d20
The status field moves through the life of the message:
| Status | Meaning |
|---|---|
queued |
Accepted, waiting for a phone |
dispatched |
Handed to a phone, waiting for its confirmation |
sent |
The phone confirmed it sent the SMS |
delivered |
The carrier confirmed delivery to the recipient |
failed |
Given up after the planned attempts. errorCode says why (list of codes) |
pending_quota_exceeded |
Every usable SIM reached its daily quota: it leaves when a quota frees up |
paused |
Held back, for example by a paused campaign |
cancelled |
Withdrawn before leaving. errorCode says why (CAMPAIGN_CANCELLED, CONTACT_BLACKLISTED) |
A queued message that waits for the pace of its SIMs also carries holdReason (sim_cadence: the
minimum interval between two SMS per SIM; sim_rate: the per-minute limit) and heldUntil, the
time it is tried again. Both are null otherwise. It leaves on its own, without spending an attempt.
delivered depends on the carrier sending a delivery receipt: some do not, and a message may then
stay at sent even though it arrived. How the queue, retries and send windows work is explained in
Sending messages.
Rather than asking again and again, you can let the gateway call you when the status changes:
see Webhooks and the message.sent, message.delivered and
message.failed events.
Idempotency: retry without sending twice
A network drops at the wrong moment, your script did not get the answer, and it tries again. Without a precaution, the recipient receives the SMS twice.
To avoid that, add an Idempotency-Key header to POST /messages, with a value you choose and that
identifies the operation (between 8 and 128 characters, for example order-10482-confirmation):
- the same key sent again within 24 hours with the same body returns the original answer,
without sending anything. That answer carries the
Idempotency-Replayed: trueheader; - the same key sent with a different body is refused with
409 IDEMPOTENCY_CONFLICT: a key names one operation, not a slot to reuse; - a request that failed (an error answer) does not use the key up: you can correct it and send it again with the same key.
Keys are tied to the caller: another application that happens to choose the same value never receives your answer. Still, choose values that are unique to your application (a UUID, or your order number prefixed with the application's name), because a value already used for a different message is refused. The header is optional, but any integration that retries on its own should send it.
Pagination
Every list is paginated by cursor, never by page number:
| Parameter | Role |
|---|---|
limit |
Items per page, from 1 to 200. 50 by default |
cursor |
The nextCursor of the previous page. Leave it out for the first page |
The answer has the list in data, and the cursor of the next page in nextCursor, which is null
on the last page:
curl -H "X-API-KEY: sk_..." "https://sms.example.com/api/v1/messages?limit=100&direction=in"
{ "data": [ ... ], "nextCursor": "0192f0c1-6b2d-7a10-8c3e-5d4f3a2b1c09" }
Pass the cursor back as it is, without trying to interpret it. The message history comes newest first. Because the cursor points at the last item you received rather than at a page number, messages that arrive while you page through do not shift the pages: nothing is skipped or read twice.
Limits and their headers
When the key has a calls per minute limit, every answer carries:
| Header | Content |
|---|---|
X-RateLimit-Limit |
The limit of the key per minute |
X-RateLimit-Remaining |
Calls left in the current minute |
X-RateLimit-Reset |
When the window reopens, in Unix seconds |
Beyond the per-minute limit or the daily quota, the answer is 429 RATE_LIMITED with a
Retry-After header, in seconds. Wait that long before trying again: a refused call still counts, so
retrying faster does not help. The daily quota is counted per UTC day.
Errors
Every error has the same shape, whatever the endpoint:
{
"error": {
"code": "INVALID_PHONE",
"message": "recipient is not a valid international number",
"details": { "recipient": "0341234567" },
"requestId": "req_01J8XK2M9F"
}
}
| Field | Use |
|---|---|
code |
A stable identifier. Your code should test this, never the message |
message |
A short explanation in English, for a human reading a log |
details |
Optional context: the field at fault, the limit, the value that was expected |
requestId |
Give it to whoever looks at the gateway's logs: it finds the request straight away |
The codes you are most likely to meet:
| HTTP | Code | What to do |
|---|---|---|
| 401 | MISSING_API_KEY |
Add the X-API-KEY header |
| 403 | INVALID_API_KEY |
The key is unknown or revoked. Check it, or create a new one |
| 403 | IP_NOT_ALLOWED |
The call comes from an address outside the key's allowed list |
| 403 | FORBIDDEN_SCOPE |
The key's scope does not cover this operation. Use a key with a wider scope |
| 400 | VALIDATION_ERROR |
A field is missing or out of range. details says which |
| 400 | INVALID_PHONE |
A number is not in E.164 format (+ and country code) |
| 400 | EMPTY_BODY |
The message has no text |
| 403 | CONTACT_BLACKLISTED |
A recipient opted out. Nothing was queued |
| 422 | SCHEDULED_AT_IN_PAST |
scheduledAt must be in the future |
| 404 | NOT_FOUND |
The identifier does not exist on this gateway |
| 409 | IDEMPOTENCY_CONFLICT |
That Idempotency-Key was already used for a different request |
| 409 | VALIDATION_ERROR |
A name that must be unique is already taken |
| 413 | PAYLOAD_TOO_LARGE |
The body is over 2 MiB |
| 429 | RATE_LIMITED |
Limit reached. Wait for Retry-After |
| 403 | LICENSE_BLOCKED |
The licence was withdrawn. Reads keep working, writes are refused. See Licence |
| 500 | INTERNAL_ERROR |
An unexpected problem on the gateway. Retry later and pass on the requestId |
An error message never contains the text of an SMS, a full phone number or a key. The complete list
of codes is in the reference, under the ErrorCode schema.
What does not produce an error. A phone that is offline or a SIM that reached its daily quota do
not refuse the request: the message is accepted (202) and the problem shows later, in its status.
When every usable SIM is out of quota, the message waits as pending_quota_exceeded until the quota
frees up. When no phone can take it, the gateway retries according to Settings > Sending (4
attempts by default, with growing delays between them) and marks it failed after the last one.
Watch its status, or subscribe to message.failed.
The interactive reference
Every gateway carries the complete reference of its own API, generated from the same contract as the code that answers. It cannot describe an endpoint your version does not have.
| Where | What you find |
|---|---|
| API reference in the dashboard menu | Every operation grouped by topic, with its fields, answers and error codes |
https://<your gateway>/api/v1/ |
The same reference as a standalone page, with "Try it out": paste a key and run a call |
https://<your gateway>/api-docs/openapi.json |
The raw OpenAPI 3.1 contract, to generate a client in your language |
These pages are readable without signing in: the contract contains no secret and no data of yours. Running a call from them still needs a valid key, with the same checks as any other call. Nothing is loaded from the internet to display them.
To generate a client, point your usual tool (OpenAPI Generator, openapi-typescript, oapi-codegen
and so on) at openapi.json.
Before you install. The same reference is on our website, under API reference at the top of
this documentation (/docs/api-reference), with its contract at /docs/openapi.json. It describes the
latest published version, and its examples use https://your-gateway.example.com/api/v1 as the base
URL: replace it with your gateway's address. Nothing you do on that page reaches a gateway, and it has
no "Try it out". Once your gateway runs, trust its own reference: it matches your installed version
exactly.