Skip to main content

Using the API

Call your gateway from your own software: base URL, API keys and scopes, a first SMS with curl, errors, pagination, idempotency and the interactive reference.

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: true header;
  • 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.

Going further

  • API keys: create, restrict, rotate and revoke keys.
  • Webhooks: be called when an SMS arrives or a status changes.
  • Security: what protects your installation and what you should check.

Search the documentation

Type a few words, then pick a page.