G-SMS.

Send an SMS to Cameroon from PHP

You have a PHP application that needs to send a login code, an order confirmation or a reminder to an Orange, MTN, Camtel or Nexttel number. There is nothing to install: the G-SMS API is one HTTP request, and the cURL extension shipped with PHP is enough. This page gives you the whole thing, from the first message to delivery receipts.

Prerequisites

  • PHP 8.0 or newer with the curl extension (enabled by default on nearly every host).
  • A G-SMS account and an API key starting with gsk_. Keys are created in the console, for a given application.
  • A Cameroonian destination number. Three spellings are accepted: +2376XXXXXXXX, 2376XXXXXXXX or 6XXXXXXXX.
  • Some credit on the account. Signing up gives you enough for your first tests.

The key carries every sending right of the application: it does not belong in a Git repository or in browser JavaScript. Load it from an environment variable.

The client, in about thirty lines

One transport function, reused for every call. It only throws when the server is unreachable: 4xx codes are answers, not accidents, and two of them are part of normal operation.

gsms.php

<?php
/**
 * gsms.php — minimal G-SMS client. Requires PHP 8.0 and the cURL extension.
 */

const GSMS_BASE_URL = 'https://api.sms.gworldship.com';
const GSMS_API_KEY  = 'gsk_votre_cle_api'; // in production: getenv('GSMS_API_KEY')

/**
 * Returns the HTTP status, the decoded body and the response headers.
 * Never throws on a 4xx: on this API, a 409 is not a failure.
 *
 * @return array{status:int, body:array, headers:array<string,string>}
 */
function gsms_request(string $method, string $path, ?array $payload = null): array
{
    $headers = [];

    $ch = curl_init(GSMS_BASE_URL . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . GSMS_API_KEY,
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        CURLOPT_HEADERFUNCTION => function ($curl, string $line) use (&$headers): int {
            $parts = explode(':', $line, 2);
            if (count($parts) === 2) {
                $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
            }
            return strlen($line); // cURL expects the raw line length
        },
    ]);

    if ($payload !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
    }

    $raw = curl_exec($ch);
    if ($raw === false) {
        $message = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('G-SMS unreachable: ' . $message);
    }

    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return ['status' => $status, 'body' => json_decode($raw, true) ?? [], 'headers' => $headers];
}

/** POST /v1/sms — clientReference is optional, but it is what prevents duplicates. */
function gsms_send_sms(string $to, string $text, ?string $clientReference = null): array
{
    $payload = ['to' => $to, 'text' => $text];
    if ($clientReference !== null) {
        $payload['clientReference'] = $clientReference;
    }

    return gsms_request('POST', '/v1/sms', $payload);
}

Your first message

clientReference is optional, but it is what makes a call replayable: if your script runs twice, the second call returns the original message instead of sending a second SMS. Pick a value that is stable and unique on your side — an order, session or login attempt identifier — of at most 64 characters.

send.php

<?php
require __DIR__ . '/gsms.php';

$result = gsms_send_sms(
    to: '+237690000000',
    text: 'Your login code is 4821.',
    clientReference: 'login-2026-09-12-4821',
);

$body = $result['body'];

switch ($result['status']) {
    case 202:
        // Accepted and charged. The SMS is not delivered yet: status is QUEUED.
        printf(
            "Accepted %s — %s, %d segment(s), %d XAF, %d XAF left\n",
            $body['messageId'], $body['operator'], $body['segments'],
            $body['priceXaf'], $body['balanceAfterXaf']
        );
        break;

    case 409:
        // The body is NOT {code, message}: it is the message already stored
        // under this clientReference. Nothing has been charged twice.
        printf("Duplicate: message %s already exists (status %s)\n",
            $body['messageId'], $body['status']);
        break;

    case 402:
        // INSUFFICIENT_BALANCE: nothing charged. Top up, then replay the same call.
        fwrite(STDERR, "Not enough credit.\n");
        break;

    case 429:
        // RATE_LIMITED (over 10 requests/second) or QUOTA_EXCEEDED (daily quota).
        $wait = (int) ($result['headers']['retry-after'] ?? 1);
        fwrite(STDERR, "Too fast ({$body['code']}) — retry in {$wait}s\n");
        break;

    case 503:
        // OPERATOR_DISABLED: sending to this carrier is paused, nothing charged.
        fwrite(STDERR, "Carrier unavailable, reschedule the message.\n");
        break;

    default:
        fwrite(STDERR, sprintf("Error %d: %s — %s\n", $result['status'],
            $body['code'] ?? '?', $body['message'] ?? ''));
}

What you get back:

HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "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 from the response: on a message that was just accepted, neither failureReason nor deliveredAt is there. Reach for ?? rather than assuming the key exists. Conversely, balanceAfterXaf is only returned here, on the 202.

The responses you have to handle

