Zig client for the kwtSMS API. Send SMS, check balance, validate numbers, list sender IDs, check coverage, get delivery reports.
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 Zig 0.13+ to compile and run. Zero runtime dependencies.
zig versionIf you see a version number, you're ready. If not, install Zig:
- All platforms (recommended): Download from ziglang.org/download
- macOS:
brew install zig - Ubuntu/Debian: Download the tarball from ziglang.org and add to PATH
- Windows: Download the zip from ziglang.org and add to PATH
Add to your build.zig.zon:
.dependencies = .{
.kwtsms = .{
.url = "https://github.com/boxlinknet/kwtsms-zig/archive/refs/tags/v0.3.0.tar.gz",
.hash = "...", // run `zig fetch --save` to populate this automatically
},
},Then in your build.zig:
const kwtsms_dep = b.dependency("kwtsms", .{});
exe.root_module.addImport("kwtsms", kwtsms_dep.module("kwtsms"));const std = @import("std");
const kwtsms = @import("kwtsms");
pub fn main() !void {
const allocator = std.heap.page_allocator;
// Load credentials from environment variables / .env file.
// Call client.deinit() when done if you use fromEnv() — it frees heap-owned strings.
// When using KwtSMS.init() with string literals, deinit() is not needed.
var client = try kwtsms.KwtSMS.fromEnv(allocator, null);
defer client.deinit();
// Verify credentials
const verify_result = try client.verify();
if (verify_result.ok) {
std.debug.print("Balance: {d:.2}\n", .{verify_result.balance.?});
}
// Send SMS
const resp = try client.sendOne("96598765432", "Hello from Zig!", null);
if (resp.isOk()) {
std.debug.print("Sent. msg-id: {s}\n", .{resp.msg_id.?});
}
}Looking for a CLI tool? Use kwtsms-cli, the dedicated command-line client for kwtSMS.
Create a .env file or set these environment variables:
KWTSMS_USERNAME=zig_username
KWTSMS_PASSWORD=zig_password
KWTSMS_SENDER_ID=YOUR-SENDER
KWTSMS_TEST_MODE=1
KWTSMS_LOG_FILE=kwtsms.logOr pass credentials directly:
var client = kwtsms.KwtSMS.init(
allocator,
"zig_username",
"zig_password",
"YOUR-SENDER", // null defaults to "KWT-SMS"
true, // test mode
"kwtsms.log", // null defaults to "kwtsms.log", "" disables logging
);KwtSMS.fromEnv() reads environment variables first, falls back to .env file.
Never hardcode credentials. Use one of these approaches:
-
Environment variables / .env file (default):
KwtSMS.fromEnv(allocator, null)loads from env vars, then.envfile. The file is.gitignored and editable without redeployment. -
Constructor injection:
KwtSMS.init(allocator, username, password, ...)for custom config systems or remote config. -
Secrets manager: Load from AWS Secrets Manager, HashiCorp Vault, Google Secret Manager, or your own config API, then pass to the constructor.
-
Admin settings UI (for web apps): Store credentials in your database with a settings page. Include a "Test Connection" button that calls
verify().
const result = try client.verify();
if (result.ok) {
std.debug.print("Balance: {d:.2}\n", .{result.balance.?});
} else {
std.debug.print("Error: {s}\n", .{result.err.?});
}// Single number
const resp = try client.sendOne("96598765432", "Hello!", null);
// Multiple numbers
const mobiles = [_][]const u8{ "96598765432", "+96512345678", "0096587654321" };
const resp = try client.send(&mobiles, "Bulk message", null);
// Custom sender ID
const resp = try client.sendOne("96598765432", "Hello!", "MY-SENDER");
resp.result; // "OK" or "ERROR"
resp.msg_id; // message ID (save this!)
resp.numbers; // count of numbers sent
resp.points_charged; // credits deducted
resp.balance_after; // balance after send (save this!)
resp.code; // error code (e.g., "ERR003")
resp.description; // error description
resp.action; // developer-friendly action messageUse sendBulk() instead of send() when you need individual msg_id per batch or want to distinguish which batches succeeded or failed:
const mobiles = [_][]const u8{ "96598765432", "+96512345678" /* ... */ };
const result = try client.sendBulk(&mobiles, "Hello!", null);
defer result.deinit(client.allocator); // always call deinit to free slices
std.debug.print("Batches: {d}, Total: {d}, Charged: {d}\n", .{
result.batches, result.numbers, result.points_charged,
});
for (result.msg_ids) |id| {
std.debug.print("msg-id: {s}\n", .{id});
}
for (result.batch_errors) |err_resp| {
std.debug.print("Batch error: {s}\n", .{err_resp.code.?});
}
// result.result is "OK", "PARTIAL" (some batches failed), or "ERROR"// API call: fetches live balance and caches it
const bal = try client.balance();
if (bal) |b| {
std.debug.print("Balance: {d:.2}\n", .{b});
}
// No API call: returns last cached value (from verify/send/balance responses)
const cached = client.cachedBalance();
const purchased = client.cachedPurchased();const phones = [_][]const u8{ "96598765432", "invalid", "+96512345678" };
const resp = try client.validate(&phones);const resp = try client.senderids();const resp = try client.coverage();const resp = try client.status("f4c841adee210f31307633ceaebff2ec");const resp = try client.dlr("f4c841adee210f31307633ceaebff2ec");const kwtsms = @import("kwtsms");
// Normalize phone number
const normalized = try kwtsms.normalizePhone(allocator, "+965 9876-5432");
defer allocator.free(normalized);
// normalized = "96598765432"
// Validate phone input
const result = try kwtsms.validatePhoneInput(allocator, "user@example.com");
// result.valid = false, result.err = "This looks like an email address, not a phone number"
// Clean message text
const cleaned = try kwtsms.cleanMessage(allocator, "Hello \xF0\x9F\x98\x80 <b>bold</b>");
defer allocator.free(cleaned);
// cleaned = "Hello bold"cleanMessage() is called automatically by send() before every API call. It prevents the #1 cause of "message sent but not received" support tickets:
| Content | Effect without cleaning | 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, message rejected | Stripped |
| Directional marks (LTR, RTL) | May cause display issues | Stripped |
Arabic letters and Arabic text are fully supported and never stripped.
Every ERROR response includes an action field with a developer-friendly fix:
const resp = try client.sendOne("96598765432", "Test", null);
if (resp.isError()) {
std.debug.print("[{s}] {s}\n", .{ resp.code.?, resp.description.? });
if (resp.action) |action| {
std.debug.print("Fix: {s}\n", .{action});
}
}Raw API errors should never be shown to end users. Map them:
| Situation | API error | Show to user |
|---|---|---|
| Invalid phone number | 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." (log + alert admin) |
| No balance | ERR010, ERR011 | "SMS service is temporarily unavailable. Please try again later." (alert admin) |
| 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." |
| Queue full | ERR013 | "SMS service is busy. Please try again in a few minutes." (library retries automatically) |
| Network error | Connection timeout | "Could not connect to SMS service." |
| Code | Description | Action |
|---|---|---|
| ERR001 | API disabled | Enable at kwtsms.com -> Account -> API |
| ERR003 | Wrong credentials | Check KWTSMS_USERNAME / KWTSMS_PASSWORD |
| ERR006 | No valid numbers | Include country code (e.g., 96598765432) |
| ERR008 | Sender ID banned | Use a different registered sender ID |
| ERR009 | Empty message | Provide non-empty message text |
| ERR010 | Zero balance | Recharge at kwtsms.com |
| ERR013 | Queue full | Wait and retry |
| ERR024 | IP not whitelisted | Add IP at kwtsms.com -> API -> IP Lockdown |
| ERR025 | Invalid number | Include country code |
| ERR026 | Country not active | Contact kwtSMS support |
| ERR028 | Rate limited | Wait 15 seconds before resending to same number |
All 28 error codes are mapped. Use kwtsms.errors.getAction("ERR003") to look up any code.
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 |
٩٦٥٩٨٧٦٥٤٣٢ |
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 |
Use these for copy-paste testing of Arabic/Persian digit normalization:
| Script | Digits | Example Phone |
|---|---|---|
| Latin | 0123456789 |
96598765432 |
| Arabic-Indic | ٠١٢٣٤٥٦٧٨٩ |
٩٦٥٩٨٧٦٥٤٣٢ |
| Extended Arabic-Indic (Persian/Urdu) | ۰۱۲۳۴۵۶۷۸۹ |
۹۶۵۱۲۳۴۵۶۷۸ |
| Mixed (Arabic-Indic + Latin) | ٩٦٥98765٤٣٢ |
All variants normalize to Latin digits before sending.
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.
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 |
| Delivery to DND numbers | Blocked/filtered, credits lost | Bypasses DND (whitelisted) |
| Speed | May have delays | Priority delivery |
| Cost | 10 KD one-time | 15 KD one-time |
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. Sender ID is case sensitive.
if (resp.isOk()) {
// Save immediately: you need msg-id for status/DLR, and balance-after
// eliminates the need to call balance() separately
db.save(resp.msg_id.?, resp.balance_after.?);
}const validation = try kwtsms.validatePhoneInput(allocator, user_input);
if (!validation.valid) {
// Return error to user without hitting the API
return validation.err.?;
}- Always include 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
- New code on resend: always generate a fresh code, invalidate previous
- Use Transactional Sender ID for OTP (not Promotional, not KWT-SMS)
- One number per OTP request: never batch OTP sends
The KwtSMS client is thread-safe. Cached balance uses std.Thread.Mutex for synchronization. Create one instance and share it across threads.
unix-timestamp values in API responses are in GMT+3 (Asia/Kuwait) server time, not UTC. Convert when storing or displaying.
Before going live:
- Bot protection enabled (CAPTCHA for web)
- 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)
- 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 (codepoint-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 1s delay between batches.
- Error enrichment: Every API error response includes an
actionfield with a developer-friendly fix hint. - Credential masking: Passwords are always masked as
***in log files. Never exposed. - Balance caching: Balance is cached from every
verify()andsend()response.balance()falls back to the cached value on API failure.
See the examples/ directory:
| Example | Description |
|---|---|
| 00_raw_api | Raw HTTP calls to every API endpoint (no library, copy-paste ready) |
| 01_basic_usage | Verify credentials, send SMS, check balance |
| 02_otp_flow | Validate phone, send OTP with best practices |
| 03_bulk_sms | Bulk send with >200 number batching |
| 04_error_handling | All error paths, user-facing message mapping |
| 05_otp_production | Production OTP: rate limiting, expiry, secure code generation |
Build and run an example: zig build example-00
# Unit tests (no credentials needed)
zig build test
# Integration tests (real API, test mode, no credits consumed)
ZIG_USERNAME=zig_username ZIG_PASSWORD=zig_password zig build test-integrationJSONL format, one line per API call. Password is always masked as ***.
{"ts":"2026-03-06T12:00:00Z","endpoint":"send","request":{...},"response":{...},"ok":true,"error":null}Set log_file to "" to disable logging.
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, 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.
- 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
- Best Practices: SMS API implementation best practices
- Integration Test Checklist: Pre-launch testing checklist
- Sender ID Help: How to register, whitelist, and troubleshoot sender IDs
- kwtSMS Dashboard: Recharge credits, buy Sender IDs, view message logs, manage coverage
- Other Integrations: Plugins and integrations for other platforms and languages