G-SMS.

Documentation

Delivery status and webhooks

A 202 means the message was accepted and charged, not that it reached the handset. What happens next is reported by the operator, and G-SMS passes it on in two ways: you poll, or we call you. Webhooks are the reliable option — polling every second for a message that arrives in four is a waste on both sides.

The lifecycle of a message

QUEUED ──▶ SENT ──▶ DELIVERED
   │          │
   └──────────┴──▶ FAILED
StatusMeaningBalance
QUEUEDAccepted, priced and waiting to be handed to the network.Charged
SENTHanded to the operator. No delivery report yet.Charged
DELIVEREDThe operator confirmed delivery to the handset. Terminal.Charged
FAILEDThe message will not be delivered. Terminal.Refunded automatically, once
  • A status never moves backwards. A late report that contradicts a terminal status is ignored, so your own state machine can trust the last value it received for a given message.
  • failureReason carries the cause reported by the operator, or TIMEOUT when the message stayed queued past its 24-hour lifetime, or SUBMIT_FAILED when we could not hand it over at all.
  • A refund is credited once per failed message, in the same transaction as the status change. You can see it in the ledger of the console.

Option 1 — poll

curl 'https://api.sms.gworldship.com/v1/sms/9f1c4b2e-5d3a-4c7e-8b21-6a0f7d9c1e45' \
  -H 'Authorization: Bearer gsk_your_api_key'

Response

{
  "messageId": "9f1c4b2e-5d3a-4c7e-8b21-6a0f7d9c1e45",
  "to": "+237690000000",
  "operator": "ORANGE",
  "segments": 1,
  "priceXaf": 5,
  "status": "DELIVERED",
  "createdAt": "2026-09-12T08:41:03.512Z",
  "sentAt": "2026-09-12T08:41:06.118Z",
  "deliveredAt": "2026-09-12T08:41:09.744Z"
}

sentAt and deliveredAt appear as they are filled in; fields without a value are omitted entirely. To sweep several messages at once, use GET /v1/sms?status=QUEUED with from and until — 20 results per page by default, 100 at most, newest first.

Option 2 — webhooks

Register a callback URL on your application in the console — use HTTPS, the body carries phone numbers. You receive a secret starting with whsec_, shown once and never again. G-SMS then posts a JSON body to that URL.

There is one event type, sms.sent, and it is emitted on every status change — not only when the message is sent. Read data.status to know what actually happened; the event name alone tells you nothing.

Headers

POST /your/webhook HTTP/1.1
Content-Type: application/json
X-GSms-Event: sms.sent
X-GSms-Timestamp: 1757664069
X-GSms-Signature: sha256=4c1f0b8d…
X-GSms-Delivery: 6b8e2a71-93c4-4a55-9f0d-2c7a1e4d8b30
HeaderDescription
X-GSms-TimestampSigning time, epoch seconds. Part of the signed string.
X-GSms-Signaturesha256= followed by the hex digest, lowercase.
X-GSms-EventEvent type. Always sms.sent today.
X-GSms-Delivery Identifier of this attempt. It changes on every retry, so it is useless for deduplication — use eventId from the body.

Body

{
  "eventId": "b21d7f04-8c6e-4a1b-9d52-71f0c3a8e947",
  "type": "sms.sent",
  "occurredAt": "2026-09-12T08:41:09.801Z",
  "applicationId": "my-shop",
  "data": {
    "messageId": "9f1c4b2e-5d3a-4c7e-8b21-6a0f7d9c1e45",
    "clientReference": "otp-42-1757664063",
    "to": "+237690000000",
    "operator": "ORANGE",
    "segments": 1,
    "priceXaf": 5,
    "status": "DELIVERED",
    "failureReason": null
  }
}
  • applicationId contains the application slug, not its UUID, despite the name. If you route events by application, compare it to the slug you chose in the console.
  • Unlike API responses, the webhook body keeps its null fields: clientReference and failureReason are present with the value null when they are empty.
  • occurredAt is the time of the status change; X-GSms-Timestamp is the time of this attempt. They differ on a retry, and only the header is signed.

Verify the signature

Your endpoint is a public URL: anyone can post to it. The signature is what separates our call from someone else's. Verification is three lines and it is not optional.

signature = "sha256=" + hex( HMAC_SHA256( secret, rawBody + "." + timestamp ) )
  • Sign the raw body, byte for byte as received. Parsing the JSON and re-serialising it changes spacing and key order, and the digest no longer matches.
  • The digest is lowercase hexadecimal, prefixed with sha256=.
  • Reject a timestamp more than 300 seconds away from your clock, in either direction. Without that window, a captured call can be replayed forever.
  • Compare in constant time — timingSafeEqual in Node, hash_equals in PHP. A plain equality check leaks the expected value one byte at a time.

Node.js

// Express — the raw body is required: a re-serialised JSON never matches.
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.GSMS_WEBHOOK_SECRET;
const seen = new Set(); // in production: Redis, or a unique index on eventId

app.post('/webhooks/gsms', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body.toString('utf8');
  const timestamp = req.get('X-GSms-Timestamp');
  const received = req.get('X-GSms-Signature') ?? '';

  // 1. Freshness: reject anything older than 5 minutes (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.sendStatus(400);
  }

  // 2. Signature over rawBody + "." + timestamp, lowercase hex.
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(`${body}.${timestamp}`).digest('hex');

  // 3. Constant-time comparison. Never use ===.
  const ok =
    expected.length === received.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(body);

  // 4. Acknowledge first, work after: you have 10 seconds.
  res.sendStatus(200);

  // 5. Deduplicate: the same eventId can arrive more than once.
  if (seen.has(event.eventId)) return;
  seen.add(event.eventId);

  handleStatus(event.data.messageId, event.data.status, event.data.failureReason);
});

PHP

<?php
// Raw body, before any framework parses it.
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_GSMS_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_GSMS_SIGNATURE'] ?? '';

// 1. Freshness window: 5 minutes either way.
if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit;
}

// 2. Signature over the raw body + "." + timestamp.
$expected = 'sha256=' . hash_hmac('sha256', $body . '.' . $timestamp, getenv('GSMS_WEBHOOK_SECRET'));

// 3. Constant-time comparison.
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);

// 4. Acknowledge immediately, then process.
http_response_code(200);
flush();

// 5. Deduplicate on eventId — delivery is at-least-once.
if (already_processed($event['eventId'])) {
    exit;
}
mark_processed($event['eventId']);
handle_status($event['data']['messageId'], $event['data']['status']);

Retries, and why you must deduplicate

Delivery is at-least-once. If your server is slow, restarts, or answers 500, the same event comes back — and it may also arrive twice when the first attempt succeeded but the acknowledgement was lost. Store eventId and ignore an identifier you have already processed. A unique index in your database is the simplest guard.

RuleValue
SuccessAny 2xx status code
Timeout10 seconds per attempt
Retry schedule1 min, 5 min, 30 min, 2 h, 12 h, then every 12 h
Give-upAfter 48 hours the delivery is marked EXHAUSTED

Answer 2xx as soon as the signature checks out, then do your work asynchronously. A handler that writes to a slow database inside the request will hit the 10-second timeout and be retried while it is still running. Exhausted deliveries can be replayed by hand from the console.

Ready to send your first SMS?

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

Create account