G-SMS.

Documentation

Idempotency

Networks time out. Load balancers retry. Users double-click. Sooner or later the same send request reaches the API twice, and the user receives two codes while you pay for two messages. One optional field prevents it: clientReference.

The field

  • Any string you choose, up to 64 characters.
  • Unique inside one application. The same reference used from two different applications produces two separate messages — the scope is the application, not the account.
  • Pick something your own system already guarantees to be unique: an order number, a payment identifier, or otp-<userId>-<timestamp> for one-time passwords. A random value defeats the purpose, since a retry must reuse the exact same string.
  • It is echoed in every read of the message and in the data.clientReference field of the webhook payload, which makes it a convenient correlation key on your side.

First call

curl -X POST 'https://api.sms.gworldship.com/v1/sms' \
  -H 'Authorization: Bearer gsk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
        "to": "+237690000000",
        "text": "Your verification code is 123456",
        "clientReference": "otp-42-1757664063"
      }'
HTTP/1.1 202 Accepted

{
  "messageId": "9f1c4b2e-5d3a-4c7e-8b21-6a0f7d9c1e45",
  "to": "+237690000000",
  "operator": "ORANGE",
  "segments": 1,
  "priceXaf": 5,
  "status": "QUEUED",
  "clientReference": "otp-42-1757664063",
  "balanceAfterXaf": 95,
  "createdAt": "2026-09-12T08:41:03.512Z"
}

The message is queued and XAF 5 leaves the balance. So far, nothing unusual.

Same call again

HTTP/1.1 409 Conflict

{
  "messageId": "9f1c4b2e-5d3a-4c7e-8b21-6a0f7d9c1e45",
  "to": "+237690000000",
  "operator": "ORANGE",
  "segments": 1,
  "priceXaf": 5,
  "status": "SENT",
  "clientReference": "otp-42-1757664063",
  "createdAt": "2026-09-12T08:41:03.512Z",
  "sentAt": "2026-09-12T08:41:06.118Z"
}
  • No second message. The recipient receives one SMS.
  • No second charge. The balance does not move.
  • You get the existing message back, with its current status — here it has already moved to SENT.
  • balanceAfterXaf is absent, precisely because no money moved. That absence is how you tell a 409 body from a 202 body without looking at the status line.

The trap: this 409 is not an error object

Every other 4xx in this API returns { code, message }. The duplicate 409 does not: it returns a message object. An integration that parses error.code on any non-2xx response reads undefined here, logs a meaningless failure, and often retries a send that already succeeded.

Ordinary error — 400

HTTP/1.1 400 Bad Request

{ "code": "INVALID_NUMBER", "message": "Numéro invalide" }

Duplicate reference — 409

{
  "messageId": "9f1c4b2e-…",
  "status": "SENT",
  "clientReference": "otp-42-…"
}
Treat 409 on POST /v1/sms as a success: the message exists, it was charged once, and its identifier is in the body. Only other 4xx and 5xx responses carry code.

A retry that is safe

Node.js

async function sendOtp(user, code) {
  // Built once, stored with the record: a retry must reuse this exact string.
  const reference = `otp-${user.id}-${Math.floor(Date.now() / 1000)}`;

  const res = await fetch('https://api.sms.gworldship.com/v1/sms', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.GSMS_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ to: user.phone, text: `Your code is ${code}`, clientReference: reference }),
  });

  const body = await res.json();

  // 202 = sent now. 409 = already sent with this reference, and NOT charged again.
  // Both bodies are messages; only 409 has no balanceAfterXaf.
  if (res.status === 202 || res.status === 409) {
    return { messageId: body.messageId, alreadySent: res.status === 409 };
  }

  // Everything else is a real error: { code, message }.
  throw new Error(`${res.status} ${body.code}`);
}

PHP

<?php
// Same rule in PHP: 409 belongs with the success branch, not with the errors.
[$status, $body] = gsms_post('/v1/sms', [
    'to' => $user->phone,
    'text' => "Your code is {$code}",
    'clientReference' => "otp-{$user->id}-" . time(),
]);

if ($status === 202 || $status === 409) {
    return $body['messageId']; // charged exactly once, whichever branch we came from
}

throw new RuntimeException($body['code'] ?? 'HTTP ' . $status);

Build the reference before the first attempt and store it with your record. A reference computed inside the retry loop changes at every attempt and protects nothing.

Two requests at the same instant

Two identical calls can arrive in parallel, fast enough that neither sees the other's row. The database settles it: the unique constraint on (application, clientReference) accepts one, and the losing transaction is rolled back in full — the charge included. The loser then receives the same 409 with the winning message. You never pay twice, even in a race.

Two details worth knowing

  • The duplicate check runs before validation. If you reuse a reference with a different text, the API returns the first message and ignores the new text — it does not compare payloads. One reference means one message, whatever you send with it.
  • There is no filter by client reference on GET /v1/sms. Store the messageId returned by the API. If you did lose it, posting the same reference again returns the existing message — a lookup that costs nothing, since it never charges.

Ready to send your first SMS?

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

Create account