> ## 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 the Risk Score

> Learn how to turn the Risk Score and its signals into an allow, challenge, review, or block decision.

ShieldLabs scores. Your code decides. The Risk Score (0 to 100) and its `signals` arrive on your server by [webhook](/setup/webhooks) and are readable from the [Server API](/api/server-api). What happens next, allow / challenge / review / block, is logic you write in your own application. There is no in-product rules engine and nothing blocks on its own.

This page is the recommendation toolkit: a default band-to-action ladder, per-scenario thresholds you can copy, how to drive decisions off the score band and the action context, the honest caveats, and the implementation notes that keep your decisions correct under at-most-once webhook delivery.

<Info>
  Treat everything here as a **starting point, not a rule**. The 0 to 100 scale is fixed. Where you draw the action line is yours, and 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: Score plus signals plus action context

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 three inputs:

| Input              | What it is                                                                                                                                                                    |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Score**          | The 0 to 100 total. A fast read on how anonymous or abusive the visit looks. Higher means more anonymous (more likely masked, spoofed, or abusive), not a confirmed verdict.  |
| **Signals**        | The signals that fired and the points each added. This is the explainability: a 30 from one privacy-relay signal is very different from a 30 built out of several mismatches. |
| **Action context** | What the user is doing right now (signup, login, payment, withdrawal) and what a wrong decision costs you. The same score warrants different friction at different stakes.    |

The Risk Score is **explainable**: every [webhook](/api/webhooks) ships with a `signals` array of `{ "name": "<name>", "weight": <int> }` entries, so you can see and log the reasons behind a number instead of acting on a black box. (The [History API](/api/server-api) exposes the same breakdown.) Drive the decision off the Risk Score band, the numeric `weight` of each signal, and which anonymity signals fired (read the stable `detection_flags`, not the free-form `signal` label string, which is not a stable contract). Read [the Risk Score](/features/risk-scoring) for how the score is built and its weights, and [Anonymity Signals](/features/anonymity-signals) for what each signal means.

## The default band-to-action ladder

ShieldLabs maps every score into one of four bands. These labels are fixed; the recommended action per band is a sensible default you should adapt per scenario below.

| Band       | Range  | What it means                                       | A reasonable default action               |
| ---------- | ------ | --------------------------------------------------- | ----------------------------------------- |
| **Clean**  | 0–9    | No meaningful signals fired                         | Pass through, no friction                 |
| **Low**    | 10–29  | One minor signal                                    | Allow, worth logging                      |
| **Medium** | 30–59  | Several overlapping signals, or one moderate signal | Step-up challenge, second look, or review |
| **High**   | 60–100 | Strong anonymity 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';        // Clean: no friction
  if (score < 30) return 'allow_log';    // Low: allow, but log it
  if (score < 60) return 'challenge';    // Medium: step-up or review
  return 'review_or_block';              // High: block, review, or verify
}
```

<Note>
  The bands come straight from the score. `Clean 0–9`, `Low 10–29`, `Medium 30–59`, `High 60–100`. They are the only band labels ShieldLabs uses. `999` is a rate-limit ban marker, not a 0–100 score, but it can still arrive in the 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 signals

The band folds every signal into one number, but sometimes you want to act on a specific tell no matter the score. Branch on the stable `detection_flags` booleans for that, not the free-form `signal` labels. The object ships on every [webhook](/api/webhooks):

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

```js Flag-level override theme={null}
function decide(risk) {
  const f = risk.detection_flags ?? {};
  // Always step up on the strongest tells, even when the band looks low.
  if (f.tor || f.anti_detect_browser || f.abuser) return 'verify';
  return defaultAction(risk.risk_score);
}
```

## Per-scenario recommended thresholds

The cost of a mistake changes with the action, so the threshold 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](#honest-caveats-read-before-you-tune).

<Tip>
  A useful mental rule: the higher the cost of a wrong allow, the lower you set the friction threshold. Low-stakes actions can tolerate a Medium score; money-movement should react in the Low band already.
</Tip>

### Signup

The most common entry point for multi-accounting, promo and bonus abuse, and account farms. You usually have nothing else to go on yet, so the score and signals carry the decision. Be generous in the Clean and Low bands so you do not tax real users, and reserve hard friction for clear High-band anonymity.

| Score  | Band   | Recommended action at signup                                                           |
| ------ | ------ | -------------------------------------------------------------------------------------- |
| 0–9    | Clean  | Allow, create the account, no friction                                                 |
| 10–29  | Low    | Allow, log the signals for later correlation                                           |
| 30–59  | Medium | Add friction: require email verification or a CAPTCHA (a human-verification challenge) |
| 60–100 | High   | 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'; // Clean / Low: create the account
}
```

