Send an SMS
One request, one message. The API validates the number, picks the operator from its prefix, counts the segments, charges your balance and queues the message — all inside a single transaction. Everything below is what the server actually does, in the order it does it.
Endpoint
POST https://api.sms.gworldship.com/v1/sms Authenticated with Authorization: Bearer gsk_…. The account and the application are resolved from the key, never from the request body.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
| to | string | Required | Cameroonian mobile number. Accepted as +2376XXXXXXXX, 2376XXXXXXXX or 6XXXXXXXX; spaces are ignored. The response always returns it normalised to E.164. |
| text | string | Required | Message body, 1 to 1600 characters. An empty or blank text is rejected with TEXT_EMPTY. |
| clientReference | string | Optional | Your own identifier, up to 64 characters, unique inside the application. Sending it twice returns the first message instead of sending a second one. See idempotency. |
Request
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"
}'Response
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"
}| Field | Description |
|---|---|
| messageId | UUID of the message. Keep it: it is the key for status lookups and webhooks. |
| to | The number normalised to E.164, whatever format you sent. |
| operator | ORANGE, MTN, CAMTEL, NEXTTEL or DEFAULT, resolved from the number prefix. |
| segments | Number of segments billed for this text. See the section below. |
| priceXaf | Total charged, in XAF: operator price per segment × segments. |
| status | Always QUEUED on a 202. Delivery comes later — statuses and webhooks. |
| balanceAfterXaf | Your balance after the charge. Present only on a 202, because only a 202 moved money. |
| clientReference | Echoed back when you sent one; absent otherwise. |
| createdAt | Acceptance time, ISO-8601 UTC. |
Null fields are omitted from the JSON. On a 202 you will not see failureReason, sentAt or deliveredAt: they appear on later reads, once they have a value.
Segments and price
SMS billing counts segments, not messages. Which alphabet your text uses decides how many characters fit in one segment.
| Encoding | Single segment | Per segment when split |
|---|---|---|
| GSM-7 (plain text) | 160 characters | 153 characters |
| UCS-2 (rare accents, emoji) | 70 characters | 67 characters |
- A single character outside GSM-7 switches the whole message to UCS-2. One emoji in a 150-character text turns one segment into three. Write plain text in anything you send at volume.
- These four sizes are settings, not constants: an administrator can change them. Treat them as the current values and rely on the
segmentsfield of the response for what you were actually charged, or on the segment calculator before sending. - Price per segment: XAF 5 to Orange and MTN, XAF 7 to Camtel and Nexttel, XAF 10 on the default route. Full pricing.
How the operator is chosen
The operator comes from the number prefix, following the ART numbering plan. A prefix that belongs to no operator in the plan is not rejected: the message goes out through the fallback route, recorded as DEFAULT and billed XAF 10 per segment. You can check any number with the carrier lookup tool before you send.
The order of checks
This order matters more than it looks: it tells you exactly when your balance is touched and when it is not. Nothing is charged before the last step.
| # | Check | Status | Code |
|---|---|---|---|
| 1 | Key is valid and not revoked | 401 | INVALID_API_KEY |
| 2 | Account is active | 403 | ACCOUNT_SUSPENDED |
| 3 | Client reference not already used | 409 | the existing message, not an error object |
| 4 | Text and number are valid, a price exists | 400 | TEXT_EMPTY · TEXT_TOO_LONG · INVALID_NUMBER · PRICE_NOT_CONFIGURED |
| 5 | Target operator is enabled | 503 | OPERATOR_DISABLED |
| 6 | Daily quota not reached | 429 | QUOTA_EXCEEDED |
| 7 | Balance is sufficient — charge and queue | 402 | INSUFFICIENT_BALANCE |
OPERATOR_DISABLEDis returned before any charge, with aRetry-After: 3600header. A disabled network is never silently rerouted through another one.- The daily quota is disabled by default, so most accounts never see step 6. Quotas and limits.
- Requests are also rate limited to ten per second per key, before any of this:
429 RATE_LIMITEDwithRetry-After: 1.
Node.js
// Node.js 18+ — no dependency, fetch is built in.
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: '+237690000000',
text: `Your verification code is ${code}`,
clientReference: `otp-${userId}-${Date.now()}`,
}),
});
const body = await res.json();
if (res.status === 202) {
console.log('queued', body.messageId, 'balance', body.balanceAfterXaf);
} else if (res.status === 409) {
// Duplicate client reference: body is the EXISTING message, not an error.
console.log('already sent', body.messageId);
} else {
throw new Error(`${res.status} ${body.code}`);
}PHP
<?php
// PHP 8, cURL from the standard library.
$payload = json_encode([
'to' => '+237690000000',
'text' => "Your verification code is {$code}",
'clientReference' => "otp-{$userId}-" . time(),
]);
$ch = curl_init('https://api.sms.gworldship.com/v1/sms');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('GSMS_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$body = json_decode($response, true);
if ($status === 202 || $status === 409) {
// 409 = same clientReference already used: the existing message is returned.
$messageId = $body['messageId'];
} else {
throw new RuntimeException($body['code'] ?? 'HTTP ' . $status);
} Both examples treat 409 as a success: the message exists, it was charged once, and its identifier is in the body. That single branch is what makes a retry safe. Why 409 is not an error here.
Reading a message back
GET https://api.sms.gworldship.com/v1/sms/9f1c4b2e-5d3a-4c7e-8b21-6a0f7d9c1e45 Returns the same object, with the current status. A message that belongs to another application returns 404, never 403: the API does not confirm the existence of identifiers you are not allowed to see.
Ready to send your first SMS?
Create an account, get 100 FCFA of credit and an API key straight away.
Create account