SyntraX API docs
Overview SMS OTP Voice Sender IDs Webhooks Errors
SyntraX API

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:

BASE URLhttps://sms.syntrax.dev/v1

Quick start

You can send your first test message in about five minutes.

  1. Create an account. Sign up in the portal. New accounts get a few free welcome credits.
  2. 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.
  3. Test in sandbox. Add "sandbox": true to your request. Nothing is sent to a real phone and nothing is charged.
  4. 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.

{ "id": 1, "type": "sms", "recipient": "+233241234567", "status": "delivered", "provider_message_id": "sbx_16eb07e1ae9866833eb0", "parts": 1, "sandbox": true, "cost_credits": 0, "cost_cash": 0, "created_at": "2026-11-02 17:12:28", "charged": { "credits": 0, "cash": 0, "balance": 25, "wallet_balance": 0 } }

Authentication

Send your API key as a Bearer token in the Authorization header on every request.

Authorization: Bearer sx_live_your_key_here

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.

SettingWhat it does
NameA label so you can tell keys apart.
Webhook URLWhere we send delivery events for this key. See Webhooks.
IP allowlistOnly 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 limitThe most SMS credits this key may spend. Leave empty for no limit.
OTP limitThe most credits this key may spend on OTP codes. Leave empty for no limit.
VoiceVoice 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": true to 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 back failed. 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 SyntraX or 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.

Idempotency-Key: order-1042
  • 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/otp and POST /v1/voice.

Phone numbers

Send numbers in any of these formats. We turn them all into +233241234567.

You sendWe 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.

ProductPricePaid from
SMS1 credit per partCredits first. If you run short, each missing credit costs GHS 0.05 from main balance.
OTP3 credits per code (up to 6 digits)
4 credits per code (7 or 8 digits)
Credits first. Main balance covers any shortfall.
VoiceGHS 2.50 per callMain balance only. Credits are never used.
  • Every send response has a charged object 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_funds and 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, rejected or undelivered by the network, or
  • stays in sent for more than 24 hours and is marked expired.

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

GET/v1/balance

Returns your balances, how many messages you can send right now, and your current rates. Credits are counted first, then main balance.

{ "sms_credits": 240, "main_balance": "12.50", "currency": "GHS", "sms_sendable": 490, "otp_sendable": 163, "voice_sendable": 5, "rates": { "sms_credits_per_part": 1, "sms_cash_per_part": "0.05", "otp_credits": 3, "otp_credits_long": 4, "voice_cash": "2.50" } }
FieldTypeDescription
sms_creditsintegerSMS credits you hold.
main_balancestringMain balance in cedis, like "12.50".
sms_sendableintegerSingle-part SMS you can send with credits plus main balance.
otp_sendableintegerEstimate of 6-digit OTP codes you can send.
voice_sendableintegerVoice calls your main balance covers.
ratesobjectYour 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.

{ "error": "rate_limited", "retry_after_seconds": 42 }
SMS

Send text messages

Send one SMS per request and track it until it is delivered.

Send an SMS

POST/v1/messages

Sends one message to one phone number. To reach many people, make one call per number.

NameTypeRequiredDescription
tostringRequiredRecipient phone number. See phone formats.
textstringRequiredThe message. Long messages are split into parts.
senderstringOptionalName shown to the receiver. Defaults to SyntraX. Anything else must be one of your approved sender IDs.
sandboxbooleanOptionaltrue to test without sending or paying.

Optional headers: Idempotency-Key and X-Sandbox: 1.

{ "id": 2, "type": "sms", "recipient": "+233241234567", "status": "sent", "provider_message_id": "job_8f2c41d07a", "parts": 1, "sandbox": false, "cost_credits": 1, "cost_cash": 0, "created_at": "2026-11-02 17:12:28", "charged": { "credits": 1, "cash": 0, "balance": 239, "wallet_balance": 1250 } }

Response fields

FieldTypeDescription
idintegerSyntraX message ID. Use it to check status.
typestringsms, otp or voice.
recipientstringThe number in +233 format.
statusstringCurrent status.
provider_message_idstringID from the delivery network. Also works for status lookups.
partsintegerNumber of SMS parts billed.
sandboxbooleanWhether this was a test send.
cost_creditsintegerCredits taken.
cost_cashintegerMain balance taken, in pesewas.
created_atstringTime in UTC, YYYY-MM-DD HH:MM:SS.
chargedobjectcredits 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 type1 partEach part when longer
Plain text (basic English letters, numbers, symbols)up to 160 characters153 characters
Unicode (emoji, accents, any non-ASCII character)up to 70 characters67 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

GET/v1/messages/{id}

Look up any message on your account (SMS, OTP or voice). Use the SyntraX id or the provider_message_id.

{ "id": 2, "provider_message_id": "job_8f2c41d07a", "status": "delivered", "type": "sms", "recipient": "+233241234567", "created_at": "2026-11-02 17:12:28" }

If the message is not on your account you get 404 with {"error": "not_found"}.

Status values

StatusMeaning
queuedSaved and about to go to the network.
sentHanded to the network. Waiting for a delivery report.
deliveredThe phone received it.
failedIt could not be delivered. You are refunded.
expiredNo 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.

OTP

One-time codes

Send a login or confirmation code by SMS, then check the code your user types back.

How it works

  1. You call POST /v1/otp with the phone number.
  2. We create the code and text it. The code is made by our delivery network. Neither you nor SyntraX ever sees it.
  3. Your user types the code into your app.
  4. You call POST /v1/otp/verify with 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:

Your SyntraX code is %otp_code%. Valid for 5 minutes.

Send a code