StatusCodeMeaningWhat to do
202—Accepted, charged, queued.Store messageId to match the webhook later.
402INSUFFICIENT_BALANCECredit does not cover this message. Nothing charged.Top up, then replay with the same clientReference.
409—This clientReference was already used.Read the returned message; do not send again.
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).Retry tomorrow or ask for a higher quota.
400INVALID_NUMBER, TEXT_EMPTY, TEXT_TOO_LONGMalformed request. Nothing charged.Fix the call; retrying it unchanged will not help.
401INVALID_API_KEYKey missing, invalid or revoked.Check the Authorization header.
503OPERATOR_DISABLEDSending to this carrier is paused. Nothing charged.Reschedule after Retry-After (one hour).
The 409 is the trap of this API. Unlike every other error, its body is not a { code, message } object: it is the message already stored, in the same shape as the 202 and without balanceAfterXaf, since nothing was charged. A client that treats all 4xx alike reads $body['code'], finds null, and concludes there was a failure — while the SMS is on its way.

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: first SENT, then DELIVERED or FAILED. DELIVERED and FAILED are final.

webhook-gsms.php

<?php
/**
 * webhook-gsms.php — receives status changes (QUEUED → SENT → DELIVERED,
 * or FAILED). Publish it over https and declare the URL in the console.
 */

const GSMS_WEBHOOK_SECRET = 'whsec_your_secret';

// The RAW body, never a re-serialised array: the signature covers the bytes received.
$raw       = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_GSMS_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_GSMS_SIGNATURE'] ?? '';

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(400); // outside the ±5 minute tolerance window
    exit;
}

$expected = 'sha256=' . hash_hmac('sha256', $raw . '.' . $timestamp, GSMS_WEBHOOK_SECRET);

// hash_equals: constant-time comparison. A === here is a vulnerability.
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);

// At-least-once delivery: the same eventId may arrive more than once.
if (gsms_event_already_handled($event['eventId'])) {
    http_response_code(200);
    exit;
}

$data = $event['data'];
// Careful: $event['applicationId'] holds the application SLUG, not its UUID.
gsms_update_status(
    $data['messageId'],
    $data['status'],          // SENT, DELIVERED or FAILED
    $data['failureReason'],   // null until something fails
    $data['clientReference']  // your own identifier, if you supplied one
);
gsms_mark_handled($event['eventId']);

// Any 2xx counts as an acknowledgement. Past 10 s, G-SMS treats it as a failure.
http_response_code(200);

What you receive:

POST /webhook-gsms.php
X-GSms-Event: sms.sent
X-GSms-Timestamp: 1789041667
X-GSms-Signature: sha256=3f1c…
X-GSms-Delivery: 0d4e0b56-1f1f-4a5e-9cf1-3a2c11d0b8e2

{
  "eventId": "b2b0a1f4-84a3-4a59-9c4e-25ec6c0f2d31",
  "type": "sms.sent",
  "occurredAt": "2026-09-12T08:41:12Z",
  "applicationId": "my-application",
  "data": {
    "messageId": "8f14e45f-ceea-467a-9c1e-1a4f1c3b0d21",
    "clientReference": "login-2026-09-12-4821",
    "to": "+237690000000",
    "operator": "ORANGE",
    "segments": 1,
    "priceXaf": 5,
    "status": "DELIVERED",
    "failureReason": null
  }
}
  • The signature is HMAC_SHA256(secret, raw_body + "." + timestamp), lowercase hexadecimal, prefixed with sha256=. The body must be the one you received, byte for byte.
  • Reject a timestamp more than five minutes away from your server clock: that is what stops an intercepted request from being replayed.
  • applicationId holds the application slug, not its technical identifier, despite the name.
  • The same event may be delivered several times. Deduplicate on eventId, never on X-GSms-Delivery, which changes on every attempt.
  • Unlike API responses, the webhook body keeps its null fields: clientReference and failureReason are always present, possibly as null.
  • On failure, G-SMS retries after 1 min, 5 min, 30 min, 2 h, 12 h, then every 12 h, and gives up after 48 h. The delivery is then marked EXHAUSTED and can be replayed from the console.
  • A FAILED message is refunded in credits automatically, once. failureReason carries the cause reported by the carrier, or TIMEOUT if nothing was confirmed within 24 h, or SUBMIT_FAILED if handing the message to the carrier failed.

Watching the balance

A message refused for lack of credit is a message that does not go out, and you will find out at the worst possible moment. Read the balance before batch jobs, or watch balanceAfterXaf on every send.

<?php
require __DIR__ . '/gsms.php';

$balance = gsms_request('GET', '/v1/balance')['body']['balanceXaf'];

if ($balance < 500) {
    // Nothing warns you when credit runs low: watching it is the caller's job.
    alert_team("G-SMS balance low: {$balance} XAF");
}

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

The same message costs 5 XAF to MTN, 7 XAF to Camtel or Nexttel, and 10 XAF on the default route, used for prefixes outside the ART numbering plan. The sign-up credit covers roughly twenty messages of this kind.

What will surprise you

  • One character can double the bill. A message is split into 160-character segments as long as it fits the GSM-7 alphabet, and 70-character ones otherwise. Lowercase "ç", "ê", the typographic apostrophe "’" and the guillemets "«»" are not in that alphabet. A single one of them switches the whole message to UCS-2. Beyond one segment, capacity drops to 153 characters in GSM-7 and 67 in UCS-2. Those four sizes are administrable settings, not constants: trust 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. Put your service name in the text of the message.
  • The limits. 10 requests per second per key, 1,600 characters per message, 64 characters for clientReference. 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. Do not look for a permission error that does not exist.

Ready to send your first SMS?

Create an account, get 100 FCFA of credit and an API key straight away.

Create account

Read next