### Login and 2FA

At login you already have an account and its history, so you can be a little more permissive on the raw score and lean harder on step-up authentication (an extra verification step) you already own. A Medium score is a strong reason to require a second factor; reserve a block for High-band signals or an account-takeover pattern.

| Score  | Band        | Recommended action at login                                      |
| ------ | ----------- | ---------------------------------------------------------------- |
| 0–29   | Clean / Low | Allow the login                                                  |
| 30–59  | Medium      | Require 2FA (two-factor authentication) / step-up authentication |
| 60–100 | High        | 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 threshold drops. Anonymity 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 Medium band and gate the High band behind verification.

| Score  | Band   | Recommended action at checkout                                                                    |
| ------ | ------ | ------------------------------------------------------------------------------------------------- |
| 0–9    | Clean  | Allow                                                                                             |
| 10–29  | Low    | Allow, log; watch combined with order value                                                       |
| 30–59  | Medium | Step-up: 3-D Secure (the card issuer's verification step), extra verification, or hold for review |
| 60–100 | High   | 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 signals you would wave through elsewhere. Add verification as early as the Low band, and route the High band to a human.

| Score  | Band         | Recommended action at withdrawal                                   |
| ------ | ------------ | ------------------------------------------------------------------ |
| 0–9    | Clean        | Allow                                                              |
| 10–59  | Low / Medium | Require extra verification (2FA, cooldown, or a confirmation step) |
| 60–100 | High         | 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 score to decide who must complete identity verification before they get a sensitive capability, not to make the identity decision itself. A Medium or High score is a strong reason to require full KYC up front rather than letting the user defer it.

| Score  | Band        | Recommended action for KYC gating                        |
| ------ | ----------- | -------------------------------------------------------- |
| 0–29   | Clean / Low | Standard onboarding; KYC on your normal schedule         |
| 30–59  | Medium      | Require KYC before enabling the sensitive capability     |
| 60–100 | High        | 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 High band.

| Score  | Band        | Recommended action for content/posting                       |
| ------ | ----------- | ------------------------------------------------------------ |
| 0–29   | Clean / Low | Publish normally                                             |
| 30–59  | Medium      | Allow but rate-limit, queue for moderation, or shadow-review |
| 60–100 | High        | Hold for moderation or require a verified account to post    |

## Driving decisions off the Risk Score band

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

Two visits with the same total can still differ, and the lever for that is **action context plus your own data**, layered on top of the band:

* **Raise the stakes, lower the threshold.** On money-movement (checkout, withdrawal) react in the Low band already; on a low-stakes action you can wave a Medium-band visit through. The per-scenario tables above set those cut-points as a recommendation.
* **A high score on a sensitive step is a hard look.** A Medium or High score at a payment, withdrawal, or password reset warrants a step-up or a hold, because the score is already telling you the visit looks masked or spoofed.
* **Cross-reference your own data.** A low Risk Score plus a DeviceID that matches a known-bad device in your database is still a block. You get the identity and the signals; your own history is the other half of the decision.

The shape of that logic is: check your own data 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 anonymity signals fired and the points each added — for the decision, for logging, and for later review. Drive the decision off the Risk Score band, each signal's numeric `weight`, and which signals fired (branch on the stable `detection_flags`, not the free-form `signal` label string, which is not a stable contract). What each signal means is documented in [Anonymity Signals](/features/anonymity-signals), and the weights in [Risk Scoring](/features/risk-scoring).
</Note>

## Honest caveats (read before you tune)

<Warning>
  **A legitimate user can score high.** A corporate VPN, an enterprise proxy, iCloud Private Relay, or a privacy-focused browser all raise the score on a perfectly real person. The score measures how anonymous the traffic looks, not whether the user is bad. Reserve hard blocks for high-stakes actions where a real-user false positive 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 Medium-band visit: 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 thresholds gradually, starting in log-only mode.** Ship the integration first with no enforcement: record `score` and `signals` for every check and watch how your real traffic distributes in the [analytics dashboard](/features/traffic-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 Medium-band visit through on a low-stakes action and to challenge a Low-band visit on a withdrawal. The per-scenario tables above exist precisely so the same Risk Score earns different friction.

<Info>
  ShieldLabs is the detection layer. It surfaces the identity and the signals; your application owns the verdict. The subject of every "block", "challenge", and "allow" is your code, not ShieldLabs.
</Info>

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

### 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)
  applyDecision(req.body).catch(console.error);
});
```

### Be idempotent on request\_id

ShieldLabs sends one webhook per scored identification: the server waits up to \~60 seconds for any follow-up network check, then sends the final 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}
async function applyDecision(payload) {
  // The webhook exposes the score as `risk_score`; the History API row exposes it
  // as `score`. Normalize so a webhook and a History fallback converge on one shape.
  const { request_id, signals, device_id } = payload;
  const score = payload.risk_score ?? payload.score;

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

  // Compute the decision idempotently; do not re-charge, re-email, or re-block.
  const decision = decide({ score, signals, device_id, 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 RequestID 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 snapshots, newest first. You can also look up recent activity by `device_id`, `visitor_id`, `ip`, `user_hid`, `request_id`, `session_id`, or `cookie_id`. History reads through `account.shieldlabs.ai` do not consume request balance. The [Server API](/api/server-api) has the full field list and search types.

<Warning>
  The 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/debug snapshots (`api.shieldlabs.ai`). The signal breakdown is `signals[{ name, weight }]` on the webhook but `score_details` (a JSON **string** of `[{ Value, Description }]`) on the History rows. Normalize both when a decision path can be fed by either the webhook or a History fallback, as the `applyDecision` sketch above does.
</Warning>

<Warning>
  The HTTP `429` rate-limit response and the internal ban marker are infrastructure protections, not scores. A customer-facing Risk Score is always 0 to 100. If you see a request rejected with `429`, that is the per-IP [rate limit](/rate-limits) (a DDoS protection), not a high-risk verdict.
</Warning>

## A complete decision handler

Putting the pieces together: verify, cross-reference your own data, drive the verdict off the per-action band ladder, log the `signals` for review, and stay idempotent.

```js Full handler sketch (Node.js) theme={null}
function bandLadder(score, 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 (score >= cuts.block) return 'block_or_manual_review';
  if (score >= cuts.challenge) return 'challenge';
  return 'allow';
}

function decide({ score, signals, device_id, action }) {
  // 999 is a rate-limit ban marker, not a 0-100 score. Guard it before banding.
  if (score > 100) return 'block';

  // Your own data can override even a low score.
  if (knownBadDevice(device_id)) return 'block';

  // Log the fired signals for transparency; do not branch on the label text.
  logSignals(signals);

  // The per-action band ladder is the verdict.
  return bandLadder(score, action);
}
```

## Where to go next

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

  <Card title="Signals" icon="radar" href="/features/anonymity-signals">
    Every signal you can branch on, in plain language, with weights and combination rules.
  </Card>

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

  <Card title="Cookbook" 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 cookbook has worked examples: [Login and 2FA](/use-case/step-up-authentication), [Checkout](/use-case/payment-fraud), [Account signup](/use-case/new-account-fraud), [Affiliate fraud](/use-case/affiliate-fraud), and [Traffic quality](/use-case/traffic-quality).
