G-SMS.

Send an SMS to Cameroon from Node.js

Your Node service needs to send a login code, a payment confirmation or an alert to an Orange, MTN, Camtel or Nexttel number. Since Node 18, fetch is part of the platform: there is no package to install, and the client fits in about forty lines. This page gives you those lines, all the way to delivery receipts.

Prerequisites

  • Node.js 18 or newer — fetch, AbortSignal.timeout and node:crypto are built in.
  • ES modules ("type": "module" in package.json, or .mjs files).
  • A G-SMS API key starting with gsk_, created in the console for a given application.
  • A Cameroonian number. Three spellings are accepted: +2376XXXXXXXX, 2376XXXXXXXX or 6XXXXXXXX.

The key grants the right to send, and therefore to spend: keep it server-side, in an environment variable. Calling the API from the browser would hand it to every visitor.

The client

Two response codes are not errors and deserve to be returned rather than thrown: the 202, which accepts the message, and the 409, which says this message already exists. Everything else becomes a typed exception carrying the error code and the suggested wait.

gsms.mjs

// gsms.mjs — G-SMS client for Node.js 18 or newer.
// No dependencies: fetch and AbortSignal.timeout ship with the platform.

const BASE_URL = process.env.GSMS_URL ?? 'https://api.sms.gworldship.com';
const API_KEY = process.env.GSMS_API_KEY ?? 'gsk_votre_cle_api';

export class GSmsError extends Error {
  constructor(status, body, retryAfterSeconds) {
    super(`${body.code ?? status} — ${body.message ?? 'unexpected error'}`);
    this.name = 'GSmsError';
    this.status = status;
    this.code = body.code;
    this.retryAfterSeconds = retryAfterSeconds;
  }
}

/**
 * POST /v1/sms.
 * Returns { duplicate, message }. duplicate is true on a 409, which is not an
 * error: it confirms this message has already been accepted.
 */
export async function sendSms({ to, text, clientReference }) {
  const response = await fetch(`${BASE_URL}/v1/sms`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify(clientReference ? { to, text, clientReference } : { to, text }),
    signal: AbortSignal.timeout(15_000),
  });

  const body = await response.json();

  if (response.status === 202) return { duplicate: false, message: body };
  if (response.status === 409) return { duplicate: true, message: body };

  const retryAfter = Number(response.headers.get('retry-after'));
  throw new GSmsError(response.status, body, Number.isFinite(retryAfter) ? retryAfter : undefined);
}

/** GET /v1/balance — account balance, in CFA francs. */
export async function getBalanceXaf() {
  const response = await fetch(`${BASE_URL}/v1/balance`, {
    headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' },
    signal: AbortSignal.timeout(10_000),
  });

  const body = await response.json();
  if (!response.ok) throw new GSmsError(response.status, body);
  return body.balanceXaf;
}

gsms.mjs (continued)

// gsms.mjs (continued)

/**
 * Only retries a 429: it is the one case a second of patience solves.
 * A 503 OPERATOR_DISABLED asks for an hour — put the message back on a queue
 * rather than tying up the process. A 402 is only solved by topping up.
 * The clientReference makes retrying safe: at worst the API answers 409.
 */
export async function sendSmsWithRetry(input, attempts = 3) {
  for (let attempt = 1; ; attempt += 1) {
    try {
      return await sendSms(input);
    } catch (error) {
      const retryable = error instanceof GSmsError && error.status === 429;
      if (!retryable || attempt === attempts) throw error;
      const waitSeconds = error.retryAfterSeconds ?? 1;
      await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
    }
  }
}

Your first message

clientReference is optional, but it is what makes a send replayable: on a second call with the same value, the API returns the original message with a 409 instead of sending and charging a second SMS. An order, session or login attempt identifier works well, within 64 characters.

send.mjs

// send.mjs
import { GSmsError, sendSmsWithRetry } from './gsms.mjs';

try {
  const { duplicate, message } = await sendSmsWithRetry({
    to: '+237690000000',
    text: 'Your login code is 4821.',
    // A stable identifier on your side: it makes the call replayable. 64 chars max.
    clientReference: 'login-2026-09-12-4821',
  });

  if (duplicate) {
    console.log('Already sent:', message.messageId, message.status);
  } else {
    console.log('Accepted:', message.messageId, message.operator,
      message.segments, 'segment(s),', message.priceXaf, 'XAF,',
      message.balanceAfterXaf, 'XAF left');
  }
} catch (error) {
  if (error instanceof GSmsError && error.code === 'INSUFFICIENT_BALANCE') {
    console.error('Out of credit: top up, then replay the same call.');
  } else if (error instanceof GSmsError && error.code === 'OPERATOR_DISABLED') {
    console.error('Carrier paused, nothing charged. Reschedule in an hour.');
  } else {
    throw error;
  }
}

Body of the 202:

