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
curlextension (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,2376XXXXXXXXor6XXXXXXXX. - 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
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 202 | — | Accepted, charged, queued. | Store messageId to match the webhook later. |
| 402 | INSUFFICIENT_BALANCE | Credit 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. |
| 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). | Retry tomorrow or ask for a higher quota. |
| 400 | INVALID_NUMBER, TEXT_EMPTY, TEXT_TOO_LONG | Malformed request. Nothing charged. | Fix the call; retrying it unchanged will not help. |
| 401 | INVALID_API_KEY | Key missing, invalid or revoked. | Check the Authorization header. |
| 503 | OPERATOR_DISABLED | Sending to this carrier is paused. Nothing charged. | Reschedule after Retry-After (one hour). |
{ 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 withsha256=. 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.
applicationIdholds the application slug, not its technical identifier, despite the name.- The same event may be delivered several times. Deduplicate on
eventId, never onX-GSms-Delivery, which changes on every attempt. - Unlike API responses, the webhook body keeps its null fields:
clientReferenceandfailureReasonare always present, possibly asnull. - 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
EXHAUSTEDand can be replayed from the console. - A
FAILEDmessage is refunded in credits automatically, once.failureReasoncarries the cause reported by the carrier, orTIMEOUTif nothing was confirmed within 24 h, orSUBMIT_FAILEDif 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 |
| 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 |
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
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. 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