Official Kotlin/JVM client for the kwtSMS SMS gateway API. Zero dependencies, thread-safe, works on Android and server-side JVM.
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 👍
You need Java (8 or newer) and Gradle installed. If you are using Android Studio, both are already included.
java -versionIf not installed:
- All platforms: Download from https://adoptium.net/ (Temurin JDK, free)
- macOS:
brew install openjdk - Ubuntu/Debian:
sudo apt update && sudo apt install default-jdk
In your settings.gradle.kts (or build.gradle.kts under repositories):
repositories {
mavenCentral()
maven("https://jitpack.io")
}In your build.gradle.kts:
dependencies {
implementation("com.github.boxlinknet:kwtsms-kotlin:0.1.4")
}Then sync your project (Android Studio: click "Sync Now", or run ./gradlew build).
import com.kwtsms.KwtSMS
fun main() {
// Load credentials from .env file or environment variables
val sms = KwtSMS.fromEnv()
// Verify credentials
val verify = sms.verify()
println("Balance: ${verify.balance} credits")
// Send an SMS
val result = sms.send("96598765432", "Hello from kwtSMS!")
println("Result: ${result.result}, Message ID: ${result.msgId}")
}Create a .env file in your project root:
KWTSMS_USERNAME=kotlin_username
KWTSMS_PASSWORD=kotlin_password
KWTSMS_SENDER_ID=YOUR-SENDERID
KWTSMS_TEST_MODE=1
KWTSMS_LOG_FILE=kwtsms.logEnvironment variables take precedence over .env file values. Set KWTSMS_TEST_MODE=0 when ready for production.
val sms = KwtSMS.fromEnv() // reads KWTSMS_USERNAME, KWTSMS_PASSWORD, etc.val sms = KwtSMS(
username = "kotlin_username",
password = "kotlin_password",
senderId = "YOUR-SENDERID",
testMode = true
)Backend proxy (strongly recommended): Your mobile app calls YOUR backend server, which holds the kwtSMS credentials and makes the API call. The app never touches the SMS API directly. This is the only pattern that fully protects credentials.
If direct API access is required (not recommended):
- Store credentials in
EncryptedSharedPreferences - Provide a settings Activity for entering/updating credentials
- Include a "Test Connection" button that calls
verify() - NEVER store credentials in
strings.xml,BuildConfigfields, or hardcoded in source
Loading from Firebase Remote Config:
val config = Firebase.remoteConfig
config.fetchAndActivate().addOnCompleteListener {
val sms = KwtSMS(
username = config.getString("kwtsms_username"),
password = config.getString("kwtsms_password")
)
}Bot protection for Android (Google Play Integrity API):
Android apps do NOT need CAPTCHA. Use Google Play Integrity API instead — it verifies app genuineness and device integrity, blocking scripts, bots, and modified binaries. Verify the integrity token on your backend server before allowing OTP sends. See Play Integrity documentation.
Test credentials and get balance. Never throws.
val result: VerifyResult = sms.verify()
// result.ok -> true if credentials are valid
// result.balance -> current balance (Double?)
// result.error -> error message if ok is falseGet current SMS credit balance. Returns cached value if API call fails.
val balance: Double? = sms.balance()Send SMS to one or more phone numbers.
// Single number
val result: SendResult = sms.send("96598765432", "Hello!")
// Multiple numbers (comma-separated)
val result = sms.send("96598765432,96512345678", "Hello!")
// Multiple numbers (list)
val result = sms.send(listOf("96598765432", "96512345678"), "Hello!")
// Custom sender ID
val result = sms.send("96598765432", "Hello!", "MY-SENDER")SendResult fields:
| Field | Type | Description |
|---|---|---|
result |
String |
"OK" or "ERROR" |
msgId |
String? |
Message ID (save for status/DLR lookups) |
numbers |
Int? |
Count of numbers accepted |
pointsCharged |
Int? |
Credits consumed |
balanceAfter |
Double? |
Balance after send (save to avoid extra API calls) |
unixTimestamp |
Long? |
Server timestamp (GMT+3, not UTC) |
code |
String? |
Error code (e.g., "ERR003") |
description |
String? |
Error description from API |
action |
String? |
Developer-friendly guidance |
invalid |
List<InvalidEntry> |
Numbers that failed local validation |
Send to >200 numbers with automatic batching.
val result: BulkSendResult = sms.sendBulk(phoneList, "Bulk message")
// result.result -> "OK", "PARTIAL", or "ERROR"
// result.batches -> number of batches sent
// result.numbers -> total numbers accepted
// result.pointsCharged -> total credits consumed
// result.balanceAfter -> balance after last batch
// result.unixTimestamp -> server timestamp from last batch (GMT+3)
// result.msgIds -> list of message IDs (one per batch)
// result.errors -> List<BatchError>(batch, code, description)
// result.invalid -> numbers that failed local validation
// result.action -> developer guidance for error (if applicable)Validate phone numbers with the kwtSMS API.
val result: ValidateResult = sms.validate(listOf("96598765432", "+96512345678"))
// result.ok -> valid and routable numbers
// result.er -> format errors
// result.nr -> no route (country not activated)
// result.rejected -> locally rejected (email, too short, etc.)List available sender IDs.
val result: SenderIdResult = sms.senderIds()
// result.senderIds -> ["KWT-SMS", "MY-APP"]List active country prefixes.
val result: CoverageResult = sms.coverage()
// result.prefixes -> ["965", "966", "971", ...]Check message queue status.
val result: StatusResult = sms.status("f4c841adee210f31307633ceaebff2ec")
// result.status -> "sent", "pending", etc.
// result.statusDescription -> human-readable statusGet delivery reports (international numbers only, not available for Kuwait).
val result: DeliveryReportResult = sms.deliveryReport("f4c841adee210f31307633ceaebff2ec")
// result.report -> list of DeliveryReportEntry(number, status)import com.kwtsms.PhoneUtils
PhoneUtils.normalizePhone("+96598765432") // "96598765432"
PhoneUtils.normalizePhone("0096598765432") // "96598765432"
PhoneUtils.normalizePhone("965 9876 5432") // "96598765432"
PhoneUtils.normalizePhone("٩٦٥٩٨٧٦٥٤٣٢") // "96598765432"
PhoneUtils.normalizePhone("+9660559876543") // "966559876543" (trunk prefix stripped)val (valid, error, normalized) = PhoneUtils.validatePhoneInput("+96598765432")
// valid=true, error=null, normalized="96598765432"
val (valid, error, _) = PhoneUtils.validatePhoneInput("[email protected]")
// valid=false, error="'[email protected]' is an email address, not a phone number"import com.kwtsms.MessageUtils
MessageUtils.cleanMessage("Hello 😀 World") // "Hello World"
MessageUtils.cleanMessage("<b>Bold</b>") // "Bold"
MessageUtils.cleanMessage("\uFEFFBOM text") // "BOM text"
MessageUtils.cleanMessage("OTP: ١٢٣٤") // "OTP: 1234"Match a country code from a normalized phone number. Tries 3-digit codes first, then 2-digit, then 1-digit (longest match wins).
PhoneUtils.findCountryCode("96598765432") // "965" (Kuwait)
PhoneUtils.findCountryCode("201012345678") // "20" (Egypt)
PhoneUtils.findCountryCode("12125551234") // "1" (USA/Canada)
PhoneUtils.findCountryCode("99999999999") // null (unknown)Validate a normalized phone number against country-specific format rules (local length + mobile starting digits). Numbers with no matching country rules pass through.
PhoneUtils.validatePhoneFormat("96598765432") // Pair(true, null)
PhoneUtils.validatePhoneFormat("96518765432") // Pair(false, "Invalid Kuwait mobile number: after +965 must start with 4, 5, 6, 9")
PhoneUtils.validatePhoneFormat("96655987") // Pair(false, "Invalid Saudi Arabia number: expected 9 digits after +966, got 5")80+ country-specific phone validation rules. Each entry maps a country code to valid local number lengths and mobile starting digits.
val kuwaitRule = PhoneUtils.PHONE_RULES["965"]
// PhoneRule(localLengths=[8], mobileStartDigits=["4", "5", "6", "9"])
val saudiRule = PhoneUtils.PHONE_RULES["966"]
// PhoneRule(localLengths=[9], mobileStartDigits=["5"])Countries not in the rules table pass through with generic E.164 validation (7-15 digits).
Human-readable country names by country code, used in validation error messages.
PhoneUtils.COUNTRY_NAMES["965"] // "Kuwait"
PhoneUtils.COUNTRY_NAMES["966"] // "Saudi Arabia"The client caches balance information to reduce unnecessary API calls:
sms.cachedBalance: updated after everyverify()and successfulsend()callsms.cachedPurchased: updated afterverify()calls
balance() returns the live balance on success. If the API call fails, it returns the cached value. Returns null if no cached value exists.
val result = sms.send("96598765432", "Hello!")
println("Balance after send: ${result.balanceAfter}") // from API response
println("Cached balance: ${sms.cachedBalance}") // same value, cached
// No need to call balance() after send, the value is already availablesend() automatically calls cleanMessage() on every message before sending. Three categories of content cause silent delivery failure (API returns OK, but the message gets stuck in the queue and is never delivered):
| Content | Effect | What cleanMessage() does |
|---|---|---|
| Emojis | Stuck in queue, credits wasted, no error | Stripped |
| Hidden control characters (BOM, zero-width space, soft hyphen) | Spam filter rejection or queue stuck | Stripped |
| Arabic/Hindi numerals in body | OTP codes render inconsistently | Converted to Latin digits |
| HTML tags | ERR027 rejection | Stripped |
Arabic text is fully supported and preserved. Only digits, invisible chars, emojis, control chars, and HTML are affected.
val result = sms.send("96598765432", "Hello!")
when (result.result) {
"OK" -> {
println("Sent! Balance: ${result.balanceAfter}")
}
"ERROR" -> {
println("Error: ${result.description}")
println("Action: ${result.action}")
}
}| Code | Meaning | Action |
|---|---|---|
| ERR003 | Wrong credentials | Check KWTSMS_USERNAME and KWTSMS_PASSWORD |
| ERR006 | No valid numbers | Include country code (e.g., 96598765432) |
| ERR009 | Empty message | Provide non-empty message text |
| ERR010 | Zero balance | Recharge at kwtsms.com |
| ERR013 | Queue full | Auto-retried with backoff |
| ERR025 | Invalid number format | Use digits-only international format |
| ERR028 | Same number too fast | Wait 15 seconds between sends to same number |
All 29 error codes are mapped. Access the full map via API_ERRORS:
import com.kwtsms.API_ERRORS
for ((code, action) in API_ERRORS) {
println("$code: $action")
}| Input | Normalized | Valid? |
|---|---|---|
96598765432 |
96598765432 |
Yes |
+96598765432 |
96598765432 |
Yes |
0096598765432 |
96598765432 |
Yes |
965 9876 5432 |
96598765432 |
Yes |
965-9876-5432 |
96598765432 |
Yes |
٩٦٥٩٨٧٦٥٤٣٢ |
96598765432 |
Yes |
+9660559876543 |
966559876543 |
Yes (Saudi trunk prefix stripped) |
9710501234567 |
971501234567 |
Yes (UAE trunk prefix stripped) |
[email protected] |
No (email) | |
12345 |
No (too short) | |
abcdef |
No (no digits) | |
96518765432 |
No (invalid Kuwait mobile prefix) |
Set KWTSMS_TEST_MODE=1 or pass testMode = true to the constructor. In test mode:
- Messages enter the kwtSMS queue but are NOT delivered to handsets
- No SMS credits are consumed
- Test messages appear in the Sending Queue at kwtsms.com
- Delete them from the queue to recover any tentatively held credits
Set KWTSMS_TEST_MODE=0 before going live.
KWT-SMS is a shared test sender. It causes delivery delays, is blocked on Virgin Kuwait numbers, and must never be used in production.
Register a private Sender ID through your kwtSMS account:
- Promotional (10 KD): for marketing, offers, announcements
- Transactional (15 KD): for OTP, alerts, notifications (bypasses DND)
For OTP/authentication, you MUST use a Transactional Sender ID. Promotional sender IDs are filtered by DND (Do Not Disturb) on Zain and Ooredoo, meaning OTP messages silently fail to deliver and credits are still deducted.
Sender ID is case sensitive: Kuwait is not the same as KUWAIT.
- Phone number normalization (Arabic digits, +/00 prefix, spaces, dashes, trunk prefix)
- Country-specific phone validation (80+ countries, local length + mobile prefix)
- Domestic trunk prefix stripping (e.g., 9660559... to 966559...)
- Phone number deduplication before sending
- Local phone validation before API calls (no wasted requests)
- Message cleaning (emojis, HTML, invisible chars, Arabic digits)
- Empty-after-cleaning detection
- Auto-batching for >200 numbers
- ERR013 (queue full) retry with exponential backoff
- Balance caching from send responses
- Error enrichment with developer-friendly action messages
- Password masking in logs
- JSONL logging of all API calls (never crashes main flow)
BEFORE GOING LIVE:
[ ] Bot protection enabled (CAPTCHA for web, Play Integrity for Android)
[ ] Rate limit per phone number (max 3-5/hour)
[ ] Rate limit per IP address (max 10-20/hour)
[ ] Rate limit per user/session if authenticated
[ ] Monitoring/alerting on abuse patterns
[ ] Admin notification on low balance
[ ] Test mode OFF (KWTSMS_TEST_MODE=0)
[ ] Private Sender ID registered (not KWT-SMS)
[ ] Transactional Sender ID for OTP (not promotional)
The #1 cause of wasted API calls is sending invalid input and letting the API reject it. Validate locally first:
// BAD: sends everything to the API, wastes round-trips
val result = sms.send(userPhone, userMessage)
// GOOD: validate locally, only send clean input
val (valid, error, normalized) = PhoneUtils.validatePhoneInput(userPhone)
if (!valid) return mapOf("error" to error)
val message = MessageUtils.cleanMessage(userMessage)
if (message.isBlank()) return mapOf("error" to "Message is empty after cleaning.")
val result = sms.send(normalized!!, message)| Check | When | Why |
|---|---|---|
| Phone number format | Before send() |
Reject emails, too-short numbers, non-numeric input locally |
| Message not empty | Before send() |
Don't waste an API call to get ERR009 |
| Country prefix active | Before send() |
Call coverage() once at startup, cache prefixes. Reject unsupported countries locally |
| Balance sufficient | Before send() |
Use cachedBalance from previous sends. If 0, show error immediately |
| Sender ID valid | Before send() |
Cache senderIds() result. Reject unknown senders locally |
Never expose raw API errors like "ERR006" to end users. Map them to friendly messages:
| Situation | Raw error | Show to user |
|---|---|---|
| Invalid phone | ERR006, ERR025 | "Please enter a valid phone number (e.g., +965 9876 5432)." |
| Wrong credentials | ERR003 | "SMS service is temporarily unavailable. Please try again later." |
| No balance | ERR010, ERR011 | "SMS service is temporarily unavailable. Please try again later." |
| Country not supported | ERR026 | "SMS delivery to this country is not available." |
| Rate limited | ERR028 | "Please wait a moment before requesting another code." |
| Message rejected | ERR031, ERR032 | "Your message could not be sent. Please try again with different content." |
| Network error | timeout | "Could not connect to SMS service. Check your internet connection." |
| Queue full | ERR013 | "SMS service is busy. Please try again in a few minutes." |
Key principle: User-recoverable errors (bad phone, rate limited) get helpful messages. System-level errors (auth, balance, network) get a generic message + admin alert.
- Always include app name:
"Your OTP for APPNAME is: 123456" - Resend timer: minimum 3-4 minutes (KNET standard is 4 minutes)
- OTP expiry: 3-5 minutes
- Always generate a new code on resend, invalidate previous codes
- Use Transactional Sender ID (promotional is filtered by DND)
- Send to one number per request (never batch OTP sends)
Call coverage() once at startup and cache the active prefixes. Before every send, check the number's country prefix against the cache. If the country is not active, return an error immediately without hitting the API.
- Save
balanceAfterfrom every send response to your database/cache - Set up low-balance alerts (e.g., below 50 credits)
- Before bulk sends, estimate cost (recipients x pages) and warn if insufficient
See the examples/ directory for complete runnable examples:
| Example | Description |
|---|---|
| 01-basic-usage | Verify credentials, send a message, check status |
| 02-otp-flow | OTP generation, sending, and verification |
| 03-bulk-sms | Sending to large number lists with auto-batching |
| 04-ktor-endpoint | Web framework integration with input validation |
| 05-error-handling | Handling all error codes and edge cases |
| 06-otp-production | Production OTP with DB, rate limiting, CAPTCHA |
1. My message was sent successfully (result: OK) but the recipient didn't receive it. What happened?
Check the Sending Queue at kwtsms.com. 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, 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.
- kwtSMS FAQ: Answers to common questions about credits, sender IDs, OTP, and delivery
- kwtSMS Support: Open a support ticket or browse help articles
- Contact kwtSMS: Reach the kwtSMS team directly for Sender ID registration and account issues
- API Documentation (PDF): kwtSMS REST API v4.1 full reference
- kwtSMS Dashboard: Recharge credits, buy Sender IDs, view message logs, manage coverage
- Other Integrations: Plugins and integrations for other platforms and languages
MIT