kwtsms

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 13, 2026 License: MIT Imports: 11 Imported by: 0

README

kwtSMS Go Client

Go Reference CI CodeQL Go Report Card Go Version Release

Go client library for the kwtSMS SMS API. Zero external dependencies. Go 1.18+.

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

Prerequisites

You need Go (1.18 or newer) installed.

Step 1: Check if Go is installed
go version

If you see a version number, Go is installed. If not:

  • All platforms: Download from https://go.dev/dl/
  • macOS: brew install go
  • Ubuntu/Debian: sudo apt update && sudo apt install golang-go
Step 2: Create a project (if you don't have one)
mkdir my-project && cd my-project
go mod init my-project
Step 3: Install kwtsms
go get github.com/boxlinknet/kwtsms-go

Quick Start

package main

import (
    "fmt"
    "log"

    kwtsms "github.com/boxlinknet/kwtsms-go"
)

func main() {
    // Load credentials from environment variables or .env file
    sms, err := kwtsms.FromEnv("")
    if err != nil {
        log.Fatal(err)
    }

    // Verify credentials and check balance
    ok, balance, err := sms.Verify()
    if !ok {
        log.Fatalf("verification failed: %v", err)
    }
    fmt.Printf("Balance: %.2f credits\n", balance)

    // Send an SMS
    result, err := sms.Send("96598765432", "Your OTP for MYAPP is: 123456", "")
    if err != nil {
        log.Fatal(err)
    }
    if result.Result == "OK" {
        fmt.Printf("Sent! msg-id: %s, balance: %.2f\n", result.MsgID, result.BalanceAfter)
    } else {
        fmt.Printf("Error: %s\nAction: %s\n", result.Description, result.Action)
    }
}

Setup / Configuration

CLI Tool

For a standalone command-line tool (all platforms), see kwtsms-cli.

Environment variables

Set these environment variables or create a .env file in your project root:

KWTSMS_USERNAME=go_api_user
KWTSMS_PASSWORD=go_api_pass
KWTSMS_SENDER_ID=YOUR-SENDERID
KWTSMS_TEST_MODE=1
KWTSMS_LOG_FILE=kwtsms.log
Constructor
// From environment variables / .env file (recommended)
sms, err := kwtsms.FromEnv("")

// From .env at a custom path
sms, err := kwtsms.FromEnv("/path/to/.env")

// Direct constructor with options
sms, err := kwtsms.New("username", "password",
    kwtsms.WithSenderID("MY-APP"),
    kwtsms.WithTestMode(true),
    kwtsms.WithLogFile("sms.log"),
)

Environment variables take priority over .env file values.

Credential Management

Never hardcode credentials in source code. Credentials must be changeable without recompiling.

sms, err := kwtsms.FromEnv("")  // reads KWTSMS_USERNAME, KWTSMS_PASSWORD

The .env file must be in .gitignore. Never commit credentials.

Constructor injection (for custom config systems)
sms, err := kwtsms.New(
    config.Get("sms_username"),
    config.Get("sms_password"),
)

Works with any config source: Vault, AWS Secrets Manager, database, DI containers, etc.

Provide a settings page where an admin can enter API credentials and toggle test mode. Include a "Test Connection" button that calls Verify().

Load credentials from AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, or your own config API. Credentials rotate without redeployment.

API Reference

Verify Credentials
ok, balance, err := sms.Verify()
// ok=true:  credentials valid, balance is the available credit count
// ok=false: err describes the problem with an action to take
Check Balance
balance, err := sms.Balance()
// Returns live balance, or cached value if the API call fails
Send SMS
// Single number
result, err := sms.Send("96598765432", "Hello from Go!", "")

// Multiple numbers (comma-separated)
result, err := sms.Send("96598765432,96512345678", "Bulk message", "")

// Multiple numbers (slice)
result, err := sms.SendMulti(
    []string{"96598765432", "+96512345678", "0096587654321"},
    "Hello everyone!",
    "",
)

// Override sender ID for one call
result, err := sms.Send("96598765432", "Hello", "OTHER-SENDER")

Always save msg-id immediately after a successful send. You need it for status checks and delivery reports. If you do not store it at send time, you cannot retrieve it later.

Never call Balance() after Send(). The send response already includes your updated balance in result.BalanceAfter. Save it to your database. The client also caches it internally.

Send with Retry (ERR028)
// Auto-retries on ERR028 (15-second rate limit), waits 16s between retries
result, err := sms.SendWithRetry("96598765432", "Hello", "", 3)
Bulk Send (>200 numbers)

Sending to more than 200 numbers is handled automatically. The client splits numbers into batches of 200, adds a 0.5s delay between batches, and retries on ERR013 (queue full) with 30s/60s/120s backoff.

numbers := make([]string, 500)
// ... populate numbers ...
result, err := sms.SendMulti(numbers, "Campaign message", "MY-SENDER")
Validate Phone Numbers
result := sms.Validate([]string{"96598765432", "+96512345678", "bad@email.com", "123"})
fmt.Println("Valid:", result.OK)       // valid and routable
fmt.Println("Errors:", result.ER)      // format errors
fmt.Println("No route:", result.NR)    // country not activated
fmt.Println("Rejected:", result.Rejected) // locally rejected (email, too short, etc.)

Numbers that fail local validation (empty, email, too short, too long, no digits) are rejected before the API call is made.

Sender IDs
result := sms.SenderIDs()
if result["result"] == "OK" {
    sids := result["senderids"].([]string)
    fmt.Println("Sender IDs:", sids)
}
Coverage
result := sms.Coverage()
if result["result"] == "OK" {
    fmt.Println("Active coverage:", result)
}
Message Status
result := sms.Status("msg-id-from-send-response")
fmt.Println(result)
Delivery Report (international only)
result := sms.DLR("msg-id-from-send-response")
fmt.Println(result)

Kuwait numbers do not support DLR. Only international (non-Kuwait) numbers have delivery reports. Wait at least 5 minutes after sending before checking.

Cached Balance
// Available after Verify() or successful Send()
if bal := sms.CachedBalance(); bal != nil {
    fmt.Printf("Cached balance: %.2f\n", *bal)
}

// Total purchased credits (available after Verify())
if p := sms.CachedPurchased(); p != nil {
    fmt.Printf("Purchased: %.2f\n", *p)
}

Utility Functions

These are exported for direct use:

// Normalize a phone number: Arabic digits to Latin, strip non-digits, strip leading zeros,
// strip domestic trunk prefix (e.g. 9660559... becomes 966559...)
normalized := kwtsms.NormalizePhone("+965 9876 5432") // "96598765432"
normalized = kwtsms.NormalizePhone("9660559876543")   // "966559876543" (Saudi trunk 0 stripped)

// Validate phone input before sending (includes country-specific rules)
v := kwtsms.ValidatePhoneInput("user@gmail.com")
// v.Valid=false, v.Error="'user@gmail.com' is an email address, not a phone number"

v = kwtsms.ValidatePhoneInput("+96598765432")
// v.Valid=true, v.Normalized="96598765432"

v = kwtsms.ValidatePhoneInput("+96512345678")
// v.Valid=false, v.Error="Invalid Kuwait mobile number: after +965 must start with 4, 5, 6, 9"

// Find the country code from a normalized number
cc := kwtsms.FindCountryCode("96598765432") // "965" (Kuwait)
cc = kwtsms.FindCountryCode("12125551234")  // "1" (USA/Canada)

// Validate against country-specific format rules
err := kwtsms.ValidatePhoneFormat("96598765432") // "" (valid)
err = kwtsms.ValidatePhoneFormat("96512345678")  // "Invalid Kuwait mobile number: ..."

// Look up phone rules for a country (90+ countries covered)
rule := kwtsms.PhoneRules["965"] // {LocalLengths: [8], MobileStartDigits: ["4","5","6","9"]}
name := kwtsms.CountryNames["965"] // "Kuwait"

// Load .env file manually (returns key-value map, never panics)
env := kwtsms.LoadEnvFile("/path/to/.env")

// Clean a message: strip emojis, HTML, control chars, convert Arabic digits
cleaned := kwtsms.CleanMessage("Hello  <b>World</b> 123")
// "Hello  World 123"

// API error map (for building custom error UIs)
action := kwtsms.APIErrors["ERR003"]
// "Wrong API username or password. Check KWTSMS_USERNAME and KWTSMS_PASSWORD..."

// Enrich an error response with action guidance
enriched := kwtsms.EnrichError(apiResponse)

Phone Validation Rules

ValidatePhoneInput() and Send() automatically validate numbers against country-specific rules for 90+ countries. Validation checks:

  1. Local number length: each country has specific valid lengths (e.g., Kuwait: 8 digits after +965)
  2. Mobile prefix: each country has valid starting digits for mobile numbers (e.g., Kuwait: 4, 5, 6, 9)
  3. Domestic trunk prefix: automatically stripped during normalization (e.g., 9660559... becomes 966559...)

Numbers from countries not in the rules table pass through with generic E.164 validation (7-15 digits).

GCC coverage:

Country Code Local Digits Mobile Prefixes
Kuwait 965 8 4x, 5x, 6x, 9x
Saudi Arabia 966 9 5x
UAE 971 9 5x
Bahrain 973 8 3x, 6x
Qatar 974 8 3x, 5x, 6x, 7x
Oman 968 8 7x, 9x

The full rules table (kwtsms.PhoneRules) covers GCC, Levant, North Africa, Europe, Americas, Asia, Africa, and Oceania. See phone.go for the complete list.

Input Sanitization

The CleanMessage() function runs automatically before every send. It prevents the most common cause of "message sent but not received" support tickets:

Content Problem Fix
Emojis Message stuck in queue indefinitely, credits wasted, no error returned Stripped before send
Hidden control characters (BOM, zero-width spaces, soft hyphens) Spam filter rejection or queue stuck, common in text from Word/PDF/rich editors Stripped before send
Arabic/Hindi numerals in body OTP codes and amounts may render inconsistently Converted to Latin digits
HTML tags ERR027, message rejected Stripped before send
C0/C1 control characters Unprintable binary from copy-pasting terminals or binary content Stripped (except newlines and tabs)
Directional marks (LTR, RTL, LRE, etc.) Introduced by rich-text editors and RTL-aware apps Stripped before send

Arabic letters and Arabic text are fully preserved. Only digits are converted, invisible characters are removed, and emojis are stripped.

Send() calls CleanMessage() automatically, but you can also call it directly to preview what the API will receive:

cleaned := kwtsms.CleanMessage(userInput)
if strings.TrimSpace(cleaned) == "" {
    // Message was only emojis or control characters
    return fmt.Errorf("message is empty after cleaning")
}

Exports

Types
kwtsms.KwtSMS           // Client struct
kwtsms.SendResult       // Send response
kwtsms.BulkSendResult   // Bulk send response (>200 numbers)
kwtsms.ValidateResult   // Validate response
kwtsms.InvalidEntry     // Rejected phone number with error message
kwtsms.BatchError       // Error from a single batch in bulk send
kwtsms.PhoneValidation  // Result of ValidatePhoneInput
kwtsms.PhoneRule        // Country-specific phone validation rule
kwtsms.Option           // Functional option for New()
Functions
kwtsms.NormalizePhone()      // Normalize phone: Arabic digits, strip non-digits, trunk prefix
kwtsms.ValidatePhoneInput()  // Validate phone input with country-specific rules
kwtsms.FindCountryCode()     // Extract country code from normalized number
kwtsms.ValidatePhoneFormat() // Validate against country-specific length and prefix rules
kwtsms.CleanMessage()        // Strip emojis, HTML, control chars, convert Arabic digits
kwtsms.EnrichError()         // Add action guidance to API error response
kwtsms.LoadEnvFile()         // Parse .env file into key-value map
Variables
kwtsms.APIErrors     // map[string]string: error code to action message (33 codes)
kwtsms.PhoneRules    // map[string]PhoneRule: country code to validation rules (90+ countries)
kwtsms.CountryNames  // map[string]string: country code to human-readable name

Error Handling

Every API error includes a developer-friendly action field explaining what to do. All 33 kwtSMS error codes are mapped.

result, _ := sms.Send("96598765432", "Hello", "")
if result.Result == "ERROR" {
    fmt.Println("Code:", result.Code)             // "ERR003"
    fmt.Println("Description:", result.Description) // "Authentication error..."
    fmt.Println("Action:", result.Action)           // "Wrong API username or password. Check..."
}
User-Facing Error Messages

Raw API errors are for developers, not end users. Map them for your UI:

Situation API error Show to user
Invalid phone ERR006, ERR025 "Please enter a valid phone number in international format (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. Please contact support."
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 a helpful message. System-level errors (auth, balance, network) get a generic message + log the real error + alert the admin.

Phone Number Formats

All formats are accepted and normalized automatically:

Input Normalized Valid?
96598765432 96598765432 Yes
+96598765432 96598765432 Yes
0096598765432 96598765432 Yes
965 9876 5432 96598765432 Yes
965-9876-5432 96598765432 Yes
(965) 98765432 96598765432 Yes
965.9876.5432 96598765432 Yes
٩٦٥٩٨٧٦٥٤٣٢ 96598765432 Yes
۹۶۵۹۸۷۶۵۴۳۲ 96598765432 Yes
+٩٦٥٩٨٧٦٥٤٣٢ 96598765432 Yes
٠٠٩٦٥٩٨٧٦٥٤٣٢ 96598765432 Yes
٩٦٥ ٩٨٧٦ ٥٤٣٢ 96598765432 Yes
٩٦٥-٩٨٧٦-٥٤٣٢ 96598765432 Yes
965٩٨٧٦٥٤٣٢ 96598765432 Yes
123456 (too short) rejected No
user@gmail.com rejected No

Numbers must be in international format with country code. Arabic-Indic (U+0660-U+0669) and Persian (U+06F0-U+06F9) digits are converted to Latin automatically.

Test 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 during development and testing.

Live mode (KWTSMS_TEST_MODE=0) delivers the message for real and deducts credits.

// Enable test mode via constructor
sms, _ := kwtsms.New("user", "pass", kwtsms.WithTestMode(true))

// Or via environment variable / .env file
// KWTSMS_TEST_MODE=1

Always develop in test mode and switch to live only when ready for production. Test messages appear in the Sending Queue at kwtsms.com. Delete them from the queue to recover any tentatively held credits.

Sender ID

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

Promotional Transactional
Use for Bulk SMS, marketing, offers OTP, alerts, notifications
DND numbers Blocked/filtered, credits lost Bypasses DND
Speed May have delays Priority delivery
Cost 10 KD one-time 15 KD one-time

KWT-SMS is the shared test sender. It causes delays and is blocked on Virgin Kuwait. Never use in production. Register a private sender ID at kwtsms.com.

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 and credits are still deducted.

Sender ID is case sensitive: Kuwait is not the same as KUWAIT.

Registration takes ~5 working days for Kuwait and 1-2 months for international.

Best Practices

1. Validate before calling the API

The #1 cause of wasted API calls: sending invalid input and letting the API reject it. Validate locally first:

// BAD: wastes an API call on every invalid input
result, _ := sms.Send(userInput, message, "")

// GOOD: validate locally, only hit API with clean input
v := kwtsms.ValidatePhoneInput(userInput)
if !v.Valid {
    return fmt.Errorf("invalid phone: %s", v.Error)
}
cleaned := kwtsms.CleanMessage(message)
if strings.TrimSpace(cleaned) == "" {
    return fmt.Errorf("message is empty after cleaning")
}
result, _ := sms.Send(v.Normalized, message, "")

The Send() method does validate and clean internally, but checking first lets you return errors to the user immediately without a network round-trip.

2. Cache coverage at startup

Call Coverage() once at application startup and cache the active country prefixes. Before every send, check the number's country prefix against the cached list. If the country is not active, return an error immediately without hitting the API:

coverage := sms.Coverage()
// Cache the active country prefixes, check before every send
// "SMS delivery to [country] is not available on this account."
3. Save balance-after and msg-id
if result.Result == "OK" {
    db.SaveBalance(result.BalanceAfter)   // track balance without extra API calls
    db.SaveMsgID(result.MsgID)            // needed for Status() and DLR() later
}

Set up low-balance alerts (e.g., when balance drops below 50 credits). Before bulk sends, estimate credit cost (number of recipients x pages per message) and warn if balance is insufficient.

4. Sender ID

KWT-SMS is the shared test sender. It causes delays and is blocked on Virgin Kuwait. Never use in production. Register a private sender ID at kwtsms.com.

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 and credits are still deducted.

5. OTP implementation
  • Always include the app/company name: "Your OTP for APPNAME is: 123456"
  • Resend timer: minimum 3-4 minutes (KNET standard is 4 minutes)
  • OTP expiry: 3-5 minutes
  • Generate a new code on resend, invalidate all previous codes
  • Send to one number per request (avoid ERR028 batch rejection)
  • Use a Transactional sender ID (not Promotional)
6. Rate limiting

Wait at least 15 seconds before sending to the same number again (ERR028). The entire request is rejected if any number in a batch triggers this, even if other numbers are fine.

7. Monitoring and alerting

Set up alerts for:

  • Failed sends: sudden increase in error responses
  • Balance depletion: rapid decrease or approaching zero
  • Error rate spikes: especially ERR003 (credentials), ERR010/ERR011 (balance), ERR028 (rate limit)
  • Queue buildup: messages stuck in kwtSMS queue (check via dashboard)
8. Keep libraries updated

Monitor for security patches and updates to the kwtSMS client library. Subscribe to kwtSMS announcements for API changes.

9. Compliance

Stay informed about local telecom regulations regarding sender IDs, message content, and user consent. Promotional SMS may require opt-in consent from recipients. Different countries have different rules: check before enabling international coverage.

Timestamps

unix-timestamp values in API responses are in GMT+3 (Asia/Kuwait) server time, not UTC. Convert when storing or displaying. Log timestamps written by the client are always UTC ISO-8601.

Security Checklist

Before going live:

[ ] Bot protection enabled (CAPTCHA for web apps)
[ ] 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)

