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
Field
Type
Req
Notes
recipient
string
Yes
E.164 (+254712345678). Numbers without + are treated as Kenyan (+254).
message
string
Yes
Up to 1,600 characters. Long messages are split and reassembled.
priority
int (0–10)
No
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.
devices
array
No
Target specific phones/SIMs. Omit to let SMSKit choose.
idempotencyKey
string
No
Submission deduplication while the original job is retained; delivery retries can still duplicate SMS.
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:
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.
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.
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.
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.
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) returnfalse;
// Replay protection: reject stale timestamps (older than 5 minutes).if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) returnfalse;
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.returnif sender in opted_out:
returnif 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).
Endpoint
What it does
POST /api/v1/devices/{deviceId}/archive-requests
Request 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/messages
Unified, 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/conversations
Conversations 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/export
Bulk 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/contacts
Bulk upsert contacts ({ contacts: [{ displayName, phoneE164 }] }, max 1,000/call) so peer numbers resolve to names.
GET /api/v1/contacts
List stored contacts (paginated).
DELETE /api/v1/contacts/{contactId}
Delete a single contact.
GET, POST /api/v1/contact-records
Complete 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/export
Download 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/import
Restore 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.
Invalid input (bad number, empty message, message > 1600, priority out of 0–10)
Fix the request; the body explains what.
401 Unauthorized
Missing/invalid key, or suspended client
Check the key and the client's status.
403 Forbidden
Referenced 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 Found
Unknown job id
Verify the id.
422 Unprocessable
No eligible device/SIM to send
Bring a phone online or relax targeting.
429 Too Many Requests
Rate limited
Honor 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.asyncfunction 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);
awaitnew 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.