Every integration reaches the same question within about ten minutes of the first successful call: what risk score should I block at? Sixty? Seventy-five?

It is the wrong first question. Answering it directly is how teams end up with a threshold that refuses paying customers on Tuesday and waves a proxy farm through on Wednesday. decision and risk_score are two different outputs, computed on two different axes, and only one of them is meant to be compared against a number you picked.

What the score is actually made of

No model, no training set, no drift. risk_score is a weighted sum of the signals that fired, clamped to 0–100. The weights are fixed and worth knowing:

  • Network — Tor exit 60, residential proxy 50, VPN 35, datacenter ASN 30, otherwise-anonymous network 15.
  • Device — automation 40, browser tampering 35, emulator 30, antidetect browser 25, blocklisted IP seen on the device 25, virtual machine 15, high-activity device 10.
  • Email — disposable domain 15.

They accumulate. A residential proxy carrying an antidetect browser is 50 plus 25 plus whatever the tampering score adds, and it saturates at 100 quickly. That is the intended behaviour: past a certain point the difference between 85 and 100 is not information you should be acting on.

There is one more rule, and it is the one that surprises people. If neither the network nor the device threat flag is set, the score is capped at 15 regardless of what accumulated underneath. A datacenter ASN on its own scores 30 before the cap and reports 15 after it, because a server-hosted IP address is a fact about infrastructure, not a verdict about a person.

The decision is a separate axis

The decision does not read the score at all. It applies hard rules:

  • block — a residential proxy, a Tor exit, automation, an emulator, or browser tampering. Any one of them, on its own.
  • review — a threat signal fired but none of the hard ones did. A plain VPN lands here.
  • allow — nothing in the network or device layer flagged.

Which means a session can be blocked at a low score, and that is not a rounding error. Run the five sandbox tokens and it falls out immediately:

curl -X POST https://maskbreak.com/v1/evaluate \
  -H "Authorization: Bearer sk_test_sandbox" \
  -H "Content-Type: application/json" \
  -d '{"token":"test_tor"}'

# token            decision   risk_score   reasons
# test_clean       allow        0          []
# test_datacenter  allow       15          [datacenter_asn]
# test_tor         block       15          [tor_exit_node, anonymous_network]
# test_vpn         review      65          [vpn_detected, datacenter_asn]
# test_proxy       block       80          [proxy_detected, datacenter_asn]

Look at the two rows scoring 15. One is an ordinary cloud IP that should sail through your signup form. The other is a Tor exit node the engine wants refused. A threshold of 60 treats them identically and lets both through — while simultaneously refusing the VPN user at 65, who is the single most likely person in that list to be a paying customer.

That is the whole argument. The number is a magnitude, not a verdict.

The shape that works

Route on decision. Use risk_score for ordering and reporting, where a magnitude is exactly what you want.

const v = await sentinel.evaluate({ token, email, accountId });

switch (v.decision) {
  case 'block':  return refuse();                 // hard signal fired
  case 'review': return stepUp(v.risk_score);     // OTP, card check, queue
  default:       return proceed();
}

The important property: review must not route to the same place as block. If it does, you have built a hard block with extra steps, and every VPN user in your funnel is now a refused signup you will never hear about.

Where the score earns its keep is downstream of that switch. A manual review queue sorted by risk_score descending puts the worst sessions in front of an analyst first. A weekly histogram of scores by outcome tells you whether your pipeline is drifting. Neither of those needs a threshold.

If you must have a number, have several

Some platforms genuinely need a numeric gate — a rules engine that only speaks in integers, a risk committee that wants one dial. Fine. Then the rule is that there is no such thing as the threshold, only a threshold per action, set by what the action costs when it goes wrong:

  • Withdrawal, payout, promo redemption. Money leaves and does not come back. Escalate aggressively; the cost of a false positive is one annoyed user and a support ticket.
  • Checkout. A chargeback costs the goods plus the fee plus the ratio. Worth friction, not worth refusal.
  • Signup. The abuse is multi-accounting, and device linking answers that far more precisely than any score. Refusing here is usually the more expensive error.
  • Login. Step up, never block. A locked-out real customer calls support; a blocked attacker just tries again from another exit.
  • Newsletter, waitlist, free read. The cost of a fake row is a row. Log it and move on.

