> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Read and respond to SMS spam-complaint (FBL) thresholds

> Monitor the per-DID SMS complaint feedback loop — read the complaint rate and threshold level, run the response playbook for elevated/high/critical, and keep complaint pressure from becoming a carrier block.

# 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`.

<Note>
  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.
</Note>

***

## 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.

A report carries:

```json theme={null}
{
  "source": "tmobile",
  "period_start": "2026-08-01",
  "period_end": "2026-08-07",
  "complaint_count": 12,
  "message_count": 9000,
  "reference": "carrier-case-8842"
}
```

* `source` — one of `ctia`, `tmobile`, `att`, `verizon`, `uscellular`,
  `aggregator`, or `operator`. Use `operator` for a report you received
  through your own carrier relationship and are entering by hand.
* `period_start` / `period_end` — the window the counts cover
  (`period_end` must be on or after `period_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.

Orbit keeps the last 200 reports per number — the oldest drop off as new
ones arrive, so ingest per report rather than re-sending history.

### How the level is computed

`GET` rolls the stored reports into a window (default: last 30 days) and
classifies:

| Level      | Rate line (window-wide complaints ÷ messages) | Volume floor (thin samples) |
| ---------- | --------------------------------------------- | --------------------------- |
| `elevated` | ≥ 0.1%                                        | ≥ 5 complaints              |
| `high`     | ≥ 0.2%                                        | ≥ 20 complaints             |
| `critical` | ≥ 0.5%                                        | ≥ 100 complaints            |
| `ok`       | below every line                              | below every floor           |
| `unknown`  | no reports in the window                      | —                           |

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 `critical` no
  matter how large the denominator; carriers act on raw complaint volume,
  not only the ratio.

### The suppression hold

When the level crosses `critical`, 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 `PATCH`
  endpoint is `mode: "manual"` — a later auto pass updates the level but
  never overrides your decision.

The same data is on the number's **Compliance** tab in the dashboard —
rollup, hold, and the lift control — so an operator does not need the API
to clear a hold after remediation.

***

## Read the loop

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/numbers/pn_0123456789abcdef/sms-complaints" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": {
    "phone_number_id": "pn_0123456789abcdef",
    "phone_number": "+14155550134",
    "monitored": true,
    "rollup": {
      "window_days": 30,
      "level": "elevated",
      "complaint_count": 14,
      "message_count": 9800,
      "complaint_rate": 0.0014,
      "sample_sufficient": true,
      "sources": ["tmobile", "ctia"],
      "report_count": 4,
      "first_report_at": "2026-08-01",
      "last_report_at": "2026-08-21",
      "auto_suppress_recommended": false
    },
    "suppression": null,
    "thresholds": {
      "rate": { "elevated": 0.001, "high": 0.002, "critical": 0.005 },
      "absolute_floors": { "elevated": 5, "high": 20, "critical": 100 },
      "min_message_sample": 500
    },
    "reports": [ ... ],
    "last_ingested_at": "2026-08-24T14:03:11.000Z"
  }
}
```

How to read it:

* **`monitored: false`** means no reports have ever been ingested for this
  number — the loop is silent, not green. `rollup.level` will read
  `unknown`. Start feeding reports (below) before you read anything into it.
* **`complaint_rate` is the trend to watch** when `sample_sufficient` is
  true. A number sitting at 0.08% with `level: "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: false` downgrades the rate.** With a thin
  denominator, go by `complaint_count` against the absolute floors in
  `thresholds`, and treat the rate as directional only.
* **`auto_suppress_recommended: true`** is the point where the platform
  believes sending should stop — act on it even if the hold has not been
  applied yet.
* **A non-null `suppression` is current state, not history.** `since` is
  when the current posture began; `updated_by` names the operator on a
  manual change. `reason` carries the trigger ("Complaint rate 0.62%
  crossed the critical threshold …") or your own note.

The `thresholds` block echoes the exact lines the classification used, so
your alerting can compare against the shipped values instead of hardcoding
them.

***

## Response playbook

| Level      | Meaning                                    | What you do                                                                                         |
| ---------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `ok`       | Within accepted A2P bounds                 | Nothing — keep funneling reports and hold your preventive posture (below).                          |
| `elevated` | Above the watch line — early drift         | Audit opt-in provenance and message content (below). Fix list and copy now; do not wait for `high`. |
| `high`     | Approaching carrier-intervention territory | **Pause campaigns on the number**, scrub the audience, investigate, rotate the sender.              |
| `critical` | Carrier-block risk; auto hold recorded     | Stay paused. Run the remediation arc, then lift the hold deliberately.                              |

### `elevated` — audit before you throttle

An elevated number is still deliverable, so the move is correction, not
restriction:

1. **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](/compliance/consent-management). Complaint spikes
   after a list import or a new acquisition channel almost always trace
   back to provenance.
2. **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.
3. **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](/guides/opt-out-lists).
4. **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

