Build with SMS, OTP and voice
One API key lets your app send text messages, one-time codes and voice calls to phones in Ghana. Requests and responses are JSON.
Introduction
The SyntraX API is a small REST API. Every endpoint lives under /v1 on the site you signed up on. In the examples below we use:
https://sms.syntrax.dev/v1Quick start
You can send your first test message in about five minutes.
- Create an account. Sign up in the portal. New accounts get a few free welcome credits.
- Create an API key. Open API keys in the portal and create a key. Copy it right away. We show the full key only once.
- Test in sandbox. Add
"sandbox": trueto your request. Nothing is sent to a real phone and nothing is charged. - Go live. Remove the sandbox flag. Make sure you have SMS credits or main balance, and the message goes out for real.
Here is a sandbox SMS. Swap in your own key and run it.
Authentication
Send your API key as a Bearer token in the Authorization header on every request.
Keys start with sx_live_ followed by 32 characters. Keep them on your server. Never put a key in a mobile app or in browser code.
Key settings
Each key has its own settings. You change them in the portal under API keys. There is no API endpoint for managing keys.
| Setting | What it does |
|---|---|
| Name | A label so you can tell keys apart. |
| Webhook URL | Where we send delivery events for this key. See Webhooks. |
| IP allowlist | Only accept calls from these addresses. Use IPv4 or IPv6 addresses, or IPv4 ranges like 41.66.0.0/16, one per line or comma separated. Leave empty to allow any IP. |
| SMS limit | The most SMS credits this key may spend. Leave empty for no limit. |
| OTP limit | The most credits this key may spend on OTP codes. Leave empty for no limit. |
| Voice | Voice calls are off by default on new keys. Turn this on before calling /v1/voice. |
You can also roll a key (get a new secret, the old one stops working), reset its usage counters, or revoke it.
Sandbox mode
Sandbox lets you test your integration for free. Turn it on per request in either of these ways:
- Add
"sandbox": trueto the JSON body. - Send the header
X-Sandbox: 1.
In sandbox:
- Nothing reaches a real phone and nothing is charged.
- SMS and voice get a simulated final status straight away. Most numbers come back
delivered, a few come backfailed. The same number always gives the same result, so your tests stay stable. - The OTP code is always
123456. Verify it with sandbox turned on too. - Sender ID rules still apply. Use
SyntraXor one of your approved sender IDs. - Key limits are still checked, but sandbox calls do not use them up.
Idempotency
Networks fail. To retry a send safely, add an Idempotency-Key header with a value that is unique to that send, like your order number.
- If we already handled a successful request with that key, we return the first response again and add
"idempotent_replay": true. You are not charged twice. - Failed requests are not saved, so you can fix the problem and retry with the same key.
- Keys are shared across your whole account, not per API key.
- It works on
POST /v1/messages,POST /v1/otpandPOST /v1/voice.
Phone numbers
Send numbers in any of these formats. We turn them all into +233241234567.
| You send | We use |
|---|---|
0241234567 | +233241234567 |
233241234567 | +233241234567 |
+233241234567 | +233241234567 |
Spaces, dashes and brackets are removed. A number starting with 00 is read as an international number.
Billing
Your account has two balances: SMS credits and a main balance in Ghana cedis (GHS). You top up both in the portal.
| Product | Price | Paid from |
|---|---|---|
| SMS | 1 credit per part | Credits first. If you run short, each missing credit costs GHS 0.05 from main balance. |
| OTP | 3 credits per code (up to 6 digits) 4 credits per code (7 or 8 digits) | Credits first. Main balance covers any shortfall. |
| Voice | GHS 2.50 per call | Main balance only. Credits are never used. |
- Every send response has a
chargedobject showing what was taken and what is left. - If credits come from bundles with an expiry date, the ones closest to expiry are used first.
- If you cannot cover the cost, the call fails with
insufficient_fundsand nothing is taken.
Automatic refunds
You get your credits and cash back automatically when a message:
- fails at the gateway while sending (refunded at once),
- is reported
failed,rejectedorundeliveredby the network, or - stays in
sentfor more than 24 hours and is markedexpired.
Units: cost_cash, charged.cash and charged.wallet_balance are in pesewas (100 pesewas = GHS 1). GET /v1/balance returns cedis as strings.
Check your balance
/v1/balanceReturns your balances, how many messages you can send right now, and your current rates. Credits are counted first, then main balance.
| Field | Type | Description |
|---|---|---|
| sms_credits | integer | SMS credits you hold. |
| main_balance | string | Main balance in cedis, like "12.50". |
| sms_sendable | integer | Single-part SMS you can send with credits plus main balance. |
| otp_sendable | integer | Estimate of 6-digit OTP codes you can send. |
| voice_sendable | integer | Voice calls your main balance covers. |
| rates | object | Your current prices. Cash values are in cedis. |
Rate limits
You can make up to 120 requests per minute to /v1 from one IP address. Every response tells you where you stand:
X-RateLimit-Limit: requests allowed per minute.X-RateLimit-Remaining: requests left in this minute.
Go over and you get 429 with error rate_limited, a Retry-After header and retry_after_seconds in the body. Wait that long, then try again.
Send text messages
Send one SMS per request and track it until it is delivered.
Send an SMS
/v1/messagesSends one message to one phone number. To reach many people, make one call per number.
| Name | Type | Required | Description |
|---|---|---|---|
| to | string | Required | Recipient phone number. See phone formats. |
| text | string | Required | The message. Long messages are split into parts. |
| sender | string | Optional | Name shown to the receiver. Defaults to SyntraX. Anything else must be one of your approved sender IDs. |
| sandbox | boolean | Optional | true to test without sending or paying. |
Optional headers: Idempotency-Key and X-Sandbox: 1.
Response fields
| Field | Type | Description |
|---|---|---|
| id | integer | SyntraX message ID. Use it to check status. |
| type | string | sms, otp or voice. |
| recipient | string | The number in +233 format. |
| status | string | Current status. |
| provider_message_id | string | ID from the delivery network. Also works for status lookups. |
| parts | integer | Number of SMS parts billed. |
| sandbox | boolean | Whether this was a test send. |
| cost_credits | integer | Credits taken. |
| cost_cash | integer | Main balance taken, in pesewas. |
| created_at | string | Time in UTC, YYYY-MM-DD HH:MM:SS. |
| charged | object | credits and cash taken, plus balance (credits left) and wallet_balance (main balance left, in pesewas). |
Message length and parts
Long messages are split into parts. You pay 1 credit per part.
| Text type | 1 part | Each part when longer |
|---|---|---|
| Plain text (basic English letters, numbers, symbols) | up to 160 characters | 153 characters |
| Unicode (emoji, accents, any non-ASCII character) | up to 70 characters | 67 characters |
One emoji or curly quote turns the whole message into Unicode. A 100 character message with one emoji costs 2 credits instead of 1.
Check message status
/v1/messages/{id}Look up any message on your account (SMS, OTP or voice). Use the SyntraX id or the provider_message_id.
If the message is not on your account you get 404 with {"error": "not_found"}.
Status values
| Status | Meaning |
|---|---|
queued | Saved and about to go to the network. |
sent | Handed to the network. Waiting for a delivery report. |
delivered | The phone received it. |
failed | It could not be delivered. You are refunded. |
expired | No delivery report after 24 hours. You are refunded. |
The network may also report rejected or undelivered. Treat these like failed. They are refunded too.
We update statuses from the network's delivery reports and by checking pending messages in the background. Want updates pushed to you? Set up webhooks.
One-time codes
Send a login or confirmation code by SMS, then check the code your user types back.
How it works
- You call
POST /v1/otpwith the phone number. - We create the code and text it. The code is made by our delivery network. Neither you nor SyntraX ever sees it.
- Your user types the code into your app.
- You call
POST /v1/otp/verifywith the number and the code. We tell you if it matches.
By default codes are 6 digits and stay valid for 5 minutes. The default message is:
Send a code
/v1/otp| Name | Type | Required | Description |
|---|---|---|---|
| to | string | Required | Phone number to send the code to. |
| length | integer | Optional | Number of digits. Default 6. Use 4 to 8. Codes of 7 or 8 digits cost 4 credits instead of 3. |
| message | string | Optional | Your own wording. Put %otp_code% where the code should go. |
| sender | string | Optional | Sender ID. Defaults to SyntraX. |
| sandbox | boolean | Optional | true to test. The code is then always 123456. |
The response has the same fields as an SMS send. It may also include ussd_code when the network returns one. Sandbox responses do not include it. The code itself is never in the response.
Verify a code
/v1/otp/verify| Name | Type | Required | Description |
|---|---|---|---|
| to | string | Required | The same phone number you sent the code to. |
| code | string | Required | The code your user typed. |
| sandbox | boolean | Optional | Set this if the code was sent in sandbox. |
A wrong or expired code still returns 200, with "verified": false. When the network gives a reason, it is in detail. Always check verified, not just the status code.
Verifying is free. The Idempotency-Key header is ignored on this endpoint.
Testing OTP
Send the code with sandbox on, then verify with sandbox on and the code 123456. Any other code returns "verified": false.
Do not mix modes. A live code checked with sandbox on, or a sandbox code checked live, will not verify.
Voice calls
Call a phone number and have your text read aloud. Good for reminders and for people who prefer to listen.
Before you start
- Turn on voice for your key. It is off by default. Open API keys in the portal and switch voice on. Without it you get
403 voice_disabled. - Top up your main balance. Each call costs GHS 2.50 and is paid from main balance only. SMS credits are not used.
Make a call
/v1/voice| Name | Type | Required | Description |
|---|---|---|---|
| to | string | Required | Phone number to call. |
| text | string | Required | What the call should say. Keep it short and clear. Aim for 320 characters or fewer. |
| sender | string | Optional | Checked against your sender IDs like SMS, but it is not shown on the call. |
| sandbox | boolean | Optional | true to test without calling or paying. |
Here cost_cash is 250 pesewas, which is GHS 2.50.
Tracking calls
Use GET /v1/messages/{id} with the call's id. Voice uses the same status values as SMS. If your key has a webhook URL, it is passed to the network with each call so call events can reach you.
Sender IDs
The sender ID is the name your customers see as the sender of your SMS, like your business name.
Default sender
Every account can use SyntraX straight away. If you leave out sender, we use it for you. No approval needed.
Get your own sender ID
Request custom sender IDs in the portal under Sender IDs. This is portal only. There is no API endpoint to request, list or delete sender IDs.
- Pick a name. 3 to 11 characters, letters and numbers only, with at least one letter. No spaces.
- Explain the use. Tell us what you will send and to whom. Clear reasons are approved faster.
- Wait for approval. The request shows as
pendinguntil it isapprovedorrejected. Rejections come with a reason. - Start using it. Pass the name in the
senderfield.
Names that copy networks, banks, payment brands or government bodies (for example MTN, Telecel, MoMo, GRA or Police) are blocked.
Using a sender ID
Write the name exactly as it was approved, with the same capital letters. If it does not match, the send fails and you are not charged:
| Error | Status | Meaning |
|---|---|---|
sender_not_found | 402 | You have not requested this name, or it is spelled differently. |
sender_not_approved | 402 | The name is still pending, or it was rejected. |
The same rules apply in sandbox.
Webhooks
Let SyntraX tell your server when a message is delivered, fails or expires, and when your balance runs low.
Set up a webhook
Webhooks are set per API key in the portal under API keys. Add your HTTPS URL. We then show a signing secret that starts with whsec_. Save it. You need it to check that events really come from us.
Message events go to the webhook of the key that sent the message. Account events go to every active key that has a webhook URL.
Events
| Event | When it fires |
|---|---|
message.delivered | The network reports the message was delivered. |
message.failed | The network reports the message failed. Other network results come through the same way, for example message.rejected or message.undelivered. |
message.expired | A message stayed in sent for 24 hours. It is refunded. |
account.low_balance | Your credits or main balance dropped to your alert level. Sent once until your balance recovers. |
account.topup_succeeded | A payment went through and your balance was topped up. |
Message events come from the network's delivery reports. Sandbox sends do not trigger them. If a status is picked up by our background check instead, the status updates but no event is sent. Use GET /v1/messages/{id} as a backup.
Payload
We send a POST with a JSON body. Every event has the same outer shape: event, timestamp (ISO 8601, UTC) and data.
Each request also has Content-Type: application/json and User-Agent: SyntraX-Webhooks/1.0.
Verify the signature
Every request carries an X-SyntraX-Signature header. It is the HMAC-SHA256 of the raw request body, using your whsec_ secret as the key, written as lowercase hex.
To check it, compute the same HMAC over the raw body (before you parse the JSON) and compare. If they differ, reject the request.
Responding and retries
- Reply with any
2xxstatus within 10 seconds. Do slow work after you reply. - If we get an error, a timeout or no answer, we try again after 1, 5, 30 and 120 minutes. After 5 failed attempts we stop.
- The same event can arrive more than once. Use
data.idandeventto skip ones you already handled.
Errors
When something goes wrong you get a non-2xx status and a JSON body that says why.
Error format
Every error has an error field. Some also have message (a human-readable note) or detail (extra data).
HTTP status codes
| Status | Meaning |
|---|---|
| 200 | It worked. |
| 400 | A required field is missing or the request is not valid JSON. |
| 401 | No API key, or the key is wrong or revoked. |
| 402 | The send could not go ahead: not enough balance, a sender ID problem, or a network error. |
| 403 | The key is valid but not allowed to do this. |
| 404 | The message was not found on your account. |
| 429 | Too many requests, or the key reached its limit. |
Error codes
| Status | error | What to do |
|---|---|---|
| 400 | to and text required | Add both fields. Returned by /v1/messages and /v1/voice. |
| 400 | to required | Add the phone number. Returned by /v1/otp. |
| 400 | to and code required | Add both fields. Returned by /v1/otp/verify. |
| 400 | invalid_recipient | The phone number could not be read. Check the format. On send endpoints this comes back as 402. |
| 401 | missing_bearer_token | Add Authorization: Bearer sx_live_.... |
| 401 | invalid_api_key | The key is wrong, rolled or revoked. Copy a fresh one from the portal. |
| 402 | insufficient_funds | Top up credits or main balance. detail shows what you have (cash in pesewas). |
| 402 | sender_not_found | Request the sender ID in the portal, or check the spelling. |
| 402 | sender_not_approved | Wait for approval, or use SyntraX. |
| 402 | gateway_error | The network did not accept the message. You were refunded. detail has the reason. Try again later. |
| 403 | ip_not_allowed | Your server's IP is not on the key's allowlist. The body includes the ip we saw. Add it in the portal. |
| 403 | account_inactive | Your account is suspended or closed. Contact support. |
| 403 | voice_disabled | Turn on voice for this key in the portal. |
| 404 | not_found | No message with that ID on your account. |
| 429 | cap_exceeded | The key hit its SMS or OTP limit. message says which one. Raise the limit or reset usage in the portal. |
| 429 | rate_limited | Slow down. Wait retry_after_seconds, then retry. |
Retrying safely
- Retry on
429 rate_limited,402 gateway_errorand network timeouts. Wait a little longer each time. - Do not retry
400,401or403without fixing the request first. It will fail the same way. - Always send an
Idempotency-Keyon sends, so a retry never charges or texts twice.