Instrument before you tune

Nobody can tell you the right number for your traffic, including us. What we can tell you is how to find it, and the method is unglamorous: run the check in shadow first.

Call /v1/evaluate on the real action, log decision, risk_score and reasons against your own record id, and change nothing about the outcome. Two weeks of that, joined against what actually happened — chargebacks, refunds, bans, support contacts — and the answer stops being a guess. You will usually find two things: the volume at each decision is smaller than you feared, and a handful of reason codes account for nearly all the abuse you care about.

At that point you are not choosing a threshold. You are choosing which reason codes deserve which action, which is a much better question and one you can actually defend in a meeting.

Bind signals, not numbers

When the shadow run says a specific signal is doing the damage, the fix is a rule rather than a threshold. In the dashboard Rules tab, a signal such as antidetect can be bound to block. That override runs after the engine builds its verdict and replaces only the decision field — engine_decision keeps the pipeline’s own answer and rule_matched names the signal that fired, so a month later you can still see what would have happened without your rule.

This is strictly better than moving a global number, because it is reversible and legible. “We block antidetect browsers on withdrawal” is a policy. “We block above 70” is a number nobody in the room can explain.

The threshold that matters most is your timeout

One number does deserve attention, and it is not a risk score. Decide what happens when the check is slow or unreachable.

Allow the request. A fraud check that fails closed converts a vendor incident into an outage of your signup, checkout or login, and that is almost always the more expensive failure. Set a short client timeout, treat the timeout as allow, and increment a counter so a quiet degradation still surfaces somewhere you look. Our own middleware examples do this in every language for a reason: it is the single most common way a good integration turns into an incident.

The short version

  • decision is the verdict. Route on it.
  • risk_score is a magnitude. Sort, report and prioritise with it.
  • A low score can still be a block. A high score can still be a customer.
  • Set policy on reason codes and actions, not on a single global number.
  • Fail open, and count the failures.
FAQ

Frequently Asked Questions

What risk score should I block at?

None, if you can avoid it. Route on the decision field, which already applies the hard-signal rules, and use risk_score to order a review queue rather than to open and close the gate. If your platform genuinely needs a single number, derive it per action rather than globally, and only after you have logged a couple of weeks of real verdicts alongside your own outcome data.

Why can a session be blocked at a low risk score?

Because the two outputs are computed on different axes. The decision applies hard rules — a residential proxy, a Tor exit, automation, an emulator, browser tampering — while the score is a weighted sum that is deliberately capped when the network and device threat flags are clear. A Tor exit returns decision block at risk_score 15. A threshold of 60 would wave it straight through.

Is the score a machine learning model?

No, and we would rather say so. It is a fixed weighted sum of the signals that fired, clamped to 0 to 100, with published weights. That makes it auditable and stable across releases: the same signals always produce the same number, which matters when you have to explain a refusal to a customer or a regulator.

How do I tighten enforcement without touching a threshold?

Bind a signal to an action in the dashboard Rules tab. Setting antidetect to block means any session carrying that signal is refused regardless of what the engine decided, while engine_decision preserves the pipeline’s own answer and rule_matched names the signal that fired. You get a targeted policy instead of a global number that moves everything at once.

What should happen when the API is slow or down?

Allow the request. A fraud check that fails closed converts a vendor incident into an outage of your signup, checkout or login, which is almost always the more expensive failure. Set a short timeout, treat a timeout as allow, and count those events so a quiet degradation still shows up on a dashboard.

See the verdicts before you tune anything

The sandbox key returns the documented allow, review and block shapes with no account. Five curl calls and you can see exactly what your threshold would have done.

Try Maskbreak free →