# 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. |
