Read and respond to SMS spam-complaint (FBL) thresholds
Carriers track “spam report” pressure on every A2P sender — subscriber reports through the CTIA 7726 short-code channel and each carrier’s own complaint feed. When that pressure crosses a carrier’s line, the carrier filters or blocks your traffic, and you usually find out when delivery collapses. Orbit’s SMS complaint feedback loop (FBL) moves that moment earlier: complaint reports roll up per sending number into a complaint rate and a level (ok → elevated → high → critical), plus a
recorded sending hold you can see and override.
This guide is the operator runbook: what the loop stores, how to read it,
what to do at each level, and how to catch a level change before it costs
you a campaign.
All endpoints below are rooted at https://api.orbit.devotel.io/api/v1/numbers.
Every control on this page is yours — the reports you ingest, the
campaign you pause, the sending hold you lift. Ingesting a report records
complaint counts and raises or clears a hold; it never sends anything to
a carrier on your behalf and never changes how your outbound path works.
How the loop works
Three endpoints make up the loop for one number:POST /numbers/:id/sms-complaints/reports— ingest one complaint report: a complaint count and a message count for a period, from one source.GET /numbers/:id/sms-complaints— read the current rollup: rate, level, the hold, thresholds, and the raw reports.PATCH /numbers/:id/sms-complaints/suppression— hold or lift sending on the number by operator decision.
source— one ofctia,tmobile,att,verizon,uscellular,aggregator, oroperator. Useoperatorfor a report you received through your own carrier relationship and are entering by hand.period_start/period_end— the window the counts cover (period_endmust be on or afterperiod_start; a bad pair is a 422).complaint_count— spam complaints attributed to the number in the period.message_count— messages the number sent in the period. This is the rate denominator, so send it from the same period — a complaint count against an empty denominator classifies on raw volume alone.reference— optional external case id (carrier ticket, aggregator batch id) for audit trails.
How the level is computed
GET rolls the stored reports into a window (default: last 30 days) and
classifies:
Two rules temper the rate math:
- Sample guard. Below 500 messages in the window the rate is too noisy
to trust, so classification falls back to the absolute complaint-count
floors — a low-volume number with 30 complaints still reads
high. - Volume backstop. 100 or more complaints classifies as
criticalno matter how large the denominator; carriers act on raw complaint volume, not only the ratio.
The suppression hold
When the level crossescritical, Orbit records an auto suppression
hold on the number (suppression.suppressed: true, mode: "auto") with
the rate, level, and timestamp that triggered it. The hold is a recorded,
dashboard-visible posture — the explicit signal to pause the campaign on
that number. It does not silently black-hole traffic; your dispatch decides
to honor it, and the change is audit-logged.
The hold lifecycle:
- Auto-set at critical, unless an operator already lifted it manually.
- Self-clears when a later rollup recovers below
critical(auto mode only). - Manual states stick. A hold or a lift you set through the
PATCHendpoint ismode: "manual"— a later auto pass updates the level but never overrides your decision.
Read the loop
monitored: falsemeans no reports have ever been ingested for this number — the loop is silent, not green.rollup.levelwill readunknown. Start feeding reports (below) before you read anything into it.complaint_rateis the trend to watch whensample_sufficientis true. A number sitting at 0.08% withlevel: "ok"is healthier than the same level at 0.09% climbing week over week — the level tells you where you are; the rate tells you where you are heading.sample_sufficient: falsedowngrades the rate. With a thin denominator, go bycomplaint_countagainst the absolute floors inthresholds, and treat the rate as directional only.auto_suppress_recommended: trueis the point where the platform believes sending should stop — act on it even if the hold has not been applied yet.- A non-null
suppressionis current state, not history.sinceis when the current posture began;updated_bynames the operator on a manual change.reasoncarries the trigger (“Complaint rate 0.62% crossed the critical threshold …”) or your own note.
thresholds block echoes the exact lines the classification used, so
your alerting can compare against the shipped values instead of hardcoding
them.
Response playbook
elevated — audit before you throttle
An elevated number is still deliverable, so the move is correction, not
restriction:
- Audit opt-in provenance. Pull a sample of the complaining period’s audience and check each contact’s consent record — when and how they opted in, and whether your send matched that scope. See Consent Management. Complaint spikes after a list import or a new acquisition channel almost always trace back to provenance.
- Audit the content. Read the actual copy that went out in the period: does it identify you in the first line, is it what the recipient signed up for, does every message carry a working opt-out? Complaint rates punish “technically consented but feels like spam” the same as real spam.
- Verify opt-out handling. Confirm STOP keywords are honored and landing on your suppression lists — recipients who cannot escape report instead. See Opt-out & suppression lists.
- Watch the trend before acting further. If the next report window
still climbs after the fixes, treat the number as
high.
high / critical — stop the damage, then remediate
- Pause the campaigns. Halt every campaign sending from the affected
number. At
criticalthe auto hold is already recorded — honor it. - Scrub the audience. Re-run your list against DNC and your own suppression layer before any resume; complaint-sourced numbers belong on suppression permanently. See Batch DNC pre-flight scrubbing and Opt-out & suppression lists.
- Find the source. Slice recent sends by campaign, list, and template against the report periods. A spike tied to one list or one template is a targeting or copy problem; a spike spread across everything is a provenance problem on the whole number.
- Rotate the sender. Move surviving traffic to a healthy number or
pool while the affected number cools — see
Sender pools. A number at
criticalthat keeps sending is how a complaint problem becomes a carrier block on the whole account. - Remediate, verify, then lift. Keep the hold until new report
windows show the level recovered. An auto hold clears itself below
critical; a manual hold only clears when you lift it:Resume at reduced volume and watch the next report window before returning to full throughput.
Preventive posture
The cheapest complaint is the one that never happens:- Only send to provable opt-ins, and keep the proof. Every contact in a campaign audience should trace to a consent record matching what you send them. Consent Management.
- Honor opt-outs the first time, everywhere. STOP keywords, contact DNC flags, and list suppression all feed the same send-time gates — a recipient who told you to stop and still gets a text is a complaint waiting to be filed. Opt-out & suppression lists, Send gates.
- Scrub before every launch, and re-scrub audiences you have held for more than a couple of days. Batch DNC pre-flight scrubbing.
- Respect quiet hours. A message that lands at 2 AM reads as spam to the recipient even when it is perfectly consented. Quiet hours configuration.
- Cap frequency. Repetition is the other half of “feels like spam” — per-recipient caps keep one engaged subscriber from becoming five complaint reports. Frequency caps.
- Filter inbound abuse on shared numbers. If a number receives inbound spam or abusive traffic, the per-DID inbound SMS content filter (keyword / glob-pattern / profanity rules on the inbound route) drops junk before it fans out to your webhook, agent, or auto-reply — keeping that traffic out of your agent workflows. See Inbound message routing for the routing surface the filter rides on.
Feed and monitor the loop
The loop only sees what you ingest. Wire it into operations:- Feed every complaint channel. Whenever a carrier, aggregator, or
your own carrier relationship hands you a per-period complaint report,
POST it — with the matching message count so the rate is real:
The response (201) returns the recomputed rollup immediately, so a feed job can alert on
data.rollup.levelin the same pass. - Poll on a schedule (
GET) and trackrollup.levelper sending number. Level transitions are the alert-worthy events — especially upward moves and any appearance of a non-nullsuppression. - Route transitions to on-call. A move to
highorcritical, or a newly set hold, is deliverability-severity — push it to your on-call or incident channel. See On-call alerting for wiring notifier webhooks into your paging stack, and the dashboard notifications guide for surfacing the change to operators in the app. - Review monthly even when green. Compare
complaint_ratetrends across your sending numbers; a slow drift from 0.02% to 0.09% is the early warning the level thresholds intentionally do not give you.
number.sms_complaint_ingested, number.sms_complaint_suppression_updated),
so a post-incident review can reconstruct who knew what, when — see the
Audit log guide.
Troubleshooting
monitored: false and level: "unknown" on a number you believe has
reports. Nothing has been ingested for that number id. Check that your
feed job posts to the correct pn_… id and got HTTP 201 back; a 422 on
ingest (bad period pair or off-enum source) means no report landed.
422 on POST .../reports. The response lists the failing fields in
error.details. Usual cause: period_end earlier than period_start, a
source outside the seven accepted values, or a non-integer count.
suppression.mode: "manual" never changes on its own. Expected — a
manual hold or lift is an operator decision the auto pass respects. Re-read
the number, confirm the current rollup.level, then PATCH the state you
want with a reason describing the remediation.
The hold cleared but the level is still critical. An auto hold
self-clears only when a new ingested report recomputes the rollup below
critical. If report feeding has stopped, the last-known level stands. Keep
the feed running — recovery cannot be observed on stale data.
See also:
- Opt-out & suppression lists — the layer complaint-driven removals should land on.
- Batch DNC pre-flight scrubbing — scrub the audience before a launch or a resume.
- Sender pools — spread traffic so a burned number does not take the campaign down.
- Quiet hours configuration and Frequency caps — the preventive gates.
- On-call alerting — page on level transitions.
- API Reference → Numbers — full request/response schemas.