Skip to main content

Python client for kwtSMS — Kuwait SMS gateway trusted by top businesses to deliver messages worldwide, with private Sender ID, free API testing, and non-expiring credits.

Project description

kwtsms

Python client for the kwtSMS API — Kuwait SMS gateway.

Zero external dependencies. Python 3.8+.


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 →


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.


senderids()list

Returns the list of sender IDs registered on this account. Returns an empty list on error.

ids = sms.senderids()
# → ["KWT-SMS", "MY-APP"]

coverage()dict

Returns active country prefixes allowed on this account.

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

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 + 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 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.7.0.tar.gz (31.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.7.0-py3-none-any.whl (18.9 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for kwtsms-0.7.0.tar.gz
Algorithm Hash digest
SHA256 efb5ef021d8c6d8e6a3902ee8e34462666bc3a1b9803f5cef50d1e6c306bc210
MD5 6770e05888702edf3ab41bd53d0860cf
BLAKE2b-256 a102e0e178d8bd6b6b46c2f9237f295193428a7d59ed6cf117555c8c1f4dc6a4

See more details on using hashes here.

File details

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

File metadata

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

File hashes

Hashes for kwtsms-0.7.0-py3-none-any.whl
Algorithm Hash digest
SHA256 919d080c783907d2bbed4a868893b4df0a7ef1627877d9ad8b0c9872a7780c36
MD5 e145535f02c1ce92397fef6aafbc8f57
BLAKE2b-256 121e099a963f3d7024117b45b2c2b685df37c3466e0c138c2dc4e351f436bd3b

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