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

# SMS Pumping

> Learn how to detect and prevent SMS pumping and OTP toll fraud: rate-limit verification SMS per durable device so one session cannot flood your bill.

SMS pumping floods your verification flow with OTP requests, often toward number ranges the fraudster profits from, and the bill lands on you. ShieldLabs identifies the session asking for each code — the durable device behind it and how anonymous it is — so your own code can cap how many paid messages a single device or local network can trigger.

## What is SMS pumping?

SMS pumping, also called OTP toll fraud or artificially inflated traffic (AIT), is the abuse of any flow that sends a one-time code over SMS — signup, login, phone verification, or the resend-code step. The attacker scripts huge volumes of OTP requests, frequently to premium-rate or partner number ranges they share revenue on, so each verification SMS you pay to send turns into their payout while you absorb the messaging cost. The revenue-share mechanism behind it is sometimes called International Revenue Sharing Fraud (IRSF).

## How ShieldLabs surfaces it

The phone number, the SMS, and the carrier stay in your messaging stack. ShieldLabs resolves the **session that asks for the code** to a set of [identifiers](/features/identification) and grades how anonymous that session is. The anchor is the durable **DeviceID** — derived server-side from hundreds of stable browser components, so it survives cleared cookies, incognito, and IP rotation, while the cookie-scoped `visitor_id` resets every request. That gives you four things a pumper cannot easily rotate away:

| Layer                   | What it answers                                                                | Where you read it                                                                          | Latency               |
| ----------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | --------------------- |
| **Identification**      | "Is this the same device, even after cleared cookies, incognito, or a new IP?" | The durable `device_id` on the [webhook](/setup/webhooks) / [History API](/api/server-api) | About a second        |
| **Anonymity detection** | "How anonymous is the session requesting this OTP right now?"                  | The `signals` array on the [webhook](/setup/webhooks) / [History API](/api/server-api)     | About a second        |
| **Risk Score**          | "How risky is the visit overall, as one 0-100 number?"                         | `risk_score` on the [webhook](/setup/webhooks) / [History API](/api/server-api)            | About a second        |
| **Patterns**            | "Has this device or local IP already opened many accounts over time?"          | [Dashboard Patterns](/features/patterns) + export                                          | Background (\~10 min) |

The [Risk Score (0–100)](/features/risk-scoring) reads the session's anonymity — Datacenter, VPN, proxy, Tor, and anti-detect [signals](/features/anonymity-signals) are common on pumping traffic, which is usually scripted through anonymized infrastructure, often with the `ip_mismatch` flag set. ShieldLabs tells you which device is asking and how masked it is; your code decides whether to send the SMS.

<Warning>
  ShieldLabs supplies a durable **DeviceID** and the session's anonymity; the counting and the cap live in **your** code. You tally sends per device and per local IP in your own datastore and enforce the limit there.
</Warning>

## Stop SMS pumping

Identify the session at the exact step your app is about to send a verification SMS, then split the work cleanly: ShieldLabs returns the durable DeviceID, the anonymity signals, and the [Risk Score](/features/risk-scoring); **your code** keeps the counter. Key a per-device send counter on the DeviceID (and a second one on `local_ip.ip`, the real local network address) so a fraudster who rotates public IPs and clears cookies still hits the same caps. Weigh a masked session more heavily — a Datacenter or VPN session asking for codes in bulk is the typical pumping shape, so it earns a tighter cap or a CAPTCHA before the send. A throttle keyed only on the public IP, a cookie, or a session resets every request and never builds; keying it on the DeviceID is what makes the rotation stop working. Apply the same per-device counter to the resend-code button too — re-requesting a code is the cheapest way for one session to run up the bill, so each resend should increment the cap, not reset it. The steps below wire it up.

## Build it

