Skip to main content
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 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:
LayerWhat it answersWhere you read itLatency
Identification”Is this the same device, even after cleared cookies, incognito, or a new IP?”The durable device_id on the webhook / History APIAbout a second
Anonymity detection”How anonymous is the session requesting this OTP right now?”The signals array on the webhook / History APIAbout a second
Risk Score”How risky is the visit overall, as one 0-100 number?”risk_score on the webhook / History APIAbout a second
Patterns”Has this device or local IP already opened many accounts over time?”Dashboard Patterns + exportBackground (~10 min)
The Risk Score (0–100) reads the session’s anonymity — Datacenter, VPN, proxy, Tor, and anti-detect 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.
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.

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; 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

1

Create a ShieldLabs account and get your keys

Sign up for free 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 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, and each webhook endpoint has its own whsec_… signing secret. See Keys.
2

Identify the session at the OTP step

Add the 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.
verify.html
<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>
3

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), or fall back to the History API. Read the durable device_id, the score, and the local IP — these are what your counters key on.
api/request-otp.js
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' });
});
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.
4

Weigh masked sessions with detection_flags

When the policy depends on a specific tell rather than just the band, read the boolean 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.
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';
}
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 below.
5

Watchlist the fan-out with Patterns

Pumping for signup OTPs usually means one device opening many accounts. 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.
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.
Many accounts reached through the same local IP. Catches a single machine or NAT fanning out across signups behind a rotating public IP.
Pull the flagged entities from the dashboard Patterns tab (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.
How many accounts has this device opened?
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"
Tighten the cap on a known fan-out device
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' });
}
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.
6

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.

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. 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.
ConditionSuggested action at the send step
Clean session (Score under 30), under your send capSend 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, and a verify flow under bulk pressure can also be the tail of a credential-stuffing run. The same per-session DeviceID and webhook payload feed all three, so once you have the OTP step wired you can branch on intent.

Next: Acting on the Risk Score

The full decision playbook, including how to combine the device, the anonymity signals, and the per-session Risk Score into one verdict.