The full report
The same verdict as a screen, written out for somebody who has to file the wallet. Money in and out, who it dealt with, how close it sits to a listed address, what patterns it shows, and three paragraphs of prose.
One call
One address, one JSON body. The address is case-insensitive and comes back lowercased. The report shares its whole serving path with /v2/screen: same lookup, same queue on a miss, same quota charge, same audit record.
curl -H "Authorization: Bearer zsk_..." \
https://zscreenapi.zeruai.org/v2/report/0x098b716b8aaf21512996dc57eb0615e2383e2f96A bearer token is the only way in. Send no header and you get a 402 carrying an x402 offer rather than a 401. Send a dead key and you get the 401. They are different conditions: the first means no credentials were presented, the second means these credentials are finished. A 422 or a 503 refunds the quota unit, so nobody pays for an answer they did not get.
The three answers
| Field | Type | What it is |
|---|---|---|
200 | report | A scored row exists. Render it. |
202 | queued | No history on file. A fetch-and-score job was queued. Poll the job, then ask again. |
4xx | refusal | Bad address, dead key, no credentials, quota gone, or rate limited. Never a verdict. |
A 202 is not a clean wallet
Every 202 and every 4xx carries "verdict": null and a sentence saying so. A wallet is not low risk because it has not been assessed yet. If your interface treats any non-200 as nothing found and therefore fine, that is the exact failure this service exists to prevent.
{
"address": "0x1111111111111111111111111111111111111133",
"status": "queued",
"job_id": 952218,
"status_url": "/v2/job/952218",
"detail": "This wallet has no history on file, so it is being fetched from the chain...",
"jobs_ahead": 6
}Asking again for the same address while it is queued returns the same job_id. It does not pile up duplicate work.
Two rules that matter
The verdict can never disagree with the screen
verdict, risk_score and confidence are not recomputed here. They come from the same rescore of the same stored row that the screen uses, so a report cannot say REVIEW where the screen said CLEAR. Nothing else in the body feeds back into them, the exposure figures included. Those are reporting, and reporting only.
A CLEAR wallet stays quiet
On a CLEAR verdict, reasons, patterns, categories and traits come back as empty arrays. Those lists are our working, and printed beside the word CLEAR they read as an accusation the reader then has to argue with. The figures stay: scores, basics, counterparties and distance are facts, not arguments, and are returned whatever the verdict.
{
"address": "0x8d2434f834dc7d3f5698bbe0cce2a985ab789b61",
"verdict": "CLEAR",
"risk_score": 2.1,
"reasons": [],
"patterns": [],
"categories": [],
"traits": [],
"paragraphs": [ "Nothing about how this wallet moves money stands out.", "...", "..." ],
"counterparties": { "incoming": [ "..." ], "outgoing": [ "..." ] },
"partial": false,
"served_from": "cache"
}traits is populated exactly when the screen's own show_behaviours is true. One switch, read in one place, so the two pages never disagree about whether the checklist belongs in front of a customer.
Response fields
| Field | Type | What it is |
|---|---|---|
address | string | Lowercased 0x and 40 hex characters. |
verdict | string | CLEAR, REVIEW or FLAGGED. Identical to the screen for this row. |
risk_score | number | null | 0 to 100. Null for an address we refuse to score. |
confidence | string | HIGH, MED or LOW. How much history stood behind the call. |
scores | object | incoming and outgoing. See the exposure numbers below. |
basics | object | Balance, USD in and out, transaction count, counterparty count, first and last seen, age and quiet days. |
reasons | array | Up to four, in the scorer's rank order. Empty on CLEAR. |
paragraphs | array | Always exactly three, ready to render as prose. |
counterparties | object | incoming and outgoing arrays, largest first, with public labels where known. |
exposure_distance | object | direct, two_hops and three_plus. |
patterns | array | Observed patterns only, out of the 12 the catalogue checks. Empty on CLEAR. |
categories | array | What kind of wallet it resembles, one entry per category above its own review line. Empty on CLEAR. |
traits | array | The 6-entry behaviour checklist. Empty on CLEAR, or when show_behaviours is false. |
partial | boolean | True for a row scored before the report fields existed. |
caveats | array | Limits on the data, in plain words. Never merged with the evidence. |
screening_id | number | null | The permanent record id. |
scored_at | string | When the row was scored, not when you asked. |
stale | boolean | Past its TTL. Still served, with a refresh queued behind your request. |
served_from | string | "cache" or "stale", whichever you got. |
Each entry in reasons carries a rank of 1 to 4, a machine feature name, a reason written as a full sentence for a customer, an impact of high, med or low, and a direction of raises risk or lowers risk. Reasons cut both ways, so show both. Render the sentence as sent rather than paraphrasing it.
Exposure numbers
Render risk_score, one per direction. It is the share of that direction’s priced value that traces to a listed address within one hop, on a 0 to 100 scale, and risk_basis is the sentence that explains it. Print the sentence as sent rather than paraphrasing it.
"scores": {
"incoming": {
"risk_score": 100.0,
"risk_basis": "4.5% of it came from a sanctioned or hack-listed address directly, and 96% of it arrived from a wallet that had itself dealt with one.",
"score": 4.5, "ofac_share": 0.0, "hacker_share": 0.0448868,
"usd_from_ofac": 0.0, "usd_from_hackers": 25435258.62,
"usd_total": 566652491.01,
"score_2hop": 95.5, "share_2hop": 0.9551075, "usd_from_2hop": 541214077.44
},
"outgoing": {
"risk_score": 100.0,
"risk_basis": "89% of it went to a sanctioned or hack-listed address directly, and 11% of it was sent to a wallet that had itself dealt with one.",
"score": 88.5, "ofac_share": 0.7451287, "hacker_share": 0.1399658,
"usd_to_ofac": 424528619.72, "usd_to_hackers": 79743976.59,
"usd_total": 569738627.88,
"score_2hop": 11.5, "share_2hop": 0.1149053, "usd_to_2hop": 65466031.58
}
}One figure a side, and it is not the verdict
A wallet that has touched no listed address scores 0.0 on score, including a wallet whose counterparties have all dealt with OFAC. On its own that 0.0 reads as clean, and for that wallet it is not. So risk_score carries both tiers at once: score plus score_2hop, capped at 100. They are safe to add because the hops are cut by distance and cannot overlap, and a wallet that is itself listed sits at distance zero and is never also counted at one.
The components stay in the body for anyone who wants the split, but do not ask a reader to add them. Earlier versions of this page drew both as two rows per direction; that put six figures in one panel, none of which was the verdict. The only number that decides anything is risk_scoreat the top level of the body, which is the screen’s own.
Where there is no priced value to divide by, all three one-step-removed figures are 0.0 together. That means no such exposure, which is a different thing from the null a partial row carries.
Distance
exposure_distance says how far the wallet sits from a listed address. Each tier carries n, usd, n_in, n_out, usd_in, usd_out, and up to five examples. Direct examples also name the entity and the direction.
"exposure_distance": {
"direct": {
"n": 18, "usd": 529707854.92,
"n_in": 2, "n_out": 18,
"usd_in": 25435258.62, "usd_out": 504272596.3,
"examples": [
{ "usd": 98713461.02, "entity": "LAZARUS GROUP",
"address": "0x35fb...d4b1", "direction": "sent to" }
]
},
"two_hops": { "n": 694, "usd": 606680109.02, "...": "..." },
"three_plus": { "n": 5, "usd": 16.19, "...": "..." }
}Partial rows
A wallet scored before this endpoint existed has no stored report block. It is served honestly rather than guessed at: partial is true, the money figures, counterparties, distance and patterns come back as null rather than zero, an extra line in caveats says the detailed report is not available yet, and a refresh is queued behind your request.
Null matters here. Zero would read as no money moved, which is a claim about the wallet. Null says we do not have this yet, which is a claim about us. Ask again a short while later and you get the full report.
Patterns and traits
Two separate lists. patterns is the laundering-technique catalogue, and it carries only what was observed: present is always true on every entry it sends. Each has key, name, present and observed, a sentence saying what was seen. The addresses and amounts the catalogue matched on are stripped before the body is sent.
What is missing from the list, and where it went
Of the twelve the catalogue checks, four read from a graph table that is not loaded for every wallet. They used to arrive as present: false with an observedbeginning "Not checked", which meant a third of the panel said "we do not know" beside rows that were findings. They are no longer in patterns. Their count and names are in caveats, worded as a limit on the data. Do not render an absence from this list as a clean result: a pattern not in it was either looked for and not found, or never measured, and caveats is the only place that tells you which.
{ "key": "peel_chain", "name": "Peel chain", "present": true,
"observed": "Sent its whole balance on in 9 of 11 ETH sends." }traits is the plain-English behaviour checklist, six entries, each with key, label and present. The label is written from the side that is true, so render it as given rather than negating it. A present of null means the trait could not be checked. Do not draw that as a no.
What kind of wallet it is
categories names the crime. Three models, one each for phishing, fraud and exploit, score the same feature row the verdict was built on; they add no new input and they change no number. Empty on a CLEAR, and empty on a row scored before they existed.
"categories": [
{
"key": "fraud", "name": "Fraud / scam payout",
"level": "strong",
"meaning": "This wallet moves money the way a scam payout address does: many payers, few payees, and balances that leave in full rather than in part.",
"model_recall": 0.849,
"findings": [
{ "finding": "Took money from 214 wallets and paid it out to 3 - 71 payers for every payee.",
"basis": "2.70x more common than background; fires on 55.4% of labelled fraud wallets" },
{ "finding": "68% of its payments emptied the wallet completely rather than sending a part.",
"basis": "2.10x more common than background; fires on 54.4% of labelled fraud wallets" }
]
}
]level is strong above that model’s flagged line and present above its review line. meaning is one sentence on what the shape is. Each entry in findings is a condition that actually holds for this wallet, with the measurement that earned it a place in basis: its lift over a 33,645-wallet background and the share of labelled positives it fires on. At most three are sent per category, sharpest first.
An absent category is not a clearance
A category below its own review line is left out of the array entirely. It is never sent as a negative, and you must not render one. These models cannot clear a wallet:model_recallis the model’s own out-of-fold recall, and the three are not equally good. Phishing and fraud reach 0.85; exploit reaches 0.39 on 14 incidents. Show model_recall wherever you show the category, so a weak one is visibly weak.