Without rate limiting, a bot can drain your entire SMS balance in minutes.

What's Handled Automatically

  • Phone normalization: +, 00, spaces, dashes, dots, parentheses stripped. Arabic-Indic digits converted. Leading zeros removed.
  • Duplicate phone removal: If the same number appears multiple times (in different formats), it is sent only once.
  • Message cleaning: Emojis removed (surrogate-pair safe). Hidden control characters (BOM, zero-width spaces, directional marks) removed. HTML tags stripped. Arabic-Indic digits in message body converted to Latin.
  • Batch splitting: More than 200 numbers are automatically split into batches of 200 with 0.5s delay between batches.
  • ERR013 retry: Queue-full errors are automatically retried up to 3 times with exponential backoff (30s / 60s / 120s).
  • Error enrichment: Every API error response includes an action field with a developer-friendly fix hint.
  • Credential masking: Passwords are always masked as *** in log files. Never exposed.
  • Never throws: All public methods return structured error objects. They never panic on API errors.

Examples

See the examples/ directory for runnable code:

Example Description
00-raw-api Call every kwtSMS endpoint using only the Go standard library (no dependencies)
01-basic-usage Load credentials, verify, send SMS, print result
02-otp-flow Generate OTP, validate phone, send, save msg-id
03-bulk-sms Send to multiple numbers with mixed formats
04-http-handler HTTP endpoint for sending SMS with validation
05-error-handling Handle all error types with user-facing messages
06-otp-production Production OTP server: rate limiting, expiry, resend cooldown, user-facing errors

