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| Status | Meaning | Balance |
|---|---|---|
| QUEUED | Accepted, priced and waiting to be handed to the network. | Charged |
| SENT | Handed to the operator. No delivery report yet. | Charged |
| DELIVERED | The operator confirmed delivery to the handset. Terminal. | Charged |
| FAILED | The 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.
failureReasoncarries the cause reported by the operator, orTIMEOUTwhen the message stayed queued past its 24-hour lifetime, orSUBMIT_FAILEDwhen 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.
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| Header | Description |
|---|---|
| X-GSms-Timestamp | Signing time, epoch seconds. Part of the signed string. |
| X-GSms-Signature | sha256= followed by the hex digest, lowercase. |
| X-GSms-Event | Event 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
}
}applicationIdcontains 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:
clientReferenceandfailureReasonare present with the valuenullwhen they are empty. occurredAtis the time of the status change;X-GSms-Timestampis 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 —
timingSafeEqualin Node,hash_equalsin 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.
| Rule | Value |
|---|---|
| Success | Any 2xx status code |
| Timeout | 10 seconds per attempt |
| Retry schedule | 1 min, 5 min, 30 min, 2 h, 12 h, then every 12 h |
| Give-up | After 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