Current providers
| Provider | Primary markets | Failover role |
|---|---|---|
| Beem | All supported countries (default) | Primary everywhere |
| Termii | Nigeria | NG-specific fallback for Beem outages |
How routing works
Chain resolved
We look up
provider_routes for that country plus the * global defaults. Country-specific rows go first; * rows fill the tail.Failover on transient errors
5xx responses, timeouts, network errors → try the next provider. Every attempt is recorded.
Attempt history
Every send carries aprovider_attempts array showing what we tried:
Permanent vs transient errors
We classify errors so failover is smart, not noisy:| Error | Category | Behavior |
|---|---|---|
phone_invalid | Permanent | Short-circuit — don’t try other providers |
dnd_blocked | Permanent | Short-circuit |
| HTTP 5xx | Transient | Try next provider |
| Network timeout | Transient | Try next provider |
| HTTP 4xx (unexpected) | Provider-specific | Log as attempt failure, continue |
Provider-specific webhook endpoints
Each provider has its own DLR webhook endpoint we expose:| Provider | Endpoint | Signature header |
|---|---|---|
| Beem | POST /beem/dlr | X-Beem-Timestamp + X-Beem-Signature |
| Termii | POST /termii/dlr | X-Termii-Timestamp + X-Termii-Signature |
BEEM_DLR_SECRET / TERMII_DLR_SECRET). Timestamps older than 5 minutes are rejected as replays.
You don’t interact with these directly — they’re for the upstream providers to POST to. Your webhook (configured via /v1/webhooks) receives the correlated sms.delivered / sms.failed events.