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.

GET /v2/report/{address}
curl -H "Authorization: Bearer zsk_..." \
  https://zscreenapi.zeruai.org/v2/report/0x098b716b8aaf21512996dc57eb0615e2383e2f96

A 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

FieldTypeWhat it is
200reportA scored row exists. Render it.
202queuedNo history on file. A fetch-and-score job was queued. Poll the job, then ask again.
4xxrefusalBad 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.

202 Accepted
{
  "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.

200 OK, CLEAR
{
  "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

FieldTypeWhat it is
addressstringLowercased 0x and 40 hex characters.
verdictstringCLEAR, REVIEW or FLAGGED. Identical to the screen for this row.
risk_scorenumber | null0 to 100. Null for an address we refuse to score.
confidencestringHIGH, MED or LOW. How much history stood behind the call.
scoresobjectincoming and outgoing. See the exposure numbers below.
basicsobjectBalance, USD in and out, transaction count, counterparty count, first and last seen, age and quiet days.
reasonsarrayUp to four, in the scorer's rank order. Empty on CLEAR.
paragraphsarrayAlways exactly three, ready to render as prose.
counterpartiesobjectincoming and outgoing arrays, largest first, with public labels where known.
exposure_distanceobjectdirect, two_hops and three_plus.
patternsarrayObserved patterns only, out of the 12 the catalogue checks. Empty on CLEAR.
categoriesarrayWhat kind of wallet it resembles, one entry per category above its own review line. Empty on CLEAR.
traitsarrayThe 6-entry behaviour checklist. Empty on CLEAR, or when show_behaviours is false.
partialbooleanTrue for a row scored before the report fields existed.
caveatsarrayLimits on the data, in plain words. Never merged with the evidence.
screening_idnumber | nullThe permanent record id.
scored_atstringWhen the row was scored, not when you asked.
stalebooleanPast its TTL. Still served, with a refresh queued behind your request.
served_fromstring"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
"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
"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.

one pattern
{ "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
"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.