Testing

# Unit + mocked API tests (no credentials needed)
go test -v ./...

# With race detector
go test -race ./...

# Integration tests (hits live API with test_mode=true, no credits consumed)
GO_USERNAME=go_user GO_PASSWORD=go_pass go test -v -tags integration ./...

JSONL Logging

Every API call is logged to kwtsms.log (configurable) as one JSON line. Passwords are always masked as ***. Timestamps are UTC ISO-8601.

{"ts":"2026-03-06T12:00:00Z","endpoint":"send","request":{"username":"go_user","password":"***","sender":"MY-APP","mobile":"96598765432","message":"Hello","test":"1"},"response":{"result":"OK","msg-id":"abc123"},"ok":true}

Logging never crashes the main flow. Disk errors are silently ignored.

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. 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 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. Log in to your kwtSMS account and add coverage for the country prefixes you need. 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

License

MIT. See LICENSE.

Documentation

Overview

Package kwtsms provides a Go client for the kwtSMS SMS API (kwtsms.com).

Zero external dependencies. Uses only the Go standard library.

Quick start:

sms, err := kwtsms.FromEnv("")
ok, balance, err := sms.Verify()
result, err := sms.Send("96598765432", "Your OTP for MYAPP is: 123456", "")
report := sms.Validate([]string{"96598765432", "+96512345678"})
balance := sms.Balance()

