Blog Tutorial
Tutorial
SMS OTP API tutorial: send OTP by SMS and automate your alerts
Generate an OTP, send it by SMS in a single request, track its delivery by webhook and automate your payment, shipping and security alerts.
CONTENTS
- What is an OTP and a transactional SMS?
- What do you need to integrate an SMS API?
- How to send OTP by SMS with the API
- What does the 202 Accepted response mean?
- When can you resend a request? The role of the Idempotency-Key
- How do you know the SMS arrived? Webhooks and the SMS report
- Which errors can the SMS API return?
- How do you automate SMS alerts?
- How do you secure OTP codes?
- Which SMS templates should you use for codes and alerts?
- How much does sending OTPs by SMS cost in the DRC?
- Frequently asked questions
Sending an OTP by SMS remains the simplest way to verify a phone number or confirm a transaction: SMS works on every mobile phone, even without internet access. With an SMS OTP API, your server generates the code, sends it in a single HTTP request and tracks its delivery. This tutorial uses the Coussema REST API, with curl, Node.js, Python and PHP examples, then extends it to automated alerts.
KEY TAKEAWAYS
- An OTP (one-time password) is a single-use code, usually 6 digits, that proves a user controls their phone number.
- With Coussema, an SMS is sent with
POST https://api.coussema.com/v1/sms/send, a server-side API key and a mandatoryIdempotency-Keyheader. 202 Acceptedmeans “accepted into the sending queue”, not “delivered”: delivery is tracked by webhook and in the SMS report.- For 24 hours, the same
Idempotency-Keywith the same content signals a retry, not a new send. - A secure code expires within 10 minutes, works only once and is stored only as a hash.
- With Coussema, a 160-character GSM-7 SMS costs $0.03 on the Growth plan; a single curly apostrophe ’ or emoji switches it to Unicode, limited to 70 characters.
What is an OTP and a transactional SMS?
An OTP (one-time password) is a temporary code that confirms a person controls a phone number or approves an operation. A transactional SMS is a message that one recipient expects, triggered by an action: a code, a payment confirmation, a shipping notice. Marketing SMS, which requires consent, is covered in our guide to bulk SMS in the DRC.
In the DRC, according to the ARPTC report on the first quarter of 2026, published in June 2026 by the telecom regulator, SMS is narrowing down to three uses: OTP codes, technical alerts and emergency messages. At the end of March 2026, the country had 74.9 million mobile subscriptions, but mobile internet penetration was only 34.1%: SMS also reaches people who are offline.
What do you need to integrate an SMS API?
You need an account, an API key, an approved Sender ID and credit.
- An account. Signing up gives you 10 free SMS for testing.
- An API key with the “SMS sending only” profile (
sms:sendscope), created in the dashboard. - A Sender ID. A Sender ID is the name displayed as the sender of an SMS instead of a phone number, for example “MyApp”. It is free, requested in the dashboard and usable once approved, on the Growth plan.
- Credit, topped up by Mobile Money; the acceptance response shows the balance at that moment (
remainingQuota).
This key sends SMS on your balance: keep it on the server, in an environment variable (COUSSEMA_API_KEY), never in a mobile app, a web page, a Git repository or a log. GET https://api.coussema.com/v1/health checks that the API is up, and the quickstart walks through these steps.
How to send OTP by SMS with the API
One request is enough: POST https://api.coussema.com/v1/sms/send with an Authorization: Bearer <API key> header, a unique Idempotency-Key and a JSON body containing the number, the text and your Sender ID.
| Element | Example | Notes |
|---|---|---|
Authorization | Bearer csm_live_… | Or the x-api-key header |
Idempotency-Key | a UUID (unique identifier) | Required, one value per send |
to | 243810000000 | Congolese number in international format, without the leading 0; one per request |
message | the SMS text | 1 to 1,530 characters, billed per segment |
senderName | MyApp | Approved Sender ID, or senderId (its UUID) |
clientReference | otp-signup-001 | Optional: your own reference, up to 128 characters |
The optional X-Request-Id header and the other rules are in the API reference. On your server, draw the code with a cryptographically secure generator, never Math.random(), then store its hash, its expiry time and an attempt counter before sending.
With curl
curl -X POST https://api.coussema.com/v1/sms/send \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 7228b5c7-cb1d-43c0-8d68-2e9f68a5fba5' \
-H 'X-Request-Id: otp-signup-001' \
-d '{
"to": "243810000000",
"message": "482019 is your MyApp code. It expires in 10 minutes. Do not share it with anyone.",
"senderName": "MyApp",
"clientReference": "otp-signup-001"
}'With Node.js: send and verify the code
No Coussema library to install: fetch is enough.
import crypto from 'node:crypto';
const baseUrl = process.env.COUSSEMA_BASE_URL ?? 'https://api.coussema.com';
const apiKey = process.env.COUSSEMA_API_KEY;
const otpSecret = process.env.OTP_HMAC_SECRET; // your own secret, separate from the API key
if (!apiKey || !otpSecret) {
throw new Error('COUSSEMA_API_KEY and OTP_HMAC_SECRET are required.');
}
// In-memory example: in production, use your database (PostgreSQL, Redis...).
const otps = new Map();
const hashCode = (phone, code) =>
crypto.createHmac('sha256', otpSecret).update(`${phone}:${code}`).digest();
// One attempt: a code and a key, prepared once.
export function prepareOtp(phone) {
const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
const idempotencyKey = crypto.randomUUID();
// One active code per number: the new one replaces the old one.
otps.set(phone, {
hash: hashCode(phone, code),
expiresAt: Date.now() + 10 * 60 * 1000,
attempts: 0,
});
return { phone, code, idempotencyKey };
}
// After a network error, call sendOtp(attempt) again with the same attempt.
export async function sendOtp({ phone, code, idempotencyKey }) {
const response = await fetch(`${baseUrl}/v1/sms/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
'X-Request-Id': crypto.randomUUID(),
},
body: JSON.stringify({
to: phone,
message: `${code} is your MyApp code. It expires in 10 minutes. Do not share it with anyone.`,
senderName: 'MyApp',
clientReference: `otp-${idempotencyKey}`,
}),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
// Never log the code or the API key.
throw new Error(`Coussema API returned ${response.status} (x-request-id ${response.headers.get('x-request-id')}).`);
}
// 202: accepted into the sending queue, not delivered yet.
const payload = await response.json();
return { messageId: payload.message.id, remainingQuota: payload.remainingQuota };
}
export function verifyOtp(phone, submittedCode) {
const entry = otps.get(phone);
if (!entry || Date.now() > entry.expiresAt || entry.attempts >= 5) {
otps.delete(phone);
return false;
}
entry.attempts += 1;
const valid = crypto.timingSafeEqual(entry.hash, hashCode(phone, String(submittedCode)));
if (valid) otps.delete(phone); // single use
return valid;
}Prepare the attempt once with const attempt = prepareOtp(phone), then send it with sendOtp(attempt); after a network error, call sendOtp(attempt) again without calling prepareOtp. The HMAC-SHA256 hash, a one-way computation based on a secret of your own, protects the codes if your database leaks.
With Python
import os
import secrets
import uuid
import requests
def send_otp(phone, code, idempotency_key):
api_key = os.environ['COUSSEMA_API_KEY'].strip()
payload = {
'to': phone,
'message': f'{code} is your MyApp code. It expires in 10 minutes. '
'Do not share it with anyone.',
'senderName': 'MyApp',
'clientReference': f'otp-{idempotency_key}',
}
try:
response = requests.post(
'https://api.coussema.com/v1/sms/send',
headers={
'Authorization': f'Bearer {api_key}',
'Idempotency-Key': idempotency_key,
'X-Request-Id': str(uuid.uuid4()),
},
json=payload,
timeout=(5, 30),
allow_redirects=False,
)
except requests.exceptions.InvalidHeader:
# This error's message can contain the key: do not keep it.
raise RuntimeError('Invalid API header configuration.') from None
except requests.exceptions.RequestException:
# Uncertain outcome: a retry is possible with the same key and content.
raise RuntimeError('Uncertain network result.') from None
request_id = response.headers.get('x-request-id', '')
if not 200 <= response.status_code < 300:
# Never log the API key or the SMS content.
raise RuntimeError(f'HTTP {response.status_code}; request_id={request_id}')
result = response.json()
if result.get('queued') is not True or result.get('status') != 'accepted':
raise RuntimeError(f'Unexpected response; request_id={request_id}')
return result['message']['id']
code = f'{secrets.randbelow(1_000_000):06d}' # 6 digits, secure generator
idempotency_key = str(uuid.uuid4()) # keep it, with the code, for a retry
message_id = send_otp('243810000000', code, idempotency_key)With PHP
<?php
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$idempotencyKey = bin2hex(random_bytes(16)); // keep it, with the code, for a retry
$payload = [
'to' => '243810000000',
'message' => $code . ' is your MyApp code. It expires in 10 minutes.',
'senderName' => 'MyApp',
'clientReference' => 'otp-login-001',
];
$ch = curl_init('https://api.coussema.com/v1/sms/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('COUSSEMA_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . $idempotencyKey,
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body === false) {
throw new RuntimeException('Network error: ' . curl_error($ch));
}
if ($status < 200 || $status >= 300) {
throw new RuntimeException('Coussema API error HTTP ' . $status);
}
$result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
$messageId = $result['message']['id']; // 202: accepted into the sending queueWhat does the 202 Accepted response mean?
202 Accepted means Coussema has validated the request, stored the message and placed it in the sending queue. It is not proof of delivery: status updates arrive later by webhook and in the SMS report.
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"success": true,
"queued": true,
"status": "accepted",
"count": 1,
"remainingQuota": 999,
"message": {
"id": "11111111-1111-4111-8111-111111111111",
"clientReference": "otp-signup-001"
},
"provider": {
"code": "coussema",
"messageId": null,
"httpStatus": 202
}
}Keep message.id (provider.messageId is always null), accept any 2xx success and watch remainingQuota. In your interface, show “Sending code” after the 202, then offer “Resend code” after 30 to 60 seconds.
When can you resend a request? The role of the Idempotency-Key
The Idempotency-Key identifies a send: only resend a request to retry that same send, with the same key and the same content. For 24 hours, the API then recognizes the retry and returns the response stored the first time, success or error, instead of processing a new send.
After a timeout or a network error, the outcome is uncertain: replay the identical request. You get the stored response if the send went through, or a 409 error if its outcome is still unresolved. An error raised during processing is usually stored and replayed as is; in every case, check the message status in the SMS report before any new send, and keep the x-request-id. The same key with different content also returns a 409 error; clientReference does not deduplicate anything.
How do you know the SMS arrived? Webhooks and the SMS report
Status updates arrive by webhook, an HTTP call Coussema sends to your server for each event, and show in the SMS report. Queueing is recorded by Coussema; delivery is then reported by the operator’s DLR (delivery report, or delivery receipt).
Register your HTTPS endpoint with a key that has the webhooks:write scope, then store the signingSecret from the response, shown only once, in COUSSEMA_WEBHOOK_SECRET.
curl -X POST https://api.coussema.com/v1/webhooks \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com/webhooks/coussema",
"subscribed_events": ["message.delivered", "message.failed"]
}'The SMS events are message.queued, message.sent (handed to the network), message.delivered (delivered), message.failed (not delivered: check the number, offer a new code) and message.delivery_unconfirmed (no final receipt). You receive the ones your endpoint subscribes to. Every SMS webhook is signed: the x-coussema-signature header contains v1= followed by an HMAC-SHA256, computed with your secret over the x-coussema-timestamp value, a dot, then the raw body. Verify it before any JSON.parse:
import { createHmac, timingSafeEqual } from 'node:crypto';
function handleWebhook(rawBody, headers) {
const timestamp = headers.get('x-coussema-timestamp');
const signature = headers.get('x-coussema-signature');
const secret = process.env.COUSSEMA_WEBHOOK_SECRET ?? '';
if (!timestamp || !signature || !secret) throw new Error('Missing signature data.');
const match = /^v1=([0-9a-f]{64})$/.exec(signature);
if (!match) throw new Error('Invalid Coussema webhook signature.');
const timestampMs = Date.parse(timestamp);
const timestampIsFresh = Number.isFinite(timestampMs)
&& Math.abs(Date.now() - timestampMs) <= 5 * 60 * 1000;
const expectedDigest = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest();
const valid = timestampIsFresh && timingSafeEqual(Buffer.from(match[1], 'hex'), expectedDigest);
if (!valid) throw new Error('Invalid Coussema webhook signature.');
return JSON.parse(rawBody);
}Respond quickly with a 2xx, deduplicate on x-coussema-delivery-id (an event can arrive twice) and match the operation with message.id or message.client_reference. In the SMS report, a message can be En attente (pending), Envoyé (sent), Reçu (delivered), Échec (failed) or Non confirmé (unconfirmed); only Reçu proves delivery; the webhooks guide covers the rest.
Which errors can the SMS API return?
The API answers with an HTTP status: 4xx for a request to fix, 429 when the rate limit is exceeded, 5xx for a server-side incident. Handle that HTTP status first: not every error response contains a code field. Keep the x-request-id of every response for support.
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 400 | — | Request breaks the contract: number, text, sender | Fix it before resending |
| 401 | — | Key missing, revoked or invalid | Use a valid key |
| 403 | api_key_scope_required | The key lacks the required scope | Switch to the right key profile |
| 409 | — | Same key with different content, or a previous send still unresolved | Check the payload and the send status |
| 429 | — | Rate limit exceeded | Wait for Retry-After, keep the key |
| 500 | — | Unexpected error | Check the message status before a new send |
The send endpoint accepts up to 10,000 requests per rate-limit window by default; the X-RateLimit-Remaining header shows the requests still allowed. Never loop on an error.
How do you automate SMS alerts?
Automated SMS alerts are messages triggered by an event in your system: payment confirmed, order shipped, new sign-in. The call is the same as for an OTP, with three precautions.
- A key derived from the event, such as
payment-58231-sms: a job replayed within 24 hours resumes the same send, as long as the text is identical. - A background job, so that a slow call never blocks the payment.
- The right time: security alerts at once, reminders during business hours, across the DRC’s two time zones (UTC+1 in Kinshasa, UTC+2 in Lubumbashi).
// Same baseUrl and apiKey as in the previous example.
// Plain separators: toLocaleString('fr-FR') inserts a narrow no-break space, outside GSM-7.
const formatAmount = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
export async function sendPaymentAlert(payment) {
const response = await fetch(`${baseUrl}/v1/sms/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
// A key derived from the event: a job that runs twice resumes the same send.
'Idempotency-Key': `payment-${payment.id}-sms`,
},
body: JSON.stringify({
to: payment.phone,
// Text built from the event only: identical on every retry.
message: `Payment of ${formatAmount(payment.amount)} CDF received for order ${payment.orderId}. Thank you for your trust.`,
senderName: 'MyShop',
clientReference: `payment-${payment.id}`,
}),
});
if (!response.ok) throw new Error(`Coussema API returned ${response.status}.`);
return (await response.json()).message.id;
}Keep these messages informational: adding a promotion would turn them into marketing.
How do you secure OTP codes?
Secure SMS verification relies on a random, short, single-use code, with limited attempts and sends, never written in plain text in your database or logs.
| Rule | Recommended value | Why |
|---|---|---|
| Length | 6 digits | One million combinations |
| Validity | 5 to 10 minutes | NIST and OWASP ceiling |
| Attempts | 3 to 5 per code | 5 attempts: 5 chances in a million |
| Sends | Resend after 30 to 60 seconds, hourly cap per number | Limits abuse and cost |
| Active code | Only one, deleted after use | The new code cancels the old one |
| Storage | HMAC-SHA256 hash | A database leak does not expose codes |
| Logs | Neither the code nor the API key | Logs travel further than the database |
The NIST SP 800-63B-4 guideline from the US standards institute (final version, July 2025) requires at least six digits, a validity of no more than 10 minutes and single use, and classifies SMS as a “restricted” authenticator because of SIM swaps and number porting. OWASP ASVS 5.0 (May 2025) requires rate limiting and rules SMS out for the most sensitive applications.
Also cap requests per number, per IP address and per account: “SMS pumping”, waves of code requests to fraudsters’ numbers, drains your credit. If an SMS fails, offer a new code or another channel: a WhatsApp authentication template, with the customer’s consent (WhatsApp API), or a voice call.
Which SMS templates should you use for codes and alerts?
Use short messages that fit in a single segment. A segment is the billing unit of an SMS: 160 characters in GSM-7, the standard SMS alphabet, or 70 as soon as one character falls outside it; beyond that, each segment holds 153 characters, or 67 in Unicode. A few symbols such as € and most emojis count twice.
| Use | Template | Characters | Billed SMS |
|---|---|---|---|
| Verification code | 482019 is your MyApp code. It expires in 10 minutes. Do not share it with anyone. | 81 | 1 |
| Password reset | 739204 is your code to reset your MyApp password. Valid for 10 minutes. If you did not ask for it, ignore this SMS. | 115 | 1 |
| Payment received | Payment of 25,000 CDF received for order 1042. Thank you for your trust. | 72 | 1 |
| Order shipped | Your order 1042 has shipped. Delivery expected tomorrow between 10 am and 12 pm. The courier will call you. | 107 | 1 |
| Security alert | New sign-in to your account on 24/10 at 14:05. If this was not you, call 0810000000 and change your password. | 109 | 1 |
Put the code first, so it can be read in the notification. A single curly apostrophe ’, curly quote, emoji or letter such as ê or ç switches the whole message to Unicode (é, è, à and ù are fine): “We’ve received your payment of 25,000 CDF for order 1042. Thank you for your trust.” has 83 characters and costs 2 SMS, but only 1 with a straight apostrophe '. Locale formatting is another trap: toLocaleString('fr-FR') separates thousands with a narrow no-break space, outside GSM-7.
482019 is your MyApp code. It expires in 10 minutes.
@example.com #482019How much does sending OTPs by SMS cost in the DRC?
With Coussema, SMS API access is free and each single-segment SMS to the DRC costs $0.03 on the Growth plan, with no subscription; 10 free SMS come with sign-up.
| Monthly volume | Billed SMS | Cost at $0.03 |
|---|---|---|
| Verification codes, resends included | 4,000 | $120 |
| Payment confirmations | 10,000 | $300 |
| Security alerts | 1,000 | $30 |
| Total | 15,000 | $450 |
If the confirmations used a curly apostrophe (“We’ve received…”), they would take 2 segments each: 25,000 billed SMS, or $750. Purchased SMS never expire and are paid by Mobile Money; Enterprise pricing for high volumes is available on request (pricing, contact). SMS currently covers the DRC (+243); other destinations are opening. The rest of the offer is on the business SMS page.
Frequently asked questions
What is an SMS OTP API?
It is an interface that lets your application send one-time codes by SMS in a single HTTP request, then track their delivery. Your server generates and verifies the code; the API queues the message for delivery.
How many digits should an SMS OTP have?
Six digits, or one million combinations: that is the minimum set by NIST. Add a validity of 5 to 10 minutes and a limited number of attempts.
Can you call the SMS API from a mobile app or a browser?
No. The API key sends SMS on your balance and must stay on your server. The app or web page calls your server, which then calls the API.
How much does an SMS OTP cost in the DRC?
With Coussema, $0.03 per single-segment SMS on the Growth plan, with no subscription, and API access is free. So 1,000 single-segment codes cost $30.
Is SMS secure enough for two-factor authentication?
It adds a second factor to the password, but NIST classifies it as a “restricted” authenticator because of SIM swaps and number porting. For high-stakes operations, also offer an authenticator app or a passkey.
Can you send OTPs by SMS outside the DRC?
Not yet with Coussema: SMS currently covers Congolese numbers (+243), with other destinations opening. WhatsApp can reach international numbers, with authentication templates approved by Meta.