# zScreen documentation Every page below, in one file. Source: https://zscreen.zeruai.org/docs --- # Quickstart A verdict in your terminal in four steps. Everything else is on the pages after this one. ## 1. Get a key Mint one from [your dashboard](https://zscreen.zeruai.org/dashboard). It starts with `zsk_` and is shown once. ``` export ZSCREEN_KEY=zsk_... ``` ## 2. Screen an address Paste this. It runs as written. ``` curl -s https://zscreenapi.zeruai.org/v2/screen \ -H "Authorization: Bearer $ZSCREEN_KEY" \ -H "Content-Type: application/json" \ -d '{"address":"0xd08a234c5215a8122c836187b23f60046a90afd8"}' ``` `GET /v2/screen/{address}` does the same thing without a body. ## 3. Read the answer A `200` looks like this. Trimmed to what you act on. ``` { "address": "0xd08a234c5215a8122c836187b23f60046a90afd8", "verdict": "REVIEW", "risk_score": 87.4, "summary": "Needs a human look. Not on any list, and no contact with a listed address or a mixer. The model scored it 87.4 out of 100.", "decided_by": { "rule": "model", "source": "model", "reason": "Not on any list, and no contact with a listed address or a mixer. The model scored it 87.4 out of 100." }, "checks": [ { "name": "Sanctions list", "status": "clear", "detail": "No match" }, { "name": "Known hack and scam wallets", "status": "clear", "detail": "No match" }, { "name": "Mixers", "status": "clear", "detail": "No contact" }, { "name": "Behaviour", "status": "hit", "detail": "Unusual overall" } ], "why": [ { "kind": "decided", "reason": "Not on any list, and no contact with a listed address or a mixer. The model scored it 87.4 out of 100." }, { "kind": "model", "reason": "Money moved straight back out within a day.", "pushed": "up" } ], "confidence_label": "HIGH", "screening_id": 418822, "scored_at": "2026-09-16T09:14:22Z", "served_from": "cache" } ``` Four fields carry the decision: - `verdict` is `CLEAR`, `REVIEW` or `FLAGGED`. Branch on this. - `risk_score` is 0 to 100 and always agrees with the verdict. A list or contact hit is 100. - `checks` is every check that ran, including the ones that came back clean. Show it to an analyst. - `why` starts with what decided the verdict. The rest is what stood out. The full body carries more: measured behaviours, caveats, confidence and the model record. [The response](https://zscreen.zeruai.org/docs/responses) lists every field. ## 4. Handle a 202 A wallet we have never seen has to be fetched from the chain first. You get a `202` and a job. ``` { "address": "0x1f39eb3896756172728a2cfb116a6ec37d807125", "status": "queued", "job_id": 90114, "status_url": "/v2/job/90114", "jobs_ahead": 3, "detail": "This wallet has no history on file, so it is being fetched from the chain. Poll status_url. A wallet is NOT low risk because it has not been assessed yet." } ``` Poll the job until `state` is `done`. ``` curl -s https://zscreenapi.zeruai.org/v2/job/90114 \ -H "Authorization: Bearer $ZSCREEN_KEY" ``` ``` { "job_id": 90114, "address": "0x1f39eb3896756172728a2cfb116a6ec37d807125", "state": "done", "result": { "verdict": "CLEAR", "risk_score": 4.1, "...": "the same body as above" } } ``` Most addresses come back in under a minute. `jobs_ahead` tells you how busy the queue is. ## The one rule No verdict is not a clean wallet A `202`, a `402`, a `503` and every other non-answer carry `verdict: null` or no verdict field at all. Treating any of them as CLEAR is the one mistake this API is built to make impossible. Branch on the presence of `verdict`, never on the status code alone. ## Next - [Endpoints](https://zscreen.zeruai.org/docs/endpoints) for batch screening, the permanent record, and every parameter. - [The response](https://zscreen.zeruai.org/docs/responses) for every field in the body. - [The full report](https://zscreen.zeruai.org/docs/report) for the long answer, field by field. - [Reading a verdict](https://zscreen.zeruai.org/docs/reading-a-verdict) for what the three words mean and what to do with each. - [Limits and errors](https://zscreen.zeruai.org/docs/limits) for quota, rate limits and every status code. - [MCP server](https://zscreen.zeruai.org/docs/mcp) to give the same screening to an assistant. --- # Endpoints Every route, what it takes, and what comes back. All of them need a bearer key and all of them answer in JSON. v2 is the API Screening, polling and the report are all on `/v2`. The older `/v1` paths still answer and return the same body, so nothing built on them breaks, but they are not where new work goes. Three endpoints have no `/v2` address yet and are marked below: the batch screen, one permanent record, and a wallet's own history. ## Screen an address v2 is the screening surface Screening and its jobs are on `/v2`, where the number and the word always agree. Batch, the permanent record and wallet history have no v2 yet and stay on `/v1`. Both read the same stored row, so a wallet cannot be CLEAR on one door and queued on the other. Takes one field, `address`. Returns `200` with the scored body when the address has a record, or `202` with a job id when its history still has to be fetched. ``` curl https://zscreenapi.zeruai.org/v2/screen \ -H "Authorization: Bearer zsk_..." \ -H "Content-Type: application/json" \ -d '{"address":"0xd08a234c5215a8122c836187b23f60046a90afd8"}' ``` ``` const res = await fetch("https://zscreenapi.zeruai.org/v2/screen", { method: "POST", headers: { "Authorization": "Bearer zsk_...", "Content-Type": "application/json", }, body: JSON.stringify({ address: "0xd08a234c5215a8122c836187b23f60046a90afd8" }), }); const data = await res.json(); ``` Identical behaviour with the address in the path. Convenient from a browser or a shell, and it is a read, so it is safe to retry. ``` curl https://zscreenapi.zeruai.org/v2/screen/0xd08a234c5215a8122c836187b23f60046a90afd8 \ -H "Authorization: Bearer zsk_..." ``` ``` const res = await fetch("https://zscreenapi.zeruai.org/v2/screen/0xd08a234c5215a8122c836187b23f60046a90afd8", { headers: { "Authorization": "Bearer zsk_..." }, }); const data = await res.json(); ``` Both are billable A screen spends one unit of your allowance whether it comes back scored or queued, and whether it was computed now or served from the stored record. Reading a record you already have, through the two routes below, costs nothing. ## Screen a batch Takes `addresses`, an array of 1 to 100. Each one is screened in turn and sorted into one of three lists, so a malformed address in position 4 does not cost you the other 99 answers. ``` curl https://zscreenapi.zeruai.org/v1/screen/batch \ -H "Authorization: Bearer zsk_..." \ -H "Content-Type: application/json" \ -d '{"addresses":["0xd08a234c5215a8122c836187b23f60046a90afd8","0x97b1043abd9e6fc31681635166d430a458d14f9c"]}' ``` ``` const res = await fetch("https://zscreenapi.zeruai.org/v1/screen/batch", { method: "POST", headers: { "Authorization": "Bearer zsk_...", "Content-Type": "application/json", }, body: JSON.stringify({ addresses: ["0xd08a234c5215a8122c836187b23f60046a90afd8", "0x97b1043abd9e6fc31681635166d430a458d14f9c"] }), }); const { results, queued, errors } = await res.json(); ``` ``` { "results": [ { "address": "0xd08a234c5215a8122c836187b23f60046a90afd8", "verdict": "REVIEW", "...": "..." } ], "queued": [ { "address": "0x1f39...7125", "status": "queued", "job_id": 90114 } ], "errors": [ { "address": "0xnope", "error": "address must be 0x followed by 40 hex characters" } ], "counts": { "scored": 1, "queued": 1, "failed": 1 } } ``` If the allowance runs out partway through, everything already screened stands and every address after it is reported in `errors` as not screened. No verdict is ever invented to fill a gap. Batch is key-only: it never takes payment per screen. A spent allowance is told where a single screen can be bought instead. ## The full report A screen is built to be read in two seconds: a word, a number, one reason. The report is the same answer for somebody who has to file the wallet, with money in and out, the counterparties behind it, how close it sits to a listed address, the patterns it shows, and three paragraphs of prose. ``` curl https://zscreenapi.zeruai.org/v2/report/0xd08a234c5215a8122c836187b23f60046a90afd8 \ -H "Authorization: Bearer zsk_..." ``` It shares its serving path with the screen, so the verdict can never disagree, and the three answers are the same three. Field by field, it is on [its own page](https://zscreen.zeruai.org/docs/report). ## Check a job `state` moves through `queued`, `running`, and then either `done` or `failed`. Once it is `done`, the full scored body is inside `result`, so a successful poll ends the exchange with no second call. ``` const res = await fetch("https://zscreenapi.zeruai.org/v2/job/90114", { headers: { "Authorization": "Bearer zsk_..." }, }); const job = await res.json(); if (job.state === "done") console.log(job.result.verdict); ``` ``` { "job_id": 90114, "address": "0x1f39eb3896756172728a2cfb116a6ec37d807125", "state": "done", "attempts": 1, "error": null, "created_at": "2026-09-01T09:12:40Z", "finished_at": "2026-09-01T09:14:22Z", "result": { "verdict": "CLEAR", "risk_score": 4.1, "...": "the v2 screen body" } } ``` A `failed` job carries the reason in `error` and no result. It is a failure to gather data, not a finding about the wallet. ## Records and history Every scored response is written down and handed back an id. This route returns that record byte for byte, which is what makes a decision you made last quarter reviewable this quarter. Records are scoped to the key that created them. A record belonging to another customer answers `404` rather than `403`, because which ids exist is not yours to learn either. ``` curl https://zscreenapi.zeruai.org/v1/screening/418822 \ -H "Authorization: Bearer zsk_..." ``` ``` const res = await fetch("https://zscreenapi.zeruai.org/v1/screening/418822", { headers: { "Authorization": "Bearer zsk_..." }, }); const record = await res.json(); ``` Every check your key has run against one address, newest first. Takes an optional `limit`, default 50 and capped at 500. Scoped the same way: one customer's search history is not another customer's business. ## Operational These three need no key. `/healthz` answers as soon as the process is running; `/readyz` answers only once its dependencies are reachable, which is the one to put in front of a load balancer. Status codes, rate limits and quota behaviour are on [Limits and errors](https://zscreen.zeruai.org/docs/limits). --- # The response Field by field, what a scored body contains. Every 200 has this shape, whether the answer came from a list lookup or from the model. ## The whole body One live body, so the sections below have something to point at. It is a real response, copied from a real screen, and every 200 carries the same keys in the same places. ``` { "address": "0x97b1043abd9e6fc31681635166d430a458d14f9c", "verdict": "FLAGGED", "risk_score": 100.0, "decided_by": { "rule": "ofac_list", "source": "rule", "reason": "This wallet is on the OFAC sanctions list. Dealing with it is a legal risk." }, "summary": "This wallet is on the OFAC sanctions list. Dealing with it is a legal risk.", "checks": [ { "name": "Sanctions list", "status": "hit", "detail": "Match" }, { "name": "Known hack and scam wallets", "status": "clear", "detail": "No match" }, { "name": "Mixers", "status": "clear", "detail": "No contact" }, { "name": "Behaviour", "status": "hit", "detail": "4 of 6, above the 2 a typical wallet shows" } ], "behaviours": [ { "key": "exchange_exit", "label": "Sends most of its money straight into an exchange", "clear_label": "Does not funnel its money into an exchange", "present": true }, { "key": "fast_onward", "label": "The wallets it pays move the money on again quickly", "clear_label": "The wallets it pays hold on to the money", "present": true }, { "key": "round_amounts", "label": "Sends round, hand-picked amounts", "clear_label": "Amounts look ordinary, not hand-picked", "present": false }, { "key": "forwards_fast", "label": "Forwards nearly everything within a day of receiving it", "clear_label": "Holds what it receives rather than passing it straight on", "present": true }, { "key": "keeps_nothing", "label": "Keeps nothing back — sends out as much as it takes in", "clear_label": "Keeps some of what it receives", "present": true }, { "key": "burst_then_quiet", "label": "Worked in a burst, then went quiet for months", "clear_label": "Used steadily rather than in one burst", "present": false } ], "show_behaviours": true, "behaviour_note": "4 of 6. A typical wallet shows 2, and even wallets we know are sanctioned typically show 4.", "why": [ { "kind": "decided", "reason": "This wallet is on the OFAC sanctions list. Dealing with it is a legal risk.", "feature": "ofac_list", "value": null, "pushed": null, "percentile": null }, { "kind": "model", "reason": "The wallet has been in use for 6 years.", "feature": "age_days", "value": 1981.5, "pushed": null, "percentile": null }, { "kind": "model", "reason": "It has moved $18.3M in and $18.4M out. Almost everything that came in went straight back out.", "feature": "usd_in", "value": 18322838.163848, "pushed": null, "percentile": null }, { "kind": "model", "reason": "It has dealt with 15 different wallets and handled 4 different tokens.", "feature": "n_counterparties", "value": null, "pushed": null, "percentile": null } ], "caveats": [], "confidence": 0.927, "confidence_label": "HIGH", "ofac_hard_match": true, "data_quality": { "capped": false, "truncated": false, "n_tx_total": 246, "coverage_ratio": 1.0, "payload_missing": false }, "model": { "bags": 200, "name": "pu_bag_variant_a_plus500", "note": "percentile is the model's own number, 0-100, against the wallets it was trained on; when a list or contact rule fires the risk_score is 100 regardless of it", "variant": "a", "trained_at": "2026-08-26T13:55:21Z", "recall_guarantee": "100% of the 88 OFAC-listed positives sit at or above the REVIEW line in out-of-fold validation", "percentile": 98.2 }, "scoring_version": "v2", "screening_id": 45779, "scored_at": "2026-09-17T11:24:01.860246Z", "stale": false, "served_from": "cache" } ``` ## The answer | Field | Type | What it is | | --- | --- | --- | | `address` | `string` | The address you asked about, lowercased. | | `verdict` | `string` | CLEAR, REVIEW or FLAGGED. Derived from risk_score, never set beside it. | | `risk_score` | `number | null` | 0 to 100. Null means the address was not scored at all, which is not the same as scoring low. | | `decided_by.rule` | `string` | The one thing that set the score: ofac_list, hacker_list, ofac_contact, hacker_contact, mixer_contact, contract, exchange_custody, unchecked or model. | | `decided_by.source` | `string` | "rule" for a deterministic lookup, "model" when nothing above it fired. | | `decided_by.reason` | `string` | That decision written out as a sentence. It is repeated as the first entry of why. | | `summary` | `string` | One or two sentences for a human reader. | | `ofac_hard_match` | `boolean` | True only for a direct hit on the sanctions list. A lookup, not a prediction. | | `confidence` | `number` | 0 to 1, how much evidence stood behind the answer. Not the probability that the wallet is bad. | | `confidence_label` | `string` | HIGH, MED or LOW, the same number banded. | risk_score can be null A contract, or a trail that ends inside an exchange's own wallet, is an address this API refuses to score rather than one it scored low. The verdict comes back `REVIEW` and the number comes back `null`. Rendering that as a zero turns a refusal into a clean bill of health. ## why The reasoning, as an array. Every entry has a `kind`, and there are exactly two of them. | Field | Type | What it is | | --- | --- | --- | | `kind` | `string` | "decided" for the line that set the verdict, "model" for something the model noticed. The first entry is always the decided one. | | `reason` | `string` | The sentence itself, written for a person. | | `pushed` | `string | null` | Reserved. Intended to carry "up" or "down", which way an observation moved the score, but the scorer does not yet set it: today it is null on every entry. Do not branch on it. | | `feature` | `string | null` | The machine name of the signal behind the sentence. | | `value` | `number | null` | What that signal measured for this wallet. | | `percentile` | `number | null` | Reserved. Intended to place that value against ordinary wallets, 0 to 100, but the scorer does not yet set it per reason: today it is null on every entry. | [Reading a verdict](https://zscreen.zeruai.org/docs/reading-a-verdict) works through what to do with the two kinds, and why showing them as one flat list misleads a reader. ## checks Every check that was run and how it came back. The clean ones are the point: three passes beside one hit is what makes the hit credible. | Field | Type | What it is | | --- | --- | --- | | `name` | `string` | The check, named for a reader rather than for a log. | | `status` | `string` | "clear" if it ran and found nothing, "hit" if it found something, "unchecked" if it could not run. | | `detail` | `string` | One sentence on what that status means here. | unchecked is not clear A check that could not run has told you nothing. Count it separately in any tally you show a user, and never fold it in with the passes. ## behaviours Named patterns, each measured against the wallets confirmed to be sanctioned. They describe how a wallet moves money; on their own they accuse it of nothing. | Field | Type | What it is | | --- | --- | --- | | `key` | `string` | Stable machine name for the behaviour. | | `label` | `string` | How to say it about a wallet that shows it. | | `clear_label` | `string | null` | The same behaviour said from the other side, so a clean result can name what was looked for instead of showing an empty list. | | `present` | `boolean | null` | Null, never false, when the input that decides it was missing. A behaviour nobody could look at must not read as one the wallet lacks. | | `show_behaviours` | `boolean` | Whether this list belongs in front of a customer at all. Two or three of these is what an ordinary wallet looks like. | | `behaviour_note` | `string | null` | One sentence about how this wallet moves money, safe to show whether or not the list is. | ## caveats Limits on the answer: how old the sanctions snapshot was, where the data ran out, where a custodian's wallet made one customer indistinguishable from thousands of others. Caveats are not evidence A caveat says what this answer could not see. It is not a finding against the wallet, and stacking caveats into a risk display turns a data limit into an accusation. ## The record | Field | Type | What it is | | --- | --- | --- | | `screening_id` | `number | null` | Id of the permanent record. Fetch it later through /v1/screening/{screening_id}. | | `scored_at` | `string` | ISO 8601 timestamp of when this answer was produced. | | `scoring_version` | `string` | Which scoring contract produced it. | | `model` | `object` | Name and version of the model behind a model decision. | | `data_quality` | `object` | How much history stood behind the answer, so a thin record can be recognised as thin. | ## Freshness | Field | Type | What it is | | --- | --- | --- | | `stale` | `boolean` | True when the record is past its 24 hour life and a refresh is already running. The answer is the last known one. | | `served_from` | `string` | "cache" for a live record, "stale" for one being refreshed behind your request. | A stale answer is a real answer that has aged, and it is returned rather than withheld because the last known verdict beats no verdict. If your workflow cannot accept one, read `stale` and wait for the refresh. --- # 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/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 | 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](https://zscreen.zeruai.org/docs/endpoints) 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_score`at 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 `observed`beginning "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_recall`is 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. --- # Reading a verdict The number and the word always agree, and the body tells you which of them was decided by a lookup and which by a model. This page is how to read that. ## The three words Nothing connects this address to a sanctioned or illicit source, and the model found nothing in its behaviour worth a person's time. It is the only verdict that means what it sounds like. A person should look. Either the model put this wallet high enough to be worth an hour, or the trail reached somewhere the API refuses to clear on its own, such as a custodian's wallet holding thousands of customers at once. Exposure to a sanctioned or illicit source, either directly or through an address that has one. When this comes from a list or a contact rule it is a database match, not a prediction, and `ofac_hard_match` tells you which. ## What sets the score Exactly one thing sets `risk_score`, and `decided_by.rule` names it. The checks run in this order and stop at the first one that fires. 1. **Lists and contacts.** On the OFAC list, on our candidate list, or having sent to or received from any listed address or any known mixer, at any amount. Score 100, verdict FLAGGED, `source` is `rule`. No model runs. 2. **The model.** Nothing above fired, so the score is the model's own 0 to 100 number and the verdict is a pure function of it. Below the review line it is CLEAR, at or above it REVIEW, at or above the flag line FLAGGED. 3. **The floors.** The model said CLEAR but the address is a contract, or the trail ends in exchange custody, or a check could not run. The score is raised to the review line and the verdict becomes REVIEW. A wallet that shows most of the named behaviours also gets a floor under its score, so a reader is never shown CLEAR next to a list of ticked behaviours. The floor moves the number the verdict is read from; it does not overrule the verdict separately. The lines are configuration The review line is 85 and the flag line is 99.1 by default. Both are deployment settings, so derive the verdict from the `verdict` field rather than recomputing it from the score at your end. ## The two kinds of reason `why` looks like one list and is really two. Every entry carries a `kind`, and the difference between them is the difference between a cause and an observation. ``` "decided_by": { "rule": "mixer_contact", "source": "rule", "reason": "This wallet received funds from a known mixer." }, "why": [ { "kind": "decided", "reason": "This wallet received funds from a known mixer.", "pushed": null }, { "kind": "model", "reason": "Funds were passed along in a chain of one-to-one transfers.", "pushed": "up" }, { "kind": "model", "reason": "A long history with no flagged counterparty anywhere in it.", "pushed": "down" } ] ``` `kind: "decided"` is the first entry, always. It repeats `decided_by.reason` and it is the only line that explains the score. Its `pushed` is null, because it did not move the number: it set it. `kind: "model"` is everything the model noticed about the wallet, each with `pushed` saying which way it leaned. In the example above a mixer contact set the verdict, and the two model lines are context: one raising, one lowering. Neither of them caused the FLAGGED. Do not render them as one list Flattening the two kinds into a single bulleted list of reasons puts a lowering observation directly under a sanctions match, as though they were weighed against each other. Show the decided line on its own, above the rest. ## Addresses that are never cleared Three cases come back REVIEW no matter what the model thought, and two of them carry a null score. - **Contracts.** A contract is not a wallet with a history, so scoring it as one would be a category error. - **Exchange custody.** The trail arrived at a wallet a custodian holds for many customers at once. Beyond that point one customer is not separable from the rest, and the boundary is named in the caveats. - **A check that could not run.** Missing input is missing information, and the API will not spend it as evidence in either direction. ## What is not a verdict Several responses look like answers and are not. Each one carries a null verdict, or no verdict field at all, and a sentence saying so. - `202`, the wallet has never been screened and is being fetched now. - `402`, your allowance is spent, so no screening was performed. - `503`, the store is briefly unreachable and the request never ran. - A job in state `failed`, which is a failure to gather data. The one mistake that matters An address that has not been assessed is not a low-risk address. If your integration defaults to allowing anything that did not come back FLAGGED, an outage becomes an approval. Branch on the presence of a verdict first, and on its value second. ## Building on this - Read `verdict`. If it is absent or null, you do not have an answer. - Show `summary` to a person and `decided_by.reason` beside it. They agree by construction. - Keep `screening_id`. It is how a decision made today is explained six months from now. - Treat `caveats` as limits on the answer, never as evidence against the wallet. The full field list is on [The response](https://zscreen.zeruai.org/docs/responses). --- # Limits and errors What stops a request, what it costs, and what every status code means. No failure mode here ever produces something a client can read as a clean wallet. ## Keys Keys begin with `zsk_` and travel as a bearer token. Only a SHA-256 of the key is stored, so it can never be shown to you twice and a leaked database leaks no working credentials. Verified keys are held in memory briefly, which is why a revoked key can still work for a few seconds. Revoke and rotate rather than relying on the moment of revocation, and mint a separate key per environment so revoking one does not stop the others. ## Rate limit Each key carries its own requests-per-second limit with a burst allowance on top, because real traffic arrives in bumps: twenty counterparties screened at once, then nothing for a minute. Sustained traffic is measured against the rate, not against the burst. Going over answers `429` with a `Retry-After` header. Honour it. The limit is per key, so splitting a workload across two keys on the same account does not buy more throughput of the thing that actually costs money, which is quota. ## Quota The allowance is per account, not per key: minting a second key does not mint a second allowance. A new account opens with 100 screens, free. A screen spends one unit whether it comes back scored or queued. Reading a record you already own, through `/v1/screening/{screening_id}` or `/v1/wallet/{address}/screenings`, costs nothing. The 101st screen returns `402`, and so does a screening call sent with no key at all. Nothing is assessed either way. The body carries the quota numbers when a key was sent, and carries x402 payment requirements in both cases: the amount and the address to pay. A `PAYMENT-REQUIRED` header repeats them, base64 encoded, for a client that reads headers rather than bodies. ``` { "error": "quota exhausted", "detail": "100 of 100 free screens used; no screening was performed; this is NOT a verdict.", "verdict": null, "quota_limit": 100, "quota_used": 100, "quota_period": "lifetime", "quota_reset_at": null, "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "eip155:8453", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "500000", "payTo": "0xf13986F98F3DD6C7879482896dEFFB1Fe9755F5a", "maxTimeoutSeconds": 60, "extra": { "name": "USD Coin", "version": "2" } } ], "upgrade": { "available": true, "method": "x402", "price": "$0.50", "network": "eip155:8453", "contact": "https://calendly.com/ajay-zeru/30min" } } ``` The counter is spent against the database on every billable request rather than against a cached copy, so two requests arriving together cannot both see the last unit. ## Paying per screen A screen past the free tier costs $0.50 USDC on Base, paid with [x402](https://docs.x402.org). The caller signs an EIP-3009 `transferWithAuthorization` for that amount and sends the same request again with the signed payload in a `PAYMENT-SIGNATURE` header. Older x402 clients send `X-PAYMENT`, and both headers are accepted. A client library handles the 402 on its own: `x402` on PyPI for Python, `@x402/fetch` on npm for TypeScript. Give it a wallet holding USDC on Base and call the API as before, with a key or without one. ``` # PAYMENT_SIGNATURE is the signed x402 payload, base64. An x402 client fills it in. curl -X POST https://zscreenapi.zeruai.org/v2/screen \ -H "PAYMENT-SIGNATURE: $PAYMENT_SIGNATURE" \ -H "Content-Type: application/json" \ -d '{"address":"0xd08a234c5215a8122c836187b23f60046a90afd8"}' ``` Nothing is charged until the answer is served. A paid request that returns `200` or `202` is settled on chain, and the response carries a `PAYMENT-RESPONSE` header with the transaction hash. A `422` refusal or a `5xx` failure is not settled, and a signature sent a second time is refused with `402`. A paid `202` comes back with a status link carrying a poll token, so the payer can poll that one job with no key. A paid screen sent without a key still gets a `screening_id`, but reading it back needs a key: the record and history routes both require one. `POST /v1/screen/batch` takes a key only, and when the free tier runs out partway through a batch each remaining address is reported as not screened, with a pointer to pay per screen at `POST /v2/screen`. ## Records and freshness A scored answer lives for 24 hours. After that the next request still receives the last known answer, marked `stale`, while a refresh runs behind it. The stored record itself is permanent and never expires. A batch takes at most 100 addresses. A history read takes an optional `limit`, default 50 and capped at 500. ## Address coverage zScreen screens Ethereum mainnet history for EVM-format addresses: `0x` followed by 40 hex characters. An address in a different format, or history that only exists on another chain, is outside what this API can answer today. ## Every status code A scored answer. The one response that carries a verdict. Never screened before, so the history is being fetched. Carries a `job_id` and no verdict. Poll `GET /v2/job/{job_id}`. No key, a malformed header, or a key that is unknown or revoked. The message says which. The allowance is spent, or no key was sent. No screening was performed. The body states the numbers, carries an explicit null verdict, and carries the x402 payment requirements for paying per screen instead. No such job, or no such record. A record belonging to another customer answers 404 as well, because which ids exist is not yours to learn. The address is not 0x followed by 40 hex characters, or a field is missing. Nothing was screened and nothing was spent. Over the rate limit for this key. Carries `Retry-After`. Nothing was spent. Something unforeseen. Still JSON, still carries a null verdict, and carries no internals. The screening store is briefly unreachable. Carries `Retry-After`, and the pool usually heals within seconds. ``` { "error": "database unavailable", "detail": "The screening store is temporarily unreachable. Retry shortly. This is NOT a verdict.", "verdict": null } ``` ## Retrying safely - **429 and 503** are worth retrying, with backoff and the `Retry-After` header respected. Neither spent anything. - **401 and 422** will fail identically on a retry. Fix the key or the address. - **402** is a retry with a payment attached, or a stop. Sending the same request again unchanged fails the same way. - **202** is not a failure. Poll the job rather than resending the screen, which would spend another unit for the same answer. Never fail open Every error on this page carries a null verdict on purpose. A client that treats any non-FLAGGED response as permission to proceed turns an outage into an approval. Check that a verdict is present before you look at what it says. --- # MCP server zScreen is available as a hosted Model Context Protocol server, so an assistant can screen a wallet through a tool call instead of an HTTP request. There is nothing to install and nothing to run. ## Connect The bearer token is an ordinary zScreen API key, the same one the HTTP API takes. Sign in at [/signin](https://zscreen.zeruai.org/signin) with Google or a wallet, then create a key from [/dashboard](https://zscreen.zeruai.org/dashboard). It is shown once, on that screen, so copy it before navigating away. Paste it in place of `zsk_...` below. In Claude Code: ``` claude mcp add --transport http zscreen \ https://zscreenapi.zeruai.org/mcp \ --header "Authorization: Bearer zsk_..." ``` Clients that take a configuration file, such as Cursor and VS Code, want the same three things: the transport, the URL, and the header. ``` { "mcpServers": { "zscreen": { "type": "http", "url": "https://zscreenapi.zeruai.org/mcp", "headers": { "Authorization": "Bearer zsk_..." } } } } ``` Clients that only offer OAuth fields, which includes the Claude web connector, cannot use a key header yet. Use Claude Code, Cursor or VS Code until that lands. ## Tools - `screen_wallet(address)` screens one address and returns the verdict. - `get_job(job_id)` polls a wallet that had to be fetched. - `get_screening(screening_id)` returns one record from your account. - `list_wallet_screenings(address)` lists your screenings for an address. ## Read assessed first Every result carries an `assessed` field, and it is the one to branch on. Only a result with `assessed: true` carries a verdict. ``` { "ok": true, "assessed": true, "verdict": "FLAGGED", "risk_score": 100, "decided_by": { "rule": "ofac_contact", "source": "rule", "reason": "This wallet dealt directly with an address on the OFAC sanctions list." } } ``` A wallet nobody has looked at yet comes back with `assessed: false` and a null verdict. It is not a clean wallet. The same is true when the free tier is spent and when the chain has nothing to score. ``` { "ok": true, "assessed": false, "status": "queued", "job_id": 41, "verdict": null, "note": "This wallet has no history on file and is being fetched. Poll get_job with job_id. A wallet is NOT low risk because it has not been assessed yet." } ``` Rate limits and brief outages also return `assessed: false`, with a `retry_after`, so a failure to answer is never mistaken for an answer. ## Limits Tool calls are screens. They count against the same free tier and the same requests-per-second limit as the API, on the key you configured, so [Rate limits and the free tier](https://zscreen.zeruai.org/docs/limits) applies unchanged. When the key's free tier is spent, `screen_wallet` returns `assessed: false` with `status: "payment_required"` and a `payment` object. The agent cannot pay from inside MCP: a person, or an x402 client, pays $0.50 per screen over the HTTP API. ## No MCP in your client? An agent can call the HTTP API directly instead. [skill.md](https://zscreen.zeruai.org/skill.md) is a single file that tells it how, and [For agents](https://zscreen.zeruai.org/docs/agents) covers the rest of what this site offers machines. --- # For agents These docs are built to be read by software as well as people. Every page has a markdown twin, there is a skill you can drop into a coding agent, and the whole corpus is available as one file. ## The skill [skill.md](https://zscreen.zeruai.org/skill.md) is a single file of instructions for a coding agent: the base URL, the authentication header, the endpoints, and the rules for reading a result. Give it to an agent and it can screen an address without you explaining the API. For Claude Code, put it where skills live: ``` mkdir -p ~/.claude/skills/zscreen curl -o ~/.claude/skills/zscreen/SKILL.md https://zscreen.zeruai.org/skill.md ``` For anything else, paste the file into the system prompt or attach it as context. It is plain markdown with no dependencies. The skill leads on the one rule that matters: a response without a verdict is not a clean wallet. An agent that treats a queued or refused answer as CLEAR is the failure this product exists to prevent, so the skill states it before it states anything else. ## Every page as markdown Add `.md` to any docs URL and you get the same page as markdown, without the navigation or the styling. Cheaper for a model to read, and it is generated from the page itself, so it cannot fall out of step with what you see here. ``` https://zscreen.zeruai.org/docs.md https://zscreen.zeruai.org/docs/endpoints.md https://zscreen.zeruai.org/docs/limits.md ``` Every page also declares its twin with ``, so a crawler finds it without guessing. ## Copy page **Copy page**at the top of any page puts that page's markdown on your clipboard, ready to paste into a model. **View as Markdown** opens it instead. Both use the same file a crawler would read, so what you paste is what the machine sees. ## The whole corpus [llms.txt](https://zscreen.zeruai.org/llms.txt) indexes the documentation, with the base URL, the authentication header and the response rules at the top. [llms-full.txt](https://zscreen.zeruai.org/llms-full.txt) is every page concatenated, for when you want to hand over the lot in one go. ``` curl https://zscreen.zeruai.org/llms.txt # the index curl https://zscreen.zeruai.org/llms-full.txt # every page, one file ``` ## Tools instead of HTTP If your client speaks Model Context Protocol, it can call zScreen as tools rather than reading these docs and writing requests. See [MCP server](https://zscreen.zeruai.org/docs/mcp).