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

# Acting on results

> How to choose an action for each user and each identification from the Risk Score, its risk signals and High-Risk Events.

ShieldLabs returns a Risk Score and every named risk signal on each identification, and detects High-Risk Events on your users. You choose the action for each case (allow, step up, review or block) and act on the result in your backend. ShieldLabs stops fraud and abuse and helps block fraudulent and abusive traffic.

Two layers feed each decision:

* **The account.** Each signed-in identification carries the User HID you pass with `checkAuthenticatedUser`, so each user builds a history: the worst band of its identifications, the devices, visitors and IP addresses it uses, and any High-Risk Event on it. Read the account's identifications through the [History API](/api/server-api#read-every-identification-of-one-account) by `user_hid`. High-Risk Events are available in the analytics dashboard, the API and webhooks.
* **The identification.** Each identification's Risk Score (0 to 100) and its risk signals arrive on your server by [webhook](/setup/webhooks) about 300 ms after the check in the browser, at the moment of signup, login or checkout.

This page gives you a default band-to-action ladder, per-scenario cut-points you can copy, how to add the account's history and High-Risk Events to each decision, the caveats to read before you tune, and the implementation notes that keep decisions correct under at-most-once webhook delivery.

<Info>
  Treat everything here as a recommended starting point. The 0 to 100 scale is fixed. You choose where to draw the action line for each case; the right line depends on how costly a wrong allow or a wrong block is for the action in front of you.
</Info>

## The principle: the account, the Risk Score, its risk signals and the action

The number alone is never the decision. A Risk Score of 65 on a blog comment and a 65 on a \$5,000 withdrawal are the same number and completely different situations. Make every decision from four inputs:

| Input | What it is |
| - | - |
| **Account** | The user behind the identification: the worst band of its recent identifications, the devices, visitors and IP addresses it uses, and any High-Risk Event on it. A clean identification on an account with a High-Risk Event deserves a second look; a risky identification on a long-trusted account may only need a step-up. |
| **Risk Score** | The 0 to 100 total for this identification. Higher means riskier: more masking, spoofing or automation behind the check. |
| **Risk signals** | The risk signals that fired and the weight each added. This is the explainability: a 15 from Privacy Relay alone is a different case from a 30 built from a proxy, a datacenter IP and a timezone mismatch. |
| **Action context** | What the user is doing right now (signup, login, payment, withdrawal) and what a wrong decision costs you. The same Risk Score warrants different friction at different stakes. |

<Frame caption="One identification and the risk of the user, device, visitor and IP it belongs to, in the analytics dashboard.">
  <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/identification-identity-risk.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=dbd706ac40431a2c09610aa03f4499f1" alt="The Details and Risk of the identities in this call sections of one identification in the analytics dashboard: the Visitor ID, Device ID, Cookie ID, Session ID, User HID a91f3c7e5b2d4086 and Domain, a red Multi-accounting pill, then Visitor risk Suspicious, Device risk Dangerous, User risk Dangerous and IP risk Suspicious." width="2238" height="768" data-path="images/dashboard/identification-identity-risk.png" />

  <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/identification-identity-risk-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=7963eb5cc311b0e1f302b8ba32fe1164" alt="The Details and Risk of the identities in this call sections of one identification in the analytics dashboard in the dark theme: the Visitor ID, Device ID, Cookie ID, Session ID, User HID a91f3c7e5b2d4086 and Domain, a red Multi-accounting pill, then Visitor risk Suspicious, Device risk Dangerous, User risk Dangerous and IP risk Suspicious." width="2238" height="768" data-path="images/dashboard/identification-identity-risk-dark.png" />
</Frame>

The Risk Score is **explainable**: every [webhook](/api/webhooks) carries a `signals` array of `{ "name": "<slug>", "weight": <int> }` entries, one per risk signal that added weight, so you can log the reasons behind a number. [History API](/api/server-api) rows carry `score_details` instead, a JSON string of internal descriptions and debug entries (see the warning below). Drive the decision off the band, the weights, and which risk signals fired: branch on the `signals[].name` slugs or on the `detection_flags` booleans, and never assume one entry per slug. Read [Risk Scoring](/features/risk-scoring) for how the Risk Score is built and weighted, and [Risk Signals](/features/risk-signals) for what each risk signal means.