Server timezone: Asia/Kuwait (GMT+3). unix-timestamp values in API responses are GMT+3 server time, not UTC. Log timestamps written by this client are always UTC ISO-8601.

Index

Constants

View Source
const Version = "0.4.0"

Version is the library version.

Variables

View Source
var APIErrors = map[string]string{
	"ERR001":            "API is disabled on this account. Enable it at kwtsms.com → Account → API.",
	"ERR002":            "A required parameter is missing. Check that username, password, sender, mobile, and message are all provided.",
	"ERR003":            "Wrong API username or password. Check KWTSMS_USERNAME and KWTSMS_PASSWORD. These are your API credentials, not your account mobile number.",
	"ERR004":            "This account does not have API access. Contact kwtSMS support to enable it.",
	"ERR005":            "This account is blocked. Contact kwtSMS support.",
	"ERR006":            "No valid phone numbers. Make sure each number includes the country code (e.g., 96598765432 for Kuwait, not 98765432).",
	"ERR007":            "Too many numbers in a single request (maximum 200). Split into smaller batches.",
	"ERR008":            "This sender ID is banned or not found. Sender IDs are case sensitive (\"Kuwait\" is not the same as \"KUWAIT\"). Check your registered sender IDs at kwtsms.com.",
	"ERR009":            "Message is empty. Provide a non-empty message text.",
	"ERR010":            "Account balance is zero. Recharge credits at kwtsms.com.",
	"ERR011":            "Insufficient balance for this send. Buy more credits at kwtsms.com.",
	"ERR012":            "Message is too long (over 6 SMS pages). Shorten your message.",
	"ERR013":            "Send queue is full (1000 messages). Wait a moment and try again.",
	"ERR019":            "No delivery reports found for this message.",
	"ERR020":            "Message ID does not exist. Make sure you saved the msg-id from the send response.",
	"ERR021":            "No delivery report available for this message yet.",
	"ERR022":            "Delivery reports are not ready yet. Try again after 24 hours.",
	"ERR023":            "Unknown delivery report error. Contact kwtSMS support.",
	"ERR024":            "Your IP address is not in the API whitelist. Add it at kwtsms.com → Account → API → IP Lockdown, or disable IP lockdown.",
	"ERR025":            "Invalid phone number. Make sure the number includes the country code (e.g., 96598765432 for Kuwait, not 98765432).",
	"ERR026":            "This country is not activated on your account. Contact kwtSMS support to enable the destination country.",
	"ERR027":            "HTML tags are not allowed in the message. Remove any HTML content and try again.",
	"ERR028":            "You must wait at least 15 seconds before sending to the same number again. No credits were consumed.",
	"ERR029":            "Message ID does not exist or is incorrect.",
	"ERR030":            "Message is stuck in the send queue with an error. Delete it at kwtsms.com → Queue to recover credits.",
	"ERR031":            "Message rejected: bad language detected.",
	"ERR032":            "Message rejected: spam detected.",
	"ERR033":            "No active coverage found. Contact kwtSMS support.",
	"ERR_INVALID_INPUT": "One or more phone numbers are invalid. See details above.",
}

