SMSHub API documentation
The SMSHub API lets your application send SMS to Ethio Telecom 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://sms.hizcore.com |
|---|---|
| Send endpoint | POST /api/sms |
| Test endpoint | POST /api/test — validates without sending or charging |
| 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 one credit and hands it to Ethio Telecom. 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 one credit is reserved from your balance.
- 4Delivery to Ethio Telecom
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 15 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://sms.hizcore.com/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 and one credit was used.
Account registration & verification
Every SMSHub account belongs to a real person or business with a working Ethio Telecom number.
- Register with your full name, email, phone number (
09xxxxxxxx) 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 / Kebele) | Front and back | Must be valid and legible. |
| Passport | Photo page only | Ethiopian or foreign passport. |
| Driver's license | Front and back | Ethiopian license. |
| Other government ID | Front and back | Reviewed case by case. |
- 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: sms.hizcore.com
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 one credit. 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://sms.hizcore.com/api/sms' \
-H "Authorization: Bearer sk_live_your_key" \
-H 'Content-Type: application/json' \
-d '{"msisdn":"0911234567","text":"Your verification code is 482193. It expires in 5 minutes."}'<?php
$ch = curl_init('https://sms.hizcore.com/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 verification code is 482193. 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://sms.hizcore.com/api/sms', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SMSHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
msisdn: '0911234567',
text: "Your verification code is 482193. 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://sms.hizcore.com/api/sms',
headers={'Authorization': f"Bearer {os.environ['SMSHUB_API_KEY']}"},
json={'msisdn': '0911234567', 'text': "Your verification code is 482193. 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 Ethio Telecom number. Accepted formats: 0911234567, 251911234567, +251911234567. Spaces, dashes and brackets are ignored. Normalised to 09xxxxxxxx. |
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. Safaricom Ethiopia numbers (07xxxxxxxx) and 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": {
"msisdn": "0911234567",
"message_status": "sent",
"credits_used": 1,
"credits_remaining": 4999,
"signature_applied": true,
"phone_in_signature": false
},
"request_id": "req_8f3a1c09d2e4b7a1"
}| Field | Description |
|---|---|
data.message_status | sent — accepted by the carrier for delivery. |
data.credits_used | Always 1 for a successful send. |
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. - 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://sms.hizcore.com/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 login code is {$code}. It expires in 5 minutes. Do not share it.",
]),
]);
$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://sms.hizcore.com/api/sms', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SMSHUB_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
msisdn: phone,
text: `Your login code is ${code}. It expires in 5 minutes. Do not share it.`,
}),
});
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://sms.hizcore.com/api/sms",
headers={"Authorization": f"Bearer {os.environ['SMSHUB_API_KEY']}"},
json={"msisdn": phone, "text": f"Your login code is {code}. It expires in 5 minutes. Do not share it."},
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. SMSHub delivers to Ethio Telecom numbers only…",
"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 | Balance is zero. | 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 a valid Ethio Telecom number. | 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. |
| 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. |
429 and 502 (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://sms.hizcore.com/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://sms.hizcore.com/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://sms.hizcore.com/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://sms.hizcore.com/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 150 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 one recipient. Credits never expire.
| Package | Price | Per SMS |
|---|---|---|
| 500 SMS | 307.50 ETB | 0.615 ETB |
| 2,000 SMS | 1,025 ETB | 0.513 ETB |
| 5,000 SMS | 2,460 ETB | 0.492 ETB |
| 10,000 SMS | 4,817.50 ETB | 0.482 ETB |
| 25,000 SMS | 11,787.50 ETB | 0.472 ETB |
| 50,000 SMS | 22,550 ETB | 0.451 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: 15 credits are added once, when your KYC is approved.
Webhooks & callbacks
SMSHub does not currently send delivery-receipt webhooks to your server. The API response is the source of truth:
200withmessage_status: "sent"— accepted by the carrier.502 provider_error— not accepted; the credit was refunded.
Store the request_id and status from each response in your own database. The full history is also available under Dashboard → Message history and can be exported as CSV.
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@hizcore.com. 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://sms.hizcore.com/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 login code is {$code}. It expires in 5 minutes. Do not share it.",
]),
]);
$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://sms.hizcore.com/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://sms.hizcore.com/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?
Not yet. Only Ethio Telecom 09xxxxxxxx numbers are supported.
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 150 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@hizcore.com or call 0941907682.