Waht Messaging · Developer documentation

Three-tier Kenya
routing. Sub-100ms
failover. M-Pesa billing.

POST /v1/messages · one endpoint.
Safaricom, Airtel, and Telkom from a single API key.
Your integration doesn't change when a provider goes down.
3
Routing tiers
Sub-100ms
Failover latency
5
Fraud rules
M-Pesa
Native billing
KES wallet
No invoice. No card.
01 · Provider routing

The network knows which provider wins at which hour.

Three-tier provider stack. Primary, regional failover, global standby. Health scores on a 15-minute rolling window. Automatic failover. You never touch a routing config.

Tier 1 · Primary health ≥ 0.80 · default route Local rates
Tier 2 · Regional failover health ≥ 0.50 · automatic Competitive rates
Tier 3 · Global standby daily cost cap enforced · last resort Confirmed at onboarding
score = (deliveryRate × 0.7) + (latencyScore × 0.3)
15-min rolling window · Redis TTL 20min · recalc every 5 min
Every skip writes a failover_event. No silent routing changes.
 routing · live simulation
failover → failover_event written
failover → failover_event written
Dispatching
Tier 3 · Global standby
no threshold · cost cap active
daily spend cap enforced · dual-layer protection
Dispatched via Tier 3 · 2 failover_events written < 80ms
02 · Integration

One key. Every channel.

SMS and WhatsApp from the same credentials. The channel field routes the message. Integration logic stays identical.

POST /v1/messages Send SMS or WhatsApp
POST /v1/otp/send Generate and send OTP
POST /v1/otp/verify Verify OTP · hash-only
POST /v1/billing/topup M-Pesa STK Push top-up
GET /v1/billing/summary Wallet balance + usage
GET /health Liveness check
SMS
WhatsApp
OTP
M-Pesa
POST /v1/messages · SMS
# Send an SMS via Waht Messaging

curl -X POST https://api.waht.co.ke/v1/messages \
  -H "Authorization: Bearer wk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+254712345678",
    "channel": "sms",
    "body": "Your order #4821 has shipped.",
    "sender_id": "WAHT"
  }'

# Response · returned in < 200ms
{
  "message_id": "msg_01HXKR3...",
  "status":     "queued",
  "provider":   "tier_1",
  "cost_kes":   "[confirmed at onboarding]"
}
200 OK ↩ ~62ms · provider selected before enqueue
# Send via WhatsApp · same endpoint, channel changes

curl -X POST https://api.waht.co.ke/v1/messages \
  -H "Authorization: Bearer wk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+254712345678",
    "channel": "whatsapp",
    "body": "Your order #4821 has shipped.",
    "template_id": "order_shipped_v1"
  }'

# Response
{
  "message_id": "msg_01HXKR4...",
  "status":     "queued",
  "provider":   "meta",
  "cost_kes":   0.00
}
200 OK ↩ ~58ms · WhatsApp routes Meta only
# Step 1 · send OTP (value never returned)

curl -X POST https://api.waht.co.ke/v1/otp/send \
  -H "Authorization: Bearer wk_live_your_key_here" \
  -d '{
    "phone": "+254712345678",
    "purpose": "login",
    "expiry_seconds": 300
  }'

{ "otp_id": "otp_01HXKR5..." }

# Step 2 · verify (returns success flag only)

curl -X POST https://api.waht.co.ke/v1/otp/verify \
  -d '{
    "otp_id": "otp_01HXKR5...",
    "code": "847291"
  }'

{ "success": true }
200 OK ↩ OTP value never in any response or log
# Initiate M-Pesa STK Push top-up

curl -X POST https://api.waht.co.ke/v1/billing/topup \
  -H "Authorization: Bearer wk_billing_your_key_here" \
  -d '{
    "amount_kes": 5000,
    "phone": "+254712345678"
  }'

{
  "transaction_id": "txn_01HXKR6...",
  "status":         "pending"
}

# Daraja callback → wallet credited automatically
# No polling required. Balance updates via webhook.
200 OK ↩ STK Push initiated · idempotency gate on callback
03 · Fraud protection