## The default band-to-action ladder

Every Risk Score falls into one of three bands. The payload carries only the number (`risk_score` on the webhook, `score` on the History API), with no band field, so map the number to a band in your backend. The recommended action per band is a sensible default you should adapt per scenario below.

| Band | Range | What it means | A reasonable default action |
| - | - | - | - |
| **Trusted** | 0-29 | No meaningful risk signals, or one minor risk signal | Allow; log any risk signals that fired |
| **Suspicious** | 30-59 | Several overlapping risk signals, or one moderate risk signal | Step-up challenge, second look, or review |
| **Dangerous** | 60-100 | Strong risk signals | Block, review, or require verification |

```js Default ladder (Node.js) theme={null}
// A baseline ladder. Tune the cut-points per action context (see scenarios below).
function defaultAction(score) {
  if (score < 10) return 'allow';        // Trusted, no risk signals: no friction
  if (score < 30) return 'allow_log';    // Trusted, minor risk signal: allow, but log it
  if (score < 60) return 'challenge';    // Suspicious: step-up or review
  return 'review_or_block';              // Dangerous: block, review, or verify
}
```

<Note>
  The bands come straight from the Risk Score: `Trusted 0-29`, `Suspicious 30-59`, `Dangerous 60-100`. `999` is the rate-limit marker, not a 0 to 100 Risk Score, but it can still arrive in the Risk Score field (`risk_score` on the webhook, `score` on the History API), so guard the value `> 100`; see the [Implementation notes](#implementation-notes-getting-it-right) below.
</Note>

### Hard rules on specific risk signals

The band folds every risk signal into one number, but sometimes you want to act on a specific tell no matter the Risk Score. Branch on the `detection_flags` booleans (or the `signals[].name` slugs) for that. The object ships on every [webhook](/api/webhooks); here it is shortened, for an identification from a proxy on a datacenter IP with a record of abuse:

```json theme={null}
{
  "detection_flags": {
    "tor": false,
    "proxy": true,
    "datacenter_ip": true,
    "abuser": true,
    "anti_detect_browser": false,
    "browser_automation": false,
    "vpn": false,
    "ip_mismatch": false
  }
}
```

```js Flag-level override theme={null}
// Hard rules on the strongest tells, applied before the band ladder.
// The complete handler below calls it after the 999 guard and your own records.
function hardRule({ detection_flags, signals }) {
  const f = detection_flags ?? {};
  const slugs = (signals ?? []).map((s) => s.name);
  // Always step up on the strongest tells, even when the band looks low.
  if (f.tor || f.anti_detect_browser || f.browser_automation || f.abuser || slugs.includes('proxy_routed_antidetect')) return 'verify';
  return null; // no hard rule: fall through to the band ladder
}
```

`proxy_routed_antidetect` has no `detection_flags` key, so the rule reads it from `signals`.

## Per-scenario cut-points

The cost of a mistake changes with the action, so the cut-point should too. Be lenient where a wrong block annoys a real user but costs little (a blog comment), and strict where a wrong allow moves money or grants trust (a withdrawal, or KYC, Know Your Customer identity verification). The tables below are recommended starting points; calibrate them against your own data as described in [rule 2 below](#before-you-tune).

<Tip>
  A useful mental rule: the higher the cost of a wrong allow, the lower you set the friction cut-point. Low-stakes actions can tolerate a Suspicious Risk Score; money movement should react inside the Trusted band already, from a Risk Score of 10.
</Tip>

### Signup

The most common entry point for multi-accounting, promo and bonus abuse, and account farms. The account is new at signup, so the Risk Score and risk signals of this identification carry most of the decision, with the device's history next to them (see below). Be generous in the Trusted band so you do not tax real users, and reserve hard friction for clear Dangerous-band identifications.

| Risk Score | Band | Recommended action at signup |
| - | - | - |
| 0-9 | Trusted | Allow, create the account, no friction |
| 10-29 | Trusted | Allow, log the risk signals for later correlation |
| 30-59 | Suspicious | Add friction: require email verification or a CAPTCHA (a human-verification challenge) |
| 60-100 | Dangerous | Reject or hold for manual review before the account is usable |

```js Signup decision theme={null}
function onSignup({ score }) {
  if (score >= 60) return 'reject_or_manual_review';
  if (score >= 30) return 'email_verify_or_captcha';
  return 'allow'; // Trusted: create the account
}
```

The device is often not new at signup. Read the device's earlier identifications through the [History API](/api/server-api) by `device_id` and check which User HIDs it already carries. Then pass the new account's User HID with `checkAuthenticatedUser` from its first signed-in page, so the account builds its own history and ShieldLabs can detect [Multi-accounting](/features/high-risk-events#multi-accounting) on it.

### Login and 2FA

At login you already have an account and its history, so you can be a little more permissive on the raw Risk Score and lean on step-up authentication (an extra verification step) you already own. A Suspicious Risk Score is a strong reason to require a second factor, and so is a device the account has never used: compare the identification's `device_id` with the devices in the account's earlier identifications (History API by `user_hid`). Reserve a block for Dangerous-band identifications and for accounts with an [Account takeover](/features/high-risk-events#account-takeover) event.

| Risk Score | Band | Recommended action at login |
| - | - | - |
| 0-29 | Trusted | Allow the login |
| 30-59 | Suspicious | Require 2FA (two-factor authentication) / step-up authentication |
| 60-100 | Dangerous | Block the session and require recovery or verification |

```js Login decision theme={null}
function onLogin({ score }) {
  if (score >= 60) return 'block_require_recovery';
  if (score >= 30) return 'require_2fa';
  return 'allow';
}
```

### Checkout and payment

Money is moving, so the cut-point drops. Risk signals on a payment (proxy, Tor, a VPN that does not match the saved billing region) deserve a hard look earlier than they would at signup. Add friction in the Suspicious band and gate the Dangerous band behind verification. Add the account layer: a Trusted identification on an account with a recent Dangerous identification, or on an account with a High-Risk Event, earns the Suspicious-band action.

| Risk Score | Band | Recommended action at checkout |
| - | - | - |
| 0-9 | Trusted | Allow |
| 10-29 | Trusted | Allow, log; watch combined with order value |
| 30-59 | Suspicious | Step-up: 3-D Secure (the card issuer's verification step), extra verification, or hold for review |
| 60-100 | Dangerous | Block the charge or require manual review before fulfillment |

### Withdrawal and high-value action

The strictest scenario. A wrong allow here is an irreversible loss, so react to risk signals you would wave through elsewhere. Add verification from a Risk Score of 10, inside the Trusted band, and route the Dangerous band to a human. Hold withdrawals for accounts with a Multi-accounting or Account takeover event, whatever the Risk Score of the current identification.

| Risk Score | Band | Recommended action at withdrawal |
| - | - | - |
| 0-9 | Trusted | Allow |
| 10-59 | Trusted / Suspicious | Require extra verification (2FA, cooldown, or a confirmation step) |
| 60-100 | Dangerous | Hold for manual review; do not auto-approve |

```js Withdrawal decision (strictest) theme={null}
function onWithdrawal({ score }) {
  if (score >= 60) return 'manual_review_hold';
  if (score >= 10) return 'extra_verification'; // verify early on money out
  return 'allow';
}
```

### KYC gating

Use the Risk Score to decide who must complete identity verification before they get a sensitive capability, not to make the identity decision itself. A Suspicious or Dangerous Risk Score is a strong reason to require full KYC up front rather than letting the user defer it.

| Risk Score | Band | Recommended action for KYC gating |
| - | - | - |
| 0-29 | Trusted | Standard onboarding; KYC on your normal schedule |
| 30-59 | Suspicious | Require KYC before enabling the sensitive capability |
| 60-100 | Dangerous | Require full KYC and hold the capability until it passes |

### Content, comment, and posting

The most lenient scenario. A wrong block costs you a real contributor; a wrong allow costs you a spam comment you can remove later. Keep friction low and only react meaningfully in the Dangerous band.

| Risk Score | Band | Recommended action for content/posting |
| - | - | - |
| 0-29 | Trusted | Publish normally |
| 30-59 | Suspicious | Allow but rate-limit, or hold the post until a moderator reviews it |
| 60-100 | Dangerous | Hold for moderation or require a verified account to post |

## Acting on High-Risk Events

High-Risk Events are detected on your users rather than on single identifications, and each carries Medium or High confidence. They are available in the analytics dashboard, the API and webhooks. When a High-Risk Event arrives for a user through the API or webhooks, or when you review it on the user's card in the analytics dashboard, act on the account, reading the event together with the user's linked devices, visitors and IP addresses, each with the band of the identifications it shares with the user. You choose the action for each case; this table is a starting point.

| Event | Medium confidence | High confidence |
| - | - | - |
| **Multi-accounting** | Hold promotions, trials and referral rewards on the linked accounts until reviewed | Close or merge the linked accounts after review, and hold their payouts |
| **Account sharing** | Require step-up authentication on the account's next sign-in | End the account's other sessions and require re-authentication on each device |
| **Impossible travel** | Step up the next sensitive action | Hold withdrawals and profile changes until the account holder confirms |
| **Account takeover** | Step up the next sign-in and notify the account holder | Lock sensitive actions and start account recovery |

The confidence depends on the combination of evidence behind the event, and it is a separate axis from the Risk Score. Combine the two: a High confidence event earns its action even when the account's current identification is Trusted. The Risk Score and risk signals of the identification remain the input at signup, login, checkout and withdrawal. [High-Risk Events](/features/high-risk-events) describes each event.

<Frame caption="The devices linked to one user in the analytics dashboard, with the band of the identifications they share.">
  <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/user-card-details-linked.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=15a35b82378c6d3a007dd3394904563d" alt="The Details section of the user card for User HID a91f3c7e5b2d4086 in the analytics dashboard with Linked devices open: 2 devices, one Trusted with 7 identifications and one Dangerous with 5, and the Linked local IPs counter showing 2." width="2238" height="712" data-path="images/dashboard/user-card-details-linked.png" />

  <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/user-card-details-linked-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=74cb3f5cf0a3421e0c7e3fd2c915b467" alt="The Details section of the user card for User HID a91f3c7e5b2d4086 in the analytics dashboard in the dark theme with Linked devices open: 2 devices, one Trusted with 7 identifications and one Dangerous with 5, and the Linked local IPs counter showing 2." width="2238" height="712" data-path="images/dashboard/user-card-details-linked-dark.png" />
</Frame>

[User, device, visitor and IP cards](/dashboard/entity-card) describes each part of the user card.

## Driving decisions off the Risk Score band

The Risk Score is the decision input for each identification. It already encodes signal severity: a Tor exit carries a far higher weight than a lone VPN, several overlapping mismatches push an identification into the Dangerous band, and a stripped or automated client lands near the top. So a band-based action ladder, tuned per action context, captures the intent of "react harder to stronger signals" without you having to inspect each risk signal by hand.

Two identifications with the same Risk Score can still differ, and the lever for that is **action context plus the account and your own records**, layered on top of the band:

* **Raise the stakes, lower the cut-point.** On money movement (checkout, withdrawal) react from a Risk Score of 10 already; on a low-stakes action you can wave a Suspicious-band identification through. The per-scenario tables above set those cut-points as a recommendation.
* **A high Risk Score on a sensitive step is a hard look.** A Suspicious or Dangerous Risk Score at a payment, withdrawal, or password reset warrants a step-up or a hold, because the Risk Score is already telling you the identification looks masked or spoofed.
* **Add the account and the device.** A low Risk Score on a Device ID you have already banned, or on a device that carries accounts you have closed, is still a block. Read the device's and the user's earlier identifications through the History API by `device_id` or `user_hid`, and check them against your own records.

The shape of that logic: check the account and your own records first, then let the per-action band ladder carry the rest. The [complete handler below](#a-complete-decision-handler) expands it.

<Note>
  Use the `signals` array to see which risk signals fired and the weight each added, for the decision, for logging and for later review. Branch on the `signals[].name` slugs or the `detection_flags` booleans. What each risk signal means is on [Risk Signals](/features/risk-signals), and the weights are on [Risk Scoring](/features/risk-scoring).
</Note>

## Before you tune

<Warning>
  **Read a high Risk Score together with its named risk signals and the user's history.** A real customer on a corporate VPN, an enterprise proxy or iCloud Private Relay can reach the Suspicious band. The risk signals show why, the account's history shows whether it is new behaviour, and you choose the action for each case. Reserve hard blocks for high-stakes actions where a false positive on a real customer is worth the protection.
</Warning>

Three rules that keep you out of trouble:

1. **Match the response to the band and the action context.** A 30 is a Suspicious-band identification: on a low-stakes action it deserves no friction, while the same 30 at a password reset or a withdrawal is worth a challenge. Let the band plus the stakes of the action set the response, and use the `signals` to inform it and to log the reasoning.
2. **Tune cut-points gradually, starting in log-only mode.** Ship the integration first with no enforcement: record `risk_score`, `signals` and `user_hid` for every identification and watch how your users and traffic distribute in the [analytics dashboard](/dashboard/analytics) against your conversion and chargeback data. Only then turn on friction, starting with the highest-stakes actions, and tighten in small steps.
3. **Match friction to stakes.** It is fine to wave a Suspicious-band identification through on a low-stakes action and to challenge a Trusted-band identification with a Risk Score of 10 or more on a withdrawal. The per-scenario tables above exist precisely so the same Risk Score earns different friction.

## Implementation notes: getting it right

The recommendations above only hold if your handler reads the data correctly. At-most-once webhook delivery means a naive handler can miss a result entirely, and applying the same check twice (a webhook plus a History API fallback) can double-apply effects. Wire these in from the start.

### Tie each decision to its own identification

At a step you decide on, such as signup, login, checkout or a withdrawal, run an identification for that step and keep its request ID. Call `forceCheckAuthenticatedUser` for a signed-in user, or `forceCheckAnonymous` before sign-in: the `forceCheck*` methods run an identification every time, keep the current Session ID and restart the five-minute window. Within one visit (while a page of your site stays open in the browser, across route changes in a single-page app and across open tabs), `checkAnonymous` and `checkAuthenticatedUser` run at most one identification every five minutes for the same user. A call inside that window posts nothing, counts nothing, and its `onInitialized` handler receives `{ status: "not_initialized" }`. On a multi-page site, a full page load in the only open tab can start a new visit with its own identification.

Send the `requestID` your `onInitialized` handler receives to your server together with the step, and apply the decision when the webhook for that `request_id` arrives. The [snippet reference](/setup/snippet) covers all four methods.

### Receive via webhook, and verify it

Your decision logic lives in the webhook handler. Verify the `X-Shield-Signature` header before you trust the payload, then respond `200` fast and run your decision off the request path. The signature recipe, constant-time comparison, and the full Node, Go, and Python handlers are on [Webhooks](/setup/webhooks): this page assumes a verified payload and focuses on what you do with it.

```js Verify first, then act (Node.js / Express) theme={null}
app.post('/shieldlabs/webhook', express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }), async (req, res) => {
  // Verify X-Shield-Signature over req.rawBody first. See /setup/webhooks.
  if (!verifySignature(req)) return res.status(401).end();

  res.status(200).end();           // acknowledge fast (~1s timeout, no retries)
  if (req.body.event_type !== 'identification.scored') return;   // skip webhook.ping
  applyDecision(req.body.data).catch(console.error);
});
```

### Be idempotent on request\_id

ShieldLabs sends one webhook per identification. When follow-up network checks run, it waits for them, at most about 10 seconds after the check, then sends the final Risk Score once. Still key your apply logic on `request_id`. For anything you cannot afford to miss you may also read the same result from the [History API](/api/server-api) as a fallback, so making the write idempotent on `request_id` ensures the webhook and a History read for the same check converge instead of double-applying a business effect.

```js Idempotent apply theme={null}
// payload is body.data of an identification.scored webhook, or a History API row.
async function applyDecision(payload) {
  // The webhook names the Risk Score `risk_score`; a History API row names it `score`.
  // Normalize so a webhook and a History fallback converge on one shape.
  // A History row has no `signals` and no `detection_flags` (it carries is_* flags),
  // so the hard rules only see webhook data.
  const { request_id, signals, detection_flags, device_id, user_hid } = payload;
  const riskScore = payload.risk_score ?? payload.score;

  // The step this identification belongs to (signup, login, checkout, ...).
  // Record it when the snippet's onInitialized callback returns the requestID.
  const action = await db.pendingChecks.actionFor(request_id);

  // Upsert keyed on request_id: the webhook and a History fallback converge.
  await db.checks.upsert({ requestID: request_id, riskScore, signals, userHid: user_hid });

  // Compute the decision idempotently; do not re-charge, re-email, or re-block.
  // decide() is the complete handler below.
  const decision = await decide({ request_id, riskScore, signals, detection_flags, device_id, user_hid, action });
  await db.decisions.upsert({ requestID: request_id, decision });
}
```

### Fall back to the History API

Webhook delivery is **at-most-once**: a single attempt, no retries, with a roughly 1 second timeout. Do not assume at-least-once. For any decision you must not miss (a withdrawal, a payout, a KYC gate), read the result back from the [Server API](/api/server-api) History endpoint instead of relying solely on the webhook.

```bash Read the result back by request ID theme={null}
curl "https://account.shieldlabs.ai/api/v1/history/request_id/13f84f05-2c4a-4d8e-9b1a-6f2e7c9d0a55?limit=1" \
  -H "Authorization: Bearer sec_your_private_api_key"
```

History returns a `{ data, total }` envelope with identifications, newest first, up to 100 per page (`limit`, `offset`). Look up any account's recent identifications by `user_hid`, or any `device_id`, `visitor_id`, `ip`, `request_id`, `session_id` or `cookie_id`. History reads never count against your included identifications; they are limited to 15 requests per second per domain, so cache account reads you repeat. The [Server API](/api/server-api) has the full field list and search types.

<Warning>
  The Risk Score field is named differently on each surface: `risk_score` on the webhook `data`, `score` on the recommended `account.shieldlabs.ai` History rows, and `Score` on the PascalCase Management History snapshots (`api.shieldlabs.ai`, deprecated, sunset 1 January 2027). The risk signal breakdown is `signals[{ name, weight }]` on the webhook. History rows carry `score_details` instead, a JSON **string** of `[{ Value, Description }]` with internal descriptions and debug entries: store it for review, but never branch on it or show it to your users. Normalize the Risk Score field when a decision path can be fed by either the webhook or a History fallback, as the `applyDecision` sketch above does.
</Warning>

<Warning>
  A `risk_score` above 100 is the rate-limit marker `999`, written when a visitor IP is banned after too many identifications; treat it as that marker. A `429` on the identification request comes from a [rate limit](/rate-limits): the per-IP limit, your plan's per-domain limit, or a domain freeze. An all-zero Device ID (`00000000-0000-0000-0000-000000000000`) means no usable device signals reached ShieldLabs for that identification; the rate-limit marker (Risk Score 999) is one such case. Route it to review rather than allowing it.
</Warning>

## A complete decision handler

Putting the pieces together: verify, check your own records, apply the hard rules on the strongest tells, log the `signals` for review, drive the decision off the per-action band ladder, read the account on sensitive steps, and stay idempotent.

```js Full handler sketch (Node.js) theme={null}
const HISTORY = 'https://account.shieldlabs.ai/api/v1/history';
const NIL_DEVICE = '00000000-0000-0000-0000-000000000000';

// The account layer: the worst band of the user's earlier identifications and
// the devices it has used. History reads are free and limited to 15 requests
// per second per domain, so cache this per user.
// A shorter form of the accountView() helper on the use case tutorials hub.
async function readAccount(userHid, currentRequestId) {
  if (!userHid || userHid === 'anonymous') return null;
  const res = await fetch(`${HISTORY}/user_hid/${encodeURIComponent(userHid)}?limit=100`, {
    headers: { Authorization: `Bearer ${process.env.SHIELDLABS_API_KEY}` },
  });
  // A failed read is unverified: throw, and let the caller route the action to review.
  if (!res.ok) throw new Error(`history lookup failed: ${res.status}`);
  const { data = [] } = await res.json();
  // Skip this identification and the 999 marker. A row with the all-zero Device ID
  // still counts toward the band, but never as a device.
  const earlier = data.filter(
    (row) => row.request_id !== currentRequestId && row.score <= 100,
  );
  const worst = earlier.reduce((max, row) => Math.max(max, row.score), 0);
  return {
    band: worst >= 60 ? 'Dangerous' : worst >= 30 ? 'Suspicious' : 'Trusted',
    devices: new Set(earlier.map((row) => row.device_id).filter((d) => d && d !== NIL_DEVICE)),
    seen: earlier.length > 0,
  };
}

function bandLadder(riskScore, action) {
  // Per-action cut-points from the scenario tables above.
  const cuts = {
    signup:     { challenge: 30, block: 60 },
    login:      { challenge: 30, block: 60 },
    checkout:   { challenge: 30, block: 60 },
    withdrawal: { challenge: 10, block: 60 }, // strictest: react early
    kyc:        { challenge: 30, block: 60 },
    content:    { challenge: 30, block: 60 },
  }[action] || { challenge: 30, block: 60 };

  if (riskScore >= cuts.block) return 'block_or_manual_review';
  if (riskScore >= cuts.challenge) return 'challenge';
  return 'allow';
}

async function decide({ request_id, riskScore, signals, detection_flags, device_id, user_hid, action }) {
  // 999 is the rate-limit marker, not a 0 to 100 Risk Score; an all-zero Device ID
  // carries no usable device signals. Route both to review.
  if (riskScore > 100 || device_id === NIL_DEVICE) return 'manual_review';

  // Your own records: a banned device, or an account you acted on after a
  // High-Risk Event (from the API, webhooks or the analytics dashboard).
  if (knownBadDevice(device_id) || flaggedAccount(user_hid)) return 'block_or_manual_review';

  // Hard rules on the strongest tells (the Flag-level override above).
  const hard = hardRule({ detection_flags, signals });
  if (hard) return hard;

  // Log the fired risk signals for review (a History row has score_details instead).
  logSignals(signals ?? []);

  const decision = bandLadder(riskScore, action);

  // The account layer: on a sensitive step, a clean identification on a risky
  // account, or on a device the account has never used, earns a challenge.
  const sensitive = ['login', 'checkout', 'withdrawal'].includes(action);
  if (decision === 'allow' && sensitive) {
    let account;
    try {
      account = await readAccount(user_hid, request_id);
    } catch {
      return 'manual_review'; // a failed account read is unverified, never clean
    }
    if (account?.seen && (account.band === 'Dangerous' || !account.devices.has(device_id))) {
      return 'challenge';
    }
  }
  return decision;
}
```

## Where to go next

<CardGroup cols={2}>
  <Card title="Risk Scoring" icon="gauge" href="/features/risk-scoring">
    How the 0 to 100 Risk Score is built, what `signals` contains, and the band definitions.
  </Card>

  <Card title="Risk Signals" icon="radar" href="/features/risk-signals">
    Every risk signal you can branch on, in plain language, from masking to bots and automation.
  </Card>

  <Card title="High-Risk Events" icon="users" href="/features/high-risk-events">
    Multi-accounting, account sharing, impossible travel and account takeover on your users, at Medium or High confidence.
  </Card>

  <Card title="Users, devices, visitors and IPs" icon="fingerprint" href="/concepts/entities">
    How each identification links to a user, a device, a visitor and IP addresses, each with its own risk.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/setup/webhooks">
    At-most-once delivery, the full payload, signature verification, and idempotency.
  </Card>

  <Card title="Use case tutorials" icon="book-open" href="/use-case">
    End-to-end recipes for login and 2FA, checkout, signup, affiliate fraud, and traffic quality.
  </Card>
</CardGroup>

Ready to apply this to a specific flow? The use case tutorials have worked examples: [Step-up authentication](/use-case/step-up-authentication), [Payment fraud](/use-case/payment-fraud), [New account fraud](/use-case/new-account-fraud), [Multi-accounting](/use-case/multi-accounting), [Account takeover](/use-case/account-takeover), [Affiliate fraud](/use-case/affiliate-fraud) and [Traffic quality](/use-case/traffic-quality). To review one account in the analytics dashboard before you act, follow [Investigate a risky user](/use-case/investigate-a-user).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.