SMSHub API documentation
The SMSHub API lets your application send SMS to Ethio Telecom and Safaricom numbers in Ethiopia. It is a single JSON endpoint authenticated with a secret key. This guide covers everything from creating an account to handling errors in production.
| Base URL | https://smshub.et |
|---|---|
| Send endpoint | POST /api/sms |
| Test endpoint | POST /api/test — validates without sending or charging |
| Also | POST /api/verify/send · POST /api/verify/check · GET /api/balance · GET /api/messages/{id} · webhooks |
| Authentication | Authorization: Bearer sk_live_… |
| Format | JSON request and response bodies, UTF-8 |
| Transport | HTTPS only |
How SMSHub works
SMSHub is an SMS gateway for Ethiopia. Your application sends one HTTPS request per message; SMSHub checks it, adds your signature, uses credits (1 for Ethio Telecom, 2 for Safaricom) and hands it to the recipient's network (Ethio Telecom or Safaricom). There is nothing to install — any language that can make an HTTP request works.
- 1Your server sends a request
POST /api/smswith your secret key in theAuthorizationheader and a small JSON body:msisdn(the phone number) andtext. - 2SMSHub checks it
API key, account and KYC status, rate limit, number format, message length and content moderation. A request that fails any check gets a clear error and costs nothing.
- 3Signature & credit
Your SMS signature is appended and the credits are reserved from your balance: 1 for Ethio Telecom, 2 for Safaricom.
- 4Delivery to the network
If the carrier accepts the message you get
200with"message_status": "sent". If it refuses, you get502and the credit is refunded automatically. - 5Track it
Every message — from the API or the dashboard — appears in Dashboard → Message history, where you can search, filter and export it.
/api/test while you develop: it runs every check above and shows the final text, but never sends and never charges.Quick start
- Create an account and verify your phone number with the code we text you.
- Submit your identity documents under Dashboard → Verification. Sending unlocks when an administrator approves them, and 10 free credits are added.
- Copy your secret key from Dashboard → API access and store it as an environment variable, for example
SMSHUB_API_KEY. - Validate your request against
/api/test, then send for real with/api/sms:
curl -X POST 'https://smshub.et/api/sms' \
-H "Authorization: Bearer $SMSHUB_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"msisdn":"0911234567","text":"Hello from SMSHub"}'A 200 response with "message_status": "sent" means the carrier accepted the message; credits_used says how many credits it took.
Account registration & verification
Every SMSHub account belongs to a real person or business with a working Ethiopian mobile number (Ethio Telecom or Safaricom).
- Register with your full name, email, phone number (
09xxxxxxxxor07xxxxxxxx) and a password of at least 10 characters (common passwords are rejected). - Verify your phone. We send a 6-digit code that expires after 10 minutes. After 5 incorrect attempts the code is cancelled and you must request a new one (available once per minute).
- Complete KYC (see below). Until KYC is approved you can sign in and explore the dashboard, but sending, API access and free credits are locked.
KYC requirements
Identity verification is how we keep scammers off the network. An administrator reviews every submission manually — usually within one business day.
| Document | Sides required | Notes |
|---|---|---|
| National ID (Fayda) | Front and back | The FIN (12 digits) or FAN (16 digits) printed on your card. Must be valid, in your own name and legible. |
- Use one valid government ID in your own name. Foreign residents can use a passport or residence permit.
- Accepted formats: JPG, PNG or PDF, up to 5 MB per file.
- The legal name must match the document. Business accounts should submit the ID of the account owner.
- If a submission is rejected you'll see the reason on the Verification page and can resubmit immediately.
- Documents are stored in access-restricted storage and used only for verification and legal compliance.
Authenticating requests
Send your secret key in the Authorization header of every request using the Bearer scheme:
POST /api/sms HTTP/1.1
Host: smshub.et
Authorization: Bearer sk_live_4f9c…
Content-Type: application/jsonRequests without a valid key receive 401. Keys belonging to accounts that are unverified, not KYC-approved or suspended receive 403. Repeated invalid keys from one IP address are throttled with 429. See Errors.
Managing API keys
- Each account has one active secret key, prefixed
sk_live_. - Create, view, copy and rotate it under Dashboard → API access. The key is never embedded in the page — it is loaded only when you press Show or Copy.
- Requests are matched against a SHA-256 fingerprint of your key, and the viewable copy is encrypted with AES-256-GCM using a secret kept outside the database — a leaked database alone does not expose keys.
- Rotating creates a new key and disables the old one immediately. Deploy the new key to all your services first-thing after rotating.
- Keys are revoked automatically when an account is suspended for a serious policy violation. A reinstated account must create a new key.
- Rotate your key if it may have been exposed, when a team member with access leaves, and periodically as good practice.
Keeping your API key private
Your secret key works like the password to your balance: anyone who has it can send messages as you and spend your credits. Treat it the same way you treat a database password.
Do
- Store it in an environment variable or a secrets manager, e.g.
SMSHUB_API_KEY. - Call the API only from your own server.
- Rotate it immediately if it may have leaked, and when someone with access leaves.
- Redact it from logs, error reports and screenshots.
Don't
- Put it in a mobile app, browser JavaScript or an HTML page.
- Commit it to Git — add
.envto.gitignore. - Paste it into chats or support tickets — SMSHub support never needs it.
- Share one key across servers you don't control.
Loading the key from the environment
# .env — keep this file out of Git (add it to .gitignore)
SMSHUB_API_KEY=sk_live_your_key<?php
$key = getenv('SMSHUB_API_KEY');
if (!$key) {
throw new RuntimeException('SMSHUB_API_KEY is not set');
}// Node.js (server side only — never ship your key to the browser)
const key = process.env.SMSHUB_API_KEY;
if (!key) throw new Error('SMSHUB_API_KEY is not set');import os
key = os.environ["SMSHUB_API_KEY"] # raises KeyError if it is missingSend an SMS
Sends one message to one recipient and uses 1 credit (Ethio Telecom) or 2 credits (Safaricom). Your SMS signature is appended automatically (see Sender IDs & signatures).
Headers
| Header | Required | Value |
|---|---|---|
Authorization | Yes | Bearer sk_live_… |
Content-Type | Yes | application/json (form-encoded bodies are also accepted) |
Example
curl -X POST 'https://smshub.et/api/sms' \
-H "Authorization: Bearer sk_live_your_key" \
-H 'Content-Type: application/json' \
-d '{"msisdn":"0911234567","text":"Your MyShop verification code is 482193. Use it to confirm your phone number. It expires in 5 minutes."}'<?php
$ch = curl_init('https://smshub.et/api/sms');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SMSHUB_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'msisdn' => '0911234567',
'text' => 'Your MyShop verification code is 482193. Use it to confirm your phone number. It expires in 5 minutes.',
]),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status !== 200) {
error_log('SMSHub ' . $status . ': ' . ($body['message'] ?? 'unknown'));
}const res = await fetch('https://smshub.et/api/sms', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SMSHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
msisdn: '0911234567',
text: "Your MyShop verification code is 482193. Use it to confirm your phone number. It expires in 5 minutes.",
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error_type}: ${body.message}`);
console.log(body.data.credits_remaining);import os, requests
res = requests.post(
'https://smshub.et/api/sms',
headers={'Authorization': f"Bearer {os.environ['SMSHUB_API_KEY']}"},
json={'msisdn': '0911234567', 'text': "Your MyShop verification code is 482193. Use it to confirm your phone number. It expires in 5 minutes."},
timeout=30,
)
body = res.json()
if res.status_code != 200:
raise RuntimeError(f"{body['error_type']}: {body['message']}")
print(body['data']['credits_remaining'])Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
msisdn | string | Yes | Recipient Ethiopian mobile number, Ethio Telecom or Safaricom. Accepted formats: 0911234567 / 0711234567, 251911234567, +251911234567. Spaces, dashes and brackets are ignored. Normalised to 09xxxxxxxx or 07xxxxxxxx. |
track_links | boolean | No | Default false. When true, ?utm_source=smshub is added to every link in text before sending (see Links & UTM tracking). |
text | string | Yes | Message body, UTF-8. Leading and trailing whitespace is trimmed. Maximum 160 characters (GSM-7) or 70 characters (Unicode, e.g. Amharic) including your signature. |
Unknown fields are ignored. Request bodies larger than 64 KB are rejected. International numbers are rejected with invalid_msisdn.
Responses
Every response is JSON and includes a request_id (also sent as the X-Request-Id header). Include it when contacting support.
Success — 200
{
"status": "success",
"data": {
"message_id": "msg_9b2d4e6f8a0c1e3b5d7f",
"msisdn": "0911234567",
"network": "ethio",
"message_status": "sent",
"credits_used": 1,
"credits_remaining": 4999,
"signature_applied": true,
"phone_in_signature": false
},
"request_id": "req_8f3a1c09d2e4b7a1"
}| Field | Description |
|---|---|
data.message_id | ID of this message — look it up with GET /api/messages/{id}; webhooks use the same ID. |
data.network | ethio (Ethio Telecom) or safaricom (Safaricom), from the number. |
data.message_status | sent — accepted by the carrier for delivery. |
data.credits_used | 1 for Ethio Telecom, 2 for Safaricom. |
data.credits_remaining | Balance after this message. |
data.signature_applied | Whether your signature was appended. |
data.phone_in_signature | Whether your phone number fit in the signature. |
SMS character limits
SMSHub sends each message as a single SMS segment. The limit depends on the characters in the final text, including your signature:
| Encoding | Used when | Limit |
|---|---|---|
| GSM-7 | Only Latin letters, digits and common punctuation | 160 characters |
| Unicode (UCS-2) | Any Amharic (Ge'ez) character, emoji or other non-GSM symbol | 70 characters |
- A single Amharic character switches the whole message to Unicode — including the signature.
- Your signature adds a blank line plus
- Your Name(and optionally| 09xxxxxxxx). The phone number is dropped automatically if it doesn't fit, and it is never added on messages to Safaricom (07…) numbers (see Safaricom content check). - Use
/api/testto see the exact final text, character count and encoding before sending.
Links & UTM tracking
Messages can contain links (for example https://yourshop.et/o/1043) — the recipient's phone makes them clickable. Links are screened for phishing like the rest of the message.
Send "track_links": true (or tick Track links in the dashboard) and SMSHub adds utm_source=smshub to every link before sending, so visits from your SMS show up under the smshub source in Google Analytics and similar tools. Links that already have a utm_source are left unchanged. The added characters count toward the message limit — test with /api/test to see the final text.
{
"msisdn": "0911234567",
"text": "Your order has shipped: https://yourshop.et/o/1043",
"track_links": true
}Delivered text: Your order has shipped: https://yourshop.et/o/1043?utm_source=smshub followed by your signature.
Test mode
Accepts exactly the same headers and body as /api/sms and runs every check — authentication, account state, rate limit, number format, length, moderation and signature — but never contacts the carrier and never uses credits. The dashboard's API page includes a test console.
{
"status": "success",
"data": {
"mode": "test",
"message_status": "test_passed",
"would_send": true,
"msisdn": "0911234567",
"final_text": "Hello from SMSHub\n\n- Abebe Bekele | 0911000000",
"characters": 44,
"character_limit": 160,
"encoding": "gsm7",
"credits_used": 0,
"provider_contacted": false,
"sms_sent": false
},
"request_id": "req_0c9d8e7f6a5b4c3d"
}OTP & verification codes
SMSHub delivers the message; your application creates and checks the code. That keeps codes under your control and works with any sign-up, login or payment-confirmation flow.
- 1Generate
Create a 6-digit code with a cryptographically secure random generator (
random_int,crypto.randomInt,secrets). - 2Store a hash
Save a hash of the code with an expiry (e.g. 5 minutes) and an attempt counter. Never store or log the plain code.
- 3Send it
POST /api/smswith a short message such as “Your Abay Market code is 604218. It expires in 5 minutes. Do not share it.” - 4Verify
Compare in constant time, check the expiry and attempts, then delete the code so it can be used only once.
- 5Limit
Allow a resend after about 60 seconds and lock the code after about 5 wrong attempts.
Writing the message
- Keep it within 160 characters (Latin) or 70 (Amharic), including your signature — see character limits.
- Say who it's from and how long it's valid, and add “Do not share it”.
- Never ask the recipient to reply with or send the code — moderation blocks that as phishing (
422 message_rejected). - Test your template with
/api/testto see the exact final text.
Example: send and verify
<?php
// Your own table, e.g.: otp_codes(phone PK, code_hash, expires_at, attempts)
function startOtp(PDO $db, string $phone): void {
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$db->prepare('REPLACE INTO otp_codes (phone, code_hash, expires_at, attempts)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0)')
->execute([$phone, password_hash($code, PASSWORD_DEFAULT)]);
$ch = curl_init('https://smshub.et/api/sms');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SMSHUB_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'msisdn' => $phone,
'text' => "Your MyShop login code is {$code}. Use this code to sign in. It expires in 5 minutes. If you did not request this, ignore this message.",
]),
]);
$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException(($body['error_type'] ?? 'network_error') . ' (' . ($body['request_id'] ?? '-') . ')');
}
}
function checkOtp(PDO $db, string $phone, string $input): bool {
$st = $db->prepare('SELECT code_hash, attempts FROM otp_codes WHERE phone = ? AND expires_at > NOW()');
$st->execute([$phone]);
$row = $st->fetch();
if (!$row || $row['attempts'] >= 5) return false; // expired or locked
if (!password_verify($input, $row['code_hash'])) {
$db->prepare('UPDATE otp_codes SET attempts = attempts + 1 WHERE phone = ?')->execute([$phone]);
return false;
}
$db->prepare('DELETE FROM otp_codes WHERE phone = ?')->execute([$phone]); // single use
return true;
}// Node.js 18+
import crypto from 'node:crypto';
const codes = new Map(); // use Redis or your database in production
const hash = (s) => crypto.createHash('sha256').update(String(s)).digest();
export async function startOtp(phone) {
const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
codes.set(phone, { hash: hash(code), expires: Date.now() + 5 * 60_000, attempts: 0 });
const res = await fetch('https://smshub.et/api/sms', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SMSHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
msisdn: phone,
text: `Your MyShop login code is ${code}. Use this code to sign in. It expires in 5 minutes. If you did not request this, ignore this message.`,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`${err.error_type || res.status} (${err.request_id || '-'})`);
}
}
export function checkOtp(phone, input) {
const entry = codes.get(phone);
if (!entry || entry.expires < Date.now() || entry.attempts >= 5) return false;
if (!crypto.timingSafeEqual(hash(input), entry.hash)) { entry.attempts++; return false; }
codes.delete(phone); // single use
return true;
}import hashlib, hmac, os, secrets, time
import requests
codes = {} # use Redis or your database in production
def _hash(value):
return hashlib.sha256(str(value).encode()).hexdigest()
def start_otp(phone):
code = f"{secrets.randbelow(1_000_000):06d}"
codes[phone] = {"hash": _hash(code), "expires": time.time() + 300, "attempts": 0}
res = requests.post(
"https://smshub.et/api/sms",
headers={"Authorization": f"Bearer {os.environ['SMSHUB_API_KEY']}"},
json={"msisdn": phone, "text": f"Your MyShop login code is {code}. Use this code to sign in. It expires in 5 minutes. If you did not request this, ignore this message."},
timeout=30,
)
if res.status_code != 200:
body = res.json()
raise RuntimeError(f"{body.get('error_type')} ({body.get('request_id')})")
def check_otp(phone, user_input):
entry = codes.get(phone)
if not entry or entry["expires"] < time.time() or entry["attempts"] >= 5:
return False
if not hmac.compare_digest(_hash(user_input), entry["hash"]):
entry["attempts"] += 1
return False
del codes[phone] # single use
return True200 response and the same errors as any other message.Errors
Errors use standard HTTP status codes and a consistent body. Branch on error_type; message is human-readable and may change.
{
"status": "error",
"error_type": "invalid_msisdn",
"message": "Invalid msisdn. Use an Ethiopian mobile number: 09xxxxxxxx (Ethio Telecom) or 07xxxxxxxx (Safaricom)…",
"request_id": "req_1b2c3d4e5f6a7b8c"
}| HTTP | error_type | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_json | Body isn't valid JSON. | Check serialisation and Content-Type. |
| 401 | missing_authorization | No Bearer token was sent. | Add the Authorization header. |
| 401 | invalid_api_key | Key is wrong, rotated or revoked. | Rotate the key in the dashboard and deploy the new one. |
| 402 | insufficient_credits | Not enough credits for this message (a Safaricom SMS needs 2). | Buy credits; retrying won't help. |
| 403 | account_not_verified | Phone not verified. | Finish phone verification. |
| 403 | kyc_not_approved | KYC pending or rejected. | Check Dashboard → Verification. |
| 403 | account_suspended | Account suspended. | Contact support. Do not retry. |
| 405 | method_not_allowed | Not a POST request. | Use POST. |
| 422 | invalid_msisdn | Number isn't an Ethiopian mobile number (09… or 07…). | Validate numbers before sending. |
| 422 | missing_text | text is empty. | Provide a message. |
| 422 | message_too_long | Over 160 / 70 characters. | Shorten the text. See limits. |
| 422 | signature_too_long | Fits alone but not with your signature. | Shorten the text or use a shorter signature name. |
| 422 | message_rejected | Blocked by moderation. Includes a reference (MOD-…). | Revise the content. Do not retry the same text. |
| 422 | carrier_rejected | The network refused the text, e.g. Safaricom: a code without saying what it is for. The message says why. Credit refunded. | Reword it — see Safaricom content check. Do not retry the same text. |
| 429 | rate_limited | Over 60 requests/minute, or many invalid keys from one IP. | Wait for Retry-After seconds. |
| 502 | provider_error | Carrier didn't accept the message. Credit refunded. | Retry with exponential backoff. |
| 503 | network_unavailable | Sending to that network is paused for a moment. Credit refunded. | Retry after Retry-After seconds. |
Safaricom content check
Messages to Safaricom numbers (07…) go through one more check at the network. A message that contains a code or a long number without saying what it is for is blocked — for example Your code: 472913, or a bare phone number. Nothing is sent, your credit is refunded and the API answers 422 carrier_rejected with the network's reason:
{
"status": "error",
"error_type": "carrier_rejected",
"message": "Safaricom did not accept this message: This message looks like a verification code without the required context and was blocked. OTP messages must clearly state what the code is for. Example: 'Your [Company] verification code is 472913. Please use this code to verify. If you did not request this, please ignore this message.' Your credit was refunded. Change the text and send again.",
"request_id": "req_5d1e9a7c3b2f4a60"
}Write one-time codes so they say what they are for, then it goes through on every network:
Your MyShop verification code is 472913. Use it to confirm your phone number. It expires in 5 minutes. If you did not request this, ignore this message.- Name your business and what the code does: verify an account, sign in, reset a password, confirm a payment.
- Add “If you did not request this, ignore this message.”
- Your phone number is never added to the signature on Safaricom messages, so the signature can't trigger this check.
- Test a new message template with
POST /api/testfirst, then send one real message to a Safaricom number.
429, 502 and 503 (and network timeouts) are safe to retry automatically. Retrying other errors will fail the same way.HTTP status codes
Check the HTTP status first, then error_type for the exact reason (see Errors). Every JSON response carries a request_id — log it and quote it to support.
| Status | Meaning | Typical error_type | Retry? | How to handle |
|---|---|---|---|---|
| 200 | OK — accepted by the carrier (or validated on /api/test) | — | — | Store request_id; one credit was used. |
| 400 | Bad Request — the body isn't valid JSON | invalid_json | No | Fix serialisation; send Content-Type: application/json. |
| 401 | Unauthorized — key missing, wrong, rotated or revoked | missing_authorization, invalid_api_key | No | Check the Authorization: Bearer header; copy or rotate the key. |
| 402 | Payment Required — not enough credits | insufficient_credits | After top-up | Buy credits in Billing, then send again. |
| 403 | Forbidden — this account can't send | account_not_verified, kyc_not_approved, account_suspended | No | Finish verification, or contact support. |
| 404 | Not Found — wrong URL (an HTML page, not JSON) | — | No | Use POST https://smshub.et/api/sms or /api/test. |
| 405 | Method Not Allowed | method_not_allowed | No | Send a POST. |
| 422 | Validation error — the request was understood but rejected | invalid_msisdn, missing_text, message_too_long, signature_too_long, message_rejected | No | Fix the number or text. Don't resend the same content. |
| 429 | Too Many Requests — over 60/minute, or many invalid keys from one IP | rate_limited | Yes | Wait Retry-After seconds, then continue at a steady pace. |
| 500 | Internal Server Error — unexpected error on our side | — | Yes | Retry with exponential backoff; send the X-Request-Id to support if it persists. |
| 502 | Bad Gateway — the carrier didn't accept the message; credit refunded | provider_error | Yes | Retry with exponential backoff. |
| 503 | Service Unavailable — scheduled maintenance or a temporary outage | maintenance | Yes | Wait Retry-After seconds (maintenance sends 900). |
Handling errors in code
async function sendSms(msisdn, text) {
const res = await fetch('https://smshub.et/api/sms', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SMSHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ msisdn, text }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) return body.data; // 200 — accepted by the carrier
const err = new Error(`${res.status} ${body.error_type || 'error'}: ${body.message || res.statusText}`);
err.retryable = [429, 500, 502, 503].includes(res.status); // safe to try again later
err.retryAfter = Number(res.headers.get('Retry-After') || 0); // seconds (429 / 503)
err.requestId = body.request_id; // quote this to support
throw err;
}<?php
function sendSms(string $msisdn, string $text): array {
$ch = curl_init('https://smshub.et/api/sms');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SMSHUB_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['msisdn' => $msisdn, 'text' => $text]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$body = json_decode((string) $raw, true) ?: [];
if ($status === 200) return $body['data']; // accepted by the carrier
$retryable = in_array($status, [0, 429, 500, 502, 503], true); // 0 = network error
throw new RuntimeException(sprintf('%d %s (%s)%s', $status, $body['error_type'] ?? 'error',
$body['request_id'] ?? '-', $retryable ? ' — retry later' : ''));
}import os, requests
RETRYABLE = {429, 500, 502, 503}
def send_sms(msisdn, text):
res = requests.post(
"https://smshub.et/api/sms",
headers={"Authorization": f"Bearer {os.environ['SMSHUB_API_KEY']}"},
json={"msisdn": msisdn, "text": text},
timeout=30,
)
body = res.json() if res.content else {}
if res.status_code == 200:
return body["data"] # accepted by the carrier
retry_after = int(res.headers.get("Retry-After", 0))
raise RuntimeError(
f"{res.status_code} {body.get('error_type')} ({body.get('request_id')})"
+ (f" — retry in {retry_after or 'a few'}s" if res.status_code in RETRYABLE else "")
)Rate limits
Each account can make 60 requests per minute, shared across /api/sms, /api/test and dashboard sending. Every authenticated response includes:
| Header | Description |
|---|---|
X-RateLimit-Limit | Requests allowed per window (60). |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Unix time when the window resets. |
Retry-After | On 429 only: seconds to wait. |
For large sends, queue messages on your side and send at a steady pace. Need a higher limit? Contact us on Telegram.
Sender IDs & signatures
Messages are delivered from SMSHub's shared route, so the sender shown on the recipient's phone is set by the carrier. To tell recipients who a message is from, SMSHub appends your SMS signature to every message:
Your appointment is confirmed for 10:00 AM.
- Bethel Clinic | 0911000000- By default the signature is your ID-verified legal name (changing your account name doesn't change it). You also choose whether your phone number is included.
- The signature is always on — it can't be switched off or changed directly. To use a custom signature of up to 11 characters, submit a request under Dashboard → Settings → Request Custom Signature. An administrator approves or rejects it and you're notified either way. If it's approved, pay the one-time 200 ETB fee through Chapa and the signature is activated as soon as the payment is confirmed. Names of banks, wallets, telecoms or government bodies aren't allowed.
- Custom alphanumeric sender IDs (e.g.
BETHEL) require registration with the carrier and aren't self-service yet. Contact us on Telegram if you need one.
Pricing & credits
SMSHub is prepaid. One credit sends one single-segment SMS to an Ethio Telecom number; an SMS to a Safaricom number uses 2 credits. Credits never expire.
| Package | Price | Per credit |
|---|---|---|
| 500 SMS | 450 ETB | 0.900 ETB |
| 2,000 SMS | 1,800 ETB | 0.900 ETB |
| 5,000 SMS | 4,500 ETB | 0.900 ETB |
| 10,000 SMS | 9,000 ETB | 0.900 ETB |
| 25,000 SMS | 22,500 ETB | 0.900 ETB |
| 50,000 SMS | 45,000 ETB | 0.900 ETB |
- When credits are used: when the carrier accepts a message. If the carrier rejects it (
502), the credit is refunded immediately. - Not charged: validation errors, moderation blocks, rate-limited requests and anything sent to
/api/test. - Payments: through Chapa (telebirr, CBE Birr, M-Pesa, Visa, Mastercard). Every payment is verified server-to-server with Chapa — reference, currency and amount must match — and credited exactly once, even if you close the browser.
- Free credits: 10 credits are added once, when your KYC is approved.
Webhooks
Add an HTTPS endpoint under Dashboard → API access → Webhooks and choose the events you want. SMSHub sends each event as a JSON POST:
| Event | When |
|---|---|
message.sent | A message was accepted by the carrier |
message.failed | A message failed (the credit was refunded) |
message.delivered | The phone confirmed it received a message (Ethio Telecom delivery report) |
message.undelivered | The network reported a sent message could not reach the phone |
campaign.completed | A bulk campaign finished |
verification.approved | A Verify API code was entered correctly |
credits.low | Your balance reached the low-credit level or zero |
payment.completed | A payment was confirmed and credits were added |
refund.completed | A refund reached the customer |
ping | The Send test button |
{
"id": "evt_3f1c9a0b7d2e4c6a8b10",
"type": "message.sent",
"created": "2026-10-13T10:15:02+03:00",
"data": {
"message_id": "msg_9b2d4e6f8a0c1e3b5d7f",
"msisdn": "0911234567",
"network": "ethio",
"status": "sent",
"source": "api",
"campaign_id": null,
"credits_used": 1,
"text": "Your order #1042 is ready.\n\n- MyShop",
"error": null
}
}Headers
X-SMSHub-Event— the event type.X-SMSHub-Delivery— the eventid(use it to ignore duplicates).X-SMSHub-Signature: t=1760339702,v1=5f2c…—v1is the hex HMAC-SHA256 oft + "." + raw body, keyed with your endpoint's signing secret (whsec_…, shown once when you add the endpoint).
Verifying and answering
<?php
// webhook.php — SMSHub POSTs JSON here
$raw = file_get_contents('php://input');
if (!SmsHub::webhookValid($raw, $_SERVER['HTTP_X_SMSHUB_SIGNATURE'] ?? '', getenv('SMSHUB_WEBHOOK_SECRET'))) {
http_response_code(400); exit;
}
$event = json_decode($raw, true);
switch ($event['type']) {
case 'message.failed': /* mark $event['data']['message_id'] as failed */ break;
case 'campaign.completed': /* notify your team */ break;
case 'credits.low': /* top up */ break;
}
http_response_code(200); // answer 2xx quickly; do slow work later// Express — keep the RAW body for the signature check
app.post('/smshub/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!SmsHub.webhookValid(raw, req.get('X-SMSHub-Signature'), process.env.SMSHUB_WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(raw);
if (event.type === 'message.failed') { /* … */ }
res.sendStatus(200);
});# Flask
@app.post('/smshub/webhook')
def smshub_webhook():
raw = request.get_data()
if not SmsHub.webhook_valid(raw, request.headers.get('X-SMSHub-Signature', ''), os.environ['SMSHUB_WEBHOOK_SECRET']):
return '', 400
event = request.get_json()
if event['type'] == 'message.failed':
pass # …
return '', 200- Answer with any
2xxwithin 6 seconds. Anything else is retried after 1 min, 5 min, 30 min, 2 h and 6 h, then given up. - Reject signatures older than 5 minutes, and compare with a constant-time function (as the samples do).
- Only public HTTPS addresses on port 443 or 8443 are accepted; redirects are not followed. An endpoint that fails 50 times in a row is switched off and you get a notification.
message.sentmeans the network accepted the message. Ethio Telecom and Safaricom don't give us handset delivery receipts yet; when they do, amessage.deliveredevent will be added here.
Message IDs & status
Every successful /api/sms answer includes data.message_id (msg_ + 20 characters). Look a message up later:
curl 'https://smshub.et/api/messages/msg_9b2d4e6f8a0c1e3b5d7f' \
-H "Authorization: Bearer $SMSHUB_API_KEY"{
"status": "success",
"data": {
"message_id": "msg_9b2d4e6f8a0c1e3b5d7f",
"msisdn": "0911234567",
"network": "ethio",
"message_status": "sent",
"delivery_report": null,
"source": "api",
"campaign_id": null,
"credits_used": 1,
"created_at": "2026-10-13T10:15:02+03:00"
},
"request_id": "req_…"
}delivery_report is null until the network reports back. For Ethio Telecom numbers it becomes {"status": "delivered" | "pending" | "undelivered", "provider_status": …, "updated_at": …} — subscribe to the message.delivered webhook to hear about it right away. Safaricom doesn't send handset reports yet. New messages can take a few seconds to appear.
Balance
curl 'https://smshub.et/api/balance' -H "Authorization: Bearer $SMSHUB_API_KEY"{
"status": "success",
"data": { "credits": 4210, "credit_rule": { "ethio": 1, "safaricom": 2 }, "low_credit_threshold": 20 },
"request_id": "req_…"
}Prefer a push? Subscribe to the credits.low webhook.
Verify API (OTP)
SMSHub creates the code, texts it with a wording every network accepts, remembers it securely and tells you whether what your customer typed is right. You never store codes. Each code SMS costs the same as one message; checks are free.
1. Send a code — POST /api/verify/send
| Field | Required | Description |
|---|---|---|
msisdn | Yes | 09… (Ethio Telecom) or 07… (Safaricom); +251/251 also accepted. |
app_name | No | 2–30 characters shown in the text: “Your MyShop verification code is …”. Bank, telecom and government names are refused. |
code_length | No | 4–8 digits, default 6. |
expires_in | No | Seconds, 60–1800, default 600. |
{
"status": "success",
"data": {
"verification_id": "ver_a1b2c3d4e5f60718293a",
"msisdn": "0911234567",
"network": "ethio",
"verification_status": "pending",
"expires_at": "2026-10-13T10:25:02+03:00",
"expires_in": 600,
"code_length": 6,
"max_attempts": 5,
"message_id": "msg_…",
"credits_used": 1,
"credits_remaining": 4209
},
"request_id": "req_…"
}2. Check the code — POST /api/verify/check
Send verification_id and code (or msisdn and code to check the newest code for that number). The answer is always 200 with data.valid:
{
"status": "success",
"data": {
"verification_id": "ver_a1b2c3d4e5f60718293a",
"msisdn": "0911234567",
"valid": true,
"verification_status": "approved",
"attempts_left": 0,
"message": "Code is correct."
},
"request_id": "req_…"
}verification_status | Meaning |
|---|---|
pending | Wrong code; attempts_left tells you how many tries remain. |
approved | Correct. A code can be approved only once — a second check returns valid: false. |
expired / failed / replaced | Too late, 5 wrong tries, or a newer code was sent. Send a new code. |
# 1. send a code
curl -X POST 'https://smshub.et/api/verify/send' \
-H "Authorization: Bearer $SMSHUB_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"msisdn":"0911234567","app_name":"MyShop"}'
# 2. check what the customer typed
curl -X POST 'https://smshub.et/api/verify/check' \
-H "Authorization: Bearer $SMSHUB_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"verification_id":"ver_…","code":"482193"}'<?php
$sms = new SmsHub(getenv('SMSHUB_API_KEY'));
// sign-up form: send a code
$v = $sms->verifySend('0911234567', ['app_name' => 'MyShop']);
$_SESSION['verification_id'] = $v['verification_id'];
// next screen: check the code
$r = $sms->verifyCheck($_SESSION['verification_id'], $_POST['code']);
if ($r['valid']) {
// phone number confirmed
}import { SmsHub } from './smshub.js';
const sms = new SmsHub(process.env.SMSHUB_API_KEY);
const { verification_id } = await sms.verifySend('0911234567', { app_name: 'MyShop' });
// …later, with what the customer typed:
const { valid, attempts_left } = await sms.verifyCheck(verification_id, req.body.code);from smshub import SmsHub
sms = SmsHub(os.environ['SMSHUB_API_KEY'])
v = sms.verify_send('0911234567', app_name='MyShop')
# …later, with what the customer typed:
r = sms.verify_check(v['verification_id'], code)
if r['valid']:
print('phone confirmed')- Limits: 5 codes per number per hour, and 30 seconds between codes to the same number (
429 verify_rate_limited/verify_too_soon). - The code is masked (
••••••) in your message history and in webhooks.
Several keys & IP allowlists
Besides your main key you can create up to 10 named keys (for example Website, Backend, Staging) in Dashboard → API access. Each can be revoked on its own and shows when and from which IP it was last used.
Restrict any key to your servers' public IP addresses or ranges (IPv4 and IPv6, e.g. 196.188.12.40, 102.218.0.0/16). Requests with that key from anywhere else are refused:
{
"status": "error",
"error_type": "ip_not_allowed",
"message": "Requests with this API key are only accepted from the IP addresses in its allowlist. This request came from 41.223.0.10.",
"request_id": "req_…"
}Bulk & scheduled messages
In Dashboard → Bulk & scheduled, paste numbers, upload a CSV file or pick contact groups, write one message with variables such as {name}, and send it now or at a set time. Before anything is sent you see the exact messages, the number of recipients per network, the credits needed, and every problem (invalid numbers, duplicates, opted-out contacts, messages that would be too long).
- Variables:
{name},{phone}and any CSV column name.{name|Customer}uses “Customer” when the value is empty. - CSV: a header row such as
phone,name,amountis recognised. Numbers missing the leading 0 (as Excel saves them) are fixed. - Credits are taken as messages go out; failed messages are refunded. If the balance runs out the campaign pauses — top up and press Resume.
- Up to 10,000 recipients per campaign; results can be exported as CSV. The
campaign.completedwebhook fires at the end.
Teams & agencies
Invite colleagues in Dashboard → Team. They sign in with their own SMSHub accounts and get one role: Admin, Developer, Sender, Billing, Viewer. Messages they send use the account owner's verified signature and credits, and show who sent them. Agencies work the same way: each client invites the agency and the agency switches between client workspaces.
Client libraries
Single-file clients for PHP, Node.js and Python — copy one into your project. They cover sending, test mode, balance, message lookup, the Verify API and webhook signature checks, and turn API errors into exceptions with the error_type.
<?php
/** SMSHub client — save as SmsHub.php. Needs PHP 8.0+ with curl. */
final class SmsHub
{
public function __construct(private string $key, private string $base = 'https://smshub.et') {}
public function send(string $msisdn, string $text, bool $trackLinks = false): array
{ return $this->call('POST', '/api/sms', ['msisdn' => $msisdn, 'text' => $text, 'track_links' => $trackLinks]); }
public function test(string $msisdn, string $text): array
{ return $this->call('POST', '/api/test', ['msisdn' => $msisdn, 'text' => $text]); }
public function balance(): array { return $this->call('GET', '/api/balance'); }
public function message(string $id): array { return $this->call('GET', '/api/messages/' . rawurlencode($id)); }
public function verifySend(string $msisdn, array $opts = []): array
{ return $this->call('POST', '/api/verify/send', ['msisdn' => $msisdn] + $opts); }
public function verifyCheck(string $verificationId, string $code): array
{ return $this->call('POST', '/api/verify/check', ['verification_id' => $verificationId, 'code' => $code]); }
/** Webhooks: true when the X-SMSHub-Signature header matches the raw body (and is under 5 minutes old). */
public static function webhookValid(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
if (!preg_match('/t=(\d+),v1=([a-f0-9]{64})/', $header, $m) || abs(time() - (int)$m[1]) > $tolerance) return false;
return hash_equals(hash_hmac('sha256', $m[1] . '.' . $rawBody, $secret), $m[2]);
}
private function call(string $method, string $path, ?array $body = null): array
{
$ch = curl_init($this->base . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->key, 'Content-Type: application/json', 'Accept: application/json'],
] + ($body !== null ? [CURLOPT_POSTFIELDS => json_encode($body)] : []));
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
$json = json_decode((string)$raw, true);
if ($status !== 200 || !is_array($json) || ($json['status'] ?? '') !== 'success') {
throw new RuntimeException(($json['error_type'] ?? 'http_' . $status) . ': ' . ($json['message'] ?? $err ?: 'request failed'), $status);
}
return $json['data'];
}
}
// $sms = new SmsHub(getenv('SMSHUB_API_KEY'));
// $sent = $sms->send('0911234567', 'Your order #1042 is ready.');
// echo $sent['message_id'], ' · ', $sent['credits_remaining'], " credits left\n";// SMSHub client — save as smshub.js. Node 18+ (built-in fetch). Use it on your server, never in a browser.
import crypto from 'node:crypto';
export class SmsHub {
constructor(key, base = 'https://smshub.et') { this.key = key; this.base = base; }
send(msisdn, text, trackLinks = false) { return this.#call('POST', '/api/sms', { msisdn, text, track_links: trackLinks }); }
test(msisdn, text) { return this.#call('POST', '/api/test', { msisdn, text }); }
balance() { return this.#call('GET', '/api/balance'); }
message(id) { return this.#call('GET', '/api/messages/' + encodeURIComponent(id)); }
verifySend(msisdn, opts = {}) { return this.#call('POST', '/api/verify/send', { msisdn, ...opts }); }
verifyCheck(verificationId, code) { return this.#call('POST', '/api/verify/check', { verification_id: verificationId, code }); }
/** Webhooks: pass the RAW request body (a string), the X-SMSHub-Signature header and your signing secret. */
static webhookValid(rawBody, header, secret, tolerance = 300) {
const m = /t=(\d+),v1=([a-f0-9]{64})/.exec(header || '');
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > tolerance) return false;
const want = crypto.createHmac('sha256', secret).update(m[1] + '.' + rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(m[2]));
}
async #call(method, path, body) {
const res = await fetch(this.base + path, {
method,
headers: { Authorization: `Bearer ${this.key}`, 'Content-Type': 'application/json', Accept: 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.status !== 'success') {
const e = new Error(`${json.error_type || 'http_' + res.status}: ${json.message || 'request failed'}`);
e.status = res.status; e.type = json.error_type; e.requestId = json.request_id;
throw e;
}
return json.data;
}
}
// const sms = new SmsHub(process.env.SMSHUB_API_KEY);
// const { message_id, credits_remaining } = await sms.send('0911234567', 'Your order #1042 is ready.');# SMSHub client — save as smshub.py. Needs: pip install requests
import hashlib, hmac, re, time
import requests
class SmsHubError(Exception):
def __init__(self, status, error_type, message, request_id=None):
super().__init__(f"{error_type}: {message}")
self.status, self.error_type, self.request_id = status, error_type, request_id
class SmsHub:
def __init__(self, key, base='https://smshub.et', timeout=30):
self.key, self.base, self.timeout = key, base, timeout
def send(self, msisdn, text, track_links=False):
return self._call('POST', '/api/sms', {'msisdn': msisdn, 'text': text, 'track_links': track_links})
def test(self, msisdn, text):
return self._call('POST', '/api/test', {'msisdn': msisdn, 'text': text})
def balance(self):
return self._call('GET', '/api/balance')
def message(self, message_id):
return self._call('GET', '/api/messages/' + message_id)
def verify_send(self, msisdn, **opts):
return self._call('POST', '/api/verify/send', {'msisdn': msisdn, **opts})
def verify_check(self, verification_id, code):
return self._call('POST', '/api/verify/check', {'verification_id': verification_id, 'code': code})
@staticmethod
def webhook_valid(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
m = re.search(r't=(\d+),v1=([a-f0-9]{64})', header or '')
if not m or abs(time.time() - int(m.group(1))) > tolerance:
return False
want = hmac.new(secret.encode(), m.group(1).encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, m.group(2))
def _call(self, method, path, body=None):
r = requests.request(method, self.base + path, json=body, timeout=self.timeout,
headers={'Authorization': f'Bearer {self.key}', 'Accept': 'application/json'})
try:
data = r.json()
except ValueError:
data = {}
if r.status_code != 200 or data.get('status') != 'success':
raise SmsHubError(r.status_code, data.get('error_type', f'http_{r.status_code}'), data.get('message', 'request failed'), data.get('request_id'))
return data['data']
# sms = SmsHub(os.environ['SMSHUB_API_KEY'])
# print(sms.send('0911234567', 'Your order #1042 is ready.')['message_id'])Security
- Transport: all traffic uses HTTPS. Plain HTTP is not supported.
- Secrets: keep API keys in environment variables or a secrets manager. Never log full keys.
- Least exposure: call the API from your backend only. If a key leaks, rotate it immediately.
- Browser protection: a strict Content-Security-Policy, HSTS, CSRF tokens on every form, host-only secure session cookies, and automatic sign-out after 2 hours of inactivity or 7 days.
- API keys: matched by SHA-256 fingerprint and stored encrypted (AES-256-GCM) for viewing. Suspended accounts have their keys revoked automatically.
- Accounts: passwords (minimum 10 characters, common passwords rejected) are hashed with bcrypt and upgraded automatically; verification and reset codes are stored hashed, single-use, expire after 10 minutes and lock after repeated failures. Changing your password signs out every other device.
- Payments: Chapa secret keys never reach the browser; every credit is preceded by a server-side verification with Chapa.
- OTP messages: include a line such as “Do not share this code” and keep codes short-lived on your side.
- Reporting: found a vulnerability? Email support@smshub.et. Please don't test against other users' accounts.
Moderation policy
Every outgoing message — dashboard, API and test — is screened in English, Amharic and transliterated Amharic before a credit is reserved. Screening looks at context, not just keywords:
- Allowed: “Your code is 482193. Do not share it with anyone.” Blocked: “Your account is locked. Send us your PIN to unlock it.”
- An insult word on its own is allowed (e.g. “ውሻዬ ታሟል” — my dog is sick); the same word aimed at the recipient is not.
- News, education and counter-speech are routed to human review rather than automatic suspension.
| Category | Enforcement |
|---|---|
| Hate speech | Immediate suspension. All sessions ended, API keys revoked, account locked. |
| Child sexual exploitation | Immediate suspension. All sessions ended, API keys revoked, account locked. |
| Scam or phishing | Message blocked and held for review. One strike. |
| Threat of violence | Message blocked. One strike. |
| Harassment, sexual content | Message blocked. One strike. |
| Profanity (undirected) | Message blocked. No strike. |
Strikes expire after 90 days; 3 active strikes suspend sending until an administrator reviews the account. Every blocked message gets a MOD- reference you can quote to support to request a review. Where required or appropriate under Ethiopian law, information about serious violations may be reported to the Ethiopian Federal Police and/or INSA. Read the full Acceptable Use Policy.
Examples
Sending a one-time password (PHP)
<?php
function sendOtp(string $phone): string {
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$ch = curl_init('https://smshub.et/api/sms');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SMSHUB_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'msisdn' => $phone,
'text' => "Your MyShop login code is {$code}. Use this code to sign in. It expires in 5 minutes. If you did not request this, ignore this message.",
]),
]);
$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException(($body['error_type'] ?? 'network_error') . ': ' . ($body['message'] ?? ''));
}
// Store a hash of $code with a 5-minute expiry — never the plain code.
return $code;
}Retrying safely (Node.js)
async function sendSms(msisdn, text, attempt = 1) {
const res = await fetch('https://smshub.et/api/sms', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SMSHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ msisdn, text }),
});
const body = await res.json();
// Only 429 and 502 are worth retrying.
if ((res.status === 429 || res.status === 502) && attempt < 4) {
const wait = res.status === 429
? Number(res.headers.get('Retry-After') || 1) * 1000
: 2 ** attempt * 500;
await new Promise(r => setTimeout(r, wait));
return sendSms(msisdn, text, attempt + 1);
}
if (!res.ok) throw new Error(`${body.error_type}: ${body.message} (${body.request_id})`);
return body.data;
}Amharic message (Python)
import os, requests
res = requests.post(
'https://smshub.et/api/sms',
headers={'Authorization': f"Bearer {os.environ['SMSHUB_API_KEY']}"},
json={'msisdn': '+251911234567', 'text': 'ትዕዛዝዎ ተልኳል። ዛሬ ይደርሳል።'}, # 70-char Unicode limit
timeout=30,
)
print(res.status_code, res.json())Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 missing_authorization but the header is set | Your web server strips Authorization (common on Apache/CGI). | Check the header reaches us using /api/test; contact support with the request_id. |
401 invalid_api_key | Key was rotated or revoked, or has extra whitespace. | Copy the current key from Dashboard → API access (or rotate it) and update your server. |
403 kyc_not_approved | Verification is pending or was rejected. | Check Dashboard → Verification. |
422 message_too_long for a short Amharic text | Amharic uses the 70-character limit. | Shorten the text or split it across messages. |
422 signature_too_long | Message plus signature exceeds the limit. | Shorten the message or the signature name, or untick “include my phone number”. |
422 message_rejected | Content matched a moderation rule. | Revise the message. If you think it's a mistake, open a ticket with the MOD- reference. |
| Paid but credits not added | Chapa confirmation is delayed or you closed the page early. | Billing → Check status. Credits are added automatically once Chapa confirms. Still missing? Contact support with the payment reference. |
| Payment shows “Under review” | Chapa reported an amount or reference that doesn't match the order. | Nothing is lost — a person checks it. Contact support with the reference if it's urgent. |
| Recipient says they didn't get it | Phone off, out of coverage or number inactive. | Confirm the number and that the response was 200; message history shows what was accepted. |
429 rate_limited | More than 60 requests in a minute. | Queue and pace sends; honour Retry-After. |
Frequently asked questions
Can I send to Safaricom numbers?
Yes. Ethio Telecom (09…) and Safaricom (07…) numbers both work. A message uses 1 credit to Ethio Telecom and 2 credits to Safaricom. Safaricom blocks codes that don't say what they are for — write “Your MyShop verification code is 472913. Use it to confirm your phone number…”.
Can I send to many recipients in one request?
No — one request sends one message. Loop over recipients on your side, respecting the rate limit.
Do credits expire?
No.
Is there a sandbox?
Yes: /api/test runs every validation without sending or charging. It uses your live key.
Why did my OTP get blocked?
Most likely it asked the recipient to send or reply with the code. Tell users to enter the code in your app and not to share it.
Can I send without my signature?
No. The signature is always on. You can request a custom signature name under Settings → Request Custom Signature; if an administrator approves it, a one-time 200 ETB fee activates it.
Can I get a refund for unused credits?
Contact support. Credits on accounts suspended for serious violations may be forfeited under the Terms of Use.
Do you store message content?
Yes, in your message history, so you can audit what was sent. Blocked messages are retained for moderation review.
Still stuck? Message us on Telegram, email support@smshub.et or call 0941907682.