SMS Guard
Bound what a leaked storefront key or API credential can spend on text messages — destination checks, per-organization and platform budgets, an automatic circuit breaker, alerts, pauses and a send ledger.
SMS Guard
Every text message Fleetbase sends passes through the SMS guard before a provider is called. The guard refuses numbers that cannot receive a text, enforces a budget per organization and for the platform as a whole, and pauses an organization automatically when its traffic looks like SMS pumping (an attacker driving paid messages to numbers they profit from). Navigate to Admin → API Traffic → SMS Guard to manage it.
The guard exists because the storefront key and the driver app's API credential are, by design, public: anyone who extracts one from an app can call the endpoints that send a verification code. Rate limits per address slow such a caller down but do not stop one with many addresses. The budgets here do, and they hold whichever provider sends the message (Twilio, AWS SNS, Vonage, MessageBird, SMPP or a custom gateway).
Everything on this page is a runtime setting stored in the database. Changes apply within a minute on every worker, with no deployment or restart. The environment variables below only supply the defaults.
What is covered
Any code path that sends a text through core's SMS service is guarded, including:
- Storefront customer sign-up codes, SMS login, phone verification and account closure codes.
- FleetOps driver and customer SMS login and password reset codes.
- The public tracking page's delivery codes, inspection PINs and credential delivery.
- Onboarding phone verification, two-factor codes, and administrator test sends.
- Storefront new-order alerts to store staff.
Two-factor codes and administrator sends are trusted purposes: they are counted and ledgered, but they skip organization pauses and budgets so a pumped organization cannot lock its own staff out of the console.
Mode
| Mode | Behaviour |
|---|---|
| Enforce | Sends that break the policy are refused and the breaker pauses organizations. The default. |
| Monitor | Every send goes through. Each send that would have been refused is written to the log as sms.would_block, and the breaker alerts without pausing. |
Switch to Monitor right after a deployment, watch the sms.would_block entries for an hour of real traffic, then switch back to Enforce. The sms:mode command does the same from a shell:
php artisan sms:mode monitor
php artisan sms:mode enforce
php artisan sms:modeWith no argument the command shows the current mode and the pauses in force.
How a send is checked
The guard runs these checks in order and refuses at the first that fails:
- Destination. The number must parse, be valid for its country and be a mobile number (or the "fixed line or mobile" type some countries report). Premium-rate, shared-cost, satellite and other paying ranges are refused, as are any countries or prefixes you block.
- Pauses. A paused organization, store or network, or a paused platform, sends nothing.
- Same number. A cooldown between messages to one number, and caps per hour and per day, scoped to the purpose of the message.
- Same address. A small cap per client IP address, as an extra layer only.
- Store or network budget, when you set one, otherwise the organization's.
- Organization budget per minute, hour and day. Organizations younger than the probation period get a smaller daily budget, so a self-signup farm cannot mint a fresh budget per account.
- Platform budget, counted across every organization, in two buckets: public sends and trusted sends.
Only when every check passes is the send counted and allowed.
Destination rules
There is deliberately no per-organization country restriction by default. Organizations run cross-border operations and their countries cannot be inferred reliably. Instead:
- Numbers are validated with libphonenumber and must be of an allowed type.
- A platform-wide list of blocked prefixes removes the ranges that pay out per message. The default covers international freephone, shared-cost, satellite, personal-number and premium ranges (
+800,+808,+870,+878,+881,+882,+883,+888,+979). - You may add blocked countries.
- An organization exception may carry an allowed countries list, which restricts that organization alone.
The breaker's new-country signal (below) learns each organization's real footprint from the ledger rather than guessing it.
Budgets
Budgets are counted in messages, never in money. A blank field means no limit for that window.
| Budget | Default | Scope |
|---|---|---|
| Same number: cooldown | 60 s | per number and purpose |
| Same number per hour / per day | 3 / 5 | per number and purpose |
| Same address per 15 minutes / per day | 5 / 20 | per client IP within an organization |
| Organization per minute / hour / day | 10 / 60 / 200 | per organization |
| New organizations (first 7 days) per day | 20 | per organization |
| Store or network per minute / hour / day | inherits | per store or network, when set |
| Platform per minute / hour / day | 100 / 1,500 / 10,000 | all organizations together, public and trusted buckets separately |
Two-factor and login codes have their own, shorter per-number cooldowns so a user who taps "resend" is not refused.
Organization exceptions
Add an organization under Organization Exceptions to give it a larger (or smaller) daily budget, lift its budget entirely, or restrict the countries it may text. The note field records why the exception exists. Exceptions are the way to onboard a large customer: the default budget stays small for everyone else.
Circuit breaker
After a send is allowed, the breaker reads the counters and the ledger. Any of these pauses the organization (or the store or network the send came from) for the pause length, 60 minutes by default, and alerts the administrators:
| Signal | Default threshold |
|---|---|
| Daily budget used up | 100 % |
| Hourly budget used up twice in a day | 2 |
| Distinct new numbers texted inside 10 minutes | 25 |
| Messages to a country the organization has not texted in 30 days (once it has 50 ledgered sends) | 10 in 10 minutes |
| Verification codes sent by SMS in the last hour against codes actually verified | 20 or more sent, under 10 % verified |
Real customers verify most of the codes they ask for; pumping verifies none. Alerts are also sent when an organization reaches 50 % and 80 % of its daily budget, and when the platform reaches 80 % of its hourly budget, without pausing.
An organization paused automatically twice in one day stays paused until an administrator lifts the pause.
Pauses
A paused organization sends no text messages. Flows with an email fallback (customer and driver SMS login, account closure) keep working by email; flows without one answer with a 423 status until the pause ends.
Pause an organization yourself from Pauses on the page, or from a shell:
php artisan sms:pause company_abc123 --minutes=60 --reason="incident 42"
php artisan sms:pause platform
php artisan sms:unpause company_abc123sms:pause accepts an organization's public id or uuid, platform, or a scope key such as tenant:store:<uuid>. Without --minutes the pause lasts until it is lifted.
Alerts
Alerts go to every system administrator (by email and as an in-app notification) and to the addresses in Alert emails. Organizations are not alerted. Each alert names the organization, the purpose and source of the last message, the last client address, and what tripped.
Usage and the ledger
Every allowed send and every refusal is written to the send ledger (sms_sends): organization, store or network, purpose, source, client address, destination country and type, provider, provider message id and outcome, and the verification code the message carried. The Usage panel summarises the last 24 hours per organization against its budget; the ledger is what an incident is traced from.
Retention is applied by sms:ledger:prune: the full number is replaced by its last four digits after 30 days and rows are deleted after 90. Schedule it daily:
php artisan sms:ledger:pruneWhat apps see
A refused send answers with the same error body as any other API error and one of these statuses:
| Status | Meaning |
|---|---|
422 | The number can never be texted (invalid, not a mobile number, blocked range), or, on sign-up, an account already exists for that identity. |
429 | A cooldown or budget. The Retry-After header says how long to wait. |
423 | The organization or the platform is paused. |
Login endpoints with an email fallback (storefront, FleetOps driver and customer) email the code instead when the account has an address, and answer with the refusal only when nothing else can carry the code.
Verification codes sent by SMS expire after ten minutes, verify once, and lock after five wrong guesses. Storefront endpoints that check a code also lock an identity out for fifteen minutes after ten wrong codes, independent of the caller's address. Asking for a sign-up code again inside a minute answers ok without sending a second message.
Environment variables
These set the defaults the admin page starts from.
| Variable | Default | Purpose |
|---|---|---|
SMS_GUARD_ENABLED | true | Turn the guard off entirely (not recommended). |
SMS_GUARD_MODE | enforce | enforce or monitor. |
SMS_GUARD_CACHE_STORE | default store | The cache store that holds the counters. It must be shared by every worker and task (Redis in production). |
SMS_GUARD_ALERT_EMAILS | empty | Comma-separated addresses alerted in addition to administrators. |
SMS_GUARD_VALIDATE | true | Destination validation. |
SMS_GUARD_BLOCKED_COUNTRIES | empty | Comma-separated two-letter codes. |
SMS_GUARD_BLOCKED_PREFIXES | the paying ranges above | Comma-separated prefixes. |
SMS_GUARD_LEDGER_RETENTION_DAYS | 90 | Delete ledger rows older than this. |
SMS_GUARD_LEDGER_MASK_AFTER_DAYS | 30 | Mask the number on rows older than this. |
STOREFRONT_THROTTLE_SMS_REQUESTS_PER_MINUTE | 5 | Requests per minute, per storefront key and client address, on the four storefront endpoints that send a text. |
STOREFRONT_VERIFICATION_RESEND_SECONDS | 60 | Window inside which a repeated sign-up code request answers ok without a new message. |
If the counter store is unreachable, public sends (storefront, API credential, public pages) fail closed and are refused, while trusted sends such as two-factor codes go through. Keep Redis healthy, and keep TRUSTED_PROXIES correct behind a load balancer so the per-address layer sees real client addresses.
Provider-side backstops
The guard is the primary control. Keep the provider's own protections on as a last line: Twilio's SMS Pumping Protection and geo permissions, or an AWS SNS monthly spend limit. They cannot tell one organization from another, which is why they are a backstop and not the budget.