{
  "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: on a message that was just accepted, neither failureReason nor deliveredAt is there. And balanceAfterXaf only appears on this creation response.

TypeScript types

The contract, written once. The optional properties are not caution: they are exactly the fields the API omits when they are null.

// gsms.d.ts — if your project is written in TypeScript.

export type Operator = 'ORANGE' | 'MTN' | 'CAMTEL' | 'NEXTTEL' | 'DEFAULT';
export type MessageStatus = 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED';

/**
 * Response of POST /v1/sms and GET /v1/sms/{messageId}.
 * Null fields are OMITTED, hence the optional properties.
 */
export interface SmsMessage {
  messageId: string;
  to: string;                    // always normalised to E.164, even if you sent 6XXXXXXXX
  operator: Operator;
  segments: number;
  priceXaf: number;
  status: MessageStatus;
  createdAt: string;
  clientReference?: string;
  balanceAfterXaf?: number;      // only on the 202 that creates the message
  failureReason?: string;
  sentAt?: string;
  deliveredAt?: string;
}

/** Error body — except for the 409, which returns an SmsMessage. */
export interface GSmsErrorBody {
  code: string;
  message: string;
}

The responses you have to handle

StatusCodeMeaningWhat to do
202—Accepted, charged, queued.Keep 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; send nothing.
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).Postpone to tomorrow or ask for a higher quota.
400INVALID_NUMBER, TEXT_EMPTY, TEXT_TOO_LONGMalformed request. Nothing charged.Fix the call; an identical retry will fail the same way.
503OPERATOR_DISABLEDSending to this carrier is paused. Nothing charged.Reschedule after Retry-After (one hour).
The 409 is the trap of this API. Its body is not a { code, message } object like the other errors: it is the message already stored, shaped like the 202 and without balanceAfterXaf. A hastily written if (!response.ok) throw new Error(body.code) will therefore throw with undefined as its message, while the SMS is on its way.

Receiving delivery receipts

A 202 means "accepted", not "received". The rest arrives as a push to a URL declared in the console. There is a single event, sms.sent, emitted on every status change: SENT, then DELIVERED or FAILED, the last two being final. A FAILED message is refunded automatically, once; failureReason then carries the cause reported by the carrier, or TIMEOUT if nothing was confirmed within 24 h, or SUBMIT_FAILED.

webhook.mjs

// webhook.mjs — status receiver, no dependencies.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const SECRET = process.env.GSMS_WEBHOOK_SECRET;
const handled = new Set(); // in production: Redis or a table, not process memory

function isSignatureValid(raw, timestamp, signature) {
  const expected = `sha256=${createHmac('sha256', SECRET).update(`${raw}.${timestamp}`).digest('hex')}`;
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  // timingSafeEqual needs two buffers of equal length, and compares in constant time.
  return a.length === b.length && timingSafeEqual(a, b);
}

createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhooks/gsms') {
    res.writeHead(404).end();
    return;
  }

  // The RAW body: the signature covers the bytes received, never a
  // re-serialised JSON. With Express, use express.raw(), not express.json().
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const raw = Buffer.concat(chunks).toString('utf8');

  const timestamp = Number(req.headers['x-gsms-timestamp']);
  const signature = req.headers['x-gsms-signature'] ?? '';

  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    res.writeHead(400).end(); // outside the ±5 minute tolerance window
    return;
  }

  if (!isSignatureValid(raw, timestamp, signature)) {
    res.writeHead(401).end();
    return;
  }

  const event = JSON.parse(raw);

  // At-least-once delivery: the same eventId may arrive more than once.
  if (!handled.has(event.eventId)) {
    handled.add(event.eventId);
    const { messageId, status, failureReason, clientReference } = event.data;
    // event.applicationId holds the application SLUG, not its UUID.
    console.log(messageId, clientReference, status, failureReason ?? '');
  }

  // 204: any 2xx counts as an acknowledgement. Answer within 10 s.
  res.writeHead(204).end();
}).listen(3000);
  • The signature is HMAC_SHA256(secret, raw_body + "." + timestamp), lowercase hexadecimal, prefixed with sha256=.
  • Reject a timestamp more than five minutes away from your clock: that is the replay protection.
  • Compare signatures with timingSafeEqual. A === on two strings compares character by character and leaks, through response time, where the first difference is.
  • Deduplicate on eventId, not on X-GSms-Delivery, which changes on every attempt.
  • applicationId holds the application slug, not its technical identifier, despite the name.
  • Unlike API responses, the webhook body keeps its null fields: clientReference and failureReason are always present, possibly as null.
  • On failure, attempts space out: 1 min, 5 min, 30 min, 2 h, 12 h, then every 12 h, stopping after 48 h. The delivery becomes EXHAUSTED and stays replayable from the console.

If you cannot expose a public URL:

// gsms.mjs (continued) — read a message on demand. Keep this for deferred
// jobs: it is not meant to be polled every second.
const response = await fetch(`${BASE_URL}/v1/sms/${messageId}`, {
  headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' },
});

// 404 if the message belongs to another application: there is never a 403.
const message = await response.json();
console.log(message.status, message.failureReason ?? '');

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

5 XAF to MTN as well, 7 XAF to Camtel and Nexttel, 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

  • An emoji is expensive. As long as the text fits the GSM-7 alphabet, a segment holds 160 characters; a single character outside it brings that down to 70. An emoji, a typographic apostrophe "’", a lowercase "ç" or guillemets "«»" are enough. Beyond one segment, capacity falls to 153 characters in GSM-7 and 67 in UCS-2. Those four sizes are administrable settings, not constants: read 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. Name your service 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.
  • No Promise.all over a thousand numbers. Above ten requests per second you will collect 429s. Pace your sends, or put them on a queue.

Ready to send your first SMS?

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

Create account

Read next