API Documentation

kiwiSMS REST API v1. Create an API key in your dashboard. Base URL: your site origin (e.g. https://your-domain.com).

OpenAPI spec: /openapi.json · /openapi.yaml · pricing catalog

Authentication

Send your API key on every authenticated request:

X-API-Key: lsms_xxxxxxxx_your_secret
# or
Authorization: Bearer lsms_xxxxxxxx_your_secret

Service codes

Each catalog row has three related fields:

  • kind — line type: s1, s2, s3, s4, or tm1
  • serviceNo — integer within that kind (starts at 1)
  • code — display reference kind-serviceNo (e.g. s1-5) for short-term
  • Long-term plans use kind (l1, l2, l3), planNo, and code (e.g. l2-3)

When renting, send kind + serviceNo from GET /api/v1/services. The code field is for display and search only.

Recommended flow

  1. GET /api/v1/services — pick a service with stock (note code)
  2. GET /api/v1/balance — check wallet
  3. POST /api/v1/rentals — rent with kind + serviceNo
  4. Poll GET /api/v1/rentals/{id} until codes is non-empty, or use webhooks
  5. POST .../done when finished

Endpoints

GET/api/v1/balance

Wallet balance (USD).

Response

{ "balance": 12.5, "currency": "USD" }
GET/api/v1/services?country=187

Full authenticated catalog with live stock. Optional country code (default 187 = US).

Response

{
  "countryCode": "187",
  "catalogMode": true,
  "count": 42,
  "services": [
    {
      "kind": "s1",
      "serviceNo": 5,
      "code": "s1-5",
      "name": "Google",
      "price": "0.12",
      "count": 120,
      "multiSms": true,
      "durationMinutes": 20
    }
  ]
}
GET/api/public/services?country=187&page=1&pageSize=100&q=google

Public paginated catalog (no auth). Max 100 items per page. Search by name or code.

Response

{
  "countryCode": "187",
  "services": [{ "kind": "s1", "serviceNo": 5, "code": "s1-5", "name": "Google", "price": "0.12", "count": 120, "multiSms": true, "durationMinutes": 20 }],
  "total": 1,
  "page": 1,
  "pageSize": 100,
  "totalPages": 1,
  "q": "google"
}
GET/api/public/long-term-plans?page=1&pageSize=100&q=google

Public paginated long-term catalog (no auth). Max 100 per page.

Response

{
  "available": true,
  "plans": [{
    "kind": "l2",
    "planNo": 3,
    "code": "l2-3",
    "serviceName": "Google",
    "countryName": null,
    "durationLabel": "30 days",
    "durationKey": "30d",
    "price": "5.00",
    "renewPrice": "4.00"
  }],
  "total": 1,
  "page": 1,
  "pageSize": 100,
  "totalPages": 1
}
GET/api/v1/long-term/plans?page=1&pageSize=100

Authenticated long-term plan catalog with pagination and search.

GET/api/v1/long-term/rentals?sync=1

List your long-term rentals. sync=0 skips provider sync.

POST/api/v1/long-term/rentals

Rent a long-term number using kind + planNo from the catalog.

Request

{
  "kind": "l2",
  "planNo": 3,
  "area_code": "212",
  "quantity": 1
}

Response

{
  "rental": {
    "id": "clx...",
    "kind": "l2",
    "planNo": 3,
    "code": "l2-3",
    "serviceName": "Google",
    "phone": "+12025551234",
    "sellPrice": 5.0,
    "status": "ACTIVE",
    "lineStatus": "active"
  }
}
GET/api/v1/long-term/rentals/{id}?sync=1

Poll one long-term rental.

GET/api/public/pricing

Small public snapshot for landing page (top services, no auth).

GET/api/v1/rentals?sync=1

Active rentals (WAITING/RECEIVED). sync=0 skips SMS sync (faster).

Response

{
  "count": 1,
  "rentals": [{
    "id": 1042,
    "kind": "s1",
    "serviceNo": 5,
    "phone": "+12025551234",
    "serviceName": "Google",
    "status": "RECEIVED",
    "codes": ["123456"],
    "otpCode": "123456",
    "secondsRemaining": 840,
    "active": true
  }]
}
GET/api/v1/rentals/{id}?sync=1

Poll one rental — same shape as list item. Syncs SMS when still active.

POST/api/v1/rentals

Rent a number (~20 min). Charges wallet. Send Idempotency-Key header for safe retries.

Request

Headers:
  Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "kind": "s1",
  "serviceNo": 5,
  "max_price": "0.50",
  "areas": "212,718",
  "carrier": "tmo",
  "country": "187"
}