APIErrors maps every kwtSMS error code to a developer-friendly action message. Exported as read-only reference for callers who want to build custom error UIs.

View Source
var CountryNames = map[string]string{

	"965": "Kuwait", "966": "Saudi Arabia", "971": "UAE", "973": "Bahrain",
	"974": "Qatar", "968": "Oman", "962": "Jordan", "961": "Lebanon",
	"970": "Palestine", "964": "Iraq", "963": "Syria", "967": "Yemen",
	"98": "Iran", "90": "Turkey", "972": "Israel", "20": "Egypt",
	"218": "Libya", "216": "Tunisia", "212": "Morocco", "213": "Algeria", "249": "Sudan",

	"27": "South Africa", "234": "Nigeria", "254": "Kenya", "233": "Ghana",
	"251": "Ethiopia", "255": "Tanzania", "256": "Uganda", "237": "Cameroon",
	"225": "Ivory Coast", "221": "Senegal", "252": "Somalia", "250": "Rwanda",

	"44": "UK", "33": "France", "49": "Germany", "39": "Italy", "34": "Spain",
	"31": "Netherlands", "32": "Belgium", "41": "Switzerland", "43": "Austria",
	"46": "Sweden", "47": "Norway", "45": "Denmark", "48": "Poland",
	"420": "Czech Republic", "30": "Greece", "40": "Romania", "36": "Hungary", "380": "Ukraine",

	"1": "USA/Canada", "52": "Mexico", "55": "Brazil", "57": "Colombia",
	"54": "Argentina", "56": "Chile", "58": "Venezuela", "51": "Peru",
	"593": "Ecuador", "53": "Cuba",

	"91": "India", "92": "Pakistan", "86": "China", "81": "Japan", "82": "South Korea",
	"886": "Taiwan", "65": "Singapore", "60": "Malaysia", "62": "Indonesia",
	"63": "Philippines", "66": "Thailand", "84": "Vietnam", "95": "Myanmar",
	"855": "Cambodia", "880": "Bangladesh", "94": "Sri Lanka", "960": "Maldives", "976": "Mongolia",

	"61": "Australia", "64": "New Zealand",
}

CountryNames maps country codes to human-readable country names.

