API reference · v1

Build messaging into your product.

JSON over HTTPS with Bearer API keys. Create live and test keys in your workspace under Developers → API keys.

Authentication

Send your key as Authorization: Bearer jms_live_…. Keys starting with jms_test_ behave identically but never charge your wallet or deliver messages — messages are recorded with the status sandbox. Each key has scopes (for example sms:send, campaigns:write), an optional IP allowlist and a per-minute rate limit.

Send one SMS

POST/api/v1/sms/send

to accepts local numbers for your account country (0712345678) or international format (+256712345678). reference is a UUID you generate; retrying with the same reference never sends twice. Omit sender_id to use the shared sender.

request
curl -X POST https://jmsurfsms.co.ke/api/v1/sms/send \
  -H "Authorization: Bearer $JMSURF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"0712345678","message":"Your code is 482915","reference":"8f6241c0-24df-4ce2-b39b-9ef85eab198f","sender_id":"ACMELTD"}'
202 Accepted
{
  "id": "2b0d7c1e-…",
  "reference": "8f6241c0-24df-4ce2-b39b-9ef85eab198f",
  "to": "+254712345678",
  "status": "queued",
  "segments": 1,
  "encoding": "GSM-7",
  "cost_kes": 0.7,
  "sender_id": "ACMELTD",
  "test": false
}

Statuses: queued (accepted by the carrier), delivered, failed (refunded when the carrier rejects it), unknown (outcome uncertain — do not resend; it is reconciled for you).

Bulk campaigns

POST/api/v1/sms/bulk

Queue up to 100,000 recipients in one request from to (numbers), recipients (with names and merge fields) and/or group_ids. Merge fields: {first_name}, {name}, {phone} or any key in fields, with fallbacks such as {first_name|Customer}. Duplicates, blocked numbers and disabled destinations are skipped and reported.

request
{
  "name": "March statements",
  "message": "Hi {first_name|there}, your balance is KES {balance}. Pay via M-Pesa paybill 123456.",
  "recipients": [
    { "phone": "0712345678", "name": "Amina Wanjiku", "fields": { "balance": "1,250" } },
    { "phone": "0733000111", "fields": { "balance": "480" } }
  ],
  "send_at": "2026-10-01T09:00:00+03:00",
  "shorten_links": true
}
202 Accepted
{ "campaign_id": "a1f…", "recipients": 2, "skipped": { "duplicates": 0, "invalid": 0, "blocked": 0, "unsupported_destination": 0 }, "estimated_cost_kes": 2.8, "send_at": "2026-10-01T06:00:00.000Z", "test": false }

Message status

GET/api/v1/sms/{id}

Returns the message with its status, cost, network, failure reason and delivery time. Scope: sms:read.

List messages

GET/api/v1/messages?reference=…&status=…&campaign_id=…&limit=50&before=…

Newest first. Page with before set to the last created_at you received.

Campaign progress

GET/api/v1/campaigns/{id}

Recipients, pending, accepted, delivered, failed and cost so far.

Balance

GET/api/v1/balance

200 OK
{ "balance_kes": 1520.4, "currency": "KES", "rate_kes_per_sms": 0.7, "sms_remaining": 2172 }

Contacts

GET/api/v1/contacts?search=…&limit=100&offset=0

POST/api/v1/contacts

request
{ "phone": "0712345678", "name": "Amina Wanjiku", "fields": { "branch": "Westlands" }, "group_ids": ["…"] }

Webhooks

Add HTTPS endpoints under Developers → Webhooks. Events: sms.queued, sms.delivered, sms.failed, sms.unknown, sms.inbound, payment.completed. Each request carries x-jmsurf-signature: sha256=…, an HMAC-SHA256 of the raw body with your endpoint secret. Failed deliveries retry with exponential backoff up to five times.

verify (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  return header?.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

Errors & limits

Errors return { "error": { "code", "message" } }.

HTTPCodeMeaning
400invalid_request / invalid_recipientCheck the body and phone number
401unauthorized / invalid_keyMissing, wrong or revoked key
402insufficient_creditTop up your wallet
403insufficient_scope / ip_not_allowed / account_suspendedKey or account restrictions
409reference_conflictReference reused for a different message
422recipient_blocked / destination_disabled / sender_not_approvedThe message cannot be sent as requested
429rate_limitedSlow down; retry after 60 seconds
503sending_pausedSending is temporarily paused platform-wide

Need help? Contact us or open a ticket from your workspace.