Skip to main content
Signup farms exist for one payoff: the reward. The same person spins up fresh accounts to claim a signup bonus, burn through a coupon code, or restart a free trial. The catch happens not when the account is born but when it reaches for the reward, so this tutorial lives at the redemption endpoint. Joining accounts at registration is a separate job the signup tutorial covers — wire that up for the create-account moment and treat this page as the reward-time gate on top of it.

What is promo abuse?

Promo abuse is when one person creates many accounts to claim a reward that is meant once per customer — a signup bonus, a first-order coupon, referral credit, or a free trial reset. The accounts look like different customers, but they trace back to the same person behind one machine or one network.

How ShieldLabs surfaces it

ShieldLabs resolves each redemption to a set of identifiers: the per-claim RequestID, the cookie-scoped VisitorID, your hashed UserHID for the account, and the durable, server-derived DeviceID that survives a cookie clear, incognito, and IP rotation. A farm clears cookies and goes incognito between accounts to look new every time, so counting cookies or public IPs lets it right through — the DeviceID is what holds steady and lets your code see how many “different” customers actually share one machine or local network. ShieldLabs also returns a per-request Risk Score (0–100): when a person masks the connection with a VPN, proxy, or Tor, the anonymity signals fire, and the real network IP (local_ip) can reveal the network behind the public public_ip they rotate. The dashboard grades the relationship over time with the Many Accounts on One Device and Many Accounts on One Local IP patterns; your redemption code enforces your per-customer policy. ShieldLabs gives you four things at each redemption, and your code decides what to do with them:
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”Is this redemption masked or anonymous 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”How many accounts already cluster on this device or local IP?”Dashboard Patterns + exportBackground (~10 min)
The signup and redemption stages answer two different questions, and you want both. Score at signup to thin the farm early (the patient farm creates accounts slowly, each clean on its own); then check again at redemption — ten “different” customers redeeming the same coupon from one machine is a shape no single clean signup ever shows.

Gate the redemption

The rule your code applies, wired up in ## Build it below: read the session score and its signals breakdown for masking, and read the durable DeviceID plus the real network IP (local_ip.ip) to count how many distinct UserHID accounts already claimed off that one machine or local network. Grant when the score is low and the device is fresh; require verification when the session is masked, when the device or local IP is flagged Suspicious or Dangerous, or when the account count crosses your per-customer cap. The outcome: a farm clearing cookies and rotating VPN exits between accounts collapses to one DeviceID and one local_ip, so the reward holds for review before it is granted, while a genuine first-time customer passes.

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

Wire the signup gate first

Join accounts to the DeviceID at registration with the signup tutorial, so the farm is already thinned before it reaches the reward.
3

Score the redemption session

Add the snippet to the page where the reward is claimed (the cart with the coupon applied, the “start trial” screen, the bonus-claim button). Re-identify on the action itself so you score the session that is redeeming, not a stale page load. Pass the account’s hashed id, never a raw email.
redeem.html
<script type="module">
  const mod = await import(
    'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
  );
  // Pass the hashed account id, never a raw email or user id.
  // The callback hands you the requestID to correlate with the score.
  mod.checkAuthenticatedUser('8a9f-hashed-account-id', (ip, requestID) => {
    document.getElementById('shield-request-id').value = requestID;
  });
</script>

<form method="POST" action="/api/redeem">
  <input type="hidden" id="shield-request-id" name="shieldRequestId" />
  <input type="text" name="couponCode" placeholder="Coupon code" />
  <button type="submit">Apply reward</button>
</form>
4

Read the score, gate on masking

The score arrives on the webhook — verify the X-Shield-Signature HMAC, then cache it by request_id. Your endpoint reads it back with the shared waitForScore helper from the Use Case Tutorials, or falls back to a History API read by request_id. Hold a masked session for verification here, then carry on to the account-count check in the next step.
api/redeem.js
app.post('/api/redeem', async (req, res) => {
  const { accountId, couponCode, shieldRequestId } = req.body;

  // 1. Your normal redemption checks first (code valid, not already used by
  //    this account, within campaign window).
  if (!(await couponIsRedeemable(couponCode, accountId))) {
    return res.status(409).json({ error: 'Coupon not redeemable' });
  }

  // 2. Look up the ShieldLabs Risk Score for this session.
  const shield = await waitForScore(shieldRequestId, 2000);
  const score = shield?.score ?? 0;        // 0-100, default to 0 if not yet in
  const signals = shield?.signals ?? [];   // explainable: [{ name, weight }]

  // Use the stable detection_flags to tell WHICH signal fired — a 30 from one
  // signal is not a 30 from another. These masking signals are innocent in
  // isolation, so weigh them against the device account-count below.
  const flags  = shield?.detection_flags ?? {};
  const masked = flags.vpn || flags.proxy || flags.tor || flags.private_relay
    || flags.browser_vpn_proxy || flags.anti_detect_browser || flags.datacenter_ip;

  // 3. YOUR code owns the verdict. Branch on the band, never on a label string.
  if (score >= 60 || masked) {
    // High band: strong anonymity signals on the redeeming session.
    // Hold the reward and require verification before granting it.
    return res.status(200).json({ requireVerification: true, reason: 'anonymity' });
  }

  // Clean / Low / Medium: carry on to the account-count check next.
  return grantOrGate(req, res, shield);
});
A high score is not a fraud verdict — a real customer on a corporate proxy, VPN, or privacy browser can land in the High band. Branch on the score band and the named detection_flags, never on a raw label string; treat masking as one input, not proof.
5