View Source
var PhoneRules = map[string]PhoneRule{

	"965": {LocalLengths: []int{8}, MobileStartDigits: []string{"4", "5", "6", "9"}},
	"966": {LocalLengths: []int{9}, MobileStartDigits: []string{"5"}},
	"971": {LocalLengths: []int{9}, MobileStartDigits: []string{"5"}},
	"973": {LocalLengths: []int{8}, MobileStartDigits: []string{"3", "6"}},
	"974": {LocalLengths: []int{8}, MobileStartDigits: []string{"3", "5", "6", "7"}},
	"968": {LocalLengths: []int{8}, MobileStartDigits: []string{"7", "9"}},

	"962": {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},
	"961": {LocalLengths: []int{7, 8}, MobileStartDigits: []string{"3", "7", "8"}},
	"970": {LocalLengths: []int{9}, MobileStartDigits: []string{"5"}},
	"964": {LocalLengths: []int{10}, MobileStartDigits: []string{"7"}},
	"963": {LocalLengths: []int{9}, MobileStartDigits: []string{"9"}},

	"967": {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},
	"20":  {LocalLengths: []int{10}, MobileStartDigits: []string{"1"}},
	"218": {LocalLengths: []int{9}, MobileStartDigits: []string{"9"}},
	"216": {LocalLengths: []int{8}, MobileStartDigits: []string{"2", "4", "5", "9"}},
	"212": {LocalLengths: []int{9}, MobileStartDigits: []string{"6", "7"}},
	"213": {LocalLengths: []int{9}, MobileStartDigits: []string{"5", "6", "7"}},
	"249": {LocalLengths: []int{9}, MobileStartDigits: []string{"9"}},

	"98":  {LocalLengths: []int{10}, MobileStartDigits: []string{"9"}},
	"90":  {LocalLengths: []int{10}, MobileStartDigits: []string{"5"}},
	"972": {LocalLengths: []int{9}, MobileStartDigits: []string{"5"}},

	"91":  {LocalLengths: []int{10}, MobileStartDigits: []string{"6", "7", "8", "9"}},
	"92":  {LocalLengths: []int{10}, MobileStartDigits: []string{"3"}},
	"880": {LocalLengths: []int{10}, MobileStartDigits: []string{"1"}},
	"94":  {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},
	"960": {LocalLengths: []int{7}, MobileStartDigits: []string{"7", "9"}},

	"86":  {LocalLengths: []int{11}, MobileStartDigits: []string{"1"}},
	"81":  {LocalLengths: []int{10}, MobileStartDigits: []string{"7", "8", "9"}},
	"82":  {LocalLengths: []int{10}, MobileStartDigits: []string{"1"}},
	"886": {LocalLengths: []int{9}, MobileStartDigits: []string{"9"}},

	"65":  {LocalLengths: []int{8}, MobileStartDigits: []string{"8", "9"}},
	"60":  {LocalLengths: []int{9, 10}, MobileStartDigits: []string{"1"}},
	"62":  {LocalLengths: []int{9, 10, 11, 12}, MobileStartDigits: []string{"8"}},
	"63":  {LocalLengths: []int{10}, MobileStartDigits: []string{"9"}},
	"66":  {LocalLengths: []int{9}, MobileStartDigits: []string{"6", "8", "9"}},
	"84":  {LocalLengths: []int{9}, MobileStartDigits: []string{"3", "5", "7", "8", "9"}},
	"95":  {LocalLengths: []int{9}, MobileStartDigits: []string{"9"}},
	"855": {LocalLengths: []int{8, 9}, MobileStartDigits: []string{"1", "6", "7", "8", "9"}},
	"976": {LocalLengths: []int{8}, MobileStartDigits: []string{"6", "8", "9"}},

	"44":  {LocalLengths: []int{10}, MobileStartDigits: []string{"7"}},
	"33":  {LocalLengths: []int{9}, MobileStartDigits: []string{"6", "7"}},
	"49":  {LocalLengths: []int{10, 11}, MobileStartDigits: []string{"1"}},
	"39":  {LocalLengths: []int{10}, MobileStartDigits: []string{"3"}},
	"34":  {LocalLengths: []int{9}, MobileStartDigits: []string{"6", "7"}},
	"31":  {LocalLengths: []int{9}, MobileStartDigits: []string{"6"}},
	"32":  {LocalLengths: []int{9}},
	"41":  {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},
	"43":  {LocalLengths: []int{10}, MobileStartDigits: []string{"6"}},
	"47":  {LocalLengths: []int{8}, MobileStartDigits: []string{"4", "9"}},
	"48":  {LocalLengths: []int{9}},
	"30":  {LocalLengths: []int{10}, MobileStartDigits: []string{"6"}},
	"420": {LocalLengths: []int{9}, MobileStartDigits: []string{"6", "7"}},
	"46":  {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},
	"45":  {LocalLengths: []int{8}},
	"40":  {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},
	"36":  {LocalLengths: []int{9}},
	"380": {LocalLengths: []int{9}},

	"1":   {LocalLengths: []int{10}},
	"52":  {LocalLengths: []int{10}},
	"55":  {LocalLengths: []int{11}},
	"57":  {LocalLengths: []int{10}, MobileStartDigits: []string{"3"}},
	"54":  {LocalLengths: []int{10}, MobileStartDigits: []string{"9"}},
	"56":  {LocalLengths: []int{9}, MobileStartDigits: []string{"9"}},
	"58":  {LocalLengths: []int{10}, MobileStartDigits: []string{"4"}},
	"51":  {LocalLengths: []int{9}, MobileStartDigits: []string{"9"}},
	"593": {LocalLengths: []int{9}, MobileStartDigits: []string{"9"}},
	"53":  {LocalLengths: []int{8}, MobileStartDigits: []string{"5", "6"}},

	"27":  {LocalLengths: []int{9}, MobileStartDigits: []string{"6", "7", "8"}},
	"234": {LocalLengths: []int{10}, MobileStartDigits: []string{"7", "8", "9"}},
	"254": {LocalLengths: []int{9}, MobileStartDigits: []string{"1", "7"}},
	"233": {LocalLengths: []int{9}, MobileStartDigits: []string{"2", "5"}},
	"251": {LocalLengths: []int{9}, MobileStartDigits: []string{"7", "9"}},
	"255": {LocalLengths: []int{9}, MobileStartDigits: []string{"6", "7"}},
	"256": {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},
	"237": {LocalLengths: []int{9}, MobileStartDigits: []string{"6"}},
	"225": {LocalLengths: []int{10}},
	"221": {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},
	"252": {LocalLengths: []int{9}, MobileStartDigits: []string{"6", "7"}},
	"250": {LocalLengths: []int{9}, MobileStartDigits: []string{"7"}},

	"61": {LocalLengths: []int{9}, MobileStartDigits: []string{"4"}},
	"64": {LocalLengths: []int{8, 9, 10}, MobileStartDigits: []string{"2"}},
}