Five rules. Every request. Zero exceptions.

Fraud scoring runs synchronously on every inbound request. Before any enqueue. Before any provider dispatch. A composited risk score determines the outcome.

≥ 0.9 Block · 403 fraud_event + Slack alert
≥ 0.7 Throttle · 429 Retry-After header + Slack
< 0.7 Allow Message proceeds to queue

Redis blocks carry no automatic expiry. Ops lifts them via the admin dashboard with a mandatory resolution note. block_lifted_by and block_lifted_at written atomically.

sms_pumping +0.6
>100 messages/min AND >90 unique destinations in window
otp_brute_force +0.8
>5 failed OTP verify attempts in the last 10 minutes for a single phone number
geo_anomaly +0.4
Request origin country ≠ tenant registered country on record
spend_spike +0.5
Daily spend > historical average × 2 in the last hour for this tenant
api_key_abuse +0.5
>200 messages/minute from a single API key
04 · OTP security

Five guarantees. No exceptions.

OTP is the highest-sensitivity surface in messaging infrastructure. These are architectural constraints, not configuration options.

01
Value never returned
The OTP value is never in any API response, log line, or metric label at any point in its lifecycle. /otp/send returns an otp_id only.
02
Hash-only storage
SHA-256(otp + per-OTP salt) is the only thing persisted. Plaintext is never written to database, cache, or log.
03
Indistinguishable responses
/otp/verify returns { success: true } or { success: false } only. Expired, wrong, and used codes return the same shape. Enumeration impossible.
04
Context-bound generation
OTP is bound at generation to: phone number + session_id + originating IP. Verification from a different IP fails. Always.
05
DB-enforced expiry
Expiry checked at the DB query layer: WHERE expires_at > NOW(). No application-layer time comparison. Clock skew cannot extend an OTP's window.
OTP lifecycle · guaranteed
// Generation · value computed in memory only
const otp  = crypto.randomInt(100000, 999999);
const salt = crypto.randomBytes(16).toString('hex');
const hash = sha256(otp + salt);

// Stored: hash + salt · NOT the value
INSERT INTO otps (otp_hash, salt, expires_at, ...)
VALUES (hash, salt, NOW() + INTERVAL '5 min', ...);

// Value delivered to recipient via SMS only
sendSMS(phone, `Your code: ${otp}`);

// Verification · DB enforces expiry, not app layer
SELECT * FROM otps
WHERE id = otp_id
  AND status = 'active'
  AND expires_at > NOW()   -- not checked in JS
  AND used_at IS NULL;

// Response · always this shape, nothing more
{ "success": true }
{ "success": false }  // same for expired, wrong, used
05 · Pricing

KES wallet. No invoice. No card.

Top up with M-Pesa. Spend by the message. Per-SMS cost is set by whichever tier the routing engine selects. Automatically, in real time.

Tier 2 · Failover
Regional failover
Competitive
rates
CONFIRMED AT ONBOARDING
Dispatches when Tier 1
falls below threshold.
Automatic failover activation · no configuration change on your end. Health scoring handles the switch.
Tier 3 · Global standby
Global last resort
Cost-capped
standby
CONFIRMED AT ONBOARDING
Dispatches when both Tier 1
and Tier 2 fall below threshold.
Daily spend cap enforced at the infrastructure level. Dual-layer protection: Waht cap + provider account cap. Slack alert at 50% and 90%.
Every message carries cost_kes in the dispatch response. Unit cost is locked at dispatch time, before the message enters the queue. Billing arithmetic runs exclusively in SQL NUMERIC(10,4). No JS float operations touch monetary values.
06 · Get started
Ready to route
every message.

Waht Messaging is in controlled access during MVP. Keys are provisioned by the ops team. We confirm your routing config, register your sender IDs, and walk your team through first dispatch.

Currently serving: Ace Mtihani · kaLunch · Klokd · Chapaa · Paa Elimu · Sabaki AgriChain