Skip to content

API Reference

Base URL: https://api.smskit.cloud · Auth header: X-Api-Key: sk_live_…

Send SMS — POST /api/sms/send

Queue an SMS for delivery. Returns immediately with a job id (202 Accepted); delivery happens asynchronously on your phone.

Request body

FieldTypeReqNotes
recipientstringYesE.164 (+254712345678). Numbers without + are treated as Kenyan (+254).
messagestringYesUp to 1,600 characters. Long messages are split + reassembled.
priorityint (0–10)Lower = more urgent (dispatched first). Omitted = 0, the front of the queue. Give bulk sends a higher value (e.g. 3) so OTPs and transactional messages jump ahead.
devicesarrayTarget specific phones/SIMs. Omit to let SMSKit choose.
idempotencyKeystringSafe retries — resending the same key returns the original job instead of sending twice.

Examples

curl https://api.smskit.cloud/api/sms/send \
  -H "X-Api-Key: sk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "recipient": "+254712345678", "message": "Your code is 123456" }'

Response — 202 Accepted

{ "jobId": "job_7H2K9M4P1Q6R3T8V5W0X", "status": "Pending" }

Idempotent sends

Pass an idempotencyKey (any string unique to the logical send, e.g. your own message id) and retries become safe: if a job already exists with that key, the API returns the original job — its jobId and current status — instead of queuing a duplicate. Use it whenever you retry on timeouts, 429s, or network errors, so an OTP is never sent twice.

Targeting specific phones and SIMs

By default SMSKit picks the best enabled device + SIM on your account. To restrict a send to specific phones (and optionally specific SIM slots), pass a devices array — ids come from GET /api/devices. Omit simSlots on an entry to allow any enabled SIM on that phone:

{
  "recipient": "+254712345678",
  "message": "Your order has shipped",
  "devices": [
    { "deviceId": "dvc_3K7J2H5F8M1N4Q9R6V0W", "simSlots": [0] },
    { "deviceId": "dvc_8B1C4D7E0F3G6H9J2K5L" }
  ]
}

Referencing a device from another account returns 403; if none of the targeted channels is eligible to send, you get 422.

Job status — GET /api/sms/{jobId}/status

Poll a job through its lifecycle: Pending → Assigned → Sent → Delivered, or Failed / DeadLetter. assignedDevice, sentAt, and deliveredAt are null until the job reaches that stage.

{
  "jobId": "job_7H2K9M4P1Q6R3T8V5W0X",
  "status": "Delivered",
  "assignedDevice": "dvc_3K7J2H5F8M1N4Q9R6V0W",
  "sentAt": "2026-06-23T10:00:04Z",
  "deliveredAt": "2026-06-23T10:00:11Z"
}

Prefer webhooks over polling for production — you get the state change pushed instantly.

Devices — GET /api/devices

List your account's phones and their SIMs — useful for building a targeting selection.

{
  "devices": [
    {
      "id": "dvc_3K7J2H5F8M1N4Q9R6V0W",
      "name": "Pixel 6 — Nairobi",
      "status": "Online",
      "enabled": true,
      "battery": 87,
      "network": "LTE",
      "signal": 4,
      "pendingJobCount": 0,
      "lastHeartbeatAt": "2026-06-23T10:00:05Z",
      "sims": [
        { "slot": 0, "name": "Safaricom", "status": "Active", "enabled": true },
        { "slot": 1, "name": "Airtel", "status": "Active", "enabled": false }
      ]
    }
  ]
}

Webhooks

Subscribe a URL and SMSKit POSTs an event the moment something happens — no polling. Events: sms:sent · sms:delivered · sms:failed · sms:received · archive:import_completed. Which events you can subscribe to depends on your plan.

Register — POST /api/webhooks

curl https://api.smskit.cloud/api/webhooks \
  -H "X-Api-Key: sk_live_your_key" -H "Content-Type: application/json" \
  -d '{ "url": "https://yourapp.com/hooks/smskit", "events": ["sms:delivered", "sms:received"] }'