PhoneRules maps country codes to their phone number validation rules. Countries not listed here pass through with generic E.164 validation (7-15 digits).

Sources (verified across 3+ per country):

  • ITU-T E.164 / National Numbering Plans (itu.int)
  • Wikipedia "Telephone numbers in [Country]" articles
  • HowToCallAbroad.com country dialing guides

Functions

func CleanMessage

func CleanMessage(text string) string

CleanMessage cleans SMS message text before sending to kwtSMS.

Called automatically by Send(). No manual call needed.

Strips content that silently breaks delivery:

  • Arabic-Indic / Extended Arabic-Indic digits converted to Latin
  • Emojis and pictographic symbols removed
  • Hidden control characters (BOM, zero-width space, soft hyphen, etc.) removed
  • Directional formatting characters removed
  • C0/C1 control characters removed (preserves \n and \t)
  • HTML tags stripped

Arabic letters are NOT stripped. Arabic text is fully supported by kwtSMS.

func EnrichError

func EnrichError(data map[string]any) map[string]any

EnrichError adds an "action" field to an API error response map. Returns a new map. Has no effect on OK responses.

func FindCountryCode added in v0.4.0

func FindCountryCode(normalized string) string

FindCountryCode extracts the country code prefix from a normalized phone number. Tries 3-digit codes first, then 2-digit, then 1-digit (longest match wins). Returns empty string if no known country code is found.

func LoadEnvFile added in v0.3.0

func LoadEnvFile(path string) map[string]string

LoadEnvFile parses a .env file into a map of key=value pairs. Returns an empty map if the file does not exist or cannot be read. Never panics or returns an error.

Parsing rules:

  • Ignores blank lines and lines starting with #
  • Strips inline # comments from unquoted values
  • Supports quoted values: KWTSMS_SENDER_ID="MY APP" -> MY APP
  • Does NOT modify os environment variables (read-only parsing)

func NormalizePhone

func NormalizePhone(phone string) string

NormalizePhone converts a raw phone number to kwtSMS-accepted format: digits only, no leading zeros, domestic trunk prefix stripped.

Steps: convert Arabic/Persian digits to Latin, strip all non-digit characters, strip leading zeros, strip domestic trunk prefix (e.g. 9660559... becomes 966559...).

func ValidatePhoneFormat added in v0.4.0

func ValidatePhoneFormat(normalized string) string

ValidatePhoneFormat validates a normalized phone number against country-specific format rules. Checks local number length and mobile starting digits. Numbers with no matching country rules pass through (generic E.164 only). Returns empty string if valid, or an error message if invalid.

Types

type BatchError

type BatchError struct {
	Batch       int    `json:"batch"`
	Code        string `json:"code"`
	Description string `json:"description"`
}

BatchError records an error from a single batch within a bulk send.

type BulkSendResult

type BulkSendResult struct {
	Result        string         `json:"result"`
	Bulk          bool           `json:"bulk"`
	Batches       int            `json:"batches"`
	Numbers       int            `json:"numbers"`
	PointsCharged int            `json:"points-charged"`
	BalanceAfter  *float64       `json:"balance-after"`
	MsgIDs        []string       `json:"msg-ids"`
	Errors        []BatchError   `json:"errors"`
	Invalid       []InvalidEntry `json:"invalid,omitempty"`
}

BulkSendResult is the aggregated response from sending to >200 numbers.

type InvalidEntry

type InvalidEntry struct {
	Input string `json:"input"`
	Error string `json:"error"`
}

InvalidEntry represents a phone number that failed local pre-validation.

type KwtSMS

type KwtSMS struct {
	// contains filtered or unexported fields
}

KwtSMS is the kwtSMS API client. Safe for concurrent use.

func FromEnv

func FromEnv(envFile string) (*KwtSMS, error)

FromEnv creates a KwtSMS client from environment variables, falling back to a .env file. Pass "" for envFile to use the default ".env" path.

Required env vars: KWTSMS_USERNAME, KWTSMS_PASSWORD Optional: KWTSMS_SENDER_ID (default "KWT-SMS"), KWTSMS_TEST_MODE ("1" to enable), KWTSMS_LOG_FILE (default "kwtsms.log")

func New

func New(username, password string, opts ...Option) (*KwtSMS, error)

New creates a new KwtSMS client with the given credentials.

username and password are your kwtSMS API credentials (not your account mobile number). Options can override the default sender ID ("KWT-SMS"), enable test mode, or change the log file path ("kwtsms.log").

func (*KwtSMS) Balance

func (c *KwtSMS) Balance() (float64, error)

Balance returns the current balance via the /balance/ API call. Returns the cached value if the API call fails (and no cached value exists, returns 0 with error).

func (*KwtSMS) CachedBalance

func (c *KwtSMS) CachedBalance() *float64

CachedBalance returns the balance from the last Verify() or successful Send() call. Returns nil if no cached value exists.

func (*KwtSMS) CachedPurchased

func (c *KwtSMS) CachedPurchased() *float64

CachedPurchased returns the total purchased credits from the last Verify() call. Returns nil if no cached value exists.

func (*KwtSMS) Coverage

func (c *KwtSMS) Coverage() map[string]any

Coverage lists active country prefixes via /coverage/. Never panics.

func (*KwtSMS) DLR

func (c *KwtSMS) DLR(msgID string) map[string]any

DLR retrieves delivery reports for a sent message via /dlr/. DLR only works for international (non-Kuwait) numbers.

msgID is the message ID returned by Send() in result.MsgID.

func (*KwtSMS) Send

func (c *KwtSMS) Send(mobile string, message string, sender string) (*SendResult, error)

