FleetbaseFleetbase

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

ModeBehaviour
EnforceSends that break the policy are refused and the breaker pauses organizations. The default.
MonitorEvery 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:mode

With 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:

  1. 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.
  2. Pauses. A paused organization, store or network, or a paused platform, sends nothing.
  3. Same number. A cooldown between messages to one number, and caps per hour and per day, scoped to the purpose of the message.
  4. Same address. A small cap per client IP address, as an extra layer only.
  5. Store or network budget, when you set one, otherwise the organization's.
  6. 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.
  7. 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.

BudgetDefaultScope
Same number: cooldown60 sper number and purpose
Same number per hour / per day3 / 5per number and purpose
Same address per 15 minutes / per day5 / 20per client IP within an organization
Organization per minute / hour / day10 / 60 / 200per organization
New organizations (first 7 days) per day20per organization
Store or network per minute / hour / dayinheritsper store or network, when set
Platform per minute / hour / day100 / 1,500 / 10,000all 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:

SignalDefault threshold
Daily budget used up100 %
Hourly budget used up twice in a day2
Distinct new numbers texted inside 10 minutes25
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 verified20 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_abc123

sms: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:prune

What apps see

A refused send answers with the same error body as any other API error and one of these statuses:

StatusMeaning
422The number can never be texted (invalid, not a mobile number, blocked range), or, on sign-up, an account already exists for that identity.
429A cooldown or budget. The Retry-After header says how long to wait.
423The 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.

VariableDefaultPurpose
SMS_GUARD_ENABLEDtrueTurn the guard off entirely (not recommended).
SMS_GUARD_MODEenforceenforce or monitor.
SMS_GUARD_CACHE_STOREdefault storeThe cache store that holds the counters. It must be shared by every worker and task (Redis in production).
SMS_GUARD_ALERT_EMAILSemptyComma-separated addresses alerted in addition to administrators.
SMS_GUARD_VALIDATEtrueDestination validation.
SMS_GUARD_BLOCKED_COUNTRIESemptyComma-separated two-letter codes.
SMS_GUARD_BLOCKED_PREFIXESthe paying ranges aboveComma-separated prefixes.
SMS_GUARD_LEDGER_RETENTION_DAYS90Delete ledger rows older than this.
SMS_GUARD_LEDGER_MASK_AFTER_DAYS30Mask the number on rows older than this.
STOREFRONT_THROTTLE_SMS_REQUESTS_PER_MINUTE5Requests per minute, per storefront key and client address, on the four storefront endpoints that send a text.
STOREFRONT_VERIFICATION_RESEND_SECONDS60Window 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.

SMS Guard | Fleetbase