G-SMS · Use case
OTP authentication in Cameroon: the one-time code by SMS
Proving that a user actually owns the number they claim, at sign-up and on every sensitive login. Here is how to do it with G-SMS, what it really costs, and the rules that keep a security measure from turning into an open door.
The problem: in Cameroon, identity runs through the phone number
An email address takes thirty seconds to create and is abandoned just as fast. A Cameroonian phone number is tied to a registered SIM, is used for Mobile Money, and remains the channel through which a customer will actually be reached. It is the de facto identifier of this market.
As long as that number is unverified, your account base holds mistyped numbers, numbers that do not exist and duplicates. The day you need to reach a buyer about a delivery or a refund, the data you were counting on is not there.
What skipping verification costs
Work it out on your own volumes rather than on market averages. If support spends five minutes manually reactivating an account whose number is wrong, twenty times a month, that is close to two hours of work every month. At any hourly rate, that already costs more than the few thousand francs of OTP that would have prevented it.
Then there is what never shows up in a support ticket: duplicate accounts created to claim a sign-up promotion several times, and abandoned carts you cannot follow up on by phone.
The solution in three steps
G-SMS carries the message. Generating the code, storing it and checking it stay inside your application — that is what keeps the secret from travelling.
1. Generate the code and store it hashed
import { createHash, randomInt } from 'node:crypto';
// Six digits: typable on a phone keypad, readable out loud over the phone.
const code = String(randomInt(0, 1_000_000)).padStart(6, '0');
await db.otpChallenges.upsert({
phone, // +237690000000 (E.164)
codeHash: createHash('sha256').update(code).digest('hex'), // never in clear
expiresAt: Date.now() + 5 * 60_000, // 5 minutes
attempts: 0,
});2. Send the SMS
const response = await fetch('https://api.sms.gworldship.com/v1/sms', {
method: 'POST',
headers: {
Authorization: 'Bearer gsk_votre_cle_api',
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
text: `Your login code is ${code}. It expires in 5 minutes.`,
clientReference: `otp-${userId}-${challengeId}`, // safe to replay
}),
});
if (response.status === 202) {
const sms = await response.json();
// sms.messageId, sms.segments, sms.priceXaf, sms.balanceAfterXaf
} else if (response.status === 409) {
// Already sent under that reference: the response IS the message, not an error.
} else {
const error = await response.json(); // { code, message }
logger.warn('G-SMS %s %s', response.status, error.code);
}The operator is derived from the number: you never have to specify it. The response states how many segments were billed, the amount charged and the remaining balance.
HTTP/1.1 202 Accepted
{
"messageId": "b7d1f0c2-5a3e-4c19-9f0a-2d6e8c41b3aa",
"to": "+237690000000",
"operator": "ORANGE",
"segments": 1,
"priceXaf": 5,
"status": "QUEUED",
"clientReference": "otp-4821-3",
"balanceAfterXaf": 4995,
"createdAt": "2026-09-12T09:14:07Z"
}3. Check what the user typed
import { createHash, timingSafeEqual } from 'node:crypto';
const challenge = await db.otpChallenges.find(phone);
if (!challenge || challenge.expiresAt < Date.now()) return reject('EXPIRED');
if (challenge.attempts >= 3) return reject('TOO_MANY_ATTEMPTS');
await db.otpChallenges.increment(phone, 'attempts');
// Constant-time comparison: a naive one leaks the right answer character by character.
const valid = timingSafeEqual(
createHash('sha256').update(submitted).digest(),
Buffer.from(challenge.codeHash, 'hex'),
);
if (!valid) return reject('INVALID');
await db.otpChallenges.delete(phone); // a code is good exactly once
return accept();The same mechanism — one request, one client reference, one delivery status — then serves your transactional notifications and your appointment reminders. Only the timing and frequency rules differ.
The real cost, in CFA francs
Assumption: 1,000 sign-ups a month, one code per sign-up, with an assumed split of 85% to Orange and MTN and 15% to Camtel and Nexttel. Adjust that split to your own user base.
| Item | Volume | Unit price | Monthly cost |
|---|---|---|---|
| Codes sent to Orange and MTN | 850 | XAF 5 | XAF 4,250 |
| Codes sent to Camtel and Nexttel | 150 | XAF 7 | XAF 1,050 |
| Resends on request (10% of sign-ups) | 100 | XAF 5 | XAF 500 |
| Total | 1,100 SMS | XAF 5,800 |
That is XAF 5.80 per verified sign-up. The example message above is 51 characters long: it fits in one segment, so the listed price is the real cost. Add an emoji and the message switches to UCS-2, capped at 70 characters per segment — the same text would then cost two segments. Check a message in the segment calculator →
Messages that fail are credited back automatically: what you actually spend covers the messages the operator really took in charge.
The rules that make an OTP actually protect something
- Five minutes of validity. After that, invalidate the code rather than letting it live on.
- Three entry attempts. On the fourth, destroy the code and make the user request a new one.
- One active code per number. A new send replaces the previous one; two valid codes at once double the attack surface.
- One resend after sixty seconds, three resends per hour per number. Without that cap, your bill becomes the adjustment variable of whoever triggers sends in a loop.
- The code never leaves your server except through the SMS. Not in the HTTP response, not in the logs, not in your error tracker.
- Constant-time comparison. A plain string comparison stops at the first wrong character and tells the attacker so.
- Put the code early in the text, exactly once. Android and iOS can then offer it as an autofill suggestion.
- A unique client reference per attempt. If your server retries after a network hiccup, G-SMS recognises the reference and does not bill a second send.
What to do when the code does not arrive
| Situation | What the API returns | The right response |
|---|---|---|
| Sending to that operator is suspended | 503 OPERATOR_DISABLED | Nothing is charged. Retry after the delay given in Retry-After, or offer another way to sign in. |
| Balance exhausted | 402 INSUFFICIENT_BALANCE | Watch balanceAfterXaf on every send and raise an alert well before it reaches zero. |
| Sending too fast | 429 RATE_LIMITED | The limit is 10 requests per second per key. Retry after one second. |
| The SMS leaves but is never delivered | FAILED status | The credit is refunded. Show a resend button and allow a retry after sixty seconds. |
| No delivery report after 24 hours | FAILED / TIMEOUT | The message is considered lost and refunded. Treat it as an ordinary failure. |
Status changes are pushed to you through a signed webhook, so you never have to poll the API. Tracking delivery statuses →
What G-SMS does not do
- G-SMS neither generates nor verifies codes. There is no endpoint that would produce an OTP on your behalf: the secret, its lifetime and the attempt counter stay with you. That is deliberate — a code verified anywhere other than inside your application is a code you no longer control.
- No custom sender name. The recipient sees the number of the sending SIM, never a brand name. Practical consequence: name yourself inside the message text.
- No inbound SMS. No verification through an incoming message, no “YES” reply from the user. This is a settled design decision, not a feature in the pipeline.
- No voice fallback. If a number receives nothing, plan another route inside your application rather than relying on an automated call.
Frequently asked questions
How much does an OTP SMS cost in Cameroon?
XAF 5 per segment to Orange and MTN, XAF 7 to Camtel and Nexttel. A six-digit code fits in a single segment, so a code costs 5 or 7 francs to deliver — no subscription, no commitment.
How long should a code stay valid?
Five minutes is enough. Any shorter and the user who switches apps to read the SMS comes back too late; any longer and you widen the attacker’s window for no benefit.
What happens if the SMS never arrives?
The message ends up FAILED and the amount charged is credited back automatically, once. Your application then offers a resend after sixty seconds.
Ready to send your first SMS?
Create an account, get 100 FCFA of credit and an API key straight away.
The XAF 100 welcome credit covers twenty test codes to Orange or MTN.