Response

{
  "rental": {
    "id": 1042,
    "kind": "s1",
    "phone": "+12025551234",
    "serviceName": "Google",
    "price": 0.12,
    "status": "WAITING",
    "expiresAt": "2026-05-29T12:00:00.000Z"
  }
}
POST/api/v1/rentals/{id}/cancel

Cancel WAITING rental — refunds wallet.

POST/api/v1/rentals/{id}/done

Mark rental completed (release the number).

POST/api/v1/rentals/{id}/renew

Renew same number (new session). Use the KiwiSMS rental id from a previous rental.

Testing the API

Replace placeholders with your domain and API key:

# List services
curl -s -H "X-API-Key: lsms_xxx_your_secret" \
  "https://your-domain.com/api/v1/services?country=187" | jq .

# Check balance
curl -s -H "X-API-Key: lsms_xxx_your_secret" \
  "https://your-domain.com/api/v1/balance" | jq .

# Rent (use kind + serviceNo from catalog)
curl -s -X POST -H "Content-Type: application/json" \
  -H "X-API-Key: lsms_xxx_your_secret" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"kind":"s1","serviceNo":5,"country":"187"}' \
  "https://your-domain.com/api/v1/rentals" | jq .

# Poll rental
curl -s -H "X-API-Key: lsms_xxx_your_secret" \
  "https://your-domain.com/api/v1/rentals/1042?sync=1" | jq .

# Long-term plans
curl -s -H "X-API-Key: lsms_xxx_your_secret" \
  "https://your-domain.com/api/v1/long-term/plans?page=1&pageSize=100" | jq .

# Rent long-term (kind + planNo from catalog)
curl -s -X POST -H "Content-Type: application/json" \
  -H "X-API-Key: lsms_xxx_your_secret" \
  -d '{"kind":"l2","planNo":3}' \
  "https://your-domain.com/api/v1/long-term/rentals" | jq .

Public catalog (no key): GET /api/public/services?page=1&pageSize=100&q=google

Idempotency

For POST /api/v1/rentals and POST .../renew, send header Idempotency-Key: <uuid> (max 256 chars). Reusing the same key with the same body within 24 hours returns the original response instead of creating a second charge.

  • IDEMPOTENCY_IN_PROGRESS — 409, same key still processing
  • IDEMPOTENCY_KEY_REUSED — 422, same key but different request body

Errors

JSON body { error, code? }. Common codes:

  • INSUFFICIENT_BALANCE — 402
  • SERVICE_UNAVAILABLE — 404
  • RENTAL_NOT_FOUND — 404
  • CANCEL_NOT_ALLOWED — not WAITING

Rate limits

Per API key (default 100 requests/minute). Public endpoints are limited per IP. HTTP 429 when exceeded.

Webhooks

Set webhook URL and secret on your account or API key. When an OTP arrives, kiwiSMS POSTs JSON to your URL:

{
  "event": "sms.received",
  "rentalId": "1042",
  "kind": "s1",
  "serviceNo": 5,
  "code": "s1-5",
  "serviceName": "Google",
  "phone": "+12025551234",
  "otpCode": "123456",
  "smsText": "Your Google verification code is 123456",
  "receivedAt": "2026-05-29T12:00:00.000Z"
}

Header X-Webhook-Signature = HMAC-SHA256 hex digest of the raw request body using your webhook secret. Respond with HTTP 2xx to acknowledge.

Verify (Node.js)

import crypto from "crypto";

function verifyWebhook(rawBody, secret, signatureHeader) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader.trim(), "hex"),
    Buffer.from(expected, "hex")
  );
}

// In Express: use express.raw({ type: "application/json" }) before json parser
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const sig = req.headers["x-webhook-signature"];
  if (!verifyWebhook(raw, process.env.WEBHOOK_SECRET, sig)) {
    return res.status(401).send("invalid signature");
  }
  const payload = JSON.parse(raw);
  console.log(payload.otpCode, payload.code);
  res.sendStatus(200);
});

Verify (Python)

import hmac
import hashlib

def verify_webhook(raw_body: bytes, secret: str, signature: str) -> bool:
    expected = hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature.strip())

# Flask example
@app.post("/webhook")
def webhook():
    raw = request.get_data()
    sig = request.headers.get("X-Webhook-Signature", "")
    if not verify_webhook(raw, WEBHOOK_SECRET, sig):
        return "invalid signature", 401
    payload = request.json
    return "", 200

Test locally: point your webhook URL to a tunnel (e.g. ngrok) and rent a number; the payload includes otpCode and code.