SafeCode Messaging & Bulk SMS API
Deliver any SMS (marketing broadcasts, alerts, or OTPs generated by your own backend) to mobile numbers. One API key, pay-as-you-go, prepaid client wallet.
Base URL: https://api.safecode.vip/v1
Dashboard: https://safecode.vip/otp-delivery-api
Human docs page: https://safecode.vip/developer-apis/messaging
Full-text (this file): https://safecode.vip/developer-apis/messaging.md
LLM index: https://safecode.vip/developer-apis/messaging/llms.txt
Sender ID is locked to SafeLTD. For a branded sender ID, contact the developer on WhatsApp: https://wa.me/+233538993178
How it works
You compose the message body (a promotion, an alert, or an OTP your backend generated) and we deliver it via SMS. For authentication use-cases, you generate the code and store it on your side. When the recipient enters it back into your app, you compare it against what you stored. SafeCode never verifies codes for you.
Authentication
All requests include your API key in the Authorization header. Keys are prefixed sfcd_live_ (production) or sfcd_test_ (sandbox).
Authorization: Bearer sfcd_live_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Quickstart
# Marketing message
curl -X POST https://api.safecode.vip/v1/messages/send \
-H "Authorization: Bearer sfcd_live_xxxx_yyyy" \
-H "Content-Type: application/json" \
-d '{
"phone": "+233201234567",
"country": "GH",
"message": "Flash sale! 20% off today only. Shop: https://ex.co/s"
}'
# OTP you generated in your own backend
curl -X POST https://api.safecode.vip/v1/messages/send \
-H "Authorization: Bearer sfcd_live_xxxx_yyyy" \
-H "Content-Type: application/json" \
-d '{
"phone": "+233201234567",
"country": "GH",
"message": "Your Acme code is 482910. Valid for 5 min."
}'
Response:
{
"id": "req_01jz...",
"status": "sent",
"price": 0.57000,
"currency": "GHS",
"price_usd": 0.05000,
"balance": 56.03000,
"balance_usd": 4.95000
}
HTTP clients and bot protection
Requests carrying a valid Authorization: Bearer sfcd_... key bypass browser
bot-detection entirely. Python urllib/requests, Go net/http, Java, PHP,
Node fetch and plain curl all work with their default user agents. You do
not need to spoof a browser user agent. If you ever receive an HTML error page
instead of JSON, retry and then contact support with the Cf-Ray header value.
Rate limits
Every response carries rate-limit headers so batch jobs can pace themselves:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Burst capacity (token bucket size) for your key. |
X-RateLimit-Remaining |
Whole requests you can still make right now. |
X-RateLimit-Reset |
Seconds until at least one request is available again. |
X-RateLimit-Policy |
e.g. 100;burst, 10;rps. |
Retry-After |
Present on 429 only. Seconds to wait. |
Supported countries
| Country | ISO | Phone format |
|---|---|---|
| Ghana | GH | +233XXXXXXXXX |
Additional countries roll out as we secure sender-ID clearance.
Key scopes
Keys issued from the dashboard carry every scope below. If you mint a restricted key, this is what each endpoint checks:
| Endpoint | Required scope |
|---|---|
POST /messages/send |
otp:send |
POST /messages/send-batch |
messages:send (falls back to sms:send, then otp:send) |
POST /messages/bulk |
sms:send (falls back to otp:send) |
GET /messages/status |
otp:read |
GET /balance |
balance:read |
A key without the right scope receives forbidden_scope (403).
POST /messages/send
Sends an SMS with the exact message you supply. Balance is debited at dispatch; if the provider fails, the charge is refunded automatically. Sender ID is locked to SafeLTD.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
phone |
string | yes | Recipient number in E.164 format, e.g. +233201234567. |
country |
string | yes | ISO 3166-1 alpha-2 country code. Currently GH. |
message |
string | yes | Full SMS body sent verbatim. Max 480 chars (multi-part SMS). |
channel |
string | no | sms (default) or whatsapp. See the WhatsApp section below. |
metadata |
object | no | Arbitrary key-value pairs echoed back on status lookups and webhooks. |
Idempotency: pass an Idempotency-Key header (UUID) so retries are safe. Duplicate requests return the original response without re-charging.
Response: 200 OK:
{
"id": "9f2c1d3e-…",
"status": "sent",
"expires_at": "2026-06-29T14:10:00.000Z",
"price": 0.57000,
"currency": "GHS",
"price_ghs": 0.57000,
"price_usd": 0.05000,
"balance": 56.03000,
"balance_ghs": 56.03000,
"balance_usd": 4.95000,
"request_id": "550e8400-…"
}
id is the message id to use with /messages/status. request_id identifies this HTTP call for support.
WhatsApp channel
Pass "channel": "whatsapp" to provision a WhatsApp verification number instead of sending an SMS. The response gains a whatsapp_phone field holding the number that was allocated for the recipient; hand that to your user to complete their WhatsApp verification. Everything else, pricing, idempotency, status lookups, webhooks, behaves as for SMS. Any other channel value returns unsupported_channel (400).
POST /messages/send-batch
Send up to 100 messages in one request. Each item is priced, charged and
dispatched independently, so one bad number never blocks the rest.
curl -X POST https://api.safecode.vip/v1/messages/send-batch \
-H "Authorization: Bearer sfcd_live_xxxx_yyyy" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"messages": [
{ "phone": "+233201234567", "country": "GH", "message": "Your 5GB bundle is active." },
{ "phone": "+233207654321", "country": "GH", "message": "Your 10GB bundle is active." }
]
}'
| Field | Type | Required | Description |
|---|---|---|---|
messages |
array | yes | 1-100 items. |
messages[].phone |
string | yes | E.164 recipient. |
messages[].country |
string | yes | ISO 3166-1 alpha-2. |
messages[].message |
string | yes | SMS body, max 480 chars. |
messages[].channel |
string | no | sms (default) or whatsapp. Items using whatsapp return whatsapp_phone in their result. |
messages[].metadata |
object | no | Echoed back on status lookups and webhooks. |
Response: 200 all sent, 207 partial, 402 nothing sent (no funds), 400 all rejected:
{
"batch_id": "8c1e...",
"accepted": 2,
"failed": 0,
"rejected": 0,
"total": 2,
"currency": "GHS",
"total_cost": 1.14000,
"balance": 54.89000,
"results": [
{ "index": 0, "id": "req_01jz...", "phone": "+233201234567", "status": "sent" },
{ "index": 1, "id": "req_01k0...", "phone": "+233207654321", "status": "sent" }
]
}
Per-item status is one of sent, failed (provider rejected, charge refunded),rejected (validation failed, never charged) or skipped (batch stopped because
the wallet ran out; nothing was charged for these).
POST /messages/bulk
Broadcast one freeform SMS to up to 500 recipients in a single call. Unlikesend-batch, every recipient gets the same message and the send runs in the
background: you get a job_id back immediately and poll for progress. Ghana only.
The wallet is debited for the full estimated cost up front so the job can never
overdraw mid-run; anything unused (invalid numbers, provider failures) is
refunded when the job completes.
action selects what the call does:
action |
Purpose |
|---|---|
estimate |
Validate the list and return the cost. Nothing is charged. |
send |
Charge and start the broadcast. Default when action is omitted. |
status |
Read progress for a job_id. |
Estimate
curl -X POST https://api.safecode.vip/v1/messages/bulk -H "Authorization: Bearer sfcd_live_xxxx_yyyy" -H "Content-Type: application/json" -d '{
"action": "estimate",
"message": "Flash sale! 20% off today only.",
"recipients": ["+233201234567", "+233207654321", { "phone": "+233209999999" }]
}'
| Field | Type | Required | Description |
|---|---|---|---|
message |
string | yes | SMS body sent to everyone. Up to 918 chars (6 SMS parts). |
recipients |
array | yes | 1–500 entries. Each is an E.164 string or { "phone": "+233…" }. Duplicates and non-Ghana numbers are dropped as invalid. |
Response: 200 OK:
{
"total": 3,
"invalid_count": 0,
"invalid": [],
"currency": "GHS",
"sms_parts_per_recipient": 1,
"sms_encoding": "gsm7",
"unit_price": 0.14,
"estimated_cost": 0.42,
"current_balance": 56.03,
"unit_price_usd": 0.01224,
"estimated_cost_usd": 0.03672,
"current_balance_usd": 4.95,
"sufficient_balance": true
}
Long messages are billed per SMS part; sms_parts_per_recipient tells you the multiplier before you commit. sms_encoding is gsm7 (160 chars per part) or ucs2 (70 chars per part, used when the text contains characters outside the GSM alphabet such as emoji).
Send
Same body with "action": "send" (or no action). Returns 202 Accepted as soon as the job is queued:
{
"ok": true,
"job_id": "3b8e…",
"total": 3,
"invalid_count": 0,
"invalid": [],
"currency": "GHS",
"estimated_cost": 0.42,
"estimated_cost_ghs": 0.42,
"estimated_cost_usd": 0.03672
}
Your client must have accepted the current bulk-messaging terms in the dashboard; otherwise the send is refused with terms_not_accepted (403) and estimate still works.
Status
curl -X POST https://api.safecode.vip/v1/messages/bulk -H "Authorization: Bearer sfcd_live_xxxx_yyyy" -H "Content-Type: application/json" -d '{ "action": "status", "job_id": "3b8e…" }'
Returns { "ok": true, "job": { … } } where job.status is running, completed or failed, with processed, sent, failed, total_cost_usd, balance_after_usd and up to 200 per-recipient failures.
GET /messages/status?id={id}
curl "https://api.safecode.vip/v1/messages/status?id=req_01jz..." \
-H "Authorization: Bearer sfcd_live_xxxx_yyyy"
Response: 200 OK:
{
"id": "req_01jz...",
"phone_e164": "+233201234567",
"country_code": "GH",
"status": "delivered",
"sent_at": "2026-06-29T14:05:00.000Z",
"delivered_at": "2026-06-29T14:05:07.412Z",
"cost_usd": 0.05000
}
GET /balance
curl "https://api.safecode.vip/v1/balance" \
-H "Authorization: Bearer sfcd_live_xxxx_yyyy"
Response: 200 OK:
{
"balance": 56.03000,
"balance_usd": 4.95000,
"currency": "GHS",
"plan": "live",
"allowed_countries": ["GH"]
}
Low balance: below cost-per-message, /messages/send returns insufficient_balance (402).
Webhooks
Register a webhook URL in your dashboard. We POST a JSON event every time a message changes state, and when your wallet runs low. Failed deliveries retry up to 6 times with exponential backoff (1m → 5m → 30m → 2h → 12h → 24h).
Events:
| Event | When fired |
|---|---|
message.sent |
Message successfully handed to the SMS provider. |
message.delivered |
Carrier reported the SMS was delivered to the handset. |
message.failed |
Provider rejected, carrier returned failure, or retries exhausted. Charge refunded. |
message.expired |
Message could not be delivered within its lifetime. |
balance.low |
Wallet dropped below your configured alert threshold. |
balance.depleted |
A send was rejected because the balance is below the message price. |
message.* events are aliases of the legacy otp.* names, so subscribing to either
name works, and the event you receive is labelled with the name you subscribed to.message.delivered / message.failed come from the carrier delivery report, so
they arrive after the sent response, which only confirms acceptance.
Low-balance payload:
{
"event": "balance.low",
"ts": 1729000000,
"data": {
"client_id": "8c1e...",
"balance": 3.20000,
"threshold": 5.00000,
"currency": "GHS",
"estimated_messages_remaining": 5,
"depleted": false
}
}
Configure the threshold, the alert email address, and the on/off switch under
client settings in your dashboard. Alerts repeat at most once every 6 hours and
re-arm once your balance recovers above twice the threshold.
Payload:
POST https://your-app.com/webhooks/safecode
Content-Type: application/json
X-SafeCode-Event: message.delivered
X-SafeCode-Signature: t=1729000000,v1=8a7f...e3
{
"event": "message.delivered",
"ts": 1729000000,
"data": {
"id": "req_01jz...",
"status": "delivered",
"country_code": "GH",
"phone_e164": "+233201234567"
}
}
Verify the signature by computing HMAC-SHA256 over "<t>.<rawBody>" using your endpoint secret, then compare against v1.
// Node.js
import crypto from 'node:crypto';
function verify(rawBody, header, secret) {
const [tPart, v1Part] = header.split(',');
const t = tPart.split('=')[1];
const v1 = v1Part.split('=')[1];
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
Respond fast: return 2xx within 10s or the delivery is treated as failed and queued for retry.
Errors
All errors follow the same shape:
{
"error": "insufficient_balance",
"message": "Balance GH₵0.23 below price GH₵0.57",
"request_id": "550e8400-..."
}
| Error code | HTTP | Description |
|---|---|---|
missing_key |
401 | No Authorization header, or malformed. |
invalid_key |
401 | Key doesn't match any record, or secret is wrong. |
revoked |
401 | Key was explicitly revoked. |
client_inactive |
403 | Client account suspended. |
forbidden_scope |
403 | Key lacks the required scope. |
invalid_phone |
400 | Phone must be E.164 with leading +. |
invalid_country |
400 | Country must be a 2-letter ISO code. |
country_not_allowed |
403 | Country not in client's allowed list. |
unsupported_country |
400 | Country has no pricing configured. |
invalid_message |
400 | Missing or empty message body. |
rate_limited |
429 | Too many requests. Back off and retry. |
insufficient_balance |
402 | Wallet balance below country price. Top up. |
balance_debit_failed |
409 | Concurrent race. Retry immediately. |
not_found |
404 | Request ID doesn't exist or belongs to another client. |
too_many_messages |
400 | Batch exceeded 100 messages. |
no_valid_messages |
400 | Every item in a batch failed validation. |
idempotency_conflict |
409 | Idempotency-Key reused with a different body. |
unsupported_channel |
400 | channel was something other than sms or whatsapp. |
invalid_json |
400 | Request body was not valid JSON. |
ip_not_allowed |
403 | Caller IP is not in the client's allowlist. |
service_unavailable |
503 | The API is not yet generally available for this account. |