GallantSMS API
One endpoint. You write the message once as a template, your code fills in the blanks and sends it. This page is everything you need to go from nothing to a message on a phone.
Before you start
- Create an account. No payment is needed to try the API.
- On API Management, create a sandbox key. It is shown once; copy it then. It looks like
gs_test_followed by 32 characters. - On SMS and Templates, write a template. For example:
Your login code is {{ code }}. It expires in 5 minutes.It works in the sandbox the moment you save it. Copy its slug from the card.
A sandbox key runs the whole API but stops before the gateway. Nothing is sent, nothing is charged. Each sandbox send shows up on the phone on your API Management page, rendered as a handset would show it, so you can see your variables land. Use it until your integration works, then read Going live.
Send a message
POST https://sms.gallantbyte.com/api/template/send with your key in the x-api-key
header and a JSON body naming the template, the number, and a value for each placeholder.
curl -X POST https://sms.gallantbyte.com/api/template/send \
-H "x-api-key: gs_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"templateSlug":"login_code_a1b2c3d","phoneNumber":"0712345678","variables":{"code":"482913"}}'
// Node 18 or newer; fetch is built in. Keep the key in an environment variable.
const response = await fetch("https://sms.gallantbyte.com/api/template/send", {
method: "POST",
headers: {
"x-api-key": process.env.GALLANT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
templateSlug: "login_code_a1b2c3d",
phoneNumber: "0712345678",
variables: { code: "482913" },
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(`${response.status} ${result.code}: ${result.message}`);
}
console.log(result.transactionId, result.segments);
import os
import requests # pip install requests
response = requests.post(
"https://sms.gallantbyte.com/api/template/send",
headers={"x-api-key": os.environ["GALLANT_API_KEY"]},
json={
"templateSlug": "login_code_a1b2c3d",
"phoneNumber": "0712345678",
"variables": {"code": "482913"},
},
timeout=30,
)
result = response.json()
if not response.ok:
raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
print(result["transactionId"], result["segments"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
payload, _ := json.Marshal(map[string]any{
"templateSlug": "login_code_a1b2c3d",
"phoneNumber": "0712345678",
"variables": map[string]string{"code": "482913"},
})
req, _ := http.NewRequest("POST", "https://sms.gallantbyte.com/api/template/send", bytes.NewReader(payload))
req.Header.Set("x-api-key", os.Getenv("GALLANT_API_KEY"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var result struct {
Status string `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
TransactionID string `json:"transactionId"`
Segments int `json:"segments"`
}
json.NewDecoder(res.Body).Decode(&result)
if res.StatusCode != 200 {
panic(fmt.Sprintf("%d %s: %s", res.StatusCode, result.Code, result.Message))
}
fmt.Println(result.TransactionID, result.Segments)
}
<?php
$payload = json_encode([
"templateSlug" => "login_code_a1b2c3d",
"phoneNumber" => "0712345678",
"variables" => ["code" => "482913"],
]);
$ch = curl_init("https://sms.gallantbyte.com/api/template/send");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
"x-api-key: " . getenv("GALLANT_API_KEY"),
"Content-Type: application/json",
],
]);
$result = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("$status {$result['code']}: {$result['message']}");
}
echo $result["transactionId"], " ", $result["segments"], PHP_EOL;
// Java 11 or newer. Any JSON library will do; the body is small enough to write by hand.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class SendSms {
public static void main(String[] args) throws Exception {
String body = """
{"templateSlug":"login_code_a1b2c3d","phoneNumber":"0712345678","variables":{"code":"482913"}}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://sms.gallantbyte.com/api/template/send"))
.timeout(Duration.ofSeconds(30))
.header("x-api-key", System.getenv("GALLANT_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException(response.statusCode() + " " + response.body());
}
System.out.println(response.body());
}
}
Every field is required except variables, which you can leave out for a
template with no placeholders. Values are plain strings; a value may not itself contain
{{.
The response
A 200 means the gateway accepted the message.
{
"status": "success",
"statusCode": 200,
"message": "SMS sent successfully",
"mobile": "254712345678",
"transactionId": "6017967936947375520",
"template": "login_code_a1b2c3d",
"segments": 1,
"characters": 54,
"encoding": "gsm"
}
| Field | What it is |
|---|---|
transactionId | The gateway's reference for this message. Keep it; it is what a delivery report will be matched against. |
segments | How many SMS parts the message was split into. This is what you are charged: one credit per segment. |
characters, encoding | The final message length and whether it fit GSM-7 (160 per segment) or needed UCS-2 (70 per segment). One emoji switches the whole message to UCS-2. |
mobile | The number as it was sent, in international form. |
With a sandbox key the response has the same shape, plus "sandbox": true
and a note, and transactionId starts with test_. Your code
needs no changes to go live; only the key changes.
Errors
The HTTP status is the outcome. The body always has status: "failed", a
short machine-readable code, and a message you can show in a log.
{ "status": "failed", "code": "insufficient_credits", "message": "Insufficient credits" }
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request, template_not_found, invalid_template_input, invalid_phone_number | Bad input: a missing field, an unknown template slug, a placeholder you did not supply or one the template does not have, a number that does not parse. | Fix the request. Do not retry as is. |
| 401 | unauthorized | Missing, wrong, revoked or expired key. | Check the key. Create a new one on API Management if in doubt. |
| 402 | insufficient_credits | Not enough credit for the segments this message needs. Nothing was sent. | Top up on Billing. |
| 402 | registration_required | A live key on an account that has not paid the registration yet. | Pay it on Billing; it comes back as credit. Sandbox keys are not gated. |
| 403 | account_suspended, account_banned, forbidden | The account is suspended, or the key may not do this (free-text sending is not enabled by default). | Contact us. |
| 403 | recipient_opted_out | This person has blocked you, or everyone, at /stop. Nothing was sent or charged. | Record it and stop. Do not retry. |
| 409 | idempotency_conflict | A request with the same Idempotency-Key is still running. | Wait a moment and send the same request again; you will get its result. |
| 429 | rate_limited | Over the rate limit. | Slow down; see limits. |
| 502 | gateway_error | The SMS gateway refused or could not be reached. Nothing was sent, nothing charged. | Retry with backoff. |
| 504 | gateway_timeout | The gateway did not answer in 15 seconds. Nothing was sent, nothing charged. | Safe to retry. |
Retries without duplicates
If your request times out you do not know whether the message went. Sending it again
could mean two OTPs and two charges. Put a unique value in the
Idempotency-Key header (a UUID is fine) and the second request returns the
first result instead of sending again.
curl -X POST https://sms.gallantbyte.com/api/template/send \
-H "x-api-key: gs_test_YOUR_KEY" \
-H "Idempotency-Key: 6f1c2a9e-4b7d-4e3a-9c1f-2d8e7b6a5f40" \
-H "Content-Type: application/json" \
-d '{"templateSlug":"login_code_a1b2c3d","phoneNumber":"0712345678","variables":{"code":"482913"}}'
- Same key, same account: the original response comes back with the header
Idempotent-Replayed: true. Nothing is sent or charged. - A key is final once the answer was a 2xx or 4xx. After a 502 or 504 the key is released, so a retry is a real retry.
- Keys are up to 128 characters of letters, digits,
-,_,.or:.
Templates
We send on a transactional sender ID, so the text that goes out has to be reviewed before it is sent live. A template is the reviewed text with blanks in it.
- Placeholders are written
{{ name }}. A name starts with a letter or underscore and contains only letters, digits and underscores. Spaces inside the braces are fine. - Every placeholder must be supplied on each send, and you cannot supply one the template does not have. Both are a 400 that names the placeholder.
- The character count and segments are worked out from the final text, after your values are in. A long name can push a message over 160 characters and into two segments.
Sandbox to live
| Status | Sandbox key | Live key |
|---|---|---|
| Sandbox — every new template starts here | Sends | 400 |
| In review — after you press Submit for production | Sends | 400 |
| Live — after we approve it, usually within a day | Sends | Sends |
| Changes requested / Rejected — with a note from us | Sends | 400 |
Editing the text of a template that came back from review sends it back for review. Live templates cannot be edited; make a new one.
Acceptable use
GallantSMS is for transactional messages only. Promotional messages will not be sent through it. That is not a preference; it is the condition of the sender ID we send on, and it is why every template is reviewed before it goes live.
Transactional means the recipient is expecting the message because of something they did or hold with you: a one-time code, a receipt, an order or delivery update, a balance, an appointment reminder, a notice about their account. Promotional means anything whose purpose is to sell, upsell, invite, announce an offer, or otherwise market. "Your order has shipped" is transactional; "Your order has shipped, use code SAVE20 on your next one" is not.
What happens if promotional content goes through:
- A template with promotional content is not approved, with the reason.
- Sent traffic is scanned. A live message found to be promotional gets a written warning to the account.
- A second finding suspends the account: every API key is refused until the matter is resolved with us.
- Repeated or deliberate abuse closes the account. Unused credit is forfeited, because the abuse puts the sender ID every other customer relies on at risk.
- Where the content breaks the law (the Consumer Protection Act, the Data Protection Act, the CAK's rules on unsolicited messages), we will cooperate with the authorities and say so.
If you are not sure whether a message is transactional, ask us before submitting the template. We answer that question gladly and quickly.
When a recipient says stop
Anyone who receives a message through GallantSMS can go to https://sms.gallantbyte.com/stop, verify their number with a code, and block the sender that messaged them, or every sender on the platform. Per sender is the default: someone who blocks a shop's receipts still gets their bank's codes.
A send to a number that has blocked you is refused with 403 recipient_opted_out
before anything is charged. Your code should treat it like an invalid number: record it, stop
retrying, and do not try to reach that person another way. We do not tell you who blocked you
beyond that response, and the block is theirs to lift, not yours.
You may include the stop address in a template with {{ stop_link }}. We fill it in;
you do not supply it. It is optional, and for one-time codes we would leave it out: people are
rightly told not to follow links in code messages, and it costs characters. A footer such as
Stop: https://sms.gallantbyte.com/stop on receipts or reminders is a reasonable place for it.
Phone numbers
Kenyan numbers in any of the usual forms: 0712345678,
+254712345678, 254712345678. We normalise to international
form and return it as mobile. A number that does not parse is a 400 before
anything is charged.
Rate limits
Per account: 60 requests a minute with live keys, 300 with sandbox keys. Over that you get a 429. If you need more, tell us what you are sending and we will raise it.
Going live
- On Billing, pay the registration of KES 300 by M-Pesa. It is not a fee: the whole amount comes back as SMS credit on your account.
- Press Submit for production on your template and wait for it to show Live.
- Create a live key on API Management and put it where the sandbox key was.
That is the whole change. Messages are charged per segment at the rate on the pricing page, which follows your monthly volume; credits do not expire. Top up from Billing whenever you like.
Delivery reports
A 200 means the gateway accepted the message, not that the phone received it. Delivery
reports are being set up now; when they are live, each message's delivery status will
be visible on the dashboard and, later, through the API by transactionId.
Keep the transactionId from every send so you can match them.
Help
Write to geraldombuthia@gmail.com or use
Support in the dashboard, which reaches the same inbox. Include the
template slug, the number and the transactionId if you have one. Calls:
0750815413.