Send an SMS to Cameroon from Python
An on-call script, a Celery task, a Django view: as soon as someone in Cameroon has to be notified, you need a local carrier. The G-SMS API is one HTTP request, so thirty lines of requests are enough. This page gives you those lines, from the first message to bulk sending and delivery receipts.
Prerequisites
- Python 3.9 or newer, and
requests(pip install requests). - A G-SMS API key starting with
gsk_, created in the console for a given application. - A Cameroonian number. Three spellings are accepted:
+2376XXXXXXXX,2376XXXXXXXXor6XXXXXXXX. - Some credit on the account: every message is charged when it is accepted.
The key grants the right to send, and therefore to spend. Read it from the environment, never from a versioned settings.py or a shared notebook.
The client
Two response codes are ordinary answers rather than failures: the 202, which accepts the message, and the 409, which says this message already exists. They are returned, not raised. Everything else becomes an exception carrying the error code and the suggested wait.
gsms.py
"""gsms.py — G-SMS client. One dependency: requests."""
import os
import requests
BASE_URL = os.environ.get("GSMS_URL", "https://api.sms.gworldship.com")
API_KEY = os.environ.get("GSMS_API_KEY", "gsk_votre_cle_api")
# A Session reuses the TLS connection: on a series of sends, it shows.
session = requests.Session()
session.headers.update(
{
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
}
)
class GSmsError(RuntimeError):
"""An error returned by the API — the 409 is not one of them."""
def __init__(self, status, code, message, retry_after=None):
super().__init__(f"{code} ({status}): {message}")
self.status = status
self.code = code
self.retry_after = retry_after
def send_sms(to, text, client_reference=None):
"""POST /v1/sms.
Returns (message, duplicate). duplicate is True on a 409: not an error, but
confirmation that this message has already been accepted.
"""
payload = {"to": to, "text": text}
if client_reference:
payload["clientReference"] = client_reference
response = session.post(f"{BASE_URL}/v1/sms", json=payload, timeout=15)
body = response.json()
if response.status_code == 202:
return body, False
if response.status_code == 409:
return body, True
retry_after = response.headers.get("Retry-After")
raise GSmsError(
response.status_code,
body.get("code", "UNKNOWN"),
body.get("message", ""),
int(retry_after) if retry_after else None,
)
def balance_xaf():
"""GET /v1/balance — account balance, in CFA francs."""
response = session.get(f"{BASE_URL}/v1/balance", timeout=10)
response.raise_for_status()
return response.json()["balanceXaf"]Your first message
client_reference is optional, but it is what makes a call replayable: on a second call with the same value, the API returns the original message with a 409 instead of sending and charging another SMS. Use a stable identifier from your side — order, session, login attempt — of at most 64 characters.
send.py
# send.py
from gsms import GSmsError, send_sms
try:
message, duplicate = send_sms(
to="+237690000000",
text="Your login code is 4821.",
# A stable identifier on your side: it makes the call replayable. 64 chars max.
client_reference="login-2026-09-12-4821",
)
except GSmsError as error:
if error.code == "INSUFFICIENT_BALANCE":
print("Out of credit: top up, then replay the same call.")
elif error.code == "RATE_LIMITED":
print(f"Too fast — retry in {error.retry_after or 1} s")
elif error.code == "OPERATOR_DISABLED":
print("Carrier paused, nothing charged. Reschedule in an hour.")
else:
raise
else:
if duplicate:
print("Already sent:", message["messageId"], message["status"])
else:
print(
"Accepted:",
message["messageId"],
message["operator"],
message["segments"],
"segment(s),",
message["priceXaf"],
"XAF,",
message["balanceAfterXaf"],
"XAF left",
)Body of the 202:
{
"messageId": "8f14e45f-ceea-467a-9c1e-1a4f1c3b0d21",
"to": "+237690000000",
"operator": "ORANGE",
"segments": 1,
"priceXaf": 5,
"status": "QUEUED",
"clientReference": "login-2026-09-12-4821",
"balanceAfterXaf": 95,
"createdAt": "2026-09-12T08:41:07Z"
} Null fields are omitted: use message.get("failureReason") rather than message["failureReason"], which raises a KeyError on a message that has not failed. balanceAfterXaf only exists on this creation response.
The responses you have to handle
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 202 | — | Accepted, charged, queued. | Keep messageId to match the webhook later. |
| 402 | INSUFFICIENT_BALANCE | Credit does not cover this message. Nothing charged. | Top up, then replay with the same reference. |
| 409 | — | This clientReference was already used. | Read the returned message; send nothing. |
| 429 | RATE_LIMITED | More than 10 requests per second on the key. | Wait for Retry-After (1 s), then retry. |
| 429 | QUOTA_EXCEEDED | Daily quota reached (disabled by default). | Postpone to tomorrow or ask for a higher quota. |
| 400 | INVALID_NUMBER, TEXT_EMPTY, TEXT_TOO_LONG | Malformed request. Nothing charged. | Fix the call; an identical retry changes nothing. |
| 503 | OPERATOR_DISABLED | Sending to this carrier is paused. Nothing charged. | Reschedule after Retry-After (one hour). |
{ code, message } object like the other errors: it is the message already stored, shaped like the 202 and without balanceAfterXaf. A response.raise_for_status() on the send therefore turns a message that did go out into an exception — and you will send it again. Sending a list without being cut off
The limit is ten requests per second, per key. A tight loop over a thousand numbers will collect 429s within the first second. Pace your sends, and give each recipient its own reference: a restart after an incident will then send nothing twice.
batch.py
# batch.py — send a list without being cut off by the rate limit.
import time
from gsms import GSmsError, send_sms
MAX_PER_SECOND = 10 # the API limit, counted per key
def send_batch(recipients, text):
"""recipients: an iterable of (number, unique reference) pairs."""
started = time.monotonic()
for index, (to, reference) in enumerate(recipients, start=1):
try:
send_sms(to, text, client_reference=reference)
except GSmsError as error:
if error.code == "RATE_LIMITED":
time.sleep(error.retry_after or 1)
# The reference makes this second attempt safe: at worst, a 409.
send_sms(to, text, client_reference=reference)
elif error.code == "INSUFFICIENT_BALANCE":
raise # no point continuing, nothing else will go out
else:
print("Permanent failure for", to, ":", error)
# Pacing: at most MAX_PER_SECOND requests per second.
time.sleep(max(0.0, started + index / MAX_PER_SECOND - time.monotonic()))G-SMS is a transactional API: login codes, confirmations, reminders. Bulk sending is for telling a list of customers about something that concerns them, not for pushing advertising to a purchased file.
Reading back what you sent
GET /v1/sms returns a page of messages, newest first. Available filters are status, to, from, until, page (0 by default) and size (20 by default, 100 maximum).
# gsms.py (continued)
def list_messages(status=None, since=None, page=0, size=50):
"""GET /v1/sms — sorted by createdAt descending, 100 items per page at most."""
params = {"page": page, "size": size}
if status:
params["status"] = status # QUEUED, SENT, DELIVERED or FAILED
if since:
# "from" is a Python keyword: it has to go through the dictionary.
params["from"] = since # ISO 8601 date, e.g. 2026-09-01T00:00:00Z
response = session.get(f"{BASE_URL}/v1/sms", params=params, timeout=15)
response.raise_for_status()
body = response.json()
return body["content"], body["totalElements"]
# How many failures today?
failures, total = list_messages(status="FAILED", since="2026-09-12T00:00:00Z")
print(total, "failure(s) — all refunded automatically")Receiving delivery receipts
A 202 means "accepted", not "received by the handset". The rest arrives as a push to a URL you declare in the console. There is a single event, sms.sent, emitted on every status change: SENT, then DELIVERED or FAILED, the last two being final. A FAILED message is refunded automatically, once; failureReason carries the cause reported by the carrier, or TIMEOUT if nothing was confirmed within 24 h, or SUBMIT_FAILED.
webhook.py
"""webhook.py — receiving delivery status.
Flask here; the principle is identical in Django or FastAPI, with one
condition: read the RAW request body, never a re-serialised JSON.
"""
import hashlib
import hmac
import os
import time
from flask import Flask, request
SECRET = os.environ["GSMS_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
handled = set() # in production: Redis or a table, not process memory
@app.post("/webhooks/gsms")
def gsms_webhook():
raw = request.get_data() # the bytes received, as they came
timestamp = request.headers.get("X-GSms-Timestamp", "")
signature = request.headers.get("X-GSms-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return "", 400 # outside the ±5 minute tolerance window
expected = "sha256=" + hmac.new(
SECRET, raw + b"." + timestamp.encode(), hashlib.sha256
).hexdigest()
# compare_digest: constant-time comparison. A == here is a vulnerability.
if not hmac.compare_digest(expected, signature):
return "", 401
event = request.get_json()
# At-least-once delivery: the same eventId may arrive more than once.
if event["eventId"] in handled:
return "", 204
handled.add(event["eventId"])
data = event["data"]
# event["applicationId"] holds the application SLUG, not its UUID.
update_status(
data["messageId"],
data["status"], # SENT, DELIVERED or FAILED
data.get("failureReason"), # None until something fails
)
# 204: any 2xx counts as an acknowledgement. Answer within 10 s.
return "", 204- The signature is
HMAC_SHA256(secret, raw_body + "." + timestamp), lowercase hexadecimal, prefixed withsha256=. - Reject a timestamp more than five minutes away from your clock: that is the replay protection.
- Deduplicate on
eventId, not onX-GSms-Delivery, which changes on every attempt. applicationIdholds the application slug, not its technical identifier, despite the name.- Unlike API responses, the webhook body keeps its null fields:
clientReferenceandfailureReasonare always present, possibly asNone. - On failure, attempts space out: 1 min, 5 min, 30 min, 2 h, 12 h, then every 12 h, stopping after 48 h. The delivery becomes
EXHAUSTEDand stays replayable from the console.
What the example on this page costs
| Text sent | "Your login code is 4821." — 24 characters |
| Encoding | GSM-7: every character is in the standard alphabet |
| Segments | 1 (up to 160 characters in GSM-7) |
| Recipient | +237690000000 — the 69 prefix, so Orange |
| Cost | 5 XAF |
5 XAF to MTN as well, 7 XAF to Camtel and Nexttel, 10 XAF on the default route, used for prefixes outside the ART numbering plan. A bulk send of this text to 200 Orange numbers therefore costs 1,000 XAF.
What will surprise you
- The wrong accent doubles the bill. As long as the text fits the GSM-7 alphabet, a segment holds 160 characters; otherwise it drops to 70. "é" and "à" are in that alphabet, but lowercase "ç" is not, nor "ê", nor the typographic apostrophe "’", nor the guillemets "«»". A single one of them flips the entire message. Beyond one segment, capacity falls to 153 characters in GSM-7 and 67 in UCS-2. These sizes are administrable: read the
segmentsvalue returned rather than recomputing it. - The recipient sees a number, not a name. There is no custom sender ID: G-SMS carries transactional messages from a Cameroonian SIM, and that number is what shows up. Name your service in the text of the message.
- The limits. 10 requests per second per key, 1,600 characters per message, 64 characters for
client_reference. The daily quota is disabled by default. - A message belongs to one application only.
GET /v1/sms/{messageId}returns 404, not 403, if the key belongs to another application.
Ready to send your first SMS?
Create an account, get 100 FCFA of credit and an API key straight away.
Create account