By now you can ask a dozen questions about a customer: is the email disposable, is the phone a VoIP number, is the IP a datacenter, does the card's issuing country match anything else, is the name on a sanctions list. Each answer on its own is weak. Plenty of honest people use a VPN. Plenty of honest people travel with a card issued at home. A risk score exists because weak signals that agree are strong, and because a single rule that blocks on any one of them will block your best customers.
The trap is building a score that nobody can explain. A number like 73 with no reasons attached is useless when a customer asks why their order was held, when a reviewer has to decide in thirty seconds, or when you want to know whether last week's threshold change helped. So the requirement for this lesson is not just a score. It is a score where every point can be traced to a named reason, and where the reasons are stored with the decision.
Decide the shape before the weights
Four design decisions come before any number.
Hard rules are not scores. Some facts end the conversation regardless of everything else: a sanctions match must go to review no matter how clean the rest looks, and a card number that fails Luhn is a typo, not a risk. Mixing those into a weighted sum lets enough "good" points outvote them. Keep them as overrides that run first.
Missing data is its own state. When a check fails or a field is absent on your plan, the score should record "unknown", not count it as clean. An outage that silently turns every check into a pass is the most common way a risk system fails.
Three outcomes, not two. Allow, review, block. Review is where uncertainty goes. Block is reserved for cases where you would be comfortable defending the decision to the customer.
The score is additive and capped. Each signal adds points; you clamp the total to 0-100. Additive scores are easy to explain ("+25 disposable email, +15 datacenter IP") in a way a multiplied probability is not.
A transaction has a clean email, a residential IP and a matching card country, but the sanctions screen returns verdict: high. In a well-designed scoring system, what happens?
Answer the check above to continue.
The scorer
ESM on Node 18+. It runs the checks in parallel, treats every failure as unknown, applies overrides, then the weights, and returns reasons alongside the number.
import { WEIGHTS, THRESHOLDS } from './weights.js';
const BASE = 'https://api.apiverve.com/v1';
async function verve(api, params) {
const res = await fetch(`${BASE}/${api}?${new URLSearchParams(params)}`, {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(3000),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') throw new Error(`${api}: ${body?.error || `HTTP ${res.status}`}`);
return body.data;
}
const settle = (p) => p.then((v) => ({ ok: true, v }), (e) => ({ ok: false, e: e.message }));
const same = (a, b) => a && b && a.trim().toLowerCase() === b.trim().toLowerCase();
export async function assess({ email, phone, ip, bin, billingCountry, name }) {
const [em, ph, geo, vpn, card, sanc] = await Promise.all([
settle(verve('emailvalidator', { email })),
phone ? settle(verve('phonenumbervalidator', { number: phone })) : null,
settle(verve('iplookup', { ip })),
settle(verve('vpndetector', { ip })),
bin ? settle(verve('binlookup', { bin: String(bin).slice(0, 8) })) : null,
name ? settle(verve('sanctionscreening', { name })) : null,
]);
const reasons = [];
const unknown = [];
const add = (code, detail) => reasons.push({ code, points: WEIGHTS[code], detail });
const missing = (check, r) => { if (r && !r.ok) unknown.push(`${check}: ${r.e}`); return r?.ok ? r.v : null; };
const e = missing('email', em);
const p = missing('phone', ph);
const g = missing('ip', geo);
const v = missing('vpn', vpn);
const c = missing('bin', card);
const s = missing('sanctions', sanc);
// Overrides first
if (name && !s) return decide('review', 0, reasons, unknown, ['sanctions screen unavailable']);
if (s?.matched) return decide('review', 0, reasons, unknown, [`sanctions candidate: ${s.verdict} ${s.topScore}`]);
if (e?.isDisposable) add('email_disposable', e.domain);
if (e && !e.canReceiveMail) add('email_undeliverable', e.domain);
if (p?.isDisposable) add('phone_disposable', p.formatted?.e164);
else if (p?.isVoip) add('phone_voip', p.formatted?.e164);
if (v?.is_datacenter) add('ip_datacenter', ip);
else if (v?.is_vpn) add('ip_vpn', ip);
const ipMismatch = g && billingCountry && !same(g.country, billingCountry);
if (ipMismatch) add('ip_billing_mismatch', `ip ${g.country} vs billing ${billingCountry}`);
const cardMismatch = c && g && !same(c.country, g.countryName);
if (cardMismatch) add('card_ip_mismatch', `card ${c.country} vs ip ${g.countryName}`);
if (c?.category === 'PREPAID') add('card_prepaid', c.bin);
if ((ipMismatch || cardMismatch) && (v?.is_vpn || v?.is_datacenter)) {
add('combo_mismatch_and_anonymised', 'location mismatch behind VPN or datacenter');
}
const score = Math.min(100, reasons.reduce((n, r) => n + r.points, 0));
let outcome = score >= THRESHOLDS.block ? 'block' : score >= THRESHOLDS.review ? 'review' : 'allow';
// Too little evidence to allow confidently: never let unknowns turn into an allow
if (outcome === 'allow' && unknown.length >= 2) outcome = 'review';
return decide(outcome, score, reasons, unknown, []);
}
function decide(outcome, score, reasons, unknown, overrides) {
return { outcome, score, reasons, unknown, overrides, thresholds: THRESHOLDS, at: new Date().toISOString() };
}
if (process.argv[2] === 'demo') {
const r = await assess({
email: '[email protected]', phone: '+14155552671', ip: '103.62.49.210',
bin: '424242', billingCountry: 'US', name: 'Acme Widgets Ltd',
});
console.log(JSON.stringify(r, null, 2));
}Run node risk.js demo. The demo input collects a disposable email (35), a datacenter IP (25), an IP country that does not match the US billing address (12), a UK card on a non-UK IP (15) and the combination bonus (15): 102, capped at 100, outcome block, with five named reasons explaining exactly why. Change the email to a real address and the IP to a residential one and watch the reasons disappear one by one.
A Python port is a straightforward translation; the one piece worth showing is the parallel fan-out, since requests is synchronous:
import os, requests
from concurrent.futures import ThreadPoolExecutor
BASE = "https://api.apiverve.com/v1"
def verve(api, params):
r = requests.get(f"{BASE}/{api}", params=params,
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]}, timeout=3)
body = r.json()
if not r.ok or body.get("status") != "ok":
raise RuntimeError(f"{api}: {body.get('error') or r.status_code}")
return body["data"]
def fan_out(calls):
"""calls: {name: (api, params)} -> {name: (ok, data_or_error)}"""
def run(item):
name, (api, params) = item
try:
return name, (True, verve(api, params))
except Exception as e:
return name, (False, str(e))
with ThreadPoolExecutor(max_workers=len(calls)) as pool:
return dict(pool.map(run, calls.items()))
if __name__ == "__main__":
out = fan_out({
"geo": ("iplookup", {"ip": "8.8.8.8"}),
"vpn": ("vpndetector", {"ip": "103.62.49.210"}),
"bin": ("binlookup", {"bin": "424242"}),
})
for k, (ok, v) in out.items():
print(k, "ok" if ok else "FAILED", v if not ok else {x: v.get(x) for x in ("country", "countryName", "is_datacenter", "type") if x in v})Cost and ordering
Run everything on every request and this assessment costs 80 credits: 20 for the email, 20 for sanctions, 10 each for phone, IP lookup, VPN and BIN. That is fine at checkout for a high-value order and wasteful on a free signup.
Two ways to bring it down. Run it at the right moment. Contact and network checks belong at signup; BIN and sanctions belong at the first payment or payout, when there is a card or a payee. Short-circuit cheaply. If the disposable-email check alone already puts the order over the block threshold, the remaining calls cannot change the outcome. Fanning out in parallel costs the full price but answers fastest; sequencing cheap checks first saves credits at the cost of latency. Pick per flow, and write down which you picked.
Cache by the right key, as earlier courses did: BIN facts per BIN for a day, VPN and IP facts per IP for an hour or so, sanctions results never across list updates.
Log reasons, then tune
Store every assessment, exactly as decide() returns it, with the order or account id. Later, join it with what actually happened: chargebacks, refunds, accounts closed for abuse, reviewer decisions.
That join is how you tune. For each reason code, compare how often it appeared on good outcomes against bad ones. A reason that shows up on 40% of your chargebacks and 2% of good orders deserves more weight. One that appears equally on both is noise; cut it. Then move the thresholds so the review queue is a size your team can actually work through. A review queue nobody clears is a block with extra steps.
Three rules for doing this safely:
- Change one thing at a time and record the date, so you can attribute the effect.
- Keep the thresholds in the log line. The
thresholdsfield in every decision makes a historical record self-describing after you change them. - Watch the allow rate as well as fraud. A tuning change that halves fraud and blocks 10% of honest customers was not an improvement.
Finally, be careful which signals you are weighting. Location and phone carrier correlate with nationality and income. A score that pushes everyone from one country to review because of a few bad actors there is a policy decision, and it should be made deliberately by someone accountable for it, with reviewer outcomes checked for exactly that bias.
You have finished the course
That completes the Verification Developer path: contact data, network signals, payments and compliance, and a decision layer on top. The certification exam tests the judgment as much as the field names: when to block versus review, what a check proves and what it does not, and how to fail safely. When you are ready, head to the Verification Developer certification to take it.