1. **Pause the campaigns.** Halt every campaign sending from the affected
   number. At `critical` the auto hold is already recorded — honor it.
2. **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](/guides/dnc-preflight-scrub) and
   [Opt-out & suppression lists](/guides/opt-out-lists).
3. **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.
4. **Rotate the sender.** Move surviving traffic to a healthy number or
   pool while the affected number cools — see
   [Sender pools](/guides/sender-pools). A number at `critical` that keeps
   sending is how a complaint problem becomes a carrier block on the whole
   account.
5. **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:
   ```bash theme={null}
   curl -X PATCH "https://api.orbit.devotel.io/api/v1/numbers/pn_0123456789abcdef/sms-complaints/suppression" \
     -H "Authorization: Bearer $ORBIT_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{ "suppressed": false, "reason": "Complaint level recovered after list scrub + template fix" }'
   ```
   Resume at reduced volume and watch the next report window before
   returning to full throughput.

<Warning>
  A manual lift does not make the number healthy — it makes the platform
  stop reminding you. If you lift while the rollup is still `critical`,
  the level stays `critical` and the next ingested report that crosses the
  line recommends suppression again. Fix the cause first; lifting is the
  last step, not the first.
</Warning>

***

## 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](/compliance/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](/guides/opt-out-lists),
  [Send gates](/compliance/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](/guides/dnc-preflight-scrub).
* **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](/guides/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](/guides/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](/concepts/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:

1. **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:
   ```bash theme={null}
   curl -X POST "https://api.orbit.devotel.io/api/v1/numbers/pn_0123456789abcdef/sms-complaints/reports" \
     -H "Authorization: Bearer $ORBIT_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "source": "aggregator",
       "period_start": "2026-08-15",
       "period_end": "2026-08-21",
       "complaint_count": 9,
       "message_count": 11200,
       "reference": "weekly-batch-2026-34"
     }'
   ```
   The response (201) returns the recomputed rollup immediately, so a feed
   job can alert on `data.rollup.level` in the same pass.
2. **Poll on a schedule** (`GET`) and track `rollup.level` per sending
   number. Level transitions are the alert-worthy events — especially
   upward moves and any appearance of a non-null `suppression`.
3. **Route transitions to on-call.** A move to `high` or `critical`, or a
   newly set hold, is deliverability-severity — push it to your on-call or
   incident channel. See [On-call alerting](/guides/oncall-alerting) for
   wiring notifier webhooks into your paging stack, and the
   [dashboard notifications guide](/guides/in-dashboard-notifications)
   for surfacing the change to operators in the app.
4. **Review monthly even when green.** Compare `complaint_rate` trends
   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.

Every ingest and every suppression change is audit-logged
(`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](/guides/audit-log).

***

## 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](/guides/opt-out-lists) — the layer
  complaint-driven removals should land on.
* [Batch DNC pre-flight scrubbing](/guides/dnc-preflight-scrub) — scrub the
  audience before a launch or a resume.
* [Sender pools](/guides/sender-pools) — spread traffic so a burned number
  does not take the campaign down.
* [Quiet hours configuration](/guides/quiet-hours-configuration) and
  [Frequency caps](/guides/frequency-caps) — the preventive gates.
* [On-call alerting](/guides/oncall-alerting) — page on level transitions.
* [API Reference → Numbers](/api-reference/endpoints/numbers) — full
  request/response schemas.