The response includes the webhook's signing secret (shown once — store it). Other endpoints: GET /api/webhooks (list), GET /api/webhooks/{id} (detail), PATCH /api/webhooks/{id} (update url / events / active state), DELETE /api/webhooks/{id}, POST /api/webhooks/{id}/ping (send a test event).

Example payload (sms:received)

{
  "event": "sms:received",
  "deliveryId": "whd_2N5Q8S1V4X7Z0B3D6F9H",
  "eventId": "evt_5T8W1X4Y7Z0A3B6C9D2E",
  "incomingMessageId": "inc_9C2F5J8M1P4S7V0Y3B6E",
  "deviceId": "dvc_3K7J2H5F8M1N4Q9R6V0W",
  "simSlot": 0,
  "sender": "+254712345678",
  "messageBody": "YES",
  "receivedAt": "2026-06-23T10:05:00Z",
  "timestamp": "2026-06-23T10:05:02Z"
}

Example payload (job events — sms:sent / sms:delivered / sms:failed)

{
  "event": "sms:delivered",
  "deliveryId": "whd_6H9K2M5P8R1T4W7Z0C3F",
  "eventId": "evt_1D4G7K0N3Q6T9W2Z5B8E",
  "jobId": "job_7H2K9M4P1Q6R3T8V5W0X",
  "recipient": "+254712345678",
  "status": "Delivered",
  "deviceId": "dvc_3K7J2H5F8M1N4Q9R6V0W",
  "simSlot": 0,
  "timestamp": "2026-06-23T10:00:11Z"
}

Verifying signatures

Every delivery is signed with your webhook's secret so you can confirm it came from SMSKit and wasn't tampered with. Two schemes are sent on each request:

  • Recommended: X-SMSKit-Timestamp (unix seconds) + X-SMSKit-Signature-256 — a hex HMAC-SHA256 over the string "{timestamp}.{rawBody}". Recompute it, compare with a constant-time compare, and reject timestamps older than 5 minutes (replay protection).
  • Legacy: X-SMSKit-Signature — a hex HMAC-SHA256 of the raw request body alone. Kept for existing integrations; it has no replay protection, so prefer the timestamped scheme.

Always verify against the raw request bytes — re-serializing parsed JSON can change the byte sequence and break the signature.

import crypto from "node:crypto";

// rawBody must be the exact bytes received — parse JSON only AFTER verifying.
function verifyWebhook(headers, rawBody, secret) {
  const ts = headers["x-smskit-timestamp"];
  const sig = headers["x-smskit-signature-256"];
  if (!ts || !sig) return false;

  // Replay protection: reject stale timestamps (older than 5 minutes).
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  return a.length === b.length && crypto.timingSafeEqual(a, b); // constant-time
}

Delivery semantics

  • At-least-once — a delivery can occasionally arrive more than once. Every payload carries a deliveryId (unique per delivery) and an eventId (unique per event); record processed ids and skip duplicates.
  • Retries — a non-2xx response or connection error is retried up to 6 attempts with exponential backoff (starting around a minute and doubling, with jitter, capped at an hour).
  • Auto-pause — an endpoint whose deliveries keep failing is automatically paused and you get an email notice. Re-enable it from the dashboard, or via PATCH /api/webhooks/{id} with { "isActive": true }.
  • Respond fast — return 2xx immediately and process the event asynchronously; slow handlers get counted as failures.

Two-way messaging

Inbound SMS are threaded into conversations and surfaced two ways: the sms:received webhook (push, recommended) and the dashboard inbox. To pull inbound and outbound history programmatically, use the archive / messages API below. Replies sent from the dashboard route back through the device that owns the conversation.

You are responsible for honoring opt-out requests (e.g. a recipient replying STOP). The sms:received webhook and inbox surface those replies so you can detect and suppress them — automatic STOP keyword handling is not enforced server-side.

