Skip to main content

Python client for the kwtSMS API (kwtsms.com)

Project description

kwtsms

Python client for the kwtSMS API — Kuwait SMS gateway.

Zero external dependencies. Python 3.8+.


Install

pip install kwtsms
uv add kwtsms
poetry add kwtsms

Setup

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

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):

kwtsms setup

Quick start

from kwtsms import KwtSMS

# Load from .env or environment variables
sms = KwtSMS.from_env()

# Or construct directly
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
)

Methods

verify()(ok, balance, error)

Tests credentials by calling the balance endpoint.

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.


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.

bal = sms.balance()

send(mobile, message, sender=None)dict

Send SMS to one or more numbers.

# 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):

{
    "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:

{
    "result":      "ERROR",
    "code":        "ERR003",
    "description": "Authentication error, username or password are not correct.",
    "action":      "Check KWTSMS_USERNAME and KWTSMS_PASSWORD...",  # always present
}

Mixed valid/invalid input — invalid numbers are reported, not raised:

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.

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

validate(phones)dict

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

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

Utility functions

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 "

CLI

kwtsms setup                                          # first-time wizard
kwtsms verify                                         # test credentials + show balance
kwtsms balance                                        # check balance
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 developer-friendly guidance:

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"])      # "Top up your kwtSMS account at kwtsms.com."

Common error codes:

Code Meaning
ERR003 Wrong username or password
ERR008 Sender ID is banned / not registered
ERR010 Zero balance
ERR011 Insufficient balance
ERR024 Your IP is not in the API whitelist
ERR025 Invalid phone number format
ERR026 No route for this country (international not activated)
ERR028 Must wait 15s before sending to the same number again

Test mode

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

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 before going live.

Use a Transactional sender ID for OTP/alerts to ensure delivery to DND numbers. Promotional sender IDs are silently blocked for DND subscribers (credits still deducted).


Logging

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

{"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.


License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

kwtsms-0.4.0.tar.gz (30.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

kwtsms-0.4.0-py3-none-any.whl (17.9 kB view details)

Uploaded Python 3

File details

Details for the file kwtsms-0.4.0.tar.gz.

File metadata

  • Download URL: kwtsms-0.4.0.tar.gz
  • Upload date:
  • Size: 30.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.5.9

File hashes

Hashes for kwtsms-0.4.0.tar.gz
Algorithm Hash digest
SHA256 d837f1a19f2a10fd10f172e5c25df08b604a9c817b481af1d24fffd85676da2f
MD5 94e0c21493670a47ede8b8cdd56df5b9
BLAKE2b-256 fa4e1551f06eed023ddbe36ca576f941f2e195abf6627094f996129ad34d3bcf

See more details on using hashes here.

File details

Details for the file kwtsms-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: kwtsms-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 17.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.5.9

File hashes

Hashes for kwtsms-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bfa82e2392ccb74ab0bcdadac10be3b23ee9a57b0a21dacaadde5606523ed3c3
MD5 66dea2ebf02abac8e6cca03aebafcc95
BLAKE2b-256 cd8b5cad6a7d9ce5a773e2fa754cfc261e2eda3acd05b91d8a39188c3e8efb3b

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page