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. Unlike
send-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.

© 2026 SafeCode. SafeCode and safecode.vip are operated by SAFECODE TECHNOLOGIES LTD, a private company limited by shares incorporated in Ghana under the Companies Act, 2019 (Act 992) (Reg. No. CS216101026, TIN C0068071701). M EXT 24 Malshegu East, Tamale-Kumbungu Road, Tamale, Northern Region, Ghana. support@safecode.vip

SafeCode