Count the accounts behind the device

The score tells you whether this one session looks masked. It does not tell you how many accounts sit behind the device — and that count off the durable DeviceID is what gives the farm away. Two dashboard Patterns grade exactly that relationship, Suspicious then Dangerous, as the count crosses a threshold in a rolling window:
  • Many Accounts on One Device — one DeviceID linked to many accounts, the classic bonus-farm shape.
  • Many Accounts on One Local IP — many accounts redeeming through one local IP (the real network IP, local_ip.ip), even when each session shows a fresh cookie and a rotated public IP. Catches a farm sitting behind one router or NAT.
Read these on the dashboard Patterns tab and export the flagged entities as a denylist. For the reward decision you often want the count live, not on the next pattern run, so read the History API by device_id (or by ip for the local_ip value) and count distinct user_hid:
Read a device's history
curl "https://account.shieldlabs.ai/api/v1/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=50" \
  -H "Authorization: Bearer sec_your_private_api_key"
Gate the reward on account count
// Prefer the pre-computed pattern export as a fast denylist, and reserve live
// device_id reads for the borderline redemptions that are worth the cost.
const flaggedDevices = await loadFlaggedDeviceIds(); // from dashboard export

async function accountsBehindDevice(deviceId) {
  const rows = await shieldHistory('device_id', deviceId, 100);
  // Distinct hashed accounts seen on this one machine.
  return new Set(rows.map((r) => r.user_hid).filter(Boolean)).size;
}

async function grantOrGate(req, res, shield) {
  const deviceId = shield?.device_id;

  // The all-zero DeviceID is "no device", not a clean new one. A blocked or
  // JS-disabled browser returns it with a score of 90 or higher — a farm that
  // strips JS would slip the device count, so hold it on the score alone.
  const NIL = '00000000-0000-0000-0000-000000000000';
  const usableDevice = deviceId && deviceId !== NIL;
  if (!usableDevice) {
    return res.status(200).json({ requireVerification: true, reason: 'no_device' });
  }

  // Known farm device from the pattern export: hold the reward.
  if (flaggedDevices.has(deviceId)) {
    return res.status(200).json({ requireVerification: true, reason: 'device_linked_to_many_accounts' });
  }

  // Live count for this redemption. YOUR_ACCOUNT_LIMIT is your policy.
  if ((await accountsBehindDevice(deviceId)) >= YOUR_ACCOUNT_LIMIT) {
    return res.status(200).json({ requireVerification: true, reason: 'reward_already_claimed_on_device' });
  }

  // Clear: grant the reward.
  return grantReward(req, res);
}
History reads through account.shieldlabs.ai do not consume request balance. For high-volume redemption flows, lean on the pattern export as your denylist and reserve live device_id reads for the rewards that are expensive to give away by mistake.
A determined person using several genuinely separate browsers shows up as several devices, since the DeviceID is browser-bound. The local-IP pattern closes that gap: ten accounts claiming through one local_ip is a strong shape even when each reports a different device. Weigh both patterns alongside your own per-code or per-campaign redemption caps.
6

Decide and tune

Grant, verify, or deny in your own code, per the policy table below. Start in logging-only mode, watch how real redemptions distribute, then raise friction where the data justifies it.

Test it

You do not need a real farm to see this work. Claim the reward once in your normal browser and note the device_id on the webhook. Then play the farm: clear cookies, open a private/incognito window, or switch to a second browser profile, and redeem again as a different account. The cookie_id and visitor_id change each time, but the same device_id returns, and the distinct-user_hid count off that device climbs with each run — exactly the count your handler gates on. Switching networks or toggling a VPN should also light up the anonymity signals on the redeeming session without changing the DeviceID. The four bands are defined in Risk Scoring, and the per-band playbook lives in Acting on the Risk Score. Mapped to a reward gate, with the account count layered on top:
Signal at redemptionSuggested reward action
Clean / Low score, no pattern flagGrant the reward
Medium score, no pattern flagGrant, but log and watch the device
High scoreRequire verification before granting
Device or local IP flagged SuspiciousRequire verification, regardless of the session score
Device or local IP flagged DangerousDeny the reward and route to review
All-zero / missing DeviceID (snippet blocked or JS disabled, score 90+)Require verification before granting
Where you draw each line is yours, and your own per-code or per-campaign redemption caps sit alongside these as a second, simpler backstop.

Next: Acting on the Risk Score

The full per-band decision playbook, including signal-aware decisioning and how to combine the score with specific signals.