SMS & login codes
When WhatsApp can’t reach someone, SMS can. Switchboard sends it through licensed providers, fails over between them, and runs login codes as a service — your product never holds a provider key or sees a code.
Licensed providers only
Sending an SMS#
const { id } = await wa.sms.send({ to: "+923001234567", text: "Your car is ready for pickup", category: "transactional", // or "otp", "promotional" }) const message = await wa.outbound.status(id) // queued → sent → delivered, or failed / dead // message.provider says which provider carried it
The same status(id) as WhatsApp sends. The category decides the route, because OTP, transactional and promotional traffic are legally different in Pakistan and the UAE.
Providers#
A provider is switched on by setting its keys. Unconfigured ones are skipped, so a deployment with one provider behaves exactly as it would with five.
| Provider | Good for | Delivery reports | Keys |
|---|---|---|---|
| SendPK | Pakistan, local and cheapest | Polled | SENDPK_API_KEY, SENDPK_SENDER |
| Infobip | Global, strong in the Gulf | Polled | INFOBIP_API_KEY, INFOBIP_BASE_URL, INFOBIP_SENDER |
| Telnyx | Global | Signed webhook (Ed25519) | TELNYX_API_KEY, TELNYX_FROM, TELNYX_PUBLIC_KEY |
| Plivo | Global | Polled | PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, PLIVO_SRC |
| Twilio | Everywhere, last resort | Signed webhook | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM |
Why some providers are polled
Routing and failover#
By default, Pakistan goes to SendPK first, then Infobip, Telnyx, Plivo, and Twilio last as the most expensive route. Other countries skip SendPK. The order is configuration, not code:
{
"+92": { "otp": ["sendpk", "telnyx"], "*": ["sendpk", "infobip"] },
"+971": { "*": ["infobip", "twilio"] },
"*": { "*": ["twilio"] }
}| What happens | When |
|---|---|
| Next provider is tried | The route fails: down, out of credit, bad key, sender not registered. |
| Nothing else is tried | The number itself is wrong. Another provider would only charge to say the same. |
| Provider is benched for 2 minutes | Five failures in a row. It goes to the back, never away: if everything is down, trying beats refusing. |
Login codes#
// Send a code. Pass the END USER's IP — the per-IP limit counts it. await wa.verify.start({ to: phone, clientIp: request.ip }) // They type it in. const { valid } = await wa.verify.check({ to: phone, code })
The code goes out on WhatsApp first. An SMS copy is queued for twenty seconds later and cancelled if WhatsApp delivers it or the code is used. A number whose last code arrived by SMS starts on SMS next time.
| Rule | Default |
|---|---|
| Code | Six digits from crypto/rand, stored only as an HMAC |
| Expires | 10 minutes |
| Guesses | 5, then the code is dead |
| Uses | Exactly once |
| A new code | Cancels the old one and any copy still queued |
Pumping defences#
A public “send me a code” endpoint is how SMS fraud works: codes requested to premium numbers, paid for by the sender. These are on by default.
| Defence | Default |
|---|---|
| Destinations codes may go to | +92, +971 |
| Gap between codes to one number | 30 seconds |
| Codes per number per hour | 5 |
| Codes per IP per hour | 10 |
| Codes per project per day | 500 |
| Alarm | 50+ codes in an hour with under 20% used |