# kwtsms

Python client for the [kwtSMS API](https://kwtsms.com) — Kuwait SMS gateway.

Zero external dependencies. Python 3.8+.

---

## About kwtSMS

kwtSMS is a Kuwaiti SMS gateway trusted by top businesses to deliver messages anywhere in the world, with private Sender ID, free API testing, non-expiring credits, and competitive flat-rate pricing. Secure, simple to integrate, built to last.

Open a free account in under 1 minute — no paperwork or payment required.

[🚀 Click here to get started →](https://www.kwtsms.com/signup/)

---

## Prerequisites

You need **Python 3.8 or newer** and **pip** (Python's package manager) installed.

### Check if Python is installed

```bash
python3 --version
```

If you see a version number (e.g., `Python 3.12.3`), you're ready. If not, install Python:

- **All platforms:** Download from https://www.python.org/downloads/
- **macOS:** `brew install python`
- **Ubuntu/Debian:** `sudo apt update && sudo apt install python3 python3-pip`
- **Windows:** Download from https://www.python.org/downloads/ — check "Add Python to PATH" during install

### Check if pip is installed

```bash
pip --version
```

If not found, try `pip3 --version`. If still not found:

```bash
python3 -m ensurepip --upgrade
```

---

## Install

```bash
pip install kwtsms
```

Other package managers:

```bash
uv add kwtsms
poetry add kwtsms
pipenv install kwtsms
```

---

## Quick start

```python
from kwtsms import KwtSMS

sms = KwtSMS.from_env()                                    # reads .env or env vars
ok, balance, error = sms.verify()                           # test credentials
result = sms.send("96598765432", "Your OTP for MYAPP is: 123456")  # send SMS
```

---

## Setup

Create a `.env` file in your project root (or set the same keys as environment variables):

```ini
KWTSMS_USERNAME=your_api_user
KWTSMS_PASSWORD=your_api_pass
KWTSMS_SENDER_ID=YOUR-SENDERID   # use KWT-SMS for testing only
KWTSMS_TEST_MODE=1                # 1 = test (safe default), 0 = live
KWTSMS_LOG_FILE=kwtsms.log        # JSONL log path, set to "" to disable
```

Or run the interactive setup wizard (verifies credentials and lists your sender IDs):

```bash
kwtsms setup
```

`from_env()` checks environment variables first, then the `.env` file as fallback.

---

## Credential Management

**Never hardcode credentials in your source code.** Credentials must be changeable without modifying code or redeploying.

```python
# Option 1: Environment variables / .env file (recommended)
sms = KwtSMS.from_env()

# Option 2: Constructor (for custom config systems, DI containers, etc.)
sms = KwtSMS(
    username="your_api_user",
    password="your_api_pass",
    sender_id="YOUR-SENDERID",  # default "KWT-SMS" (testing only)
    test_mode=False,             # default False
    log_file="kwtsms.log",       # default "kwtsms.log", "" to disable
)
```

**For web apps and SaaS:** Provide an admin settings page where API credentials can be updated without touching code. Include a "Test Connection" button that calls `verify()`.

**For production:** Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) and pass credentials to the constructor.

---

## Methods

### `verify()` → `(ok, balance, error)`

Tests credentials by calling the balance endpoint.

```python
ok, balance, error = sms.verify()
if ok:
    print(f"Balance: {balance}")   # float
else:
    print(error)  # "Authentication error... → Check KWTSMS_USERNAME..."
```

Returns `(True, float, None)` on success, `(False, None, str)` on failure. Never raises.

---

### `send(mobile, message, sender=None)` → `dict`

Send SMS to one or more numbers.

```python
# Single number
result = sms.send("96598765432", "Your OTP for MYAPP is: 123456")

# Multiple numbers (list)
result = sms.send(["96598765432", "+96512345678", "0096511111111"], "Hello!")

# Override sender ID for this call only
result = sms.send("96598765432", "Hello", sender="MY-APP")
```

**Phone numbers** are normalized automatically: strips `+`, `00`, spaces, dashes; converts Arabic/Hindi digits to Latin.

**Message text** is cleaned automatically: strips emojis, hidden control characters (BOM, zero-width space, soft hyphen), HTML tags; converts Arabic/Hindi digits to Latin.

**OK response (≤200 numbers):**
```python
{
    "result":         "OK",
    "msg-id":         "f4c841adee210f31...",  # save this — needed for status/DLR lookups
    "numbers":        1,
    "points-charged": 1,
    "balance-after":  149,                    # save this — no need to call balance() again
    "unix-timestamp": 1741000800,             # ⚠ GMT+3 server time, NOT UTC
}
```

**ERROR response:**
```python
{
    "result":      "ERROR",
    "code":        "ERR003",
    "description": "Authentication error, username or password are not correct.",
    "action":      "Wrong API username or password. Check KWTSMS_USERNAME and KWTSMS_PASSWORD...",
}
```

**Mixed valid/invalid input** — invalid numbers are reported, not raised:
```python
result = sms.send(["96598765432", "abc", "user@gmail.com"], "Hello")
# result["invalid"] → [
#   {"input": "abc",            "error": "'abc' is not a valid phone number — no digits found"},
#   {"input": "user@gmail.com", "error": "'user@gmail.com' is an email address, not a phone number"},
# ]
```

**Raises** `RuntimeError` on network/HTTP failure (single send only — bulk captures errors per batch).

---

### Bulk send (>200 numbers)

`send()` detects the count automatically and batches in groups of 200. No special call needed.

```python
result = sms.send(list_of_1000_numbers, "Hello!")

if result.get("bulk"):
    print(result["result"])          # "OK", "PARTIAL", or "ERROR"
    print(result["batches"])         # 5  (number of API calls made)
    print(result["numbers"])         # 950 (total numbers accepted)
    print(result["points-charged"])  # 950 (total credits used)
    print(result["balance-after"])   # balance after last batch
    print(result["msg-ids"])         # ["abc123", "def456", ...]  one per batch
    for err in result["errors"]:
        print(err["batch"], err["code"], err["description"])
```

- Rate: 0.5s between batches (≤2 req/s)
- ERR013 (queue full): auto-retries up to 3× with 30s / 60s / 120s backoff
- `"PARTIAL"` means some batches succeeded and some failed — check `errors`

---

### `balance()` → `float | None`

Returns current balance. Returns `None` on error (does not raise).
Also updated automatically after every successful `send()` — no need to call this after sending.

```python
bal = sms.balance()
```

---

### `validate(phones)` → `dict`

Validate phone numbers before sending. Numbers that fail local validation (email, too short, no digits) are rejected before any API call.

```python
report = sms.validate(["96598765432", "+96512345678", "abc", "123"])

report["ok"]       # ["96598765432", "96512345678"]  — valid and routable
report["er"]       # ["abc", "123"]                  — format error
report["nr"]       # []                              — no route for country
report["rejected"] # [{"input": "abc",  "error": "..."},
                   #  {"input": "123",  "error": "'123' is too short..."}]
report["error"]    # None if API call succeeded
report["raw"]      # full raw API response dict, or None if no API call was made
```

---

### `senderids()` → `dict`

Returns the sender IDs registered on this account.

```python
result = sms.senderids()
if result["result"] == "OK":
    print(result["senderids"])  # → ["KWT-SMS", "MY-APP"]
else:
    print(result["action"])
```

---

### `coverage()` → `dict`

Returns active country prefixes allowed on this account.

```python
result = sms.coverage()
if result["result"] == "OK":
    print(result["prefixes"])  # → ["965", "966", "971", "973", "974"]
else:
    print(result["action"])    # ERR033 = no active coverage, contact kwtSMS
```

---

## Utility functions

```python
from kwtsms import normalize_phone, validate_phone_input, clean_message

# Normalize a phone number — strips +, 00, spaces, dashes; converts Arabic digits
normalize_phone("+96598765432")      # → "96598765432"
normalize_phone("00 965 9876-5432") # → "96598765432"
normalize_phone("٩٦٥٩٨٧٦٥٤٣٢")     # → "96598765432"

# Validate a phone number — returns (is_valid, error, normalized)
ok, error, number = validate_phone_input("+96598765432")
# → (True, None, "96598765432")

ok, error, number = validate_phone_input("user@gmail.com")
# → (False, "'user@gmail.com' is an email address, not a phone number", "")

ok, error, number = validate_phone_input("123")
# → (False, "'123' is too short to be a valid phone number (3 digits, minimum is 7)", "123")

# Clean message text — also called automatically inside send()
clean_message("Your OTP is: ١٢٣٤٥٦ 🎉")  # → "Your OTP is: 123456 "
```

---

## Input sanitization

### Phone numbers

All phone numbers are normalized automatically before every API call:

1. Arabic/Hindi digits (`٠١٢٣٤٥٦٧٨٩` / `۰۱۲۳۴۵۶۷۸۹`) → Latin (`0123456789`)
2. All non-digit characters stripped (`+`, spaces, dashes, dots, brackets, etc.)
3. Leading zeros stripped (handles `00` country code prefix)

Numbers must include the country code (e.g., `96598765432` for Kuwait, not `98765432`).

### Message text

`send()` calls `clean_message()` automatically before every API call. Three types of content cause silent delivery failure (API returns OK, message stuck in queue, credits wasted):

| Content | Effect | What happens |
|---------|--------|-------------|
| **Emojis** | Stuck in queue indefinitely, no error returned | Stripped automatically |
| **Hidden characters** (zero-width space, BOM, soft hyphen) | Spam filter rejection or queue stuck | Stripped automatically |
| **Arabic/Hindi digits** in body (`١٢٣٤`) | OTP codes may render inconsistently | Converted to Latin automatically |
| **HTML tags** | ERR027 — message rejected | Stripped automatically |

Arabic **letters** are fully supported and are NOT stripped.

---

## CLI

```bash
kwtsms setup                                          # first-time wizard
kwtsms verify                                         # test credentials + show balance + purchased
kwtsms balance                                        # check available and purchased credits
kwtsms senderid                                       # list sender IDs on this account
kwtsms coverage                                       # list active country prefixes
kwtsms send 96598765432 "Your OTP is: 123456"        # send SMS
kwtsms send 96598765432,96512345678 "Hello!"          # multiple numbers (no spaces around commas)
kwtsms send "96598765432, 96512345678" "Hello!"       # or quote the list (spaces OK inside quotes)
kwtsms send 96598765432 "Hello" --sender MY-APP       # override sender ID
kwtsms send 96598765432 "Hello" --sender "kwt sms"   # sender ID with spaces — quote it
kwtsms validate 96598765432 +96512345678 0096511111111
```

---

## Error handling

Every API error response includes an `action` field with guidance:

```python
try:
    result = sms.send("96598765432", "Your OTP for MYAPP is: 123456")
except RuntimeError as e:
    # Network/HTTP failure — log and retry
    print(f"Network error: {e}")
else:
    if result["result"] == "OK":
        save_to_db(msg_id=result["msg-id"], balance=result["balance-after"])
    else:
        print(result["code"])        # e.g. "ERR010"
        print(result["description"]) # "Account balance is zero."
        print(result["action"])      # "Recharge credits at kwtsms.com."
```

Common error codes:

| Code | Meaning |
|------|---------|
| `ERR003` | Wrong username or password |
| `ERR006` | No valid phone numbers — missing country code |
| `ERR008` | Sender ID is banned or not found (case sensitive) |
| `ERR010` | Zero balance |
| `ERR011` | Insufficient balance |
| `ERR025` | Invalid phone number — missing country code |
| `ERR026` | Country not activated on this account |
| `ERR028` | Must wait 15s before sending to the same number again |

---

## Phone number formats

All formats are accepted — numbers are normalized automatically:

| Input | Sent as |
|-------|---------|
| `+96598765432` | `96598765432` |
| `0096598765432` | `96598765432` |
| `965 9876 5432` | `96598765432` |
| `965-9876-5432` | `96598765432` |
| `٩٦٥٩٨٧٦٥٤٣٢` (Arabic digits) | `96598765432` |

Numbers must include the country code. `98765432` (local) will be rejected by the API — use `96598765432`.

---

## Test mode

Set `KWTSMS_TEST_MODE=1` or `test_mode=True` — messages are queued but **not delivered**, no credits consumed.

```python
sms = KwtSMS.from_env()   # KWTSMS_TEST_MODE=1 in .env
result = sms.send("96598765432", "Test message")
# Message is queued — visible in kwtsms.com → Account → Queue
# Delete it from the queue to recover credits
```

Set `KWTSMS_TEST_MODE=0` before going live.

---

## Sender ID

`KWT-SMS` is a shared sender for **testing only** — it can cause delays and is blocked on some Kuwait carriers. Register a private sender ID on [kwtsms.com](https://kwtsms.com) before going live.

**Sender IDs are case sensitive** — `Kuwait` is not the same as `KUWAIT` or `kuwait`.

| | Promotional | Transactional |
|--|-------------|---------------|
| **Use for** | Bulk SMS, marketing, offers | OTP, alerts, notifications |
| **Delivery to DND numbers** | Blocked — credits lost | Bypasses DND (whitelisted) |
| **Speed** | May have delays | Priority delivery |
| **Cost** | 10 KD one-time | 15 KD one-time |

**For OTP/authentication, you must use a Transactional sender ID.** Using Promotional for OTP means messages to DND numbers are silently blocked and credits are still deducted.

---

## Best practices

### Validate locally before calling the API

Don't send invalid data to the API — validate first to avoid wasted API calls:

```python
from kwtsms import validate_phone_input, clean_message

# Check phone number before sending
ok, error, normalized = validate_phone_input(user_input)
if not ok:
    show_user_error(error)  # "Phone number is required", "'abc' is not a phone number", etc.
    return

# Check country is active (cache prefixes at startup)
if not any(normalized.startswith(p) for p in cached_prefixes):
    show_user_error("SMS delivery to this country is not available.")
    return

# Check message is not empty after cleaning
message = clean_message(user_input_message)
if not message.strip():
    show_user_error("Message is empty.")
    return

result = sms.send(normalized, message)  # only valid input reaches the API
```

### Save msg-id and balance-after from every send

```python
if result["result"] == "OK":
    db.save("sms_balance", result["balance-after"])   # track balance — no extra API call needed
    db.save_message(msg_id=result["msg-id"], ...)     # needed for status checks later
```

### OTP messages

- Always include your app/company name: `"Your OTP for APPNAME is: 123456"`
- Wait at least 3–4 minutes before allowing resend
- Generate a new code on each resend — invalidate all previous codes
- Use a **Transactional** sender ID (not Promotional)
- Send to one number per request (avoid ERR028 in batches)

### User-facing error messages

Don't show raw API errors to end users:

| Situation | Show to user | Show to admin/logs |
|-----------|-------------|-------------------|
| Invalid phone | "Please enter a valid phone number with country code" | The actual validation error |
| Auth error (ERR003) | "SMS service temporarily unavailable" | Log the error + alert admin |
| No balance (ERR010/011) | "SMS service temporarily unavailable" | Alert admin to recharge |
| Rate limited (ERR028) | "Please wait before requesting another code" | Log the rate limit hit |

---

## Security checklist

Before going live, make sure:

- [ ] CAPTCHA enabled on all forms that trigger SMS (OTP, signup, password reset)
- [ ] Rate limit per phone number (max 3–5 requests/hour)
- [ ] Rate limit per IP address (max 10–20 requests/hour)
- [ ] Monitoring/alerting on failed sends and balance depletion
- [ ] Test mode OFF (`KWTSMS_TEST_MODE=0`)
- [ ] Private sender ID registered (not `KWT-SMS`)
- [ ] Transactional sender ID for OTP (not Promotional)
- [ ] Credentials in `.env` or env vars (not hardcoded)

---

## What's handled automatically

- Phone normalization (strips `+`, `00`, spaces, dashes; converts Arabic/Hindi digits)
- Input validation (catches emails, empty strings, too short/long — before the API is called)
- Message cleaning (strips emojis, hidden control characters, HTML tags; converts Arabic digits)
- API error enrichment (`action` field added to every error response)
- Bulk batching (auto-splits lists >200 numbers into batches of 200, 0.5s between batches)
- ERR013 backoff (queue full — retries 3× at 30s / 60s / 120s automatically)
- Balance caching (every send response includes `balance-after` — no extra API call needed)
- JSONL logging (one line per API call, password always masked, timestamps in UTC)

> **Note:** `unix-timestamp` in API responses is **GMT+3** (Asia/Kuwait server time), not UTC.
> Log `ts` fields written by this client are always UTC ISO-8601.

---

## Logging

One JSON line per API call written to `kwtsms.log` (or the path in `KWTSMS_LOG_FILE`). Password is always masked.

```json
{"ts":"2026-03-04T10:00:00+00:00","endpoint":"send","request":{"username":"myuser","password":"***","sender":"MYAPP","mobile":"96598765432","message":"Your OTP is: 123456","test":"0"},"response":{"result":"OK","msg-id":"f4c841ad...","numbers":1,"points-charged":1,"balance-after":149,"unix-timestamp":1741082400},"ok":true,"error":null}
```

> `ts` is always **UTC**. `unix-timestamp` inside `response` is **GMT+3** (Asia/Kuwait server time).

Set `log_file=""` or `KWTSMS_LOG_FILE=` to disable logging.

---

## FAQ

**1. My message was sent successfully (result: OK) but the recipient didn't receive it. What happened?**

Check the **Sending Queue** at [kwtsms.com](https://www.kwtsms.com/login/). If your message is stuck there, it was accepted by the API but not dispatched — common causes are emoji in the message, hidden characters from copy-pasting, or spam filter triggers. Delete it from the queue to recover your credits. Also verify that `test` mode is off (`KWTSMS_TEST_MODE=0`) — test messages are queued but never delivered.

**2. What is the difference between Test mode and Live mode?**

**Test mode** (`KWTSMS_TEST_MODE=1`) sends your message to the kwtSMS queue but does NOT deliver it to the handset. No SMS credits are consumed. Use this during development. **Live mode** (`KWTSMS_TEST_MODE=0`) delivers the message for real and deducts credits. Always develop in test mode and switch to live only when ready for production.

**3. What is a Sender ID and why should I not use "KWT-SMS" in production?**

A **Sender ID** is the name that appears as the sender on the recipient's phone (e.g., "MY-APP" instead of a random number). `KWT-SMS` is a shared test sender — it causes delivery delays, is blocked on Virgin Kuwait, and should never be used in production. Register your own private Sender ID through your kwtSMS account. For OTP/authentication messages, you need a **Transactional** Sender ID to bypass DND (Do Not Disturb) filtering.

**4. I'm getting ERR003 "Authentication error". What's wrong?**

You are using the wrong credentials. The API requires your **API username and API password** — NOT your account mobile number. Log in to [kwtsms.com](https://www.kwtsms.com/login/), go to Account → API settings, and check your API credentials. Also make sure you are using POST (not GET) and `Content-Type: application/json`.

**5. Can I send to international numbers (outside Kuwait)?**

International sending is **disabled by default** on kwtSMS accounts. Contact kwtSMS support to request activation for specific country prefixes. Use `coverage()` to check which countries are currently active on your account. Be aware that activating international coverage increases exposure to automated abuse — implement rate limiting and CAPTCHA before enabling.

---

## Help & Support

- **[kwtSMS FAQ](https://www.kwtsms.com/faq/)** — Answers to common questions about credits, sender IDs, OTP, and delivery
- **[kwtSMS Support](https://www.kwtsms.com/support.html)** — Open a support ticket or browse help articles
- **[Contact kwtSMS](https://www.kwtsms.com/#contact)** — Reach the kwtSMS team directly for Sender ID registration and account issues
- **[API Documentation (PDF)](https://www.kwtsms.com/doc/KwtSMS.com_API_Documentation_v41.pdf)** — kwtSMS REST API v4.1 full reference
- **[kwtSMS Dashboard](https://www.kwtsms.com/login/)** — Recharge credits, buy Sender IDs, view message logs, manage coverage
- **[Other Integrations](https://www.kwtsms.com/integrations.html)** — Plugins and integrations for other platforms and languages

---

## License

MIT