Send sends an SMS to one or more numbers.

mobile can be a single number or comma-separated list. Numbers are normalized automatically (strips +, 00, spaces, dashes, Arabic digits). Duplicates after normalization are deduplicated.

message is cleaned automatically (strips emojis, hidden chars, HTML, converts Arabic digits to Latin).

sender overrides the default sender ID for this call. Pass "" to use the default.

For <= 200 numbers, returns a SendResult. For > 200 numbers, returns a BulkSendResult (accessed via the Bulk field).

func (*KwtSMS) SendMulti

func (c *KwtSMS) SendMulti(mobiles []string, message string, sender string) (*SendResult, error)

SendMulti sends an SMS to multiple numbers provided as a slice.

func (*KwtSMS) SendWithRetry

func (c *KwtSMS) SendWithRetry(mobile string, message string, sender string, maxRetries int) (*SendResult, error)

SendWithRetry sends SMS, retrying automatically on ERR028 (rate limit: wait 15 seconds). Waits 16 seconds between retries. All other errors are returned immediately.

func (*KwtSMS) SenderIDs

func (c *KwtSMS) SenderIDs() map[string]any

SenderIDs lists sender IDs registered on this account via /senderid/. Never panics.

func (*KwtSMS) Status

func (c *KwtSMS) Status(msgID string) map[string]any

Status checks the delivery status for a sent message via /status/.

msgID is the message ID returned by Send() in result.MsgID.

func (*KwtSMS) Validate

func (c *KwtSMS) Validate(phones []string) ValidateResult

Validate validates and normalizes phone numbers via /validate/.

Numbers that fail local validation (empty, email, too short, too long, no digits) are rejected immediately before any API call is made.

func (*KwtSMS) Verify

func (c *KwtSMS) Verify() (bool, float64, error)

Verify tests credentials by calling /balance/. Returns (ok, balance, error). On success, balance is the available balance. On failure, error describes the problem with an action to take. Never panics.

type Option

type Option func(*KwtSMS)

Option configures a KwtSMS client.

func WithHTTPClient added in v0.3.0

func WithHTTPClient(c *http.Client) Option

WithHTTPClient sets a custom HTTP client. Useful for testing or proxies.

func WithLogFile

func WithLogFile(path string) Option

WithLogFile sets the JSONL log file path. Set to "" to disable logging.

func WithSenderID

func WithSenderID(id string) Option

WithSenderID sets the default sender ID. Defaults to "KWT-SMS".

func WithTestMode

func WithTestMode(enabled bool) Option

WithTestMode enables test mode (messages queued but not delivered, no credits consumed).

type PhoneRule added in v0.4.0

type PhoneRule struct {
	LocalLengths      []int
	MobileStartDigits []string
}

PhoneRule defines country-specific phone number validation rules. LocalLengths lists valid digit counts AFTER the country code. MobileStartDigits lists valid first character(s) of the local number.

type PhoneValidation

type PhoneValidation struct {
	Valid      bool
	Error      string
	Normalized string
}

PhoneValidation holds the result of ValidatePhoneInput.

func ValidatePhoneInput

func ValidatePhoneInput(phone string) PhoneValidation

ValidatePhoneInput validates a raw phone number input before sending to the API.

Catches every common mistake without panicking:

  • Empty or blank input
  • Email address entered instead of a phone number
  • Non-numeric text with no digits
  • Too short after normalization (< 7 digits)
  • Too long after normalization (> 15 digits, E.164 maximum)
  • Country-specific length and mobile prefix validation
  • Domestic trunk prefix stripping (e.g. 9660559... -> 966559...)

type SendResult

type SendResult struct {
	Result        string         `json:"result"`
	Code          string         `json:"code,omitempty"`
	Description   string         `json:"description,omitempty"`
	Action        string         `json:"action,omitempty"`
	MsgID         string         `json:"msg-id,omitempty"`
	Numbers       int            `json:"numbers,omitempty"`
	PointsCharged int            `json:"points-charged,omitempty"`
	BalanceAfter  float64        `json:"balance-after,omitempty"`
	UnixTimestamp int64          `json:"unix-timestamp,omitempty"`
	Invalid       []InvalidEntry `json:"invalid,omitempty"`
}

SendResult is the structured response from a single send operation (<= 200 numbers).

type ValidateResult

type ValidateResult struct {
	OK       []string       `json:"ok"`
	ER       []string       `json:"er"`
	NR       []string       `json:"nr"`
	Raw      map[string]any `json:"raw"`
	Error    string         `json:"error,omitempty"`
	Rejected []InvalidEntry `json:"rejected"`
}

ValidateResult is the structured response from validating phone numbers.

Directories

Path Synopsis
examples
00-raw-api command
Example 00: Raw API calls to every kwtSMS endpoint.
Example 00: Raw API calls to every kwtSMS endpoint.
01-basic-usage command
Example 01: Basic usage of kwtsms-go.
Example 01: Basic usage of kwtsms-go.
02-otp-flow command
Example 02: OTP (One-Time Password) flow.
Example 02: OTP (One-Time Password) flow.
03-bulk-sms command
Example 03: Bulk SMS using SendMulti().
Example 03: Bulk SMS using SendMulti().
04-http-handler command
Example 04: Minimal HTTP handler for sending SMS.
Example 04: Minimal HTTP handler for sending SMS.
05-error-handling command
Example 05: Comprehensive error handling patterns.
Example 05: Comprehensive error handling patterns.
06-otp-production command
Production-ready OTP HTTP server using kwtsms-go.
Production-ready OTP HTTP server using kwtsms-go.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL