Skip to content

Turn an assessment into a decision

NoResponse returns one recommendation per assessment, and most platforms wrap it in a few rules of their own: a hard stop on certain reasons, a step-up on others, and a policy for answers that are not final. This page gives you a rule set to copy and tune, the values each rule reads, and what to store so you can explain a decision later.

Three enums drive every rule.

Field Values Meaning
recommendation and risk.recommendation allow, step_up, review The same value in both places. There is no deny. A pending answer has neither.
confidence low, medium, high, unavailable Confidence in the recommendation. unavailable appears only on a pending answer or beside review.
risk.confidence low, medium, high How much of the planned evidence answered.
risk.band low, medium, high, critical low is 0 to 29, medium 30 to 59, high 60 to 84 and critical 85 to 100.

risk.reasons lists every reason that fired, highest weight first, with the identifier it concerns. A summary fact that is absent was not answered yet: risk.summary.email.breach missing means the breach lane has not settled. false and 0 are definite answers.

Apply the rules in order. The first rule that matches decides.

Order When Decision Why
1 status is pending Your fallback There is no recommendation and no risk yet.
2 risk is absent, or confidence is unavailable review There is nothing to base an allow on.
3 abuse_ecosystem_member fired review The email or username appears in a hacking-forum, abuse-service or dark-web table.
4 risk.band is high or critical review The score is 60 or more.
5 breach_name_mismatch, breach_phone_mismatch, proxy_ip, recent_password_exposure or credential_list_exposure fired step_up The name or phone disagrees with breach records, the IP is a VPN, proxy, Tor or hosting address, or the credentials are exposed.
6 Both many_recent_signups and shared_ip fired review Several signups came from the same IP on your platform within 24 hours.
7 status is partial and risk.confidence is low step_up Most of the planned evidence did not answer.
8 Otherwise risk.recommendation allow, step_up or review.

Rule 7 is where most tuning happens. A partial answer at low confidence is the case where the score moves most: one real signup scored 8 and became 33 five seconds later. Treating it as step_up costs the person one step; treating it as allow means re-checking when the final assessment arrives.

decide.ts
import type { Assessment } from "@noresponse/node";
export type Decision = "allow" | "step_up" | "review" | "fallback";
const STEP_UP = new Set(["breach_name_mismatch", "breach_phone_mismatch", "proxy_ip", "recent_password_exposure", "credential_list_exposure"]);
export function decide(assessment: Assessment): Decision {
if (assessment.status === "pending") return "fallback";
const risk = assessment.risk;
if (!risk || assessment.confidence === "unavailable") return "review";
const fired = new Set(risk.reasons.map((reason) => reason.code));
if (fired.has("abuse_ecosystem_member")) return "review";
if (risk.band === "high" || risk.band === "critical") return "review";
if ([...fired].some((code) => STEP_UP.has(code))) return "step_up";
if (fired.has("many_recent_signups") && fired.has("shared_ip")) return "review";
if (assessment.status === "partial" && risk.confidence === "low") return "step_up";
// An unrecognised value from a newer API version gets your review queue.
if (risk.recommendation === "allow") return "allow";
if (risk.recommendation === "step_up") return "step_up";
return "review";
}

Write one row per decision with these fields, so a reviewer can see what your code saw.

Field Why
assessment_id Links the decision to the assessment for polling, webhooks and outcomes.
assessment_version The revision you acted on. A later webhook carries a higher version.
policy_version The weights that produced the recommendation. The weights change over time and the version tells you which applied.
risk.score, risk.band, risk.recommendation and risk.reasons What the rules read. Store them as returned.
Your decision allow, step_up, review or your fallback. Store a fallback as a fallback, so you can find the accounts that were never scored.

The weights are versioned, and policy_version names the set that produced each score, so compare scores within one policy version.

An assessment.updated event carries the whole assessment at a new assessment_version. Verify the event, keep it only if its version is higher than the one you stored, then run the same decide function and reconcile the account. Webhooks covers verification and the receiver.

const event = NoResponse.webhooks.constructEvent(rawBody, headers, secrets);
if (event.type === "assessment.updated" && !isExpiredEvent(event)) {
const stored = await decisions.get(event.data.assessmentId);
if (!stored || event.data.assessmentVersion > stored.assessmentVersion) {
await decisions.update(event.data.assessmentId, decide(event.data), event.data);
}
}