G-SMS.

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, 2376XXXXXXXX or 6XXXXXXXX.
  • 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

StatusCodeMeaningWhat to do
202—Accepted, charged, queued.Keep messageId to match the webhook later.
402INSUFFICIENT_BALANCECredit 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.
429RATE_LIMITEDMore than 10 requests per second on the key.Wait for Retry-After (1 s), then retry.
429QUOTA_EXCEEDEDDaily quota reached (disabled by default).Postpone to tomorrow or ask for a higher quota.
400INVALID_NUMBER, TEXT_EMPTY, TEXT_TOO_LONGMalformed request. Nothing charged.Fix the call; an identical retry changes nothing.
503OPERATOR_DISABLEDSending to this carrier is paused. Nothing charged.Reschedule after Retry-After (one hour).
The 409 is the trap of this API. Its body is not a { 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 with sha256=.
  • Reject a timestamp more than five minutes away from your clock: that is the replay protection.
  • Deduplicate on eventId, not on X-GSms-Delivery, which changes on every attempt.
  • applicationId holds the application slug, not its technical identifier, despite the name.
  • Unlike API responses, the webhook body keeps its null fields: clientReference and failureReason are always present, possibly as None.
  • 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 EXHAUSTED and stays replayable from the console.

What the example on this page costs

Text sent"Your login code is 4821." — 24 characters
EncodingGSM-7: every character is in the standard alphabet
Segments1 (up to 160 characters in GSM-7)
Recipient+237690000000 — the 69 prefix, so Orange
Cost5 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 segments value 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

Read next