Skip to content

API Reference

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

Send SMS: POST /api/v1/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 and reassembled.
priorityint (0–10)NoLower = 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.
devicesarrayNoTarget specific phones/SIMs. Omit to let SMSKit choose.
idempotencyKeystringNoSubmission deduplication while the original job is retained; delivery retries can still duplicate SMS.

Examples

curl https://api.smskit.cloud/api/v1/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

JSON
{ "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, including its jobId and current status, instead of queuing a duplicate. Use it whenever you retry on timeouts, 429s, or network errors while the original job is retained. After the job is deleted or expires, the same key can create a new job. Delivery retries can still produce duplicate SMS.

Targeting specific phones and SIMs

By default SMSKit picks the best enabled device and 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/v1/devices. Omit simSlots on an entry to allow any enabled SIM on that phone:

JSON
{
  "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/v1/sms/{jobId}/status

A job moves from Pending to Assigned, Sent and Delivered, or ends as DeadLetter. Poll for its current state. A Failed device acknowledgement requeues the job as Pending or exhausts retries as DeadLetter; Failed is not persisted. Dashboard/operator cancellation can produce Cancelled. For enriched messages, Sent requires successful results for all distinct parts; Delivered requires confirmed delivery reports for every part. Metadata exposes separate send/delivery state and counts. Old phones can report legacy status without this evidence. Flat sentAt and deliveredAt retain server ACK-processing meanings. Handset observations and their sources live in metadata.timestamps. Responses also include complete metadata, revision, state and message. The example below shows the older flat fields.

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

Use webhooks for sent, delivered and failed events; deliveries retry with backoff if your endpoint fails. Keep polling when you need assignment or operator cancellation updates, which have no webhook.

Complete SMS metadata

Message lists, threads, job status and NDJSON exports include the same complete metadata object by default. SMS hooks carry a common message snapshot and identical metadata. Source bags retain native types, unknown fields, original PDUs and base64 bytes. Preserve large integers without converting them to floating point. Unavailable fields are null with an object containing a reason under availability. Legacy absence is legacy_not_captured.

Use metadata.timestamps.messageAt and its source for display. Live receipt uses phone receipt, then known service-centre time; imported history uses provider dates. SMSKit sends use confirmed sent time, otherwise queued time. Label the latter Queued at. Import and upload times do not replace message time. Provider read/seen flags describe the gateway handset, not a recipient read receipt.

Import completion remains one summary hook. Its messages.href points to /api/v1/messages?importSessionId=...&deviceId=..., including accepted enriched rows. CSV adds quoted metadata JSON after the original columns. Truncation markers require a narrower export range. Exports above 64 MiB return HTTP 413 with export_too_large, preserving the stored data. Hook retries retain their logical event ID and snapshot; snapshots have independent 30-day retention. Account deletion removes both primary evidence and snapshots.

Devices: GET /api/v1/devices

List your account's phones and their SIMs for targeting. isCharging is nullable; unknown is not unplugged. archiveReadiness reports phone consent, SMS/contact grants, freshness, readiness and remediation guidance; account entitlement is separate.

JSON
{
  "devices": [
    {
      "id": "dvc_3K7J2H5F8M1N4Q9R6V0W",
      "name": "Pixel 6 (Nairobi)",
      "status": "Online",
      "enabled": true,
      "battery": 87,
      "isCharging": null,
      "archiveReadiness": {
        "enabled": null, "smsReadGranted": null, "contactsReadGranted": null,
        "reportedAt": null, "ready": false,
        "reasonCode": "ARCHIVE_READINESS_UNKNOWN",
        "guidance": "Update and open SMSKit on this phone. Enable archiving on this phone first: SMSKit Android app -> Device details -> Data & Archive -> Enable archiving. Grant SMS and Contacts access.",
        "expiresAt": null
      },
      "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 for the listed events. SMSKit queues deliveries and retries them with backoff. Events: sms:sent, sms:delivered, sms:failed, sms:received and archive:import_completed. Which events you can subscribe to depends on your plan.

Register: POST /api/v1/webhooks

BASH
curl https://api.smskit.cloud/api/v1/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, so store it). Other endpoints: GET /api/v1/webhooks (list), GET /api/v1/webhooks/{id} (detail), PATCH /api/v1/webhooks/{id} (update url / events / active state), DELETE /api/v1/webhooks/{id}, POST /api/v1/webhooks/{id}/ping (send a test event). Inspect GET /api/v1/webhooks/{id}/deliveries, replay with POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/redeliver, and rotate the signing secret with POST /api/v1/webhooks/{id}/rotate-secret. A replacement secret is shown once. Repeated failures can suspend a receiver; fix it before re-enabling.

Example payload (sms:received)

JSON
{
  "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)

JSON
{
  "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) and 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/v1/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. API replies use the same SIM when you pass the event’s deviceId and simSlot. For a same-SIM dashboard reply, staff pick that phone and SIM; otherwise automatic routing chooses any enabled SIM.

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 chatbot

Connect your own bot to answer people who texted first. The signed sms:received webhook works on every plan, including Free’s one evaluation webhook. The dashboard inbox and messages API need Standard. Use GET /api/v1/messages/conversations for history, then fetch a peer’s timeline from /api/v1/messages/conversations/{peer}.

Pass untouched request bytes and headers to the handler. Set SMSKIT_WEBHOOK_SECRET and SMSKIT_API_KEY. Replace ask_bot() with your bot and STOP/opt-out rules. Unknown SIM slots are rejected so replies cannot silently use another SIM. This compact handler verifies and sends inline. In production, split verification from reply work: verify on receipt, durably queue the verified event and return 2xx, then run the bot and send in a worker. Reuse eventId as the reply idempotency key while the original job is retained; delivery retries can still duplicate an SMS.

import hashlib, hmac, json, os, time
from urllib.request import Request, urlopen

STOP_WORDS = {"STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"}
opted_out = set()  # In production, save this to durable storage.

def ask_bot(message):
    """Placeholder for the business's own bot."""
    raise NotImplementedError("Connect your own bot here")

def receive_sms(headers, raw_body):
    """Verify an incoming event and send its answer through the event's phone and SIM."""
    ts = headers.get("X-SMSKit-Timestamp", "")
    signature = headers.get("X-SMSKit-Signature-256", "")
    if not ts.isascii() or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        raise ValueError("Invalid webhook timestamp")
    expected = hmac.new(
        os.environ["SMSKIT_WEBHOOK_SECRET"].encode(),
        ts.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, signature):
        raise ValueError("Invalid webhook signature")
    event = json.loads(raw_body)
    if event["event"] != "sms:received":
        return
    sender = event["sender"]
    if event["messageBody"].strip().upper() in STOP_WORDS:
        opted_out.add(sender)  # Record the opt-out and send nothing.
        return
    if sender in opted_out:
        return
    if not event.get("deviceId") or type(event.get("simSlot")) is not int or event["simSlot"] not in (0, 1):
        raise ValueError("Missing reply phone or SIM")
    payload = {
        "recipient": sender,
        "message": ask_bot(event["messageBody"]),
        "devices": [{"deviceId": event["deviceId"], "simSlots": [event["simSlot"]]}],
        "idempotencyKey": event["eventId"]
    }
    request = Request("https://api.smskit.cloud/api/v1/sms/send",
        data=json.dumps(payload).encode(), method="POST",
        headers={"X-Api-Key": os.environ["SMSKIT_API_KEY"], "Content-Type": "application/json"})
    with urlopen(request, timeout=15) as response:
        return json.load(response)

Carrier rates and limits apply. Your business handles STOP and opt-outs. An AI provider receiving customer messages is part of your business’s data processing.

SMS archiving & export

Business SMS Archival exposes the retained messages in your account. A device can import the inbox and sent history already on the phone (how archiving works). A pull API then returns your account's unified timeline: outbound jobs, inbound messages, and imported history, each tagged with an origin. A bulk export and a contacts store round it out, so peer numbers resolve to names. The API uses the same X-Api-Key auth as the send API. Reading and exporting the timeline needs the Standard plan: GET /api/v1/messages, /api/v1/messages/conversations and /api/v1/archive/export return 403 on other plans. These GETs use the read rate limit (600 requests/hour per API client).

EndpointWhat it does
POST /api/v1/devices/{deviceId}/archive-requestsRequest one SMS/contact import with { since: 0 }. Requires fresh Android consent and both grants; 202 means requested.
GET /api/v1/devices/{deviceId}/archive-requests/{requestId}Poll requested/running/completed/failed, failure guidance and SMS/contact acknowledgement totals.
GET /api/v1/messagesUnified, paginated message timeline (outbound + inbound + imported). Filters: peer, direction, origin, from, to, deviceId, q (text search), page and limit (1-200, default 50). Standard plan only (403 otherwise).
GET /api/v1/messages/conversationsConversations grouped by peer, with last message and counts. Supports q, page and limit. Standard plan only.
GET /api/v1/messages/conversations/{peer}Full message timeline for a single peer, oldest first. Pages with page and pageSize (default and maximum 1,000). Standard plan only.
GET /api/v1/archive/exportBulk export of the timeline. NDJSON (default) or CSV via ?format=csv. Capped at 50,000 rows, so narrow with from/to for larger ranges. Standard plan only.
POST /api/v1/contactsBulk upsert contacts ({ contacts: [{ displayName, phoneE164 }] }, max 1,000/call) so peer numbers resolve to names.
GET /api/v1/contactsList stored contacts (paginated).
DELETE /api/v1/contacts/{contactId}Delete a single contact.
GET, POST /api/v1/contact-recordsComplete contacts: several phones and emails, addresses, organizations, websites, events, notes, groups and a photo. GET lists and searches (q, page, limit up to 200). POST creates (201). The three name-and-number contact routes above keep working unchanged.
GET, PATCH, DELETE /api/v1/contact-records/{id}Read, update or delete one contact by its stable ID. PATCH sends the record revision, changes only the fields you send (null or [] clears one) and returns 409 CONTACT_REVISION_CONFLICT with the current record when the revision is old. Edits change SMSKit’s copy, never the phone.
GET /api/v1/contact-records/exportDownload every contact of the account as one complete JSON file (format smskit.contacts, version 1), whatever the paging. Photos, labels, IDs, timestamps, origins and manual-override state are included. An empty account returns an empty list.
POST /api/v1/contact-records/importRestore a contacts file. Send { mode: "preview", document } to check it and get counts and a fingerprint without writing. Send { mode: "apply", document, fingerprint, operationId } to write all contacts or none. Repeating an import updates the same records, never duplicates them. Max 10,000 contacts and 63 MiB. Errors: 400 CONTACT_IMPORT_INVALID, 409 CONTACT_IMPORT_STALE, 413.

Request, poll, then pull

First enable archiving on Android: open Device details, then Data & Archive, then Enable archiving. Read the disclosure and grant READ_SMS and READ_CONTACTS in either build. API and dashboard requests cannot grant phone consent. After fresh phone consent, enabling starts automatic new-SMS and contact uploads. Use zero for all allowed history, or a nonnegative epoch-millisecond integer no later than server time.

BASH
# First enable archiving and both permissions on Android.
curl -X POST https://api.smskit.cloud/api/v1/devices/dvc_your_phone/archive-requests -H 'X-Api-Key: sk_live_your_key' -H 'Content-Type: application/json' -d '{"since":0}'
# Poll the returned request ID until completed or failed.
curl https://api.smskit.cloud/api/v1/devices/dvc_your_phone/archive-requests/arq_returned_id -H 'X-Api-Key: sk_live_your_key'
# After completed, pull or export before retention expires.
curl 'https://api.smskit.cloud/api/v1/messages?deviceId=dvc_your_phone' -H 'X-Api-Key: sk_live_your_key'
curl 'https://api.smskit.cloud/api/v1/archive/export?format=csv&from=2026-01-01T00%3A00%3A00Z&to=2026-02-01T00%3A00%3A00Z' -H 'X-Api-Key: sk_live_your_key' -o archive.csv

202 means requested, not completed. Invalid ranges return 400; unknown/foreign devices 404; missing plan entitlement 403. Missing consent/grants, stale/unknown reports or unavailable phones return 409 with code and guidance. An active request returns 409 with its ID; import limits return 429; failed/mock dispatch returns 503 and releases the slot. Poll requested/running states. Failed includes failureCode and guidance; partial uploads remain retrievable. Requests fail after 15 minutes without activity. Only completed confirms both SMS and contacts plus server acknowledgement.

Standard permits 90 days of history, 5,000 SMS shared by manual and automatic uploads in a device’s 24-hour archive window and one imported session per device per 24 hours. SMS/request metadata expire under plan retention (30 days on Standard). Contacts remain until contacts API deletion or account deletion, separate from SMS expiry. Manual imports run once. Automatic scans archive new SMS from the consent time and contact changes. Older SMS still needs manual import. Android may delay periodic work; offline uploads retry after reconnection. Neither path restores data to the phone.

Response: GET /api/v1/messages

JSON
{
  "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 and 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 account, or the plan does not allow it (a second webhook on Free; GET /api/v1/messages, /api/v1/messages/conversations and /api/v1/archive/export off Standard)Use ids from your own GET /api/v1/devices, or upgrade the plan.
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, with separate hourly budgets: 600 requests/hour for GET requests (status polling, device list, archive reads) and 100 requests/hour for writes (send, webhook and contact changes, archive requests). On 429, read Retry-After and back off. For more throughput, add phones or SIMs. The limit is send capacity, not request rate. 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 returns its existing job while that job is retained. Delivery retries can still duplicate SMS:

JS
// Honor Retry-After on 429; reuse an idempotencyKey while its original job is retained.
async function sendSms(payload, maxTries = 5) {
  for (let attempt = 0; attempt < maxTries; attempt++) {
    const res = await fetch("https://api.smskit.cloud/api/v1/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.

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

Other languages: the API is plain REST and JSON. The cURL, Node, Python, PHP and Go snippets above are copy-paste starting points.