SMS archiving & export

Business SMS Archival keeps a complete, retrievable record of your messages. A device can import the inbox and sent history already on the phone (how archiving works); from there a pull API exposes your account's unified timeline — outbound jobs, inbound messages, and imported history, each tagged with an origin — plus a bulk export and a contacts store so peer numbers resolve to names. Same X-Api-Key auth and rate limit as the send API.

EndpointWhat it does
GET /api/messagesUnified, paginated message timeline (outbound + inbound + imported). Filters: peer, direction, origin, from, to, deviceId.
GET /api/messages/conversationsConversations grouped by peer, with last message and counts.
GET /api/messages/conversations/{peer}Full message timeline for a single peer (most recent 1,000).
GET /api/archive/exportBulk export of the timeline. NDJSON (default) or CSV via ?format=csv. Capped at 50,000 rows — narrow with from/to for larger ranges.
POST /api/contactsBulk upsert contacts ({ contacts: [{ displayName, phoneE164 }] }, max 1,000/call) so peer numbers resolve to names.
GET /api/contactsList stored contacts (paginated).
DELETE /api/contacts/{contactId}Delete a single contact.

Response — GET /api/messages

{
  "items": [
    {
      "messageId": "job_7H2K9M4P1Q6R3T8V5W0X",
      "peer": "+254712345678",
      "direction": "outbound",
      "origin": "smskit_sent",
      "body": "Your code is 123456",
      "occurredAt": "2026-06-23T10:00:11Z",
      "simSlot": 0,
      "deviceId": "dvc_3K7J2H5F8M1N4Q9R6V0W",
      "status": "Delivered",
      "contactName": "Jane Doe"
    }
  ],
  "totalCount": 1,
  "page": 1,
  "pageCount": 1
}

Errors & rate limits

CodeMeaningWhat to do
202 AcceptedJob queuedRead jobId + Location.
400 Bad RequestInvalid input (bad number, empty message, message > 1600, priority out of 0–10)Fix the request; the body explains what.
401 UnauthorizedMissing/invalid key, or suspended clientCheck the key and the client's status.
403 ForbiddenReferenced a device/SIM from another accountUse ids from your own GET /api/devices.
404 Not FoundUnknown job idVerify the id.
422 UnprocessableNo eligible device/SIM to sendBring a phone online or relax targeting.
429 Too Many RequestsRate limitedHonor Retry-After; back off.

Rate limits

The client API is limited per API client (currently 100 requests/hour). On 429, read Retry-After and back off. Need more throughput? It's about send capacity (more phones/SIMs), not request rate — add devices. See also the SMS throttling & pacing guide for the per-device sending limits.

Handling 429 — a retry recipe

Honor the Retry-After header (seconds) when present, fall back to exponential backoff, and pair retries with an idempotency key so a retried request can never double-send:

// Honor Retry-After on 429 — safe to retry because payload carries an idempotencyKey.
async function sendSms(payload, maxTries = 5) {
  for (let attempt = 0; attempt < maxTries; attempt++) {
    const res = await fetch("https://api.smskit.cloud/api/sms/send", {
      method: "POST",
      headers: { "X-Api-Key": "sk_live_your_key", "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    if (res.status !== 429) return res;
    const waitSeconds = Number(res.headers.get("Retry-After") ?? 2 ** attempt * 5);
    await new Promise((r) => setTimeout(r, waitSeconds * 1000));
  }
  throw new Error("Still rate limited after retries");
}

SDKs & samples

.NET — SMSKit.ApiClient (official reference SDK). Typed methods for send, status, and devices; handles auth, base URL, JSON, and error mapping.

{ "SmsKit": { "ServerUrl": "https://api.smskit.cloud", "ApiKey": "sk_live_..." } }

Other languages — the API is plain REST + JSON; the cURL/Node/Python/PHP/Go snippets above are copy-paste starting points.