POST/v1/otp
NameTypeRequiredDescription
tostringRequiredPhone number to send the code to.
lengthintegerOptionalNumber of digits. Default 6. Use 4 to 8. Codes of 7 or 8 digits cost 4 credits instead of 3.
messagestringOptionalYour own wording. Put %otp_code% where the code should go.
senderstringOptionalSender ID. Defaults to SyntraX.
sandboxbooleanOptionaltrue to test. The code is then always 123456.
{ "id": 3, "type": "otp", "recipient": "+233241234567", "status": "sent", "provider_message_id": "51234", "parts": 1, "sandbox": false, "cost_credits": 3, "cost_cash": 0, "created_at": "2026-11-02 17:14:05", "ussd_code": "*920*44#", "charged": { "credits": 3, "cash": 0, "balance": 236, "wallet_balance": 1250 } }

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

POST/v1/otp/verify
NameTypeRequiredDescription
tostringRequiredThe same phone number you sent the code to.
codestringRequiredThe code your user typed.
sandboxbooleanOptionalSet this if the code was sent in sandbox.
{ "verified": true, "detail": null }

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.

{ "verified": false, "detail": null }

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

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

POST/v1/voice
NameTypeRequiredDescription
tostringRequiredPhone number to call.
textstringRequiredWhat the call should say. Keep it short and clear. Aim for 320 characters or fewer.
senderstringOptionalChecked against your sender IDs like SMS, but it is not shown on the call.
sandboxbooleanOptionaltrue to test without calling or paying.
{ "id": 4, "type": "voice", "recipient": "+233241234567", "status": "sent", "provider_message_id": "job_51c0e9aa21", "parts": 1, "sandbox": false, "cost_credits": 0, "cost_cash": 250, "created_at": "2026-11-02 17:15:40", "charged": { "credits": 0, "cash": 250, "balance": 236, "wallet_balance": 1000 } }

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

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.

  1. Pick a name. 3 to 11 characters, letters and numbers only, with at least one letter. No spaces.
  2. Explain the use. Tell us what you will send and to whom. Clear reasons are approved faster.
  3. Wait for approval. The request shows as pending until it is approved or rejected. Rejections come with a reason.
  4. Start using it. Pass the name in the sender field.

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:

ErrorStatusMeaning
sender_not_found402You have not requested this name, or it is spelled differently.
sender_not_approved402The name is still pending, or it was rejected.

The same rules apply in sandbox.

Webhooks

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

EventWhen it fires
message.deliveredThe network reports the message was delivered.
message.failedThe network reports the message failed. Other network results come through the same way, for example message.rejected or message.undelivered.
message.expiredA message stayed in sent for 24 hours. It is refunded.
account.low_balanceYour credits or main balance dropped to your alert level. Sent once until your balance recovers.
account.topup_succeededA 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.

{ "event": "message.delivered", "timestamp": "2026-11-02T17:13:02.418Z", "data": { "id": 2, "recipient": "+233241234567", "type": "sms", "status": "delivered", "parts": 1 } }
{ "event": "account.low_balance", "timestamp": "2026-11-02T18:00:00.000Z", "data": { "account_id": 7, "sms_credits": 42, "wallet_balance_pesewas": 350, "low_credit_threshold": 50, "low_cash_threshold_pesewas": 500 } }
{ "event": "account.topup_succeeded", "timestamp": "2026-11-02T18:05:11.903Z", "data": { "payment_id": 18, "reference": "syntra_4f1c9a0b7e2d6c8a13f5b0e9", "purpose": "credits", "kind": "bundle", "credits": 1000, "cash_pesewas": 0, "amount_pesewas": 4000 } }

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 2xx status 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.id and event to skip ones you already handled.
Errors

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).

{ "error": "insufficient_funds", "detail": { "ok": false, "reason": "insufficient_funds", "creditsAvailable": 0, "walletBalance": 20 } }

HTTP status codes

StatusMeaning
200It worked.
400A required field is missing or the request is not valid JSON.
401No API key, or the key is wrong or revoked.
402The send could not go ahead: not enough balance, a sender ID problem, or a network error.
403The key is valid but not allowed to do this.
404The message was not found on your account.
429Too many requests, or the key reached its limit.

Error codes

StatuserrorWhat to do
400to and text requiredAdd both fields. Returned by /v1/messages and /v1/voice.
400to requiredAdd the phone number. Returned by /v1/otp.
400to and code requiredAdd both fields. Returned by /v1/otp/verify.
400invalid_recipientThe phone number could not be read. Check the format. On send endpoints this comes back as 402.
401missing_bearer_tokenAdd Authorization: Bearer sx_live_....
401invalid_api_keyThe key is wrong, rolled or revoked. Copy a fresh one from the portal.
402insufficient_fundsTop up credits or main balance. detail shows what you have (cash in pesewas).
402sender_not_foundRequest the sender ID in the portal, or check the spelling.
402sender_not_approvedWait for approval, or use SyntraX.
402gateway_errorThe network did not accept the message. You were refunded. detail has the reason. Try again later.
403ip_not_allowedYour server's IP is not on the key's allowlist. The body includes the ip we saw. Add it in the portal.
403account_inactiveYour account is suspended or closed. Contact support.
403voice_disabledTurn on voice for this key in the portal.
404not_foundNo message with that ID on your account.
429cap_exceededThe key hit its SMS or OTP limit. message says which one. Raise the limit or reset usage in the portal.
429rate_limitedSlow down. Wait retry_after_seconds, then retry.
{ "error": "cap_exceeded", "message": "Key limit reached: sms" }

Retrying safely

  • Retry on 429 rate_limited, 402 gateway_error and network timeouts. Wait a little longer each time.
  • Do not retry 400, 401 or 403 without fixing the request first. It will fail the same way.
  • Always send an Idempotency-Key on sends, so a retry never charges or texts twice.