SMSHub API · v1

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 URLhttps://smshub.et
Send endpointPOST /api/sms
Test endpointPOST /api/test — validates without sending or charging
AlsoPOST /api/verify/send · POST /api/verify/check · GET /api/balance · GET /api/messages/{id} · webhooks
AuthenticationAuthorization: Bearer sk_live_…
FormatJSON request and response bodies, UTF-8
TransportHTTPS 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.

  1. 1
    Your server sends a request

    POST /api/sms with your secret key in the Authorization header and a small JSON body: msisdn (the phone number) and text.

  2. 2
    SMSHub 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.

  3. 3
    Signature & credit

    Your SMS signature is appended and the credits are reserved from your balance: 1 for Ethio Telecom, 2 for Safaricom.

  4. 4
    Delivery to the network

    If the carrier accepts the message you get 200 with "message_status": "sent". If it refuses, you get 502 and the credit is refunded automatically.

  5. 5
    Track it

    Every message — from the API or the dashboard — appears in Dashboard → Message history, where you can search, filter and export it.

Building something new? Use /api/test while you develop: it runs every check above and shows the final text, but never sends and never charges.

Quick start

  1. Create an account and verify your phone number with the code we text you.
  2. Submit your identity documents under Dashboard → Verification. Sending unlocks when an administrator approves them, and 10 free credits are added.
  3. Copy your secret key from Dashboard → API access and store it as an environment variable, for example SMSHUB_API_KEY.
  4. Validate your request against /api/test, then send for real with /api/sms:
Terminal
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).

  1. Register with your full name, email, phone number (09xxxxxxxx or 07xxxxxxxx) and a password of at least 10 characters (common passwords are rejected).
  2. 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).
  3. 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.
Signed up with a referral link? The person who invited you earns 10 SMS once your phone is verified.

KYC requirements

Identity verification is how we keep scammers off the network. An administrator reviews every submission manually — usually within one business day.

DocumentSides requiredNotes
National ID (Fayda)Front and backThe 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:

HTTP
POST /api/sms HTTP/1.1
Host: smshub.et
Authorization: Bearer sk_live_4f9c…
Content-Type: application/json

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

Keep your key on the server. Anyone with your key can send messages and spend your credits. Never embed it in a mobile app, browser JavaScript or a public repository.

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 .env to .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
Leaked a key? Rotate it under Dashboard → API access. The old key stops working immediately, so deploy the new one to your servers right after.

Send an SMS

POSThttps://smshub.et/api/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

HeaderRequiredValue
AuthorizationYesBearer sk_live_…
Content-TypeYesapplication/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."}'

Request parameters

FieldTypeRequiredDescription
msisdnstringYesRecipient 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_linksbooleanNoDefault false. When true, ?utm_source=smshub is added to every link in text before sending (see Links & UTM tracking).
textstringYesMessage 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

Response
{
  "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"
}
FieldDescription
data.message_idID of this message — look it up with GET /api/messages/{id}; webhooks use the same ID.
data.networkethio (Ethio Telecom) or safaricom (Safaricom), from the number.
data.message_statussent — accepted by the carrier for delivery.
data.credits_used1 for Ethio Telecom, 2 for Safaricom.
data.credits_remainingBalance after this message.
data.signature_appliedWhether your signature was appended.
data.phone_in_signatureWhether 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:

EncodingUsed whenLimit
GSM-7Only Latin letters, digits and common punctuation160 characters
Unicode (UCS-2)Any Amharic (Ge'ez) character, emoji or other non-GSM symbol70 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/test to see the exact final text, character count and encoding before sending.

Test mode

POSThttps://smshub.et/api/test

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.

Test response
{
  "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"
}
Moderation applies in test mode too. Test messages don't add strikes, but hate speech and child-exploitation content still suspend the account.

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.

  1. 1
    Generate

    Create a 6-digit code with a cryptographically secure random generator (random_int, crypto.randomInt, secrets).

  2. 2
    Store 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.

  3. 3
    Send it

    POST /api/sms with a short message such as “Your Abay Market code is 604218. It expires in 5 minutes. Do not share it.”

  4. 4
    Verify

    Compare in constant time, check the expiry and attempts, then delete the code so it can be used only once.

  5. 5
    Limit

    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/test to 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;
}
Each code is one normal SMS: 1 credit (2 to Safaricom), the same 200 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.

Error response
{
  "status": "error",
  "error_type": "invalid_msisdn",
  "message": "Invalid msisdn. Use an Ethiopian mobile number: 09xxxxxxxx (Ethio Telecom) or 07xxxxxxxx (Safaricom)…",
  "request_id": "req_1b2c3d4e5f6a7b8c"
}
HTTPerror_typeMeaningWhat to do
400invalid_jsonBody isn't valid JSON.Check serialisation and Content-Type.
401missing_authorizationNo Bearer token was sent.Add the Authorization header.
401invalid_api_keyKey is wrong, rotated or revoked.Rotate the key in the dashboard and deploy the new one.
402insufficient_creditsNot enough credits for this message (a Safaricom SMS needs 2).Buy credits; retrying won't help.
403account_not_verifiedPhone not verified.Finish phone verification.
403kyc_not_approvedKYC pending or rejected.Check Dashboard → Verification.
403account_suspendedAccount suspended.Contact support. Do not retry.
405method_not_allowedNot a POST request.Use POST.
422invalid_msisdnNumber isn't an Ethiopian mobile number (09… or 07…).Validate numbers before sending.
422missing_texttext is empty.Provide a message.
422message_too_longOver 160 / 70 characters.Shorten the text. See limits.
422signature_too_longFits alone but not with your signature.Shorten the text or use a shorter signature name.
422message_rejectedBlocked by moderation. Includes a reference (MOD-…).Revise the content. Do not retry the same text.
422carrier_rejectedThe 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.
429rate_limitedOver 60 requests/minute, or many invalid keys from one IP.Wait for Retry-After seconds.
502provider_errorCarrier didn't accept the message. Credit refunded.Retry with exponential backoff.
503network_unavailableSending 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:

Error response
{
  "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:

Accepted on Safaricom
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/test first, then send one real message to a Safaricom number.
Only 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.

StatusMeaningTypical error_typeRetry?How to handle
200OK — accepted by the carrier (or validated on /api/test)——Store request_id; one credit was used.
400Bad Request — the body isn't valid JSONinvalid_jsonNoFix serialisation; send Content-Type: application/json.
401Unauthorized — key missing, wrong, rotated or revokedmissing_authorization, invalid_api_keyNoCheck the Authorization: Bearer header; copy or rotate the key.
402Payment Required — not enough creditsinsufficient_creditsAfter top-upBuy credits in Billing, then send again.
403Forbidden — this account can't sendaccount_not_verified, kyc_not_approved, account_suspendedNoFinish verification, or contact support.
404Not Found — wrong URL (an HTML page, not JSON)—NoUse POST https://smshub.et/api/sms or /api/test.
405Method Not Allowedmethod_not_allowedNoSend a POST.
422Validation error — the request was understood but rejectedinvalid_msisdn, missing_text, message_too_long, signature_too_long, message_rejectedNoFix the number or text. Don't resend the same content.
429Too Many Requests — over 60/minute, or many invalid keys from one IPrate_limitedYesWait Retry-After seconds, then continue at a steady pace.
500Internal Server Error — unexpected error on our side—YesRetry with exponential backoff; send the X-Request-Id to support if it persists.
502Bad Gateway — the carrier didn't accept the message; credit refundedprovider_errorYesRetry with exponential backoff.
503Service Unavailable — scheduled maintenance or a temporary outagemaintenanceYesWait 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;
}

Rate limits

Each account can make 60 requests per minute, shared across /api/sms, /api/test and dashboard sending. Every authenticated response includes:

HeaderDescription
X-RateLimit-LimitRequests allowed per window (60).
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetUnix time when the window resets.
Retry-AfterOn 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:

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

PackagePricePer credit
500 SMS450 ETB0.900 ETB
2,000 SMS1,800 ETB0.900 ETB
5,000 SMS4,500 ETB0.900 ETB
10,000 SMS9,000 ETB0.900 ETB
25,000 SMS22,500 ETB0.900 ETB
50,000 SMS45,000 ETB0.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:

EventWhen
message.sentA message was accepted by the carrier
message.failedA message failed (the credit was refunded)
message.deliveredThe phone confirmed it received a message (Ethio Telecom delivery report)
message.undeliveredThe network reported a sent message could not reach the phone
campaign.completedA bulk campaign finished
verification.approvedA Verify API code was entered correctly
credits.lowYour balance reached the low-credit level or zero
payment.completedA payment was confirmed and credits were added
refund.completedA refund reached the customer
pingThe Send test button
POST to your endpoint
{
  "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 event id (use it to ignore duplicates).
  • X-SMSHub-Signature: t=1760339702,v1=5f2c… — v1 is the hex HMAC-SHA256 of t + "." + 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
  • Answer with any 2xx within 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.sent means the network accepted the message. Ethio Telecom and Safaricom don't give us handset delivery receipts yet; when they do, a message.delivered event will be added here.

Message IDs & status

Every successful /api/sms answer includes data.message_id (msg_ + 20 characters). Look a message up later:

GET /api/messages/{message_id}
curl 'https://smshub.et/api/messages/msg_9b2d4e6f8a0c1e3b5d7f' \
  -H "Authorization: Bearer $SMSHUB_API_KEY"
Response
{
  "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

GET /api/balance
curl 'https://smshub.et/api/balance' -H "Authorization: Bearer $SMSHUB_API_KEY"
Response
{
  "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

FieldRequiredDescription
msisdnYes09… (Ethio Telecom) or 07… (Safaricom); +251/251 also accepted.
app_nameNo2–30 characters shown in the text: “Your MyShop verification code is …”. Bank, telecom and government names are refused.
code_lengthNo4–8 digits, default 6.
expires_inNoSeconds, 60–1800, default 600.
Response
{
  "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:

Response
{
  "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_statusMeaning
pendingWrong code; attempts_left tells you how many tries remain.
approvedCorrect. A code can be approved only once — a second check returns valid: false.
expired / failed / replacedToo 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"}'
  • 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:

403
{
  "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,amount is 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.completed webhook 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";

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.
CategoryEnforcement
Hate speechImmediate suspension. All sessions ended, API keys revoked, account locked.
Child sexual exploitationImmediate suspension. All sessions ended, API keys revoked, account locked.
Scam or phishingMessage blocked and held for review. One strike.
Threat of violenceMessage blocked. One strike.
Harassment, sexual contentMessage 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)

otp.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)

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

amharic.py
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

SymptomLikely causeFix
401 missing_authorization but the header is setYour 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_keyKey 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_approvedVerification is pending or was rejected.Check Dashboard → Verification.
422 message_too_long for a short Amharic textAmharic uses the 70-character limit.Shorten the text or split it across messages.
422 signature_too_longMessage plus signature exceeds the limit.Shorten the message or the signature name, or untick “include my phone number”.
422 message_rejectedContent 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 addedChapa 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 itPhone off, out of coverage or number inactive.Confirm the number and that the response was 200; message history shows what was accepted.
429 rate_limitedMore 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.