WhichSMS API Reference
The WhichSMS REST API gives developers, businesses, and SaaS platforms direct programmatic access to bulk SMS, OTP verification, and delivery reporting — all without ever touching our upstream provider. Our API is designed for production reliability, security, and ease of integration.
HMAC-Signed
Every v2 request is signed with HMAC-SHA256. Replay attacks are blocked with nonces.
Sub-second Latency
Atomic credit deductions and async BMS relay. No blocking round trips.
E.164 Ready
Send to any phone number worldwide in E.164 format.
Multi-language
Node.js, Python, PHP, cURL — examples for every stack.
https://whichsms.comSend Your First SMS in 5 Minutes
Follow these steps to send your first SMS using the v1 API (no signing required — great for testing).
- 1Create an API Key
Go to your Developer Dashboard → API Keys → Create Key. Copy your
Secret Key(shown only once). - 2Register a Sender ID
Go to Sender IDs and submit a registration. Once approved, you can use it in API calls.
- 3Send an SMS
Use the v1 API with your secret key as a Bearer token:
cURLcurl -X POST https://whichsms.com/api/v1/sms/send \ -H "Authorization: Bearer wh_sec_your_secret_key" \ -H "Content-Type: application/json" \ -d '{ "to": ["+233241234567"], "message": "Hello from WhichSMS!", "sender_id": "MyBrand" }' - 4Check the ResponseSuccess Response
{ "success": true, "message": "Messages sent successfully.", "data": { "campaign_id": "cm_8f9c2...", "cost": 1, "recipients_queued": 1, "recipients_skipped": 0 } }
Authentication
WhichSMS provides two authentication methods. Choose based on your use case.
v1 — Bearer Token
SimplerPass your secret key as a Bearer token. Best for server-side integration or quick testing.
Authorization: Bearer wh_sec_your_secret_key⚠️ Only use over HTTPS. Never in browser JavaScript.
v2 — HMAC-SHA256
RecommendedEvery request is signed with HMAC-SHA256. Provides tamper-proof requests and replay attack prevention.
X-API-Key: wh_pub_your_public_key
X-Signature: <hmac_sha256_hex>
X-Timestamp: <unix_timestamp>
X-Nonce: <unique_random_string>How to Sign a v2 Request
The signature is computed as:
HMAC-SHA256(secret_key, "<timestamp>\n<nonce>\n<METHOD>\n<path>\n<body_sha256_hex>")const crypto = require('crypto');
const fetch = require('node-fetch'); // or use built-in in Node 18+
const BASE_URL = 'https://whichsms.com';
const PUBLIC_KEY = 'wh_pub_your_public_key';
const SECRET_KEY = 'wh_sec_your_secret_key';
async function sendSms(to, message, senderId) {
const body = JSON.stringify({ to, message, sender_id: senderId });
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString('hex');
const path = '/api/v2/sms/send';
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const payload = `${timestamp}\n${nonce}\nPOST\n${path}\n${bodyHash}`;
const signature = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': PUBLIC_KEY,
'X-Signature': signature,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
},
body,
});
return res.json();
}
sendSms(['+233241234567'], 'Hello from WhichSMS!', 'MyBrand')
.then(console.log);Rate Limits
Rate limits protect the platform from abuse and ensure fair usage across all customers.
| Endpoint | Limit | Window | Error Code |
|---|---|---|---|
| POST /sms/send (v1 & v2) | 60 requests | per minute, per key | SMS_RATE_LIMIT |
| POST /otp/send | 10 requests | per minute, per key | OTP_API_RATE_LIMIT |
| All other API endpoints | 500 requests | per 15 minutes, per IP | RATE_LIMIT |
| Auth endpoints (login, register) | 10 requests | per 15 minutes, per IP | AUTH_RATE_LIMIT |
Rate limit headers are included in every response: RateLimit-Remaining, RateLimit-Reset.
You can also set a daily request limit per API key in your Developer Dashboard.
Send SMS
Send an SMS message to one or multiple recipients immediately.
/api/v2/sms/sendRequired Permissions
sms:sendRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
| to | string | string[] | Recipient phone number(s). Comma-separated string or array. E.164 format recommended. | |
| message | string | SMS message content. Max 1600 characters. | |
| sender_id | string | Your approved Sender ID. Must be registered and approved on your account. |
{
"to": ["+233241234567", "+233201234567"],
"message": "Your order has been shipped. Track it at track.myshop.com",
"sender_id": "MyShop"
}{
"success": true,
"message": "Messages sent successfully.",
"data": {
"campaign_id": "a3f7b2...",
"cost": 2,
"recipients_queued": 2,
"recipients_skipped": 0,
"bms_campaign_id": "bms_uuid..."
}
}Schedule SMS
Schedule an SMS to be sent at a specific date and time in the future.
/api/v2/sms/scheduleAdditional Fields
| Field | Type | Required | Description |
|---|---|---|---|
| schedule_date | string | Future date/time in format: YYYY-MM-DD HH:mm (UTC) |
{
"to": ["+233241234567"],
"message": "Flash sale starts NOW! Use code SAVE20.",
"sender_id": "MyShop",
"schedule_date": "2025-12-25 09:00"
}{
"success": true,
"message": "SMS scheduled successfully.",
"data": {
"campaign_id": "cm_x9b...",
"cost": 1,
"recipients_queued": 1,
"recipients_skipped": 0,
"scheduled": true
}
}Sender IDs
A Sender ID is the name or number that recipients see as the message source (e.g. "MyBrand"). You must register and receive approval for each Sender ID before using it.
/api/v2/sender-idsList all approved Sender IDs on your account.
{
"success": true,
"data": [
{
"id": "sid_abc123",
"name": "MyBrand",
"status": "APPROVED",
"created_at": "2025-01-10T12:00:00Z"
}
]
}Send OTP
WhichSMS manages the full OTP lifecycle — generation, storage (hashed), delivery, and verification. You don't need to store anything yourself.
/api/v2/otp/sendRequest Body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| phone | string | — | Recipient phone number in E.164 format | |
| sender_id | string | — | Your approved Sender ID | |
| length | number | 6 | OTP digit count (4–8) | |
| ttl | number | 300 | Expiry in seconds (60–1800) | |
| format | string | NUMERIC | NUMERIC or ALPHANUMERIC |
{
"phone": "+233241234567",
"sender_id": "MyApp",
"length": 6,
"ttl": 300
}{
"success": true,
"message": "OTP sent successfully.",
"data": {
"reference": "otp_8f9c2b4a...",
"phone": "+233241234567",
"expires_at": "2025-07-05T13:55:00Z"
}
}Verify OTP
Submit the OTP code entered by the user. The system verifies it against the stored hash and returns a verified status.
/api/v2/otp/verify| Field | Type | Required | Description |
|---|---|---|---|
| reference | string | The reference returned from /otp/send | |
| code | string | The OTP code entered by the user |
{
"reference": "otp_8f9c2b4a...",
"code": "482910"
}{
"success": true,
"message": "OTP verified successfully.",
"data": {
"reference": "otp_8f9c2b4a...",
"phone": "+233241234567",
"verified_at": "2025-07-05T13:54:22Z"
}
}{
"success": false,
"message": "Invalid OTP code.",
"error_code": "INVALID_CODE",
"data": {
"attempts": 2,
"max_attempts": 5,
"locked": false
}
}Contacts & Groups
Store contacts in WhichSMS to use with Group SMS. Requires the contacts:write permission on your API key.
/api/v2/contacts{
"phone": "+233241234567",
"firstname": "Kwame",
"lastname": "Mensah",
"email": "kwame@example.com",
"group_id": "grp_abc123"
}/api/v2/contactsList contacts. Filter by group: ?group_id=grp_abc123. Paginate with ?page=1&limit=50.
/api/v2/contacts/groups{ "name": "Loyal Customers" }/api/v2/contacts/groupsReturns all groups with a contact count.
Delivery Reports
Retrieve campaign and message-level delivery data. Requires reports:read.
/api/v2/reports/smsList all campaigns. Filter by status: ?status=COMPLETED.
{
"success": true,
"data": [
{
"id": "cm_abc123",
"title": "July Promo",
"sender_id": "MyShop",
"type": "QUICK",
"status": "COMPLETED",
"total_recipients": 1500,
"total_cost": 1500,
"created_at": "2025-07-05T09:00:00Z"
}
],
"meta": { "total": 24, "page": 1, "limit": 20, "totalPages": 2 }
}/api/v2/reports/campaign/:idGet detailed message-level delivery data for a specific campaign including individual recipient statuses.
Webhooks
Register a public HTTPS URL to receive real-time event notifications. All webhook payloads are signed with X-WhichSMS-Signature for verification.
Available Events
| Event | Trigger |
|---|---|
sms.sent | SMS campaign dispatched via API |
sms.delivered | Delivery confirmed by carrier |
sms.failed | Message delivery failed |
otp.sent | OTP delivered to recipient |
payment.success | Wallet top-up successful |
* | All events |
/api/v2/webhooks{
"url": "https://yourapp.com/webhooks/whichsms",
"events": ["sms.delivered", "sms.failed"],
"description": "Production SMS delivery handler"
}Verifying Webhook Signatures
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/webhooks/whichsms', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-whichsms-signature'];
const webhookSecret = process.env.WHICHSMS_WEBHOOK_SECRET;
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
console.log('Webhook received:', event.type, event.data);
res.status(200).send('OK');
});Error Code Reference
All errors return a consistent JSON shape with an error_code field for programmatic handling.
{
"success": false,
"message": "Human-readable explanation",
"error_code": "MACHINE_READABLE_CODE"
}| Error Code | HTTP | Cause | Solution |
|---|---|---|---|
| MISSING_API_KEY | 401 | No API key in request headers | Add Authorization: Bearer key or X-API-Key |
| INVALID_API_KEY | 401 | Key doesn't exist or is revoked | Regenerate your API key in the dashboard |
| INVALID_SIGNATURE | 401 | HMAC signature mismatch | Verify your signing implementation exactly matches our format |
| TIMESTAMP_EXPIRED | 401 | Request timestamp too old/future | Sync your server clock with NTP. Max drift: ±5 minutes |
| NONCE_REPLAY | 401 | Nonce was already used | Generate a fresh random nonce per request |
| MISSING_AUTH_HEADERS | 401 | v2 auth headers missing | Include X-API-Key, X-Signature, X-Timestamp, X-Nonce |
| ACCOUNT_SUSPENDED | 403 | User account is suspended | Contact support |
| SENDER_ID_NOT_APPROVED | 403 | Sender ID not found or not approved | Register and get approval for a Sender ID first |
| IP_NOT_WHITELISTED | 403 | Request IP not in key's whitelist | Add your IP to the key's whitelist or disable whitelist |
| DAILY_LIMIT_EXCEEDED | 429 | API key daily request limit reached | Wait until midnight UTC or increase limit in dashboard |
| SMS_RATE_LIMIT | 429 | SMS send rate limit exceeded | Slow down to 60 requests/minute |
| OTP_API_RATE_LIMIT | 429 | OTP rate limit exceeded | Slow down to 10 OTP sends/minute |
| INSUFFICIENT_CREDITS | 402 | Not enough SMS credits | Top up your wallet in the dashboard |
| MISSING_FIELDS | 400 | Required field missing from body | Include all required fields per endpoint |
| INVALID_RECIPIENTS | 400 | No valid phone numbers found | Use E.164 format: +233XXXXXXXXX |
| MESSAGE_TOO_LONG | 400 | Message exceeds 1600 characters | Shorten message or split into parts |
| INVALID_CODE | 400 | Submitted OTP code is wrong | User must re-enter code |
| EXPIRED | 400 | OTP has expired | Call /otp/send again to get a new code |
| LOCKED | 403 | OTP locked after too many attempts | Call /otp/send again for a new reference |
| SMS_SEND_FAILED | 502 | Upstream provider error | Credits refunded. Retry the request. |
| SERVER_ERROR | 500 | Internal server error | Retry. Contact support if persistent. |