<Steps>
  <Step title="Create a ShieldLabs account and get your keys">
    [Sign up for free](https://app.shieldlabs.ai/) and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the [Keys](/setup/keys) page. Use the **Public Key** to initialize the snippet in the browser, and keep your server-side credentials on your backend: the **Private API Key** authenticates the [History API](/api/server-api), and each webhook endpoint has its own `whsec_…` signing secret. See [Keys](/setup/keys).
  </Step>

  <Step title="Identify the session at the OTP step">
    Add the [snippet](/setup/snippet) to your app and identify the session right where you collect the phone number or trigger the code. Call `checkAnonymous` before an account exists (a signup or phone-verify form), or `checkAuthenticatedUser` with the account's hashed id (UserHID) when the user is logged in. Pass a hash, never a raw email, phone number, or user id.

    ```html verify.html theme={null}
    <script type="module">
      const mod = await import(
        'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
      );
      // Anonymous signup / phone-verify form: no account yet.
      mod.checkAnonymous(undefined, (ip, requestID) => {
        document.getElementById('shield-request-id').value = requestID;
      });
    </script>
    ```
  </Step>

  <Step title="Read the device and signals before you send">
    Before your backend calls the SMS provider, pull the scored result for that `RequestID` from your webhook cache (the shared [`waitForScore` helper](/use-case)), or fall back to the [History API](/api/server-api). Read the durable `device_id`, the `score`, and the local IP — these are what your counters key on.

    ```js api/request-otp.js theme={null}
    app.post('/api/request-otp', async (req, res) => {
      const { phone, shieldRequestId } = req.body;

      // Pull the ShieldLabs result for this session (snake_case, as the
      // webhook and account History API deliver it).
      const shield   = await waitForScore(shieldRequestId, 1500);
      const deviceId = shield?.device_id;
      const localIp  = shield?.local_ip?.ip;   // real network IP, not the public one
      const score    = shield?.score ?? 0;        // 0–100

      // A blocked or JS-disabled session can return an all-zero DeviceID.
      // Treat that as "device unknown": fall back to the local IP counter
      // instead of letting many distinct sessions collapse onto one zero key.
      const ZERO_DEVICE = '00000000-0000-0000-0000-000000000000';
      const deviceKey = (deviceId && deviceId !== ZERO_DEVICE) ? deviceId : null;

      // YOUR counters, in YOUR datastore. ShieldLabs supplies the keys, not the tally.
      const perDevice = deviceKey ? await bumpSendCount(`otp:dev:${deviceKey}`) : 0; // 1h window
      const perLocal  = localIp   ? await bumpSendCount(`otp:lip:${localIp}`)   : 0; // 1h window

      // 1. One device (or one local network) flooding the verify flow,
      //    even across rotated public IPs and cleared cookies.
      if (perDevice > YOUR_DEVICE_SEND_LIMIT || perLocal > YOUR_LOCAL_IP_SEND_LIMIT) {
        return res.status(429).json({ action: 'rate_limited' });
      }

      // 2. A masked session asking for paid codes is the typical pumping shape.
      if (score >= 60) {
        return res.status(200).json({ action: 'require_captcha' }); // High band
      }

      // Clear enough to send the SMS. YOUR provider, YOUR call.
      await sendVerificationSms(phone);
      return res.json({ action: 'sent' });
    });
    ```

    <Tip>
      Combine keys for defense in depth: count sends on the DeviceID (survives IP rotation and cleared cookies) **and** on `local_ip.ip`, the real network address. The machine behind a NAT keeps reaching you on the same `local_ip.ip` even as the public `public_ip.ip` cycles through a proxy pool, so a pumper rotating public IPs still trips the local-IP cap. The dashboard calls this same value the Local IP.
    </Tip>
  </Step>

  <Step title="Weigh masked sessions with detection_flags">
    When the policy depends on a specific tell rather than just the band, read the boolean [`detection_flags`](/glossary#detection-flags) on the webhook: `datacenter_ip`, `vpn`, `proxy`, `tor`, `anti_detect_browser`, `abuser`, `ip_mismatch`, and more. These are stable booleans built for branching, so "datacenter plus a hot device counter, require a CAPTCHA" reads cleanly. A masked session can also show `detection_flags.ip_mismatch: true` — the public `public_ip` and the real network IP (`local_ip`) resolve to different countries. Treat it as corroborating evidence, not a trigger on its own: it is informational, does not change the Risk Score, and can be benign (mobile networks often route over different paths). Use the `signals` array (`{ name, weight }`) only for the explainable breakdown.

    ```js theme={null}
    function otpFriction(shield, perDevice) {
      const score = shield?.score ?? 0;       // 0–100
      const flags = shield?.detection_flags ?? {};
      // Anonymous infra plus a warming device counter is the pumping pattern.
      if ((flags.datacenter_ip || flags.proxy) && perDevice > 3) return 'captcha';
      if (score >= 60) return 'captcha';      // High band
      return 'send';
    }
    ```

    <Note>
      Honest framing: a legitimate user on a corporate VPN sometimes verifies a phone too. Anonymity tightens the cap or adds a CAPTCHA before the send, it does not justify refusing the code outright on its own. Reserve a hard deny for the combination of anonymous infrastructure, a device or local IP already over your send limit, and the [fan-out pattern](/features/patterns) below.
    </Note>
  </Step>

  <Step title="Watchlist the fan-out with Patterns">
    Pumping for signup OTPs usually means one device opening many accounts. [Patterns](/features/patterns) link sessions over time and grade each entity **Suspicious** then **Dangerous** as that count climbs in a rolling window. Below the Suspicious threshold an entity is the unflagged baseline, which is never recorded.

    <AccordionGroup>
      <Accordion title="Many Accounts on One Device" icon="mobile-screen">
        One device opening or touching many different accounts. Keyed on the durable DeviceID, so it holds even as the pumper rotates public IPs and clears cookies between requests — the same machine driving a flood of signup OTPs.
      </Accordion>

      <Accordion title="Many Accounts on One Local IP" icon="network-wired">
        Many accounts reached through the same local IP. Catches a single machine or NAT fanning out across signups behind a rotating public IP.
      </Accordion>
    </AccordionGroup>

    Pull the flagged entities from the [dashboard Patterns tab](/features/patterns) (CSV or JSON) and feed the Dangerous DeviceIDs and local IPs into your send-cap logic as a watchlist. You can also reconstruct a device's fan-out live from the History API.

    ```bash How many accounts has this device opened? theme={null}
    curl "https://account.shieldlabs.ai/api/v1/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=100" \
      -H "Authorization: Bearer sec_your_private_api_key"
    ```

    ```js Tighten the cap on a known fan-out device theme={null}
    async function deviceAccountFanOut(deviceId) {
      const rows = await shieldHistory('device_id', deviceId, 100);
      return new Set(rows.map((r) => r.user_hid).filter(Boolean)).size; // distinct accounts
    }

    if ((await deviceAccountFanOut(deviceId)) >= YOUR_ACCOUNT_FANOUT_LIMIT) {
      return res.status(429).json({ action: 'rate_limited' });
    }
    ```

    <Note>
      Account History reads on `account.shieldlabs.ai`, the webhook stream, and the dashboard export are free; the alternate Management history path bills 1 request per returned row (an empty result still bills 1). Lean on the free sources for routine watchlisting and reserve a billed read for a device you are about to act on.
    </Note>
  </Step>

  <Step title="Tune to your product">
    A consumer signup flow sends more first-time verifications than a niche B2B tool. Start in logging-only mode, watch how many OTP requests your real sessions make per device and per local IP, then set the send caps and the band that match your traffic before you turn on enforcement. A device that requests many codes but rarely completes a verification is a classic pumping shape, so track completion against the `device_id` and feed a high request-to-completion ratio into the same watchlist.
  </Step>
</Steps>

## Test it

You do not need a real attack to confirm the cap holds. Open your phone-verify page and complete an identification, noting the `device_id` on the webhook. Now repeat the ways that should not reset your counter: a fresh incognito window, the same browser after clearing cookies and storage, and (where you can) a second public IP. Because the DeviceID is server-derived rather than stored, the same `device_id` comes back each time, so your per-device send counter keeps climbing across all of those attempts instead of starting over — meaning one machine cannot reset its way into a flood of paid SMS. Switch to a genuinely different physical device and the `device_id` changes, confirming the key is tied to the device and not to anything a pumper can clear.

## Recommended starting policy

A guide, not a rule. The right send caps depend entirely on your verification flow. Layer the conditions: friction should rise as more of them stack.

| Condition                                                                            | Suggested action at the send step                     |
| ------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| Clean session (Score under 30), under your send cap                                  | Send the code                                         |
| Score in the High band (60+), or anonymous infra (`datacenter_ip` / `proxy` / `vpn`) | Require a CAPTCHA before sending                      |
| DeviceID or local IP over your send cap (across rotated IPs)                         | Rate-limit (HTTP 429), do not send                    |
| Device or local IP flagged "Many Accounts on One..."                                 | Treat as a pumping source: deny the send, then review |

A device driving a flood of signup OTPs is often the same one behind [new account fraud](/use-case/new-account-fraud), and a verify flow under bulk pressure can also be the tail of a [credential-stuffing run](/use-case/credential-stuffing). The same per-session DeviceID and webhook payload feed all three, so once you have the OTP step wired you can branch on intent.

<Card title="Next: Acting on the Risk Score" icon="arrow-right" href="/guides/acting-on-risk-score">
  The full decision playbook, including how to combine the device, the anonymity signals, and the per-session Risk Score into one verdict.
</Card>
