{"openapi": "3.1.0", "info": {"title": "PublicAML Intelligence API", "version": "1.0.0", "summary": "Blockchain anti-money-laundering intelligence: address risk, entity attribution and fund tracing.", "description": "Screen a crypto address, find out what it is and who it belongs to, and follow its money forwards to a cash-out or backwards to its funding.\n\n## Reading a result honestly\n\nThree things about this data are easy to get wrong and each has cost somebody an incorrect conclusion:\n\n1. **An unknown address is not a clean address.** /v2/enrich puts addresses it has no data for in `not_found`, not in `entities` with a zero. A brand-new wallet is unknown, not innocent.\n2. **`risk.score` is risk, not a proportion of funds.** It combines its parts by MAX, so the components in `risk.breakdown` do not sum to it.\n3. **A negative is only as good as its window.** Endpoints that answer 'did this wallet do X' report how much they actually read (`scan`, `coverage`, `partial`, `truncated`, `degraded`). A `false` next to `negative_conclusive: false` means 'nothing known', not 'nothing happened'.\n\n## v1 is deprecated, and what that does and does not mean\n\nEvery `/v1/*` response carries `Deprecation: true`, `X-API-Recommended-Version: v2` and a `Link` header pointing here, and JSON object bodies also carry an `api_version` field saying the same thing. Read it as *migrate when you can*, not as *this stops working soon*: **no removal date is set**, which is exactly why no `Sunset` header is sent — that header carries a date, and inventing one would be a promise nobody made.\n\nv2 is not a different product. `/v2/enrich` calls the v1 handler and translates the shape, so the two cannot disagree about the facts. What changes is the form: no duplicated fields, and attribution arrives as `axes` — each question about an address answered separately instead of collapsed into one winning category.\n\n## Chains\n\nbitcoin, ethereum, bsc and tron. The same 0x address exists on ethereum, bsc and tron and is a different wallet on each, so `chain` is part of the identity and never a hint.\n\nbitcoin's graph is bipartite — address → transaction → address — so one hop there is half an economic step, and a `counterparty` field holds a transaction hash rather than an address.\n\n## Auth\n\nRead endpoints work unauthenticated, rate-limited per client IP. Send `X-API-Key` to remove the limit and see your own pushed labels. \n\n### The anonymous limit, in numbers\n\nThe budget is counted in UNITS, and a unit is one ADDRESS, not one call: a batch of 60 addresses spends 60. This is the part worth reading twice, because a script that batches looks well-behaved by request count and still exhausts an hour's budget in one call.\n\n| bucket | limit | window |\n| --- | --- | --- |\n| anonymous, standard endpoints (`/v2/enrich` among them) | 100 units | 1 hour |\n| anonymous, heavy endpoints (`/v1/trace`, `/v1/counterparties`, `/v1/transactions`, `/v1/address-transactions`, `/v1/address-flows`, `/v1/entity-transfers`, `/v1/tx-enrich`, `/v1/swap-through`, `/v1/cluster-mappings`) | 20 units | 1 hour |\n| authenticated (`X-API-Key` or HMAC) | no limit, unless the key was issued with a quota of its own | — |\n\n`/v2/enrich` costs one unit per address and draws on the standard bucket: a batch of 60 spends 60 of the 100. The same unit is what a key with a quota of its own is metered in.\n\nSo the pause to configure is: none, if you send a key. Authenticating removes the limit outright rather than raising it, which is why a bulk check should carry the key even though the endpoint does not require one.\n\nA key CAN be issued with a quota — units per window, metered per key rather than per account, so several keys on one contract get independent budgets. Yours has one only if you were told so when it was issued; the `429` body says which case you are in.\n\nA rejected request answers `429` with a JSON body naming what it cost you and when to come back — `{\"error\": \"rate limit exceeded\", \"cost\": 60, \"limit\": 100, \"window_sec\": 3600, \"retry_after\": 1973}` — plus a `Retry-After` header. It is never an empty success: an empty `entities` array always means the request was ANSWERED, and the addresses are then accounted for in `not_found` with a `reason`.\n\nWrites (POST/DELETE on /v1/labels and /v1/incidents) additionally require an HMAC signature — see the securitySchemes below.\n\nA credential that is sent and does not check out is refused with `401` on every endpoint, public ones included, and the `error` names the header at fault: `invalid_api_key`, `key_cannot_sign` (the key was issued without an HMAC secret — send it bare), `invalid_timestamp`, `timestamp_out_of_window`, `incomplete_signature`, `invalid_signature`. Sending no credential at all is not an error. Note the corollary: `X-RateLimit-*` headers appear only on requests counted as anonymous, so seeing them on a call you believe is authenticated means the credential did not apply.\n\n## Errors\n\nAll errors are JSON with an `error` field. `invalid_address_format` carries `detail` and `hint` telling you what to send instead, including which chain the address you sent actually belongs to.", "contact": {"name": "PublicAML", "url": "https://publicaml.org"}}, "servers": [{"url": "https://intelapi.publicaml.org", "description": "Production."}], "tags": [{"name": "Screening", "description": "Risk-screen an address or a transaction."}, {"name": "Tracing", "description": "Follow funds forwards to a cash-out or backwards to their source."}, {"name": "Graph", "description": "Counterparties and raw transfers."}, {"name": "Entities", "description": "Clusters — an exchange rather than one of its addresses."}, {"name": "Attestation", "description": "Signed risk verdicts for on-chain consumers."}, {"name": "Write", "description": "Contribute labels and incident reports. HMAC required."}, {"name": "Reference", "description": "Taxonomy, coverage, health, changelog."}, {"name": "Service", "description": "Internal endpoints called by PublicAML's own services. Not for integrators."}], "paths": {"/v2/enrich": {"post": {"operationId": "enrichAddresses", "summary": "Screen one or more addresses: who they are and what they are worth in risk", "description": "The primary endpoint. Give it addresses, get back for each one what is claimed about it, one winner per question (`axes`), its display name (`label`), its money-laundering risk (`risk`), and on request its counterparties, where its money came from and the incidents it appears in.\n\nBatch: up to 100 addresses in one call, each with its own chain, enriched concurrently. Each address costs one rate-limit unit.\n\n**Read `not_found` before concluding anything.** Addresses we hold no data for are NOT in `entities`; they are listed in `not_found` with a reason. Treating a missing address as a zero score is the commonest way to misread this endpoint: it turns 'we have never seen this wallet' into 'this wallet is clean', which is exactly backwards for a freshly created one.\n\n**A missing block means it was not asked for.** `include` selects blocks and the response carries exactly those: no `risk` key means the score was not requested, never that it is zero.\n\n**Who the address is lives in `axes`, not in one field.** Seven questions (`account_type`, `business`, `actor`, `membership`, `legal_status`, `asset_control`, `structure`), always all seven keys, `null` where nobody made a claim. Claims on different axes do not compete, so a sanctioned exchange is reported as both.\n\n**The platform's risk is the address's risk.** When an address resolves to a platform (`axes.business.brand`) — by its own label or through its cluster — the platform's score, a sanction on the organisation or the risk of its jurisdiction, is part of the address's direct exposure (`risk.breakdown.brand_direct`), capped by the rank of whoever linked the address to the platform. An exchange hot wallet scores 0 on its traffic and 50 to 90 if the exchange itself is designated, depending on who labelled it.\n\nAll time references are block numbers, not timestamps.", "tags": ["Screening"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["addresses"], "additionalProperties": false, "properties": {"addresses": {"type": "array", "minItems": 1, "maxItems": 100, "description": "The addresses to screen, at most 100. Each needs its own chain. More than 100 is a 400 and costs nothing.", "items": {"type": "object", "required": ["wallet_address", "chain"], "additionalProperties": false, "properties": {"wallet_address": {"type": "string", "description": "The address to screen. NOTE the field name: this endpoint uses `wallet_address`, while the trace and transaction endpoints use `address`. Sending `address` here is a 400 that says so.\n\nThe address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes.\n  * solana (enrich only) — base58 account address, 32-44 characters (no `0`, `O`, `I`, `l`), decoding to exactly 32 bytes. Case is SIGNIFICANT: send it exactly as the explorer prints it; a lowercased Solana address is a different (usually non-existent) account."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron", "solana"], "description": "Which blockchain the address belongs to. Send one of the values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like.\n\n`solana` (short form `SOL`) is LABEL-ONLY: we hold attribution for Solana accounts (who the address is, and who says so) but no transaction graph, risk seeds or propagation. A Solana entity therefore carries `label`, `label_basis` and `axes`, and its `risk` block says `scored: false` with `unscorable_reason: chain_label_only` — no score, which is NOT the same as a clean score. `counterparties`, `source_of_funds` and `incidents` are never returned for it. Solana is accepted by the enrich endpoints only; every other endpoint answers `unsupported_chain`."}}}}, "include": {"type": "array", "description": "Which blocks to compute and return. The names are the blocks of the response: `axes` returns `label` + `axes` (+ `kyc_origin`), `risk` returns `risk`, `counterparties` returns `counterparties` and the fields derived from them (`counterparties_meta`, `counterparties_truncated`, `behavioral_signals`, `inferred`, `mixer_interaction`, `peel_chain`), `source_of_funds` returns `source_of_funds`, `incidents` returns `incidents` + `incidents_total`. `category` and `aml_score` are accepted as older names for `axes` and `risk`.\n\nDefault (no `include`): `axes`, `risk`, `counterparties`, `source_of_funds`. `incidents` is an add-on: off by default, and asking for it alone ADDS it to the defaults rather than replacing them. To trim the defaults, name the blocks you want. An unknown value is a 400, never a silently dropped block.\n\nThis is the cost knob. `counterparties` and `source_of_funds` scan the graph and dominate the call; for bulk labelling `include: [\"axes\"]` is roughly an order of magnitude faster and far less likely to meet a degraded risk store.", "items": {"type": "string", "enum": ["axes", "risk", "counterparties", "source_of_funds", "incidents", "category", "aml_score"]}}, "top_n": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 10, "description": "How many counterparties (and source-of-funds buckets) to return per address."}, "debug": {"type": "boolean", "default": false, "description": "Adds `_trace`, a server-side timing report. Diagnostic; its shape is not part of the contract."}}}}}}, "responses": {"200": {"description": "Per-address results. Check both `entities` and `not_found`.", "content": {"application/json": {"schema": {"type": "object", "required": ["entities", "not_found"], "properties": {"entities": {"type": "array", "items": {"$ref": "#/components/schemas/EnrichEntity"}, "description": "One per address we hold data for. May be shorter than the request."}, "not_found": {"type": "array", "description": "Addresses that produced no entity, and why. This is NOT a clean bill of health.", "items": {"type": "object", "properties": {"wallet_address": {"type": "string"}, "chain": {"type": "string", "description": "The chain as you sent it."}, "reason": {"type": "string", "enum": ["address_not_found", "unsupported_chain", "invalid_address_format"], "description": "`address_not_found`: well-formed, but absent from our data. `unsupported_chain`: a chain we do not serve. `invalid_address_format`: the address cannot belong to that chain; `detail` and `hint` say how to fix it. A batch where EVERY address is malformed is a 400 instead."}, "detail": {"type": "string"}, "hint": {"type": "string"}}}}, "degraded": {"type": "object", "description": "Present only when part of this batch could not be scored. The risk store did not answer for those addresses, so they carry `risk.scored: false` and `risk.score: null`, never 0, while their `axes` and `label` are complete and trustworthy. This is OURS, not a property of the address: retry the SAME batch whole. Do not split it; halving a batch on this turns one transient into dozens of failing calls. If NO address in the batch could be scored the response is HTTP 503 instead, with the same body.", "properties": {"unscored": {"type": "integer"}, "addresses": {"type": "integer"}, "reason": {"type": "string", "enum": ["risk_store_unavailable"]}, "detail": {"type": "string"}}}, "request_id": {"type": "string", "description": "Quote this when reporting a problem."}, "processing_time_ms": {"type": "integer"}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited. One unit per ADDRESS, not per call: anonymous callers get 100 units per hour per IP (the cheap bucket), a key issued with a quota gets its own. The body names `cost`, `limit`, `window_sec` and `retry_after`; a `Retry-After` header carries the same seconds. Send `X-API-Key` to lift the anonymous limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "A credential was sent and did not check out: `invalid_api_key`, `key_cannot_sign`, `invalid_signature`, `timestamp_out_of_window`, … Sending no credential at all is not an error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "NO address in this batch could be scored: the risk store did not answer for any of them. The body is the ordinary one, `axes` and `label` are still there, `risk.score` is null on every entity, and `error` is `risk_store_unavailable`. Retry the same batch whole after a short pause. A batch where only SOME addresses lost their score answers 200 and reports it in `degraded`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/trace": {"post": {"operationId": "traceForward", "summary": "Follow an address's money forward to where it was cashed out", "description": "Value-weighted forward walk. Follows the largest outgoing flows hop by hop and reports two things: `cashout_summary` (funds that reached a labelled exchange / OTC / mixer / service — where the money left the chain's anonymous part) and `hanging` (funds sitting in unlabelled dead ends).\n\nBounded by wall clock, not by completeness: a partial walk is returned rather than a timeout, so `hops_traced` may be below `max_hops`. This is a lead-generating tool, not an exhaustive accounting of the funds.\n\nOn ethereum and bsc the walk continues THROUGH a DEX (`terminals[].transit`, with the swap in `terminals[].swaps[]`). A swap whose output returned to the sender, or that was settled in a batch with other people's orders, is listed with `followed: false` and a `reason`: in a batch auction net balances cannot say which recipient received this sender's leg, so no branch is attributed to it rather than attributing all of them. Such an entry carries `candidates` — the receivers that did not take the asset this sender was selling, offered as leads. When exactly one candidate is an address the same sender/venue pair paid in a settlement that WAS attributable, that branch is walked and marked `attribution: same-payee-as-attributable-settlement`.\n\nA cross-chain bridge is a terminal: the walk is single-chain and stops there. Where one leg of the crossing is a protocol vault we hold a registry for, the terminal additionally carries `crosschain[]`: the SAME vault of that protocol on another chain, matched 1:1 on the source's own key, each entry with its own `chain` and `hop: 0`. Read it as an OBSERVATION, never as an edge: it carries no amount and no direction (a bridge pool is a shared mailbox, N legs in against M legs out), it moves no risk across the chain boundary, and the node on the far side is hop 0 of a SEPARATE walk you have to run on that chain. Absent unless the deployment enables it.", "tags": ["Tracing"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["address", "chain"], "properties": {"address": {"type": "string", "description": "The address whose outgoing funds to follow.\n\nThe address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "max_hops": {"type": "integer", "minimum": 1, "maximum": 8, "description": "Default 5 (8 on bitcoin). On bitcoin one hop is HALF an economic step: the graph runs address → transaction → address."}, "max_width": {"type": "integer", "minimum": 2, "maximum": 12, "default": 6, "description": "How many outgoing edges to follow per address, largest first. A value outside 2-12 is pinned and named in `clamped`."}, "max_nodes": {"type": "integer", "minimum": 10, "maximum": 5000, "default": 300, "description": "Ceiling on addresses visited. Hitting it stops the walk with `node_ceiling` among the cut reasons; the frontier is reported as still moving. A value outside the range is pinned and named in `clamped`."}, "budget_sec": {"type": "number", "minimum": 1, "maximum": 90, "description": "Wall-clock ceiling, clamped to 1-90 s. Default 15 s on ethereum/bsc (swap-through costs a query per DEX hop), 8 s on other EVM chains, 40 s on bitcoin. A value outside the range is pinned and named in `clamped`."}}}}}}, "responses": {"200": {"description": "The forward trace.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"address": {"type": "string"}, "chain": {"type": "string"}, "hops_traced": {"type": "integer", "description": "Below `max_hops` means the budget ran out, not that the money stopped."}, "elapsed_ms": {"type": "integer"}, "cashout_summary": {"type": "array", "description": "Where the money left, biggest first.", "items": {"type": "object", "properties": {"entity": {"type": ["string", "null"], "description": "Venue name, when known."}, "category": {"type": "string"}, "value_raw": {"type": "number", "description": "Cross-token 18-decimal weight, comparable between rows but NOT a coin amount. Never show it to a person on its own."}, "assets": {"type": "array", "description": "The same value in the coins that actually arrived — this is the figure to display.", "items": {"type": "object", "properties": {"symbol": {"type": "string"}, "amount": {"type": "number"}}}}, "value_unknown": {"type": "boolean", "description": "The graph does not carry a value for this branch: `value_raw` is 0 because it is unknown, not because the amount was small. Such a branch is never removed as dust."}, "hits": {"type": "integer"}, "first_hop": {"type": "integer"}, "example_address": {"type": "string"}}}}, "terminals": {"type": "array", "description": "Every terminal venue reached, one shape, deduped.", "items": {"type": "object", "properties": {"kind": {"type": "string"}, "address": {"type": "string"}, "entity": {"type": ["string", "null"]}, "category": {"type": ["string", "null"]}, "hop": {"type": ["integer", "null"]}, "value_raw": {"type": ["number", "null"]}, "transit": {"type": "boolean", "description": "The walk also came out the far side of this venue (a DEX)."}, "crosschain": {"type": "array", "description": "The same protocol vault on ANOTHER chain. An observation matched on the source's key, not an observed transfer: no amount, no direction, no risk crosses, and `hop` restarts at 0 because the walk on that chain is a separate one. Present only on a terminal whose address is in the registry, and only when the deployment enables the step.", "items": {"type": "object", "properties": {"via": {"type": "string", "example": "crosschain:thorchain", "description": "Tag marking the entry as synthetic. Always prefixed `crosschain:`."}, "kind": {"type": "string", "example": "protocol_vault_peer"}, "method": {"type": "string", "example": "thorchain:asgard/pub_key", "description": "What the two addresses were matched ON."}, "chain": {"type": "string", "description": "The chain of THIS node, not of the trace."}, "address": {"type": "string", "description": "Often the same string as the terminal: one protocol key yields one address across EVM chains. Same string, different chain, different account."}, "hop": {"type": "integer", "enum": [0], "description": "Always 0: hop budget and decay of the traced chain do not extend across the boundary."}, "entity": {"type": ["string", "null"]}, "category": {"type": ["string", "null"]}, "key": {"type": "object", "description": "The identity the match rests on.", "properties": {"type": {"type": "string", "example": "pub_key"}, "value": {"type": "string"}}}, "vault_status": {"type": ["string", "null"], "description": "Protocol status of the vault when the registry last saw it (e.g. ActiveVault, InactiveVault, Retired). A retired vault still receives deposits and is still protocol infrastructure."}, "source": {"type": "string"}, "source_rank": {"type": ["integer", "null"], "description": "How much the registry's source is trusted, 0-100."}, "source_confidence": {"type": ["number", "null"], "description": "The same trust as 0..1, added next to every named source in this API. `source_rank` 90 reads as 0.9."}, "carries_value": {"type": "boolean", "enum": [false]}, "carries_risk": {"type": "boolean", "enum": [false]}, "observed": {"type": "boolean", "enum": [false], "description": "False always: nothing was observed moving between these two addresses."}, "note": {"type": "string"}}}}}}}, "hanging": {"type": "array", "items": {"type": "object"}, "description": "Unlabelled dead ends — funds that are resting, not exited."}, "still_moving": {"type": "array", "items": {"type": "object"}, "description": "Addresses the walk stopped at because of the hop or time limit, not because the money stopped."}, "partial": {"type": "boolean", "description": "True when the walk was cut short, by the clock or by the node ceiling. The result is a lead, not a complete accounting."}, "stop_reason": {"type": "string", "enum": ["done", "deadline", "node_ceiling", "max_hops"], "description": "Why the walk ended."}, "effective": {"type": "object", "description": "The limits the walk actually ran with, including `nodes_visited` and the `max_nodes` ceiling.", "properties": {"max_hops": {"type": "integer"}, "max_width": {"type": "integer"}, "budget_sec": {"type": "number"}, "max_nodes": {"type": "integer"}, "nodes_visited": {"type": "integer"}}}, "clamped": {"type": "array", "items": {"type": "string"}, "description": "Request fields the server pinned to a supported range."}}}}}}, "400": {"description": "Malformed request. A numeric parameter that is not a number answers `invalid_parameters`: `invalid[]` names every bad field with the reason and the accepted range, and `limits` lists the ranges of all numeric fields of this endpoint. A number OUTSIDE its range is not an error: it is pinned to the range and named in `clamped` (where the response has one).", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "One of two things, told apart by `error`:\n\n  * `rate limit exceeded` — your hourly quota (anonymous callers, and keys issued with a quota of their own). Wait `retry_after` seconds; authenticating removes the limit.\n  * `server_busy` — this endpoint is a graph walk or a range scan, and the server is already running as many of those at once as it admits. Nothing was charged for the work. Retry the SAME request after `Retry-After` seconds (a few); do not split it into smaller calls, that multiplies the load that caused the refusal.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/source-trace": {"post": {"operationId": "traceFunding", "summary": "Walk an address's funding backwards to find who paid it", "description": "The mirror of /v1/trace: walks INCOMING edges backwards to answer 'where did this wallet's money come from'. Any labelled entity is a dead end (the trail stops at the exchange rather than walking into its other customers), and so is an unlabelled wallet whose own degree makes it a service — otherwise a single dispenser pulls in everyone who ever used it.\n\nOne call replaces a client-side per-hop BFS. Long walks return `done: false` with a `cursor`; pass it back to continue the SAME traversal (state lives server-side for an hour). `hop_trace` is the delta for that call, `source_links` and `nodes_visited` are cumulative.\n\nUSD valuation is left to the caller: every flow carries `value_raw` plus a per-token breakdown.\n\n`venues` groups every casino (`gambling`) address the walk met by BRAND: 'Stake.com' and 'Stake.com (deposit)' are one venue, while in `source_links` they stay two links (that list keeps its shape). Brands are matched as exact strings, not normalised. Each address says which wallet of the venue it is (`venue_role`) and on what basis; `hot_wallet` is only ever the address's own role, never a label copied from its cluster's anchor. No attribution axes here: take them from /v2/enrich for the addresses you need.", "tags": ["Tracing"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["address", "chain"], "properties": {"address": {"type": "string", "description": "The address whose funding to trace.\n\nThe address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "max_hops": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4, "description": "How far back to walk."}, "max_width": {"type": "integer", "minimum": 2, "maximum": 12, "default": 8, "description": "Funders to expand per address, largest first."}, "max_nodes": {"type": "integer", "minimum": 10, "maximum": 5000, "description": "Hard ceiling. Hitting it ends the walk with truncated: true."}, "budget_sec": {"type": "number", "minimum": 1, "maximum": 120, "description": "Wall-clock ceiling for this call's walk. Default 8 s (40 s on bitcoin)."}, "top_n": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25, "description": "Counterparties in the root profile."}, "expand_width": {"type": "integer", "minimum": 1, "maximum": 64, "description": "How many hop-1 funders to walk back from. Defaults to max_width."}, "direct_width": {"type": "integer", "minimum": 1, "maximum": 500, "description": "How many direct funders to report at hop 1."}, "surface_categories": {"type": "array", "items": {"type": "string"}, "description": "Which labelled entity types to aggregate into `source_links`. Default ['gambling']."}, "stop_at_hubs": {"type": "boolean", "default": true, "description": "Stop at unlabelled high-degree addresses. Turning this off will pull in unrelated wallets."}, "cursor": {"type": "string", "description": "Resume token from a prior `done: false` response."}}, "description": "Any numeric field outside its range is pinned to it and named in `clamped`; a non-number is a 400 `invalid_parameters`."}}}}, "responses": {"200": {"description": "The backward funding trace.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"root": {"type": "object", "description": "The subject's own profile — score, counterparties, source of funds."}, "source_links": {"type": "array", "items": {"type": "object"}, "description": "Labelled funding sources found, aggregated. Cumulative across resumed calls."}, "venues": {"type": "array", "description": "Casino view by brand. Always present; `[]` when the walk met no `gambling` address, whatever `surface_categories` says. Cumulative across resumed calls, like `source_links`. Sorted by `flow_raw`, largest first.", "items": {"type": "object", "properties": {"venue": {"type": ["string", "null"], "description": "Brand: the attributed name without the ' (deposit)' suffix. Exact string, not normalised."}, "venue_meta": {"type": ["object", "null"], "properties": {"website": {"type": ["string", "null"], "description": "The venue's live site. null when none is known or when it was retired (see `website_deprecated`)."}, "website_deprecated": {"type": ["object", "null"], "properties": {"url": {"type": "string", "description": "The site as the label source published it."}, "status": {"type": "string", "enum": ["dns_gone", "unreachable", "http_error", "parked", "hijacked"], "description": "`dns_gone`: the domain no longer resolves; `unreachable`: it resolves but nothing answers; `http_error`: it answers with an error; `parked`: parked or offered for sale; `hijacked`: it now leads to an unrelated, sometimes fraudulent, site."}, "checked_at": {"type": ["string", "null"], "format": "date-time", "description": "When the check that retired it ran."}}, "description": "Set when the venue's published site was checked and is no longer the venue's: `website` is then null and the old URL moves here. Do not render it as a link, least of all when `status` is `hijacked`; where a dead domain redirects is never returned. null for a live site or when no site is known."}, "twitter": {"type": ["string", "null"]}}, "description": "The venue's public links from its label, when a source published any."}, "hop": {"type": "integer", "description": "Nearest hop of any of its addresses."}, "flow_raw": {"type": "string", "description": "Sum of the addresses' `flow_raw` (18-dec cross-token weight, not a coin amount)."}, "addresses": {"type": "array", "items": {"type": "object", "properties": {"address": {"type": "string"}, "hop": {"type": "integer"}, "label": {"type": ["string", "null"]}, "flow_raw": {"type": "string", "description": "The edge value this address sent toward the traced wallet: the same number folded into `source_links`."}, "tokens": {"type": "array", "items": {"type": "object"}}, "source_link": {"type": ["string", "null"], "description": "`name` of the `source_links` entry holding this address, or null if it was not surfaced."}, "venue_role": {"type": "string", "enum": ["deposit", "hot_wallet", "cold_wallet", "gas_supplier", "treasury", "custody", "cluster_member", "unspecified"], "description": "Which wallet of the venue this is. `hot_wallet` / `cold_wallet` / `gas_supplier` / `treasury` / `custody` only from the address's OWN role; `deposit` also from the ' (deposit)' naming rule or a deposit-label cluster; `cluster_member` = attributed only by cluster membership; `unspecified` = labelled directly, no role."}, "basis": {"type": ["object", "null"], "properties": {"kind": {"type": "string", "enum": ["attribution", "derived", "clustering"]}, "source": {"type": ["string", "null"]}, "confidence": {"type": ["number", "null"]}, "rule": {"type": "string", "enum": ["entity_role", "name_suffix", "via_deposit_label"]}}, "description": "Who says this address is this venue in this role."}, "cluster_id": {"type": ["string", "null"]}, "external_label_meta": {"type": ["object", "null"], "properties": {"from": {"type": "string", "enum": ["address", "anchor"]}, "anchor": {"type": ["string", "null"]}, "rows": {"type": "array", "items": {"type": "object", "properties": {"source": {"type": ["string", "null"]}, "entity_name": {"type": ["string", "null"]}, "entity_type": {"type": ["string", "null"]}, "website": {"type": ["string", "null"]}, "twitter": {"type": ["string", "null"]}, "entity_role": {"type": ["string", "null"]}, "entity_venue": {"type": ["string", "null"]}, "as_of": {"type": ["string", "null"]}, "labels": {"type": ["array", "null"], "items": {"type": "string"}}, "website_deprecated": {"type": ["object", "null"], "properties": {"url": {"type": "string", "description": "The site as the label source published it."}, "status": {"type": "string", "enum": ["dns_gone", "unreachable", "http_error", "parked", "hijacked"], "description": "`dns_gone`: the domain no longer resolves; `unreachable`: it resolves but nothing answers; `http_error`: it answers with an error; `parked`: parked or offered for sale; `hijacked`: it now leads to an unrelated, sometimes fraudulent, site."}, "checked_at": {"type": ["string", "null"], "format": "date-time", "description": "When the check that retired it ran."}}, "description": "Set when the venue's published site was checked and is no longer the venue's: `website` is then null and the old URL moves here. Do not render it as a link, least of all when `status` is `hijacked`; where a dead domain redirects is never returned. null for a live site or when no site is known."}}}}}, "description": "What external labels say. `from: anchor` = the address has no label of its own and these rows describe its cluster's anchor (named in `anchor`), not the address."}}}}}}}, "hop_trace": {"type": "array", "items": {"type": "object"}, "description": "Nodes visited by THIS call only."}, "nodes_visited": {"type": "integer"}, "hub_stops": {"type": "integer", "description": "Addresses not expanded because their degree made them a service."}, "truncated": {"type": "boolean"}, "done": {"type": "boolean", "description": "false = more remains; re-send with `cursor`."}, "cursor": {"type": ["string", "null"]}, "effective": {"type": "object", "description": "The parameters actually used."}, "clamped": {"type": "array", "items": {"type": "string"}, "description": "Parameters that were reduced to their allowed range."}}}}}}, "400": {"description": "Malformed request. A numeric parameter that is not a number answers `invalid_parameters`: `invalid[]` names every bad field with the reason and the accepted range, and `limits` lists the ranges of all numeric fields of this endpoint. A number OUTSIDE its range is not an error: it is pinned to the range and named in `clamped` (where the response has one).", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "One of two things, told apart by `error`:\n\n  * `rate limit exceeded` — your hourly quota (anonymous callers, and keys issued with a quota of their own). Wait `retry_after` seconds; authenticating removes the limit.\n  * `server_busy` — this endpoint is a graph walk or a range scan, and the server is already running as many of those at once as it admits. Nothing was charged for the work. Retry the SAME request after `Retry-After` seconds (a few); do not split it into smaller calls, that multiplies the load that caused the refusal.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/counterparties": {"post": {"operationId": "getCounterparties", "summary": "Who an address transacted with, with amounts per token", "description": "Three modes, selected by which field you send. They do not combine.\n\n1. `filter` — you already have candidate addresses and want to know whether this wallet dealt with them, and for how much. Ones with no recorded interaction are dropped from the answer.\n2. `category` — 'did this wallet touch any exchange / mixer / casino'.\n3. `kinds` — ENUMERATE the counterparties and classify each as a person, a venue, a hub, a deposit address, a contract or a mint/burn pseudo-address. This is the mode for 'which ordinary people funded this address'. An unlabelled counterparty is NOT thereby a person — that is what the degree probe in this mode is for.\n\nSelf-loops are kept here, unlike in /v2/enrich.", "tags": ["Graph"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["wallet_address", "chain"], "properties": {"wallet_address": {"type": "string", "description": "The wallet whose counterparties to look at.\n\nThe address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "filter": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 100, "description": "Mode 1: candidate counterparty addresses to check."}, "category": {"type": "string", "maxLength": 50, "description": "Mode 2: entity type to match — cex, mixer, gambling, bridge, …"}, "kinds": {"type": "array", "description": "Mode 3: which classes of counterparty to return. Sending this field is what selects the mode; it is a 400 alongside `filter` or `category`.", "items": {"type": "string", "enum": ["personal", "venue", "hub", "deposit_wallet", "contract", "mint_burn", "unknown"]}}, "direction": {"type": "string", "enum": ["in", "out", "both"], "description": "`kinds` mode. 'in' (the default there) = who paid this address."}, "rank_by": {"type": "string", "enum": ["value", "tx_count"], "default": "value", "description": "'value' weighs token units with NO price attached, so a memecoin sender can outrank an ETH sender. Use 'tx_count' to avoid that."}, "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}, "offset": {"type": "integer", "minimum": 0, "maximum": 10000}, "budget_sec": {"type": "number", "minimum": 1, "maximum": 60, "description": "`kinds` mode: wall-clock budget for the degree probe."}, "include": {"type": "array", "items": {"type": "string", "enum": ["risk"]}, "description": "['risk'] adds aml_score, risk_level and sanctioned to each row."}, "since": {"type": ["integer", "string"], "description": "Restrict to a time window. Epoch seconds or ISO-8601 ('2026-06-21')."}, "until": {"type": ["integer", "string"]}}}}}}, "responses": {"200": {"description": "Counterparties with per-token amounts.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"wallet_address": {"type": "string"}, "chain": {"type": "string"}, "counterparties": {"type": "array", "items": {"type": "object", "properties": {"address": {"type": "string"}, "label": {"type": ["string", "null"]}, "category": {"type": ["string", "null"]}, "tx_count": {"type": "integer"}, "first_block": {"type": "integer"}, "last_block": {"type": "integer"}, "tokens": {"type": "array", "description": "Per asset, in proper units (not wei). Sorted by max(sent, received).", "items": {"type": "object", "properties": {"token": {"type": "string", "description": "Contract address; empty string means the chain's native coin. EVM: bare hex with NO 0x prefix."}, "symbol": {"type": ["string", "null"], "description": "null for contracts outside the known-token registry; `decimals` then falls back to 18 and the amount may be wrong. Prefer the raw figure when this is null."}, "decimals": {"type": "integer"}, "sent": {"type": "number"}, "received": {"type": "number"}, "sent_tx_count": {"type": "integer"}, "received_tx_count": {"type": "integer"}}}}, "sanctioned": {"type": "boolean"}, "aml_score": {"type": ["number", "null"], "description": "include: ['risk'] only."}, "risk_level": {"type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], "description": "include: ['risk'] only. The coarse band `aml_score` falls in, for callers that branch rather than plot. Provided so that every integration uses the same thresholds:\n\n  * `LOW`      — score below 25\n  * `MEDIUM`   — 25 to 59\n  * `HIGH`     — 60 to 89\n  * `CRITICAL` — 90 or above, OR `sanctioned: true` at any score\n\nThe sanctions override is deliberate: a designated address whose score was floored (a known exchange, or propagation that has not re-run) must still reach a consumer that only rejects the top band. Read `sanctioned` and `sanction` for the reason; this field alone does not say why."}, "kind": {"type": "string", "enum": ["personal", "venue", "hub", "deposit_wallet", "contract", "mint_burn", "unknown"], "description": "`kinds` mode. 'unknown' means not probed (cap or budget), not 'nothing there'. One value also appears in the other two modes: 'mint_burn', on the chain's mint/burn pseudo-addresses, because a row that did not say so has been read as money moving between wallets."}, "kind_reason": {"type": "string", "description": "`kinds` mode: the label or degree that decided it. 'non_propagating_address' on a mint_burn row."}, "mint_burn": {"type": "object", "description": "Present only on a mint/burn pseudo-address (0x0 and the conventional burn sinks). These are not wallets and hold nothing on anybody's behalf, so the row is not a payment between two parties: what this wallet RECEIVED from the address was ISSUED into existence, and what it SENT there was BURNED. Read `received_usd` on such a row as issuance, never as funds arriving from a counterparty, and never as anything the issuer did to this wallet.", "properties": {"received_is": {"type": "string", "enum": ["mint"]}, "sent_is": {"type": "string", "enum": ["burn"]}, "note": {"type": "string"}}}, "value_raw": {"type": "string", "description": "`kinds` mode. Decimal STRING — a uint256 does not survive a JS number."}, "degree": {"type": ["object", "null"], "description": "Degree measured in a bounded window. A LOWER BOUND, never a lifetime degree."}}}}, "summary": {"type": "object", "description": "`kinds` mode: count per kind."}, "value_split": {"type": "object", "description": "`kinds` mode: how the value divides across kinds, tokens at face value."}, "coverage": {"type": "object", "description": "What the scan actually managed to read."}, "partial": {"type": "boolean", "description": "true = a cap or budget cut the answer, so the list is a floor, not the whole set."}, "request_id": {"type": "string"}, "processing_time_ms": {"type": "integer"}}}}}}, "400": {"description": "Malformed request. A numeric parameter that is not a number answers `invalid_parameters`: `invalid[]` names every bad field with the reason and the accepted range, and `limits` lists the ranges of all numeric fields of this endpoint. A number OUTSIDE its range is not an error: it is pinned to the range and named in `clamped` (where the response has one).", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "One of two things, told apart by `error`:\n\n  * `rate limit exceeded` — your hourly quota (anonymous callers, and keys issued with a quota of their own). Wait `retry_after` seconds; authenticating removes the limit.\n  * `server_busy` — this endpoint is a graph walk or a range scan, and the server is already running as many of those at once as it admits. Nothing was charged for the work. Retry the SAME request after `Retry-After` seconds (a few); do not split it into smaller calls, that multiplies the load that caused the refusal.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/address-transactions": {"post": {"operationId": "getAddressTransactions", "summary": "Transfers touching one address, newest first", "description": "A wallet's transaction feed, with the counterparty and signed direction resolved for each transfer. Optionally annotates each counterparty with its label, or filters the whole window down to transfers with a counterparty of one category ('did this wallet deposit to a casino last month').\n\nThe category filter is applied across the entire since/until range, not just the page being returned.\n\nPAGING. Use `cursor`: pass back the `next_cursor` of the previous page and keep going until it is absent. Cost per page does not grow with depth. `offset` still works and is unchanged, but it is a skip, so on a busy address it gets slower the deeper it goes and is not a way to walk a whole history. The two are mutually exclusive in one request.\n\nOn bitcoin a page is rounded up to the end of a transaction (the feed's sort key is not unique there), so `count` may come back one or two over `limit`. No row is ever returned twice or skipped.", "tags": ["Graph"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["address", "chain"], "properties": {"address": {"type": "string", "description": "The address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "since": {"type": ["integer", "string"], "description": "Inclusive lower bound. Epoch seconds or ISO-8601 ('2026-06-21')."}, "until": {"type": ["integer", "string"], "description": "Inclusive upper bound."}, "token": {"type": "string", "description": "Restrict to one token contract. EVM only."}, "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}, "offset": {"type": "integer", "minimum": 0, "maximum": 100000, "description": "Skip-based paging. Kept for compatibility; prefer `cursor` — a deep offset re-reads everything it skips. Above 100000 the request is a 400 `invalid_parameters` (not pinned: a pinned offset would be a different page); page deeper with `cursor`."}, "cursor": {"type": "string", "description": "The `next_cursor` of the previous page. '<block>:<tx_hash>' on bitcoin, '<block>:<log_index>:<internal_index>' elsewhere. Refused together with `offset`."}, "include": {"type": "array", "items": {"type": "string", "enum": ["counterparty_labels"]}, "description": "Adds what we know about each counterparty. Unknown values are a 400."}, "counterparty_category": {"type": "string", "description": "Keep only transfers whose counterparty carries this label category. Implies include: ['counterparty_labels']."}}}}}}, "responses": {"200": {"description": "A page of transfers, newest first.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"address": {"type": "string"}, "chain": {"type": "string"}, "count": {"type": "integer"}, "has_more": {"type": "boolean"}, "next_cursor": {"type": "string", "description": "Pass as `cursor` for the next page. ABSENT at the end of the feed, which is how a loop knows to stop."}, "nav_window": {"type": "object", "description": "Which block range this page was read from, and whether the address index ran out. Diagnostic: it explains a page's cost without guessing.", "properties": {"from_block": {"type": "integer"}, "to_block": {"type": "integer"}, "index_exhausted": {"type": "boolean"}}}, "transactions": {"type": "array", "items": {"type": "object", "properties": {"tx_hash": {"type": "string"}, "block_number": {"type": "string"}, "timestamp": {"type": "integer", "description": "Unix seconds."}, "direction": {"type": "string", "enum": ["in", "out", "self"]}, "counterparty": {"type": "string", "description": "The address on the other side — EXCEPT on bitcoin, where the graph runs address → transaction → address and this is the transaction hash. The wallets are then in `counterparties[]`."}, "counterparties": {"type": "array", "items": {"type": "string"}, "description": "bitcoin only: the wallets on the other side of the transaction."}, "symbol": {"type": ["string", "null"]}, "decimals": {"type": "integer"}, "value": {"type": ["number", "null"], "description": "Human-readable amount. null, or computed with a fallback of 18 decimals, for tokens outside the known-token registry — prefer `amount_raw` then."}, "amount_raw": {"type": "string", "description": "Exact amount in the token's smallest unit, as a decimal string."}, "counterparty_labels": {"type": "array", "items": {"type": "object", "description": "What we know about one address on the other side of a transfer.", "properties": {"address": {"type": "string"}, "label": {"type": ["string", "null"], "description": "Display name of the entity, e.g. 'Binance 14'."}, "category": {"type": ["string", "null"], "description": "Entity type: cex, dex, mixer, gambling, bridge, payment, …"}, "source": {"type": ["string", "null"], "description": "The feed behind the claim, named as it is stored — e.g. `ofac:sdn_advanced`, `arkham:sanctioned`, `chainabuse:chainabuse`. Every source is named: address answers withheld non-public feeds behind the literal `publicaml` until 2026-09-11, and no longer do. How much the claim is worth is `source_confidence`, published alongside. A null means the stored claim never carried a source at all — not that one is being hidden."}, "source_confidence": {"type": ["number", "null"], "description": "How much we trust whoever made this claim, 0..1. Derived from the source's tier: an official list answers 1.0, a vendor blocklist 0.5, a community report 0.25. Absent when no tier prices the source — that is a gap in our policy table, not a statement of trust, so it is not reported as confidence."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not the same as source_confidence, which is ours about the source."}, "risk_score": {"type": "number"}, "sanctioned": {"type": "boolean"}, "sanction": {"type": "object", "properties": {"label": {"type": ["string", "null"]}, "source": {"type": ["string", "null"]}}}}}, "description": "Opt-in. An empty array means no label is held, not that it was not looked up."}}}}, "matched_counterparties": {"type": "array", "items": {"type": "object", "description": "What we know about one address on the other side of a transfer.", "properties": {"address": {"type": "string"}, "label": {"type": ["string", "null"], "description": "Display name of the entity, e.g. 'Binance 14'."}, "category": {"type": ["string", "null"], "description": "Entity type: cex, dex, mixer, gambling, bridge, payment, …"}, "source": {"type": ["string", "null"], "description": "The feed behind the claim, named as it is stored — e.g. `ofac:sdn_advanced`, `arkham:sanctioned`, `chainabuse:chainabuse`. Every source is named: address answers withheld non-public feeds behind the literal `publicaml` until 2026-09-11, and no longer do. How much the claim is worth is `source_confidence`, published alongside. A null means the stored claim never carried a source at all — not that one is being hidden."}, "source_confidence": {"type": ["number", "null"], "description": "How much we trust whoever made this claim, 0..1. Derived from the source's tier: an official list answers 1.0, a vendor blocklist 0.5, a community report 0.25. Absent when no tier prices the source — that is a gap in our policy table, not a statement of trust, so it is not reported as confidence."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not the same as source_confidence, which is ours about the source."}, "risk_score": {"type": "number"}, "sanctioned": {"type": "boolean"}, "sanction": {"type": "object", "properties": {"label": {"type": ["string", "null"]}, "source": {"type": ["string", "null"]}}}}}, "description": "With `counterparty_category`: the labelled venues actually hit in the window."}, "counterparty_scan": {"type": "object", "description": "How much of the window the label scan covered. `truncated: true` makes the match count a lower bound."}, "request_id": {"type": "string"}, "processing_time_ms": {"type": "integer"}}}}}}, "400": {"description": "Malformed request. A numeric parameter that is not a number answers `invalid_parameters`: `invalid[]` names every bad field with the reason and the accepted range, and `limits` lists the ranges of all numeric fields of this endpoint. A number OUTSIDE its range is not an error: it is pinned to the range and named in `clamped` (where the response has one).", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "One of two things, told apart by `error`:\n\n  * `rate limit exceeded` — your hourly quota (anonymous callers, and keys issued with a quota of their own). Wait `retry_after` seconds; authenticating removes the limit.\n  * `server_busy` — this endpoint is a graph walk or a range scan, and the server is already running as many of those at once as it admits. Nothing was charged for the work. Retry the SAME request after `Retry-After` seconds (a few); do not split it into smaller calls, that multiplies the load that caused the refusal.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/entity-transfers": {"post": {"operationId": "getEntityTransfers", "summary": "Did this wallet pay a specific venue, and how much", "description": "Answers one question well: 'has this wallet sent money to <venue>'. Returns the verdict over the whole window (`found`), per-asset totals, and the individual transfers.\n\n**A `found: false` is only conclusive when you passed `since`/`until`.** Without a window the counterparty list comes from an index that can lag, and the response says so in `scan.negative_conclusive`. Read that field before reporting a wallet as clean of a venue.\n\n`entity` matches EXACTLY after lowercasing, stripping punctuation and stripping a trailing parenthetical: '1xbet' matches '1xBet' and '1xBet (deposit)'; 'satoshi' does NOT match 'Satoshibet'.", "tags": ["Graph"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["address", "chain"], "properties": {"address": {"type": "string", "description": "The wallet under investigation.\n\nThe address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "entity": {"type": "string", "description": "Venue name. Exact match — see the endpoint description."}, "category": {"type": "string", "description": "Entity type: gambling, cex, mixer, … One of `entity`/`category` is required."}, "direction": {"type": "string", "enum": ["out", "in", "both"], "default": "out", "description": "'out' = money leaving the wallet."}, "min_amount": {"type": "number", "description": "Inclusive bound PER TRANSFER, in the asset's own units (1 = 1 BTC, 1 USDT). Not a threshold on the total."}, "max_amount": {"type": "number"}, "since": {"type": ["integer", "string"], "description": "Epoch seconds or ISO-8601. Passing a window is what makes a negative answer conclusive."}, "until": {"type": ["integer", "string"]}, "token": {"type": "string", "description": "EVM only; rejected on bitcoin."}, "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 1000}, "offset": {"type": "integer", "minimum": 0}}}}}}, "responses": {"200": {"description": "The verdict, the totals and the transfers.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"found": {"type": "boolean", "description": "The verdict over the whole window, not just this page."}, "entity": {"type": "object", "description": "How the name resolved: `matched_names` are the canonical spellings hit, `suggestions` appear when it resolved to nothing."}, "totals": {"type": "array", "description": "Per-asset sums over the transfers that passed the filters.", "items": {"type": "object", "properties": {"symbol": {"type": ["string", "null"]}, "amount": {"type": "number"}, "amount_raw": {"type": "string", "description": "Stays a string: a float cannot hold 18-decimal token units."}, "transfer_count": {"type": "integer"}, "tx_count": {"type": "integer"}}}}, "transfers_below_min": {"type": "integer", "description": "Non-zero next to found: false means the wallet DID pay, outside the bounds you asked for."}, "transfers_above_max": {"type": "integer"}, "matched_counterparties": {"type": "array", "items": {"type": "object", "description": "What we know about one address on the other side of a transfer.", "properties": {"address": {"type": "string"}, "label": {"type": ["string", "null"], "description": "Display name of the entity, e.g. 'Binance 14'."}, "category": {"type": ["string", "null"], "description": "Entity type: cex, dex, mixer, gambling, bridge, payment, …"}, "source": {"type": ["string", "null"], "description": "The feed behind the claim, named as it is stored — e.g. `ofac:sdn_advanced`, `arkham:sanctioned`, `chainabuse:chainabuse`. Every source is named: address answers withheld non-public feeds behind the literal `publicaml` until 2026-09-11, and no longer do. How much the claim is worth is `source_confidence`, published alongside. A null means the stored claim never carried a source at all — not that one is being hidden."}, "source_confidence": {"type": ["number", "null"], "description": "How much we trust whoever made this claim, 0..1. Derived from the source's tier: an official list answers 1.0, a vendor blocklist 0.5, a community report 0.25. Absent when no tier prices the source — that is a gap in our policy table, not a statement of trust, so it is not reported as confidence."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not the same as source_confidence, which is ours about the source."}, "risk_score": {"type": "number"}, "sanctioned": {"type": "boolean"}, "sanction": {"type": "object", "properties": {"label": {"type": ["string", "null"]}, "source": {"type": ["string", "null"]}}}}}}, "transfers": {"type": "array", "items": {"type": "object"}}, "has_more": {"type": "boolean"}, "scan": {"type": "object", "description": "How the answer was reached — read before trusting a negative.", "properties": {"basis": {"type": "string", "enum": ["edges_window", "counterparty_index"], "description": "'edges_window' = the whole window was read. 'counterparty_index' = no window given, so the list came from an index that can lag."}, "negative_conclusive": {"type": "boolean", "description": "false means 'nothing known', NOT 'nothing happened'."}, "truncated": {"type": "boolean"}}}}}}}}, "400": {"description": "Malformed request. A numeric parameter that is not a number answers `invalid_parameters`: `invalid[]` names every bad field with the reason and the accepted range, and `limits` lists the ranges of all numeric fields of this endpoint. A number OUTSIDE its range is not an error: it is pinned to the range and named in `clamped` (where the response has one).", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "One of two things, told apart by `error`:\n\n  * `rate limit exceeded` — your hourly quota (anonymous callers, and keys issued with a quota of their own). Wait `retry_after` seconds; authenticating removes the limit.\n  * `server_busy` — this endpoint is a graph walk or a range scan, and the server is already running as many of those at once as it admits. Nothing was charged for the work. Retry the SAME request after `Retry-After` seconds (a few); do not split it into smaller calls, that multiplies the load that caused the refusal.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/transactions": {"post": {"operationId": "getTransactionsBetween", "summary": "Raw transfers between two specific addresses", "description": "Every recorded transfer from one address to another.\n\nChain-aware: on EVM chains these are the direct edges between the two addresses. On bitcoin, whose graph has no direct address-to-address edge, it returns the FULL edge set of the transactions where both addresses appear — co-inputs and change outputs included, because those are what attribution turns on.", "tags": ["Graph"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["from", "to", "chain"], "properties": {"from": {"type": "string", "description": "Sending address.\n\nThe address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "to": {"type": "string", "description": "Receiving address.\n\nThe address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}, "offset": {"type": "integer", "minimum": 0, "description": "Skip-based paging. Kept for compatibility; prefer `cursor` — a deep offset re-reads everything it skips."}, "cursor": {"type": "string", "description": "The `next_cursor` of the previous page. '<block>:<tx_hash>' on bitcoin, '<block>:<log_index>:<internal_index>' elsewhere. Refused together with `offset`."}, "token": {"type": "string", "description": "Restrict to one token contract. Rejected on bitcoin."}}}}}}, "responses": {"200": {"description": "The transfers between the two addresses.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"count": {"type": "integer"}, "transactions": {"type": "array", "items": {"type": "object"}}, "request_id": {"type": "string"}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/address-flows": {"post": {"operationId": "getAddressFlows", "summary": "Monthly value moved through an address, per token", "description": "Turnover per month per token — how much moved through, in and out.\n\n**This is not a balance and cannot be used to derive one.** Sums of movement do not reconcile to holdings.\n\nethereum only: the monthly aggregate is built and validated for that chain and other chains are rejected rather than answered approximately.", "tags": ["Graph"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["address", "chain"], "properties": {"address": {"type": "string", "description": "The address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["ethereum"], "description": "ethereum only. Other chains are a 400."}, "since": {"type": ["integer", "string"], "description": "Default: the last 12 months."}, "until": {"type": ["integer", "string"]}, "granularity": {"type": "string", "enum": ["month"], "default": "month"}, "token": {"type": "string"}, "top_tokens": {"type": "integer", "minimum": 1, "maximum": 100, "default": 10, "description": "Ranked by movement count."}, "include_native": {"type": "boolean", "default": false, "description": "Native ETH movement, with the caveat the response attaches."}}}}}}, "responses": {"200": {"description": "Monthly turnover per token.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request. A numeric parameter that is not a number answers `invalid_parameters`: `invalid[]` names every bad field with the reason and the accepted range, and `limits` lists the ranges of all numeric fields of this endpoint. A number OUTSIDE its range is not an error: it is pinned to the range and named in `clamped` (where the response has one).", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "One of two things, told apart by `error`:\n\n  * `rate limit exceeded` — your hourly quota (anonymous callers, and keys issued with a quota of their own). Wait `retry_after` seconds; authenticating removes the limit.\n  * `server_busy` — this endpoint is a graph walk or a range scan, and the server is already running as many of those at once as it admits. Nothing was charged for the work. Retry the SAME request after `Retry-After` seconds (a few); do not split it into smaller calls, that multiplies the load that caused the refusal.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/tx-enrich": {"post": {"operationId": "enrichTransaction", "summary": "Screen a single transaction by hash", "description": "Economic summary of one transaction plus a label and risk reading for every participant, and a direction-aware verdict.\n\nPass `focus_address` — the wallet you are actually screening, e.g. your own deposit address — to scope the verdict to its direct counterparties and the direction the money moved relative to it. Without it the verdict is about the transaction as a whole.\n\nSome chains need `block_number` alongside the hash to resolve it at all; the response says so with a 422 naming the reason. Locate the block with /v1/transactions if you do not have it.", "tags": ["Screening"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["tx_hash", "chain"], "properties": {"tx_hash": {"type": "string", "description": "EVM hashes are 0x-prefixed; bitcoin txids are bare hex. Both accepted."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "block_number": {"type": "integer", "description": "Required on chains that cannot resolve a bare hash — see the 422."}, "focus_address": {"type": "string", "description": "The wallet being screened, if any.\n\nThe address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "include": {"type": "array", "items": {"type": "string", "enum": ["economics", "risk", "participants", "transfers"]}, "description": "Default ['economics','risk']."}}}}}}, "responses": {"200": {"description": "The enriched transaction.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"tx_hash": {"type": "string"}, "chain": {"type": "string"}, "found": {"type": "boolean"}, "economics": {"type": "object"}, "risk": {"type": "object"}, "participants": {"type": "array", "items": {"type": "object"}, "description": "Each carries aml_score, risk_level, category, label, sanctioned and its role in the transaction."}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Transaction not found in the indexed data.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "422": {"description": "block_number is required for this chain.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/swap-through": {"post": {"operationId": "swapThrough", "summary": "Follow value through a DEX or aggregator", "description": "Net balance change per address and asset inside one transaction, with the beneficiaries of any swap flagged.\n\nThis is how a trace continues through a decentralised exchange without decoding the exchange. An address that ends the transaction having sent one asset and received a DIFFERENT one is the party the swap was for, whatever router or aggregator sat in between, so the walk resumes from it. Pools, wrapper contracts and relayers fall out of the same arithmetic without being named anywhere.\n\n`delta_raw` is a signed integer in the asset's smallest unit, as a string. `asset` is null for the chain's native coin, which covers both plain transfers and internal calls.\n\nEthereum and BSC only. Some chains need `block_number` alongside the hash to resolve it at all; the response says so with a 422.", "tags": ["Screening"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["tx_hash", "chain"], "properties": {"tx_hash": {"type": "string", "description": "0x-prefixed EVM transaction hash."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "block_number": {"type": "integer", "description": "Required on chains that cannot resolve a bare hash — see the 422."}}}}}}, "responses": {"200": {"description": "Net deltas for the transaction.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"tx_hash": {"type": "string"}, "chain": {"type": "string"}, "found": {"type": "boolean"}, "block_number": {"type": "integer"}, "edge_count": {"type": "integer"}, "participant_count": {"type": "integer"}, "swap_beneficiaries": {"type": "array", "items": {"type": "string"}, "description": "Addresses that sent one asset and received another."}, "participants": {"type": "array", "items": {"type": "object"}, "description": "Each carries address, swap_beneficiary, assets_sent, assets_received and deltas[] of {asset, symbol, decimals, delta_raw}."}, "warnings": {"type": "array", "items": {"type": "string"}, "description": "Present when the edge set hit its cap and the deltas are partial."}}}}}}, "400": {"description": "Malformed request. A numeric parameter that is not a number answers `invalid_parameters`: `invalid[]` names every bad field with the reason and the accepted range, and `limits` lists the ranges of all numeric fields of this endpoint. A number OUTSIDE its range is not an error: it is pinned to the range and named in `clamped` (where the response has one).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "One of two things, told apart by `error`:\n\n  * `rate limit exceeded` — your hourly quota (anonymous callers, and keys issued with a quota of their own). Wait `retry_after` seconds; authenticating removes the limit.\n  * `server_busy` — this endpoint is a graph walk or a range scan, and the server is already running as many of those at once as it admits. Nothing was charged for the work. Retry the SAME request after `Retry-After` seconds (a few); do not split it into smaller calls, that multiplies the load that caused the refusal.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Transaction not found in the indexed data.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "422": {"description": "block_number is required for this chain.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/rate-at": {"post": {"operationId": "rateAt", "summary": "Conversion rate between two coins as of a past moment", "description": "How many units of `to_coin_iso` one unit of `from_coin_iso` was worth on the UTC day of `timestamp`.\n\nThe figure comes from PublicAML's own daily price series (the one the risk engine values transfers with): one USD quote per token per day derived from on-chain swaps, and exchange closes for BTC. No vendor ticker is involved, so a transfer priced with this endpoint carries the same value the screening endpoints report.\n\nGranularity is a day, never a minute. `rate` is a float; `rate_e8` is the same number scaled by 10^8 as an integer string for callers that keep rates as integers. `fallback` is null when both sides had a quote for that exact day, otherwise it names what was done instead: `nearest_day` (a quote from up to 7 days earlier; the leg's `day_gap` says how many), `stable_peg` (a stablecoin with no quote in reach, pinned at 1 USD), or both joined with `+`. `legs.from` / `legs.to` show the USD value, the day and the source series of each side.\n\nSymbols are coin tickers (BTC, ETH, USDT, USDC, TRX, BNB, LINK, …), not networks; USD is accepted as the fiat unit. An unknown symbol answers 400 with the supported list; a day before any market answers 404.\n\nServed at https://intelapi.publicaml.org/v1/rate-at and available only with an API key (`X-API-Key`) or a signed request; without one the answer is 401.", "tags": ["Reference"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["from_coin_iso", "to_coin_iso", "timestamp"], "properties": {"from_coin_iso": {"type": "string", "description": "Coin ticker, case-insensitive, e.g. BTC."}, "to_coin_iso": {"type": "string", "description": "Coin ticker or USD."}, "timestamp": {"description": "Unix seconds, unix milliseconds (values above 1e12) or an ISO-8601 date/time. Up to one day ahead of now.", "oneOf": [{"type": "integer"}, {"type": "string"}]}}}}}}, "responses": {"200": {"description": "The rate for that day.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"from_coin_iso": {"type": "string"}, "to_coin_iso": {"type": "string"}, "requested_at": {"type": "integer", "description": "The timestamp as unix seconds."}, "requested_at_iso": {"type": "string"}, "day": {"type": "string", "description": "UTC day the rate is for, YYYY-MM-DD."}, "rate": {"type": "number", "description": "Units of to_coin_iso per one from_coin_iso."}, "rate_e8": {"type": "string", "description": "rate × 10^8, integer as a string."}, "inverse_rate": {"type": "number"}, "granularity": {"type": "string", "enum": ["day"]}, "fallback": {"type": "string", "nullable": true, "description": "null, `nearest_day`, `stable_peg` or `nearest_day+stable_peg`."}, "legs": {"type": "object", "description": "`from` and `to`, each {coin, usd, basis (market | pinned | stable_peg | usd), day, day_gap, source {chain, token, kind, quote_asset, swap_txs}}. basis `pinned` is a stablecoin the price series itself anchors at 1 USD; unlike `stable_peg` it is a stored row, not a fallback."}, "source": {"type": "string", "enum": ["publicaml:token_price"]}}}}}}, "400": {"description": "Unsupported coin (the body lists the supported tickers), bad timestamp or unknown field.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "No API key or signature. This endpoint is key-only.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "No quote for one side on that day or the 7 days before it; `missing` names it.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/entities": {"post": {"operationId": "getEntities", "summary": "Profile clusters by id, or list every address of a category", "description": "Two modes, selected by which field is sent; exactly one per request (both, or neither, is a 400).\n\n**By id** (`entity_ids`): takes cluster ids, the `axes.membership.group_id` values that /v2/enrich returns, and profiles the whole grouped entity: its size, type, name, and the risk reading of its anchor address (the hot wallet or hub the cluster is keyed on). Use this when the question is about an exchange rather than about one of its addresses. Open to anonymous callers; one unit per id.\n\nA cluster bound to a platform brand (`entity_ref`) also carries `brand`: a summary of the venue as a regulated business — country of registration and country of actual control, the countries that licensed it and the lists those countries are on, and `risk_score`, an integer 0..100. The licences themselves, the KYC periods and the list rows per country are one call away, at GET /v1/brands/{name_key} (`brand.detail`). The score is computed on read, never stored: an active sanction of the ORGANISATION scores 100, one no longer in force 50, a country of the brand on the FATF call-for-action list 100, on the EU high-risk third-country list 50, on the FATF increased-monitoring list 40, on our own weak-supervision list 20. The worst component wins — not the sum, not the average — and the jurisdiction carries over to the brand in full. `null` means no component at all; `complete: false` means the score is a LOWER BOUND, because some country has no list row for the date. The weights and the full rule are published once per response in `brand_risk_policy`. Read `risk_score`, not `high_risk`, which is now derived from it at >= 40 and kept for compatibility.\n\n**By category** (`category` + `chain`): pages through every address attributed to one category on one chain, with a keyset cursor. Each address carries the same facts as `venues[].addresses[]` in /v1/source-trace (label, venue, venue_role, basis, cluster_id, `external_label_meta`) EXCEPT `axes`, which this mode does not return: ask /v2/enrich for the addresses you need them on. Needs an API key (any valid key). Classed with the scans; one unit per address RETURNED: the page size is reserved up front and the unfilled part refunded. Unknown body fields are a 400 in this mode. `deposit` is refused (`category_not_listable`): it has no index a page could walk. Each address also names the platform it belongs to (`brand`): what it scores, where it is registered and managed, which countries licensed it and which lists those countries are on. The paperwork behind that score — the licences themselves, the KYC periods, the list rows per country — is at GET /v1/brands/{name_key}.", "tags": ["Entities"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"oneOf": [{"title": "By entity id", "type": "object", "required": ["entity_ids"], "not": {"required": ["category"]}, "properties": {"entity_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 100, "description": "Cluster ids, e.g. 'deposit:1:0x…'."}, "include": {"type": "array", "items": {"type": "string"}, "description": "Which blocks of each profile to compute: `aml_score`, `category`, `counterparties`, `source_of_funds`, `incidents` (`risk` and `axes` are accepted as synonyms of the first two). The profiles themselves keep this endpoint's own field names (`aml_score`, `category`, `cluster`), not the /v2/enrich shape."}, "top_n": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 10}}}, {"title": "By category", "type": "object", "required": ["category", "chain"], "additionalProperties": false, "properties": {"category": {"type": "string", "description": "An attribution category (can_attribute) from GET /v1/categories, e.g. 'gambling'. Not 'deposit'."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "The chain to list. Same values and aliases as everywhere else (ETH, BTC, TRX, BNB accepted)."}, "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}, "cursor": {"type": "string", "description": "`next_cursor` of the previous page. Bound to its chain and category; does not expire."}, "include": {"type": "array", "items": {"type": "string", "enum": ["external_label_meta", "brand"]}, "description": "Per-address blocks. Both are on by default; naming a subset turns the other off and [] omits both."}}}]}}}}, "responses": {"200": {"description": "Entity profiles (by id) or one page of a category's addresses.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"oneOf": [{"title": "By entity id", "type": "object", "properties": {"entities": {"type": "array", "items": {"type": "object", "properties": {"brand": {"type": "object", "description": "The platform this address or cluster belongs to: what it scores and where it sits, one place per fact. Everything else — the licences, the KYC periods, every ground for the score, the list rows per country, and the developer-facing completeness flags — is at GET /v1/brands/{name_key}, linked in `detail`.", "properties": {"entity_id": {"type": "integer"}, "name_key": {"type": "string", "description": "The key to ask /v1/brands for."}, "display_name": {"type": ["string", "null"]}, "risk_score": {"type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "0..100, worst component wins; null = no component at all (no country and no designation), which is NOT 0. The threshold for 'high risk' and the full rule are in brand_risk_policy."}, "reason": {"type": ["object", "null"], "description": "The WORST component — the one the score came from, and the one field that says whether it was a sanction or a jurisdiction: {kind: designation, count, status, score} or {kind: jurisdiction, code, list, status, valid_from, score}. null when there is no component."}, "designated": {"type": "boolean", "description": "The ORGANISATION itself carries an active sanction. It reaches the address score through `risk_score` (breakdown `brand_direct`), not through this flag."}, "jurisdiction": {"type": "object", "properties": {"domicile_country": {"type": ["string", "null"], "description": "Country of REGISTRATION."}, "management_country": {"type": ["string", "null"], "description": "Country of ACTUAL CONTROL — a different fact, filled only on a regulator's or court's finding."}, "licence_countries": {"type": "array", "items": {"type": "string"}, "description": "Issuing countries of the licences and registrations valid on as_of."}, "unchecked": {"type": "array", "items": {"type": "string"}, "description": "Countries of this venue that have NO row for as_of in the FATF or EU list. Empty means everything the venue has was checked; a non-empty list means risk_score is a lower bound and names who could raise it. This replaces a bare completeness flag — the flag itself is in the full record."}}}, "as_of": {"type": "string", "format": "date", "description": "The date the verdict was computed for."}, "basis": {"type": ["string", "null"], "enum": ["label", "cluster", null], "description": "What resolved the brand: `label` — the address's own label, `cluster` — the binding of its cluster."}, "detail": {"type": "string", "description": "Path of this brand's full record: /v1/brands/{name_key}."}}}}, "description": "`brand` is present only for a cluster bound to a platform brand."}}, "not_found": {"type": "array", "items": {"type": "object"}}, "brand_risk_policy": {"type": "object", "description": "The scale itself: the weight of every list status, the designation weights, the derived-boolean threshold and the rule texts. Present only when some entity carries `brand`; the same values GET /v1/admin/brands/jurisdictions serves.", "properties": {"as_of": {"type": "string", "format": "date"}, "lists": {"type": "array", "items": {"type": "string"}}, "closed_lists": {"type": "array", "items": {"type": "string"}, "description": "Lists where a missing row means NOT LISTED rather than unknown — our own ones."}, "list_status_score": {"type": "object", "additionalProperties": {"type": "integer"}, "description": "Keyed `<list>/<status>`."}, "designation_score": {"type": "object", "additionalProperties": {"type": "integer"}}, "high_risk_min": {"type": "integer"}, "score_rule": {"type": "string"}, "jurisdiction_rule": {"type": "string"}, "legacy_rule": {"type": "string"}}}}}, {"title": "By category", "type": "object", "properties": {"entities": {"type": "array", "maxItems": 0, "description": "Always empty in this mode (envelope kept)."}, "not_found": {"type": "array", "maxItems": 0}, "mode": {"type": "string", "enum": ["category"]}, "chain": {"type": "string"}, "category": {"type": "string"}, "limit": {"type": "integer"}, "count": {"type": "integer"}, "addresses": {"type": "array", "items": {"type": "object", "properties": {"venue": {"type": ["string", "null"]}, "brand": {"type": "object", "description": "The platform this address or cluster belongs to: what it scores and where it sits, one place per fact. Everything else — the licences, the KYC periods, every ground for the score, the list rows per country, and the developer-facing completeness flags — is at GET /v1/brands/{name_key}, linked in `detail`.", "properties": {"entity_id": {"type": "integer"}, "name_key": {"type": "string", "description": "The key to ask /v1/brands for."}, "display_name": {"type": ["string", "null"]}, "risk_score": {"type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "0..100, worst component wins; null = no component at all (no country and no designation), which is NOT 0. The threshold for 'high risk' and the full rule are in brand_risk_policy."}, "reason": {"type": ["object", "null"], "description": "The WORST component — the one the score came from, and the one field that says whether it was a sanction or a jurisdiction: {kind: designation, count, status, score} or {kind: jurisdiction, code, list, status, valid_from, score}. null when there is no component."}, "designated": {"type": "boolean", "description": "The ORGANISATION itself carries an active sanction. It reaches the address score through `risk_score` (breakdown `brand_direct`), not through this flag."}, "jurisdiction": {"type": "object", "properties": {"domicile_country": {"type": ["string", "null"], "description": "Country of REGISTRATION."}, "management_country": {"type": ["string", "null"], "description": "Country of ACTUAL CONTROL — a different fact, filled only on a regulator's or court's finding."}, "licence_countries": {"type": "array", "items": {"type": "string"}, "description": "Issuing countries of the licences and registrations valid on as_of."}, "unchecked": {"type": "array", "items": {"type": "string"}, "description": "Countries of this venue that have NO row for as_of in the FATF or EU list. Empty means everything the venue has was checked; a non-empty list means risk_score is a lower bound and names who could raise it. This replaces a bare completeness flag — the flag itself is in the full record."}}}, "as_of": {"type": "string", "format": "date", "description": "The date the verdict was computed for."}, "basis": {"type": ["string", "null"], "enum": ["label", "cluster", null], "description": "What resolved the brand: `label` — the address's own label, `cluster` — the binding of its cluster."}, "detail": {"type": "string", "description": "Path of this brand's full record: /v1/brands/{name_key}."}}}, "address": {"type": "string"}, "label": {"type": ["string", "null"]}, "venue_role": {"type": "string", "enum": ["deposit", "hot_wallet", "cold_wallet", "gas_supplier", "treasury", "custody", "cluster_member", "unspecified"], "description": "Which wallet of the venue this is. `hot_wallet` / `cold_wallet` / `gas_supplier` / `treasury` / `custody` only from the address's OWN role; `deposit` also from the ' (deposit)' naming rule or a deposit-label cluster; `cluster_member` = attributed only by cluster membership; `unspecified` = labelled directly, no role."}, "basis": {"type": ["object", "null"], "properties": {"kind": {"type": "string", "enum": ["attribution", "derived", "clustering"]}, "source": {"type": ["string", "null"]}, "confidence": {"type": ["number", "null"]}, "rule": {"type": "string", "enum": ["entity_role", "name_suffix", "via_deposit_label"]}}, "description": "Who says this address is this venue in this role."}, "cluster_id": {"type": ["string", "null"]}, "external_label_meta": {"type": ["object", "null"], "properties": {"from": {"type": "string", "enum": ["address", "anchor"]}, "anchor": {"type": ["string", "null"]}, "rows": {"type": "array", "items": {"type": "object", "properties": {"source": {"type": ["string", "null"]}, "entity_name": {"type": ["string", "null"]}, "entity_type": {"type": ["string", "null"]}, "website": {"type": ["string", "null"]}, "twitter": {"type": ["string", "null"]}, "entity_role": {"type": ["string", "null"]}, "entity_venue": {"type": ["string", "null"]}, "as_of": {"type": ["string", "null"]}, "labels": {"type": ["array", "null"], "items": {"type": "string"}}, "website_deprecated": {"type": ["object", "null"], "properties": {"url": {"type": "string", "description": "The site as the label source published it."}, "status": {"type": "string", "enum": ["dns_gone", "unreachable", "http_error", "parked", "hijacked"], "description": "`dns_gone`: the domain no longer resolves; `unreachable`: it resolves but nothing answers; `http_error`: it answers with an error; `parked`: parked or offered for sale; `hijacked`: it now leads to an unrelated, sometimes fraudulent, site."}, "checked_at": {"type": ["string", "null"], "format": "date-time", "description": "When the check that retired it ran."}}, "description": "Set when the venue's published site was checked and is no longer the venue's: `website` is then null and the old URL moves here. Do not render it as a link, least of all when `status` is `hijacked`; where a dead domain redirects is never returned. null for a live site or when no site is known."}}}}}, "description": "What external labels say. `from: anchor` = the address has no label of its own and these rows describe its cluster's anchor (named in `anchor`), not the address."}}, "additionalProperties": false, "description": "Ascending by stored address. A block not asked for in `include` is absent."}}, "brand_risk_policy": {"type": "object", "description": "The scale, its weights and its rule texts, once per response. Absent when no address of the page carries a brand."}, "next_cursor": {"type": ["string", "null"]}, "done": {"type": "boolean"}}}]}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/brands/{name_key}": {"get": {"operationId": "getBrand", "summary": "One platform brand: jurisdiction, licences, KYC, risk score", "description": "The venue as a REGULATED BUSINESS rather than as a graph: where it is registered, where it is actually controlled, who licensed it and under which act, what its published KYC thresholds are, which jurisdiction lists its countries are on, and what all of that scores (`brand_risk.risk_score`, 0..100, decision #050).\n\n`name_key` is the value every brand summary carries (`brand.name_key` in /v1/entities, and `brand.detail` is this path ready to use). The key of a LABEL is accepted too — `/v1/brands/stake` finds the same venue as `/v1/brands/stake-com` — and `matched_by` says which way it resolved, so landing on a different brand than expected is visible rather than silent. A numeric `entity_id` also works, but it is tried AFTER the name.\n\nEverything here is computed on read for `as_of` and nothing is stored: a country enters a list and leaves it, a licence is issued and surrendered. Facts come from primary registers only — `source` and `evidence_url` on every record name the register entry, which is why `legal_name` is often null: ask an overview site which legal entity is behind a casino brand and it will invent one.\n\nOpen to anonymous callers; one unit.", "tags": ["Entities"], "parameters": [{"name": "name_key", "in": "path", "required": true, "schema": {"type": "string", "maxLength": 200}, "description": "e.g. `1win`, `stake-com`, `garantex`.", "example": "1win"}, {"name": "as_of", "in": "query", "required": false, "schema": {"type": "string", "format": "date"}, "description": "The date the verdict is computed for. Default today. The answer to \"what did this venue look like in March\" is a different answer, not a stale one."}], "responses": {"200": {"description": "The brand's regulatory record.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"brand": {"type": "object", "description": "The platform brand this cluster is bound to (`clusters.entity_id`), with its regulatory attributes. Absent when the cluster is bound to no brand.", "properties": {"entity_id": {"type": "integer"}, "name_key": {"type": "string"}, "display_name": {"type": ["string", "null"]}, "legal_name": {"type": ["string", "null"], "description": "The operating legal entity as the REGISTER spells it. Filled only from a primary register, which is why it is often null: overview sites invent legal entities."}, "website_url": {"type": ["string", "null"]}, "logo_url": {"type": ["string", "null"]}, "domicile_country": {"type": ["string", "null"], "description": "ISO-3166-1 alpha-2 of REGISTRATION of the head operating entity."}, "management_country": {"type": ["string", "null"], "description": "Country of ACTUAL CONTROL, and a different fact — filled only on a regulator's or a court's finding. Neither of the two is the conservative one: Bybit registers in SC and is managed from AE, Garantex registers in EE and is managed from RU."}, "brand_risk": {"type": "object", "properties": {"risk_score": {"type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "The field to read. max(sanction of the organisation, jurisdiction); null = no component at all."}, "complete": {"type": "boolean", "description": "false = risk_score is a lower bound and can only go up."}, "high_risk": {"type": ["boolean", "null"], "description": "Derived (risk_score >= high_risk_min), kept for compatibility. null when risk_score is null — nothing was known to derive it from."}, "high_risk_legacy": {"type": ["boolean", "null"], "description": "The pre-scale boolean, computed from the FATF and EU lists only, published so a divergence is visible."}, "designated": {"type": "boolean", "description": "The ORGANISATION itself carries an active designation."}, "past_designations": {"type": "integer"}, "reasons": {"type": "array", "items": {"type": "object"}, "description": "What produced the score, worst first: {kind: designation, count, status, score} and {kind: jurisdiction, code, list, status, valid_from, score}."}}}, "jurisdiction": {"type": "object", "description": "The verdict for `as_of`, computed on read and never stored: the union of domicile, management and the issuing countries of currently valid licences; worst status wins; `valid_in` (passporting) is ignored.", "properties": {"as_of": {"type": "string", "format": "date"}, "risk_score": {"type": ["integer", "null"]}, "complete": {"type": "boolean"}, "high_risk": {"type": ["boolean", "null"], "description": "null = no countries, or a country with no list row for the date — unknown, not clean."}, "unchecked": {"type": "array", "items": {"type": "string"}, "description": "Countries with no row for as_of in an external list — the reason `complete` is false, named."}, "domicile": {"type": ["object", "null"], "properties": {"code": {"type": "string"}, "name": {"type": ["string", "null"]}, "kind": {"type": ["string", "null"], "description": "`country` or `bloc`."}, "risk_score": {"type": "integer"}, "complete": {"type": "boolean"}, "high_risk": {"type": ["boolean", "null"]}, "lists": {"type": "object", "description": "Per list (fatf, eu-high-risk, weak-supervision) the row valid on the date — status, validity, source, evidence_url, its score — or null when there is none."}}}, "management": {"type": ["object", "null"], "properties": {"code": {"type": "string"}, "name": {"type": ["string", "null"]}, "kind": {"type": ["string", "null"], "description": "`country` or `bloc`."}, "risk_score": {"type": "integer"}, "complete": {"type": "boolean"}, "high_risk": {"type": ["boolean", "null"]}, "lists": {"type": "object", "description": "Per list (fatf, eu-high-risk, weak-supervision) the row valid on the date — status, validity, source, evidence_url, its score — or null when there is none."}}}, "licence_countries": {"type": "array", "items": {"type": ["object", "null"], "properties": {"code": {"type": "string"}, "name": {"type": ["string", "null"]}, "kind": {"type": ["string", "null"], "description": "`country` or `bloc`."}, "risk_score": {"type": "integer"}, "complete": {"type": "boolean"}, "high_risk": {"type": ["boolean", "null"]}, "lists": {"type": "object", "description": "Per list (fatf, eu-high-risk, weak-supervision) the row valid on the date — status, validity, source, evidence_url, its score — or null when there is none."}}}}, "high_risk_basis": {"type": "array", "items": {"type": "object"}, "description": "Deduplicated by (country, list), worst first."}}}, "regulatory": {"type": "object", "properties": {"claims": {"type": "array", "items": {"type": "object"}, "description": "Regulatory records, the negative ones included: `kind` is licence / authorisation / registration / aml-registration / designation / warning / enforcement / not-registered, with `authority`, `act_ref`, `issuing_country`, `holder_legal_name`, validity window, and `source` + `evidence_url` naming the register entry. A designation lives here, one row per act."}, "kyc": {"type": "array", "items": {"type": "object"}, "description": "Published KYC periods: kyc_level, threshold, validity, `current` for the one in force, and its evidence."}}}}}, "brand_risk_policy": {"type": "object", "description": "The scale, its weights and its rule texts. Same object GET /v1/admin/brands/jurisdictions serves — one source, so the public answer and the console cannot drift."}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/brands": {"post": {"operationId": "getBrands", "summary": "Several platform brands by name_key or entity_id", "description": "The batch form of GET /v1/brands/{name_key}, for the brands of a page of addresses in one call. Same records, same `as_of` rule. Keys that resolve to nothing come back in `not_found` — an unknown brand is not an error for the whole batch. Each returned brand echoes `requested_as` and `matched_by`.\n\nPriced per requested key, like every batch method here.", "tags": ["Entities"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "properties": {"name_keys": {"type": "array", "items": {"type": "string", "maxLength": 200}, "maxItems": 100, "description": "`brand.name_key` values, or label brand keys."}, "entity_ids": {"type": "array", "items": {"type": "integer"}, "maxItems": 100, "description": "`brand.entity_id` values. At least one of the two lists is required."}, "as_of": {"type": "string", "format": "date"}}}}}}, "responses": {"200": {"description": "The brands that resolved, and the keys that did not.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"brands": {"type": "array", "items": {"type": "object", "description": "The platform brand this cluster is bound to (`clusters.entity_id`), with its regulatory attributes. Absent when the cluster is bound to no brand.", "properties": {"entity_id": {"type": "integer"}, "name_key": {"type": "string"}, "display_name": {"type": ["string", "null"]}, "legal_name": {"type": ["string", "null"], "description": "The operating legal entity as the REGISTER spells it. Filled only from a primary register, which is why it is often null: overview sites invent legal entities."}, "website_url": {"type": ["string", "null"]}, "logo_url": {"type": ["string", "null"]}, "domicile_country": {"type": ["string", "null"], "description": "ISO-3166-1 alpha-2 of REGISTRATION of the head operating entity."}, "management_country": {"type": ["string", "null"], "description": "Country of ACTUAL CONTROL, and a different fact — filled only on a regulator's or a court's finding. Neither of the two is the conservative one: Bybit registers in SC and is managed from AE, Garantex registers in EE and is managed from RU."}, "brand_risk": {"type": "object", "properties": {"risk_score": {"type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "The field to read. max(sanction of the organisation, jurisdiction); null = no component at all."}, "complete": {"type": "boolean", "description": "false = risk_score is a lower bound and can only go up."}, "high_risk": {"type": ["boolean", "null"], "description": "Derived (risk_score >= high_risk_min), kept for compatibility. null when risk_score is null — nothing was known to derive it from."}, "high_risk_legacy": {"type": ["boolean", "null"], "description": "The pre-scale boolean, computed from the FATF and EU lists only, published so a divergence is visible."}, "designated": {"type": "boolean", "description": "The ORGANISATION itself carries an active designation."}, "past_designations": {"type": "integer"}, "reasons": {"type": "array", "items": {"type": "object"}, "description": "What produced the score, worst first: {kind: designation, count, status, score} and {kind: jurisdiction, code, list, status, valid_from, score}."}}}, "jurisdiction": {"type": "object", "description": "The verdict for `as_of`, computed on read and never stored: the union of domicile, management and the issuing countries of currently valid licences; worst status wins; `valid_in` (passporting) is ignored.", "properties": {"as_of": {"type": "string", "format": "date"}, "risk_score": {"type": ["integer", "null"]}, "complete": {"type": "boolean"}, "high_risk": {"type": ["boolean", "null"], "description": "null = no countries, or a country with no list row for the date — unknown, not clean."}, "unchecked": {"type": "array", "items": {"type": "string"}, "description": "Countries with no row for as_of in an external list — the reason `complete` is false, named."}, "domicile": {"type": ["object", "null"], "properties": {"code": {"type": "string"}, "name": {"type": ["string", "null"]}, "kind": {"type": ["string", "null"], "description": "`country` or `bloc`."}, "risk_score": {"type": "integer"}, "complete": {"type": "boolean"}, "high_risk": {"type": ["boolean", "null"]}, "lists": {"type": "object", "description": "Per list (fatf, eu-high-risk, weak-supervision) the row valid on the date — status, validity, source, evidence_url, its score — or null when there is none."}}}, "management": {"type": ["object", "null"], "properties": {"code": {"type": "string"}, "name": {"type": ["string", "null"]}, "kind": {"type": ["string", "null"], "description": "`country` or `bloc`."}, "risk_score": {"type": "integer"}, "complete": {"type": "boolean"}, "high_risk": {"type": ["boolean", "null"]}, "lists": {"type": "object", "description": "Per list (fatf, eu-high-risk, weak-supervision) the row valid on the date — status, validity, source, evidence_url, its score — or null when there is none."}}}, "licence_countries": {"type": "array", "items": {"type": ["object", "null"], "properties": {"code": {"type": "string"}, "name": {"type": ["string", "null"]}, "kind": {"type": ["string", "null"], "description": "`country` or `bloc`."}, "risk_score": {"type": "integer"}, "complete": {"type": "boolean"}, "high_risk": {"type": ["boolean", "null"]}, "lists": {"type": "object", "description": "Per list (fatf, eu-high-risk, weak-supervision) the row valid on the date — status, validity, source, evidence_url, its score — or null when there is none."}}}}, "high_risk_basis": {"type": "array", "items": {"type": "object"}, "description": "Deduplicated by (country, list), worst first."}}}, "regulatory": {"type": "object", "properties": {"claims": {"type": "array", "items": {"type": "object"}, "description": "Regulatory records, the negative ones included: `kind` is licence / authorisation / registration / aml-registration / designation / warning / enforcement / not-registered, with `authority`, `act_ref`, `issuing_country`, `holder_legal_name`, validity window, and `source` + `evidence_url` naming the register entry. A designation lives here, one row per act."}, "kyc": {"type": "array", "items": {"type": "object"}, "description": "Published KYC periods: kyc_level, threshold, validity, `current` for the one in force, and its evidence."}}}}}}, "not_found": {"type": "array", "items": {"type": "object", "description": "{name_key | entity_id, reason: brand_not_found}"}}, "brand_risk_policy": {"type": "object"}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/cluster-mappings": {"post": {"operationId": "getClusterMembers", "summary": "List the member addresses of a cluster", "description": "Every address belonging to the given clusters. An exchange's deposit cluster can hold hundreds of thousands of addresses, so results are paged with a keyset cursor: send back the `next_cursor` from the previous page. A cursor is only valid with a single entity id.", "tags": ["Entities"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["entity_ids"], "properties": {"entity_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 100}, "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}, "cursor": {"type": "string", "description": "`next_cursor` from the previous page. Single entity id only."}}}}}}, "responses": {"200": {"description": "Cluster members.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"mappings": {"type": "array", "items": {"type": "object"}}, "not_found": {"type": "array", "items": {"type": "object"}}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/labels": {"post": {"operationId": "pushLabels", "summary": "Push entity labels (operator keys only)", "description": "OPERATOR KEYS ONLY since 19.10.2026: a signed request from a key whose user_id is in ADMIN_USER_IDS. Any other credential gets 403 `admin_required` (or `hmac_required` for a bare key). Every entry names the `source` it is written under — an attribution_trust source, see GET /v1/admin/source-trust — and the row records the writer: `written_by` (user_id) and `written_by_key` (key fingerprint). Source and writer are different facts.\n\nAn address that nothing else describes is answerable by /v2/enrich IMMEDIATELY; one that a feed already describes waits for the next label-sync cycle (about ten minutes), which decides by source rank who wins. The response says which is which: `visible_now` and `visible_after_sync`. Rows from other sources on the same address are reported per entry in `competing` (with their rank), not refused: rank settles it. Re-pushing under the same source is an ordinary update; an unknown source is refused per entry (`unknown source`) — create it first.\n\nAn entry may name a CLUSTER instead of an address: send `cluster_id` (the value /v2/enrich returns in `axes.membership.group_id`) in place of `wallet_address` and ONE row covers the whole cluster, including members discovered later. Use it for hubs and for multi-address entities — a hub can hold 1.96M addresses, and a claim expanded over them freezes on today's membership. A cluster label participates in attribution with its original source and source rank, just like an address label. Confidence breaks rank ties; an address claim wins an otherwise equal tie. Inherited axis claims carry `applies_to: cluster` and `group_id`. No label-sync wait is needed for cluster claims. Sending both keys in one entry is a 400.\n\nONLY OWNERSHIP CLUSTERS MAY BE LABELLED: `unnamed:` (a hub) and `entity:`. `community:` and `cex_origin:` are refused — they group addresses by graph behaviour, not by owner, and a name put on one names strangers (the largest community cluster holds 523k addresses, the largest cex_origin 29.6M). Clusters above `CLUSTER_LABEL_MAX_MEMBERS` members are refused too; check `cluster_size` from /v1/entities first.\n\n`category` must exist in the published taxonomy — see GET /v1/categories. An unknown one is refused per entry, with the nearest known names in `did_you_mean`.\n\nSOLANA (label-only chain) is accepted since 19.10.2026. A Solana row is read by /v2/enrich straight from the label store, so `visible_now` counts it when your row is the best-ranked claim on the address; a row outranked by a feed is stored but not shown, and no sync cycle changes that. Addresses are case-sensitive: push the exact spelling. `cluster_id` entries do not apply to Solana (no clusters there).", "tags": ["Write"], "security": [{"HmacSignature": [], "ApiKeyAuth": [], "HmacTimestamp": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["labels"], "properties": {"labels": {"type": "array", "minItems": 1, "maxItems": 1000, "items": {"type": "object", "required": ["category", "source"], "description": "Exactly one of `wallet_address` (with `chain`) or `cluster_id`.", "properties": {"source": {"type": "string", "pattern": "^[a-z0-9_.:-]{1,64}$", "description": "attribution_trust source the row is written under (its rank decides disputes). Must exist: GET /v1/admin/source-trust."}, "wallet_address": {"type": "string", "description": "The address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron", "solana"], "description": "Which blockchain the address belongs to. Send one of the values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like.\n\n`solana` (short form `SOL`) is accepted here as well as by the enrich endpoints: a Solana label is stored and served by /v2/enrich from attribution alone (no graph, no score). Solana addresses are case-sensitive and are stored exactly as sent; a malformed one is refused per entry with `invalid_address_format`."}, "cluster_id": {"type": "string", "description": "Label the whole cluster instead of one address, e.g. 'unnamed:1:0x…' or 'entity:1:coinbase'. The chain is read out of the id; do not also send `chain`."}, "category": {"type": "string", "description": "Entity type from GET /v1/categories: cex, mixer, gambling, … Free-text values are refused."}, "label": {"type": "string", "description": "Display name."}, "confidence": {"type": "number", "minimum": 0, "maximum": 1}}}}}}}}}, "responses": {"200": {"description": "Per-entry result.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"upserted": {"type": "integer", "description": "Address labels written."}, "cluster_upserted": {"type": "integer", "description": "Cluster labels written. Present only when the batch contained any — one of these covers a whole cluster, so it is not summed with `upserted`."}, "visible_now": {"type": "integer", "description": "Of `upserted`, how many are already answerable by /v2/enrich."}, "visible_after_sync": {"type": "integer", "description": "The rest: addresses something else already describes, where precedence is settled by the next label-sync cycle."}, "errors": {"type": "array", "items": {"type": "object"}, "description": "Per-entry rejections, each with its index. A cluster_id that matches no known cluster is rejected as entity_not_found; a community/cex_origin or oversized cluster is rejected with its `cluster_type` and `cluster_size`; an unknown category is rejected with `did_you_mean`."}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "hmac_required — an API key alone cannot write.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}, "get": {"operationId": "listMyLabels", "summary": "List the labels written under a source (operator keys only)", "description": "Your own pushes only, newest checked first. This is an admin boundary, not a privacy boundary: the underlying labels are globally visible once they join the graph.", "tags": ["Write"], "security": [{"HmacSignature": [], "ApiKeyAuth": [], "HmacTimestamp": []}], "parameters": [{"name": "source", "in": "query", "required": true, "schema": {"type": "string"}, "description": "attribution_trust source whose rows to list."}, {"name": "written_by", "in": "query", "schema": {"type": "string"}, "description": "Only rows written by this user_id."}, {"name": "chain", "in": "query", "schema": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron", "solana"], "description": "Which blockchain the address belongs to. Send one of the values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like.\n\n`solana` (short form `SOL`) is accepted here as well as by the enrich endpoints: a Solana label is stored and served by /v2/enrich from attribution alone (no graph, no score). Solana addresses are case-sensitive and are stored exactly as sent; a malformed one is refused per entry with `invalid_address_format`."}}, {"name": "category", "in": "query", "schema": {"type": "string"}}, {"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}}, {"name": "offset", "in": "query", "schema": {"type": "integer", "minimum": 0}}], "responses": {"200": {"description": "Your labels.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}, "delete": {"operationId": "deleteMyLabels", "summary": "Remove label rows (operator keys only)", "description": "Each entry names (chain, wallet_address, source) or (cluster_id, source). Operator keys only, signed.", "tags": ["Write"], "security": [{"HmacSignature": [], "ApiKeyAuth": [], "HmacTimestamp": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"labels": {"type": "array", "items": {"type": "object", "required": ["wallet_address", "chain", "source"], "properties": {"wallet_address": {"type": "string", "description": "The address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron", "solana"], "description": "Which blockchain the address belongs to. Send one of the values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like.\n\n`solana` (short form `SOL`) is accepted here as well as by the enrich endpoints: a Solana label is stored and served by /v2/enrich from attribution alone (no graph, no score). Solana addresses are case-sensitive and are stored exactly as sent; a malformed one is refused per entry with `invalid_address_format`."}, "source": {"type": "string"}, "cluster_id": {"type": "string"}}}}}}}}}, "responses": {"200": {"description": "Deletion result.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/incidents": {"post": {"operationId": "pushIncidents", "summary": "Report addresses involved in a named incident", "description": "Files incident-derived risk seeds. Each entry names an address, the incident, and a label from the published taxonomy (GET /v1/risk-labels) — free-text categories are rejected.\n\nEvery address checksum is re-verified server-side (EIP-55, base58check, bech32): a mis-transcribed character would defame an uninvolved address.\n\nAn existing seed that already scores HIGHER is never downgraded — your incident is recorded against it and the stronger label stays. The effect on the address itself is immediate; the effect on its counterparties waits for a propagation run.\n\nRequires HMAC.", "tags": ["Write"], "security": [{"HmacSignature": [], "ApiKeyAuth": [], "HmacTimestamp": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["incidents"], "properties": {"incidents": {"type": "array", "minItems": 1, "items": {"type": "object", "required": ["wallet_address", "chain", "incident_name"], "properties": {"wallet_address": {"type": "string", "description": "The address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}, "label": {"type": "string", "description": "A label from GET /v1/risk-labels."}, "label_id": {"type": "integer", "description": "The same, by id."}, "category": {"type": "string"}, "incident_name": {"type": "string"}, "description": {"type": "string"}, "source_url": {"type": "string", "description": "The published evidence. Without it the claim has nothing behind it."}, "incident_date": {"type": "string"}, "confidence": {"type": "number", "minimum": 0, "maximum": 1}}}}}}}}}, "responses": {"200": {"description": "Per-entry result plus what was recorded.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}, "get": {"operationId": "listIncidents", "summary": "Read recorded incidents", "description": "Two scopes over two different stores, selected by `scope`.\n\n**`scope=mine`** (default) — the incident seeds YOU pushed, with the score and active state of each. `parked` reaches the holding table for chains not indexed yet; those rows carry `risk_score_when_replayed`, never live risk. Add `slug` to get the curated record behind one of your pushes alongside the seeds.\n\n**`scope=canon`** — the incident RECORD itself: every public incident naming an address, whoever filed it, one row per (incident, address). Filter by `chain`, by `source` (who published it, e.g. `Chainabuse`, `SlowMist`), by `stream` (`curated` = our own investigation, `community` = a user-filed report ingested from a feed), by `label` or `category`, by `role`, and by `occurred_at` through `since`/`until`. Requires an API key, and is priced one unit per returned row: it is a dataset read, not a lookup.\n\nWhat `scope=canon` never returns, whatever you filter by: a private incident, a retracted one, and any `victim` link. A canon row is not a risk seed either, so it carries no score — `status`, `stream` and `sources[].tier` say how well evidenced the claim is, and what it is WORTH to an address is `/v2/enrich`'s answer, not this one. In particular a `community` report scores nothing on its own: those seeds were deactivated in August and the report lives here as evidence.", "tags": ["Reference"], "parameters": [{"name": "scope", "in": "query", "schema": {"type": "string", "enum": ["mine", "canon"], "default": "mine"}, "description": "`mine` = the seeds you pushed; `canon` = the incident record."}, {"name": "slug", "in": "query", "schema": {"type": "string"}, "description": "One incident by key. Under `mine` it also returns the curated record; under `canon` it narrows the listing to that incident."}, {"name": "chain", "in": "query", "schema": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"]}, "description": "Both scopes."}, {"name": "source", "in": "query", "schema": {"type": "string"}, "description": "`canon` only. Who published the incident, matched case-insensitively against the publisher of a non-retracted source."}, {"name": "stream", "in": "query", "schema": {"type": "string", "enum": ["curated", "community"]}, "description": "`canon` only. Which population: analyst-recorded investigation, or user-filed report from a feed."}, {"name": "status", "in": "query", "schema": {"type": "string", "enum": ["confirmed", "alleged", "disputed"]}, "description": "`canon` only. Confirmation level, independent of `stream`. `retracted` is not selectable: such an incident is never listed."}, {"name": "role", "in": "query", "schema": {"type": "string", "enum": ["perpetrator", "intermediary", "recipient", "mentioned"]}, "description": "`canon` only. `victim` is not selectable: victim links are never listed."}, {"name": "label", "in": "query", "schema": {"type": "string"}, "description": "Both scopes. An exact label from GET /v1/risk-labels, e.g. `pig-butchering`."}, {"name": "label_id", "in": "query", "schema": {"type": "integer"}, "description": "`canon` only. The numeric form of `label`."}, {"name": "category", "in": "query", "schema": {"type": "string"}, "description": "`canon` only. The label's category, e.g. `romance` — wider than `label`."}, {"name": "since", "in": "query", "schema": {"type": "string", "format": "date"}, "description": "`canon` only. Earliest `occurred_at`, as YYYY-MM-DD."}, {"name": "until", "in": "query", "schema": {"type": "string", "format": "date"}, "description": "`canon` only. Latest `occurred_at`, as YYYY-MM-DD."}, {"name": "active", "in": "query", "schema": {"type": "boolean"}, "description": "`mine` only — a canon row is not a seed and has no active state."}, {"name": "parked", "in": "query", "schema": {"type": "string", "enum": ["true", "false", "only"]}, "description": "`mine` only. Which store: seeds, both, or the parking table alone."}, {"name": "limit", "in": "query", "schema": {"type": "integer"}, "description": "1..1000. Defaults to 100 under `canon`."}, {"name": "offset", "in": "query", "schema": {"type": "integer"}}], "responses": {"200": {"description": "Incidents.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}, "delete": {"operationId": "deactivateIncidents", "summary": "Deactivate incident seeds you pushed", "description": "Deactivation, not deletion: the row is the audit trail of a published accusation. This stops the seed from scoring; it does NOT retract risk already propagated to counterparties. Requires HMAC.", "tags": ["Write"], "security": [{"HmacSignature": [], "ApiKeyAuth": [], "HmacTimestamp": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "Deactivation result.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/attest": {"post": {"operationId": "attestAddress", "summary": "Issue an EIP-712 signed risk attestation for on-chain use", "description": "Signs a four-field struct a smart contract can verify, so a protocol can gate on our risk reading without trusting an HTTP response. Valid for 600 seconds; there is no nonce and no revocation channel, so that window is the only bound on acting on a stale verdict.\n\n`attestation` is exactly what the contract expects. The risk detail beside it is UNSIGNED context for humans and off-chain callers — it is not part of what the signature covers.\n\nbitcoin cannot be attested at all: the struct types the subject as a Solidity address, which cannot hold a base58 or bech32 address. This endpoint also accepts the testnets SEPOLIA and NILE.\n\nThe signed payload format is FROZEN — an extra field would change the hash and break every deployed verifier.", "tags": ["Attestation"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["wallet_address", "chain"], "properties": {"wallet_address": {"type": "string", "description": "The address to look up, in its normal display form.\n\n  * bitcoin  — base58 (`1…`, `3…`) or bech32/bech32m (`bc1…`, `bc1p…`). Case is SIGNIFICANT for base58; do not lowercase it. A 64-character lowercase TRANSACTION HASH is also accepted here: on bitcoin the graph is bipartite (address → transaction → address), so a transaction is a node with its own neighbours and its own findings. CoinJoin is classified on the transaction, not on the participants, and this is how you read it.\n  * ethereum, bsc — `0x` + 40 hex characters, any case.\n  * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes."}, "chain": {"type": "string", "enum": ["ethereum", "bsc", "tron", "SEPOLIA", "NILE"], "description": "bitcoin is not attestable — see the description. SEPOLIA and NILE are testnets."}, "include": {"type": "array", "items": {"type": "string", "enum": ["typed_data"]}, "description": "Echo the full EIP-712 payload, as a debugging aid."}}}}}}, "responses": {"200": {"description": "The signed attestation.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"type": "string"}, "message": {"type": "string"}, "result": {"type": "object", "properties": {"attestation": {"type": "object", "description": "The signed struct, exactly as the contract reads it."}, "signature": {"type": "string"}, "signer": {"type": "string"}, "domain": {"type": "object"}, "risk_score": {"type": ["number", "null"], "description": "Unsigned context."}}}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Refusing to sign: the signer is unconfigured, or a fact source is unavailable. Retry rather than caching a verdict.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/categories": {"get": {"operationId": "listCategories", "summary": "The attribution vocabulary", "description": "Every category POST /v1/labels accepts, with what each one does to scoring: `counterparty_weight` (how much a counterparty in it contributes), `taint`, `neutral_infrastructure` (floors an address's exposure) and `scores_own_identity` (the address scores for BEING one).\n\nNot the same list as GET /v1/risk-labels. A risk label is an ACCUSATION and carries a score; a category here is an ATTRIBUTION and carries weights. `zero_risk` is the subset that identifies without accusing.", "tags": ["Reference"], "responses": {"200": {"description": "The category catalogue.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"categories": {"type": "array", "items": {"type": "object"}}, "zero_risk": {"type": "array", "items": {"type": "string"}}, "total": {"type": "integer"}, "source": {"type": "string", "description": "postgres = the policy table; builtin = the copy in this image, serving because PG could not be read."}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/risk-labels": {"get": {"operationId": "getRiskTaxonomy", "summary": "The risk-label taxonomy", "description": "Every risk label the API can attach to an address, its aliases, its category and its score. This is the vocabulary POST /v1/incidents validates against — read it before pushing.\n\n`source` says where the catalogue came from: 'postgres' is the authority, 'builtin' is the copy compiled into the image and means the API is degraded.", "tags": ["Reference"], "parameters": [{"name": "category", "in": "query", "schema": {"type": "string"}}], "responses": {"200": {"description": "The taxonomy.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"labels": {"type": "array", "items": {"type": "object", "properties": {"label_id": {"type": "integer"}, "label": {"type": "string"}, "aliases": {"type": "array", "items": {"type": "string"}}, "category": {"type": "string"}, "category_name": {"type": "string"}, "risk_score": {"type": "number"}}}}, "categories": {"type": "array", "items": {"type": "object"}}, "total": {"type": "integer"}, "source": {"type": "string", "enum": ["postgres", "builtin"]}}}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/supported-tokens-registry": {"get": {"operationId": "getSupportedTokens", "summary": "Tokens whose symbol and decimals the API knows", "description": "The contracts enrichment can name and denominate. Anything outside this list falls back to 18 decimals and a null symbol, which is why an amount for an unlisted token may be wrong — check here before showing one.", "tags": ["Reference"], "parameters": [{"name": "chain", "in": "query", "schema": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}}], "responses": {"200": {"description": "The registry.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/changelog": {"get": {"operationId": "getChangelog", "summary": "Published changes to this API", "description": "Customer-facing changes, newest first. Date, kind, title, body.", "tags": ["Reference"], "parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}}], "responses": {"200": {"description": "The changelog.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/feed-stats": {"get": {"operationId": "getFeedStats", "summary": "What the external feeds brought in over the last 24 hours", "description": "Freshness of the label and sanction feeds: designations added and updated (with the authority that named them and, where we can name them, the entities), addresses an authority has stopped naming, Tether/Circle freeze events, community reports, and entity attributions across every feed that names an address, broken down by feed.\n\nCounts, entity names and one address per named entity. A name under `sanctions.listed[]` carries `attribution`: `authority` is the designating body's own wording and needs no caveat, `direct` is a label on that exact address, and `cluster` is our clustering, which is a heuristic — print that one as \"attributed to\", never as \"X was sanctioned\". Names under `sanctions.delisting` are always the authority's own wording.\n\nCached, so two calls a few minutes apart return the same numbers and `cached: true`.", "tags": ["Reference"], "responses": {"200": {"description": "Feed counters for the window.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/live-counters": {"get": {"operationId": "getLiveCounters", "summary": "Approximate platform counters, with the rate they move at", "description": "Two numbers and their speed: `checks.value` is addresses screened, `attributions.value` is addresses we hold a name for, and each carries `per_second`.\n\nThe screening total is counted; the attribution total is a planner estimate, which is what `approximate: true` means: it is right to within a rounding, not to the row. The rate is there so a caller can animate the number locally instead of polling us for it. Re-poll no more often than `refresh_after_seconds`; a faster poll returns the same cached answer with `cached: true`.\n\nThe screening total counts requests we have logs for, so it starts at the day request logging was switched on, plus an operator-set baseline where one is configured. Our own machinery is not in it: the website's server-side rendering of its address pages and our news conveyor are logged but not counted, so the number is addresses somebody asked about and not work we scheduled ourselves.", "tags": ["Reference"], "responses": {"200": {"description": "The counters and their rates.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/v1/service/free-keys": {"post": {"operationId": "serviceIssueFreeKey", "summary": "SERVICE ONLY: issue a free read-only key for a Telegram user", "description": "Not for integrators. Called by the PublicAML Telegram bot (@PublicAMLbot) to hand its users a free read-only API key.\n\nOnly an allowlisted key-issuing service may call it, with an HMAC-signed request. Everyone else gets `403 key_issuer_required`: anonymous callers, bare `X-API-Key` requests (including the service's own key sent unsigned) and signed requests from any other customer.\n\nWhat is issued is fixed server-side: tier `free`, no HMAC secret (the key is sent bare as `X-API-Key` and can only read), the free-tier quota in units per `window_sec`, a `tg_` prefix, and `customer_id = tg-<telegram_user_id>`.\n\nIdempotent per `external_id`: an active key is returned again with `reissued: true` and status 200. A revoked or expired key is `409 key_revoked` and no new key is minted. New keys are capped per rolling 24 hours across the whole endpoint (`429 daily_issue_limit`); returning an existing key is never capped.\n\n`create: false` only looks: it returns the user's key (200) or `404 no_key`, never mints one, and neither spends nor is limited by the daily budget. It is what the bot's `/mykey` uses.\n\n200 and 201 carry `usage`: the key's quota spend in the current rate-limit window (the same counter as its `RateLimit-*` headers), plus 24 h and 30 d totals, or `null` when that could not be read.", "tags": ["Service"], "x-internal": true, "security": [{"HmacSignature": [], "ApiKeyAuth": [], "HmacTimestamp": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["external_id"], "additionalProperties": false, "properties": {"external_id": {"type": "string", "pattern": "^tg:[1-9][0-9]{0,19}$", "description": "`tg:<telegram_user_id>`. Decides the customer id (`tg-<id>`) one-to-one."}, "display": {"type": "string", "maxLength": 64, "description": "Optional human-readable name. Written to the service log next to the key fingerprint; not stored with the key and never returned."}, "create": {"type": "boolean", "default": true, "description": "false = look up only: return the existing key or `404 no_key`, never create one. Must be a JSON boolean."}}}}}}, "responses": {"200": {"description": "The user already holds an active key; it is returned again (`reissued: true`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IssuedFreeKey"}}}}, "201": {"description": "A new key was issued (`reissued: false`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IssuedFreeKey"}}}}, "400": {"description": "`invalid_request`: unknown field, missing or malformed `external_id`, `create` not a boolean.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "A credential was sent and did not check out (`invalid_signature`, `timestamp_out_of_window`, ...).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "`key_issuer_required`: not an allowlisted service, or not HMAC-signed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "`no_key`: `create: false` and this user has no key. Nothing was created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "`key_revoked`: this user's key was revoked or expired, whatever `create` says. Nothing new is issued.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "`daily_issue_limit`: the endpoint-wide budget of new keys for the last 24 hours is spent. Body carries `limit`, `window_sec` (86400) and `retry_after`; a `Retry-After` header too.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "`issuance_failed`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "`issuance_paused`: new keys are switched off; existing ones are still returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/stats": {"get": {"operationId": "getStats", "summary": "Dataset coverage, or one address's graph statistics", "description": "With no parameters: how much data backs the API — clusters, labelled entities, sanction seeds per chain and per jurisdiction. Some figures are estimates and say so in `estimated`.\n\nWith `?address=` and `?chain=`: cluster membership and counterparty counts for that one address, which is cheaper than a full enrich when the shape of the graph is all you need.", "tags": ["Reference"], "parameters": [{"name": "address", "in": "query", "schema": {"type": "string"}}, {"name": "chain", "in": "query", "schema": {"type": "string", "enum": ["bitcoin", "ethereum", "bsc", "tron"], "description": "Which blockchain the address belongs to. Send one of the four values in the enum. The short forms used by the older clients (ETH, BTC, TRX, BNB) and any capitalisation are still accepted, but responses always echo the canonical spelling.\n\nGetting this wrong is the single commonest integration bug: the same 0x-prefixed address exists on ethereum, bsc and tron and means a different wallet on each, so the chain is part of the identity, never a hint. If it does not match the address format the call fails with `invalid_address_format`, which names the chain the address does look like."}}], "responses": {"200": {"description": "Coverage, or per-address statistics.", "headers": {"Deprecation": {"description": "Always `true` on /v1. The version is deprecated and still supported; no removal date is set, which is why no `Sunset` header is sent (RFC 8594 would require an actual date).", "schema": {"type": "string", "enum": ["true"]}}, "Link": {"description": "`rel=\"successor-version\"` pointing at the API documentation.", "schema": {"type": "string"}}, "X-API-Recommended-Version": {"description": "Always `v2`. The same facts in a shape without duplicated fields.", "schema": {"type": "string", "enum": ["v2"]}}}, "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Malformed request.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InvalidAddressError"}, {"$ref": "#/components/schemas/Error"}]}}}}, "429": {"description": "Rate limited: anonymous callers, and keys issued with a quota of their own. Authenticate to remove the limit.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "500": {"description": "Server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/health": {"get": {"operationId": "getHealth", "summary": "Service health", "description": "Answers 503 when degraded, with the same body shape — parse it either way. A degraded API still answers queries, but with parts missing, and affected responses carry a `degraded` block of their own.", "tags": ["Reference"], "responses": {"200": {"description": "Healthy.", "content": {"application/json": {"schema": {"type": "object"}}}}, "503": {"description": "Degraded — a backing store is unavailable.", "content": {"application/json": {"schema": {"type": "object"}}}}}}}}, "components": {"schemas": {"InvalidAddressError": {"type": "object", "description": "Returned when the address cannot be an address of the chain it was sent with. It is a self-correcting error: `detail` names the chain the format DOES belong to, so the call can be retried correctly without another lookup. It is never returned for an address that is merely unknown to us — that is `not_found` (200) or `address_not_found`.", "required": ["error", "detail", "hint"], "properties": {"error": {"type": "string", "const": "invalid_address_format"}, "detail": {"type": "string", "description": "What is wrong and what to send instead, naming the likely chain.", "examples": ["'0x28c6c06298d514db089934071355e5743bf21d60' is not a valid bitcoin address — that format belongs to ethereum, bsc or tron. Re-send with chain='ethereum', or send a bitcoin address."]}, "hint": {"type": "string", "const": "Check chain matches address prefix", "description": "Constant string, safe to match on."}, "invalid": {"type": "array", "description": "POST /v2/enrich only, and only when EVERY address in the batch was malformed. A batch with at least one usable address answers 200 instead and reports the bad ones in `not_found[]`.", "items": {"type": "object", "properties": {"wallet_address": {"type": "string"}, "chain": {"type": "string"}, "detail": {"type": "string"}}}}, "request_id": {"type": "string"}}}, "Error": {"type": "object", "description": "Ordinary failure. `error` is a short machine-readable reason.", "required": ["error"], "properties": {"error": {"type": "string"}, "detail": {"type": "string", "description": "Present when there is more to say."}, "reason": {"type": "string"}}}, "EnrichEntity": {"type": "object", "description": "One enriched address. Which keys appear depends on `include`: each block is present when requested and absent when not, so a missing block means 'not asked', never 'empty'. `wallet_address`, `chain`, `first_block` and `last_block` are always present.", "required": ["wallet_address", "chain"], "properties": {"wallet_address": {"type": "string", "description": "The address as you sent it. Pair results with your request by this field."}, "chain": {"type": "string", "enum": ["BTC", "ETH", "BSC", "TRON", "SOL"], "description": "Echoed as the short chain code, whichever spelling was sent."}, "first_block": {"type": ["integer", "null"], "description": "First block the address was seen in, when known. A block number, not a timestamp."}, "last_block": {"type": ["integer", "null"], "description": "Last block the address was seen in, when known."}, "label": {"type": ["string", "null"], "description": "Display name, e.g. 'Binance' or 'unnamed hub 0x71191c61…'. A name, not a class: read `axes` for what the address is and who says so. Block `axes`. When several sources name the address, the name is taken from the more trusted one. The address's own risk finding can take the name only when it names the address: its category says what the address is (`hack`, `mixer`, `sanction`, ...), or the address is recorded as the perpetrator of the incident behind the finding. Such a finding replaces a name produced by our own graph-shape heuristics (the `structure` axis, e.g. `sweep-intermediary`), a name that is only a taxonomy tag, or a name from a feed ranked lower than the finding's source. A finding whose title only describes an event, or whose stored name is a taxonomy tag (`sanction`, `issuer-frozen`), never replaces a name. Only the name follows this rule; `axes` and `risk` do not change with it."}, "axes": {"$ref": "#/components/schemas/Axes"}, "label_basis": {"type": "object", "description": "Block `axes`, label-only chains (`SOL`) only. Who named the address and in which words. On Solana the attribution rows carry a name but no category on purpose, so `axes` is usually all `null` and this block is where the name is signed.", "properties": {"kind": {"type": "string", "const": "attribution"}, "source": {"type": "string", "description": "The feed that named the address, e.g. `pumpfun:official-docs`, `orbitflare:onchain`."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in `source` on a 0..1 scale (its rank / 100)."}, "stated_as": {"type": ["string", "null"], "description": "The name as the source gave it."}, "labels": {"type": "array", "items": {"type": "string"}, "description": "The source's own tags for the address, verbatim."}, "confidence": {"type": ["number", "null"], "description": "The source's own confidence in the row."}, "as_of": {"type": ["string", "null"], "format": "date", "description": "When the source last stated it."}, "reference": {"type": ["string", "null"], "description": "A link to the document behind the claim, when the source gave one."}}}, "label_only": {"type": "boolean", "const": true, "description": "Present (true) only for a chain served from attribution alone (`SOL`): no graph, no score, no counterparties."}, "risk": {"$ref": "#/components/schemas/Risk"}, "kyc_origin": {"type": "string", "description": "Block `axes`. The exchange (slug) whose hot wallet made this address's first on-chain funding, i.e. the owner likely passed that exchange's KYC. Provenance, not group membership. Present only when known."}, "source_of_funds": {"type": "object", "description": "Where the money came from, as CATEGORY buckets — not as addresses and not as a full accounting. The percentages need not sum to 100 and a bucket may legitimately be 0.", "properties": {"basis": {"type": "string", "enum": ["usd", "token_units"], "description": "`usd`: the percentages are a share of MONEY, each transfer at its own day's rate. `token_units`: the fallback where no prices exist (bitcoin, bsc, tron, unpriced tokens), a share of 18-decimal-equivalent units in which one PEPE weighs like one ETH. Read it before quoting a percentage."}, "coverage": {"type": "object", "description": "`usd` basis only: how many inbound edges could be priced.", "properties": {"complete": {"type": "boolean"}, "priced_edges": {"type": "integer"}, "unpriced_edges": {"type": "integer"}}}, "inflow_risk": {"type": "string", "enum": ["low", "medium", "high"], "description": "Verdict about the TOP SOURCES OF INCOMING MONEY, from their own labels only. It is not a verdict about this address: `aml_score` can be CRITICAL from the address's own seed or from propagation while this stays `low`, and that is not a contradiction."}, "risk_summary": {"type": "string", "enum": ["low", "medium", "high"], "deprecated": true, "description": "Same value as `inflow_risk`, kept for existing integrations. The old name reads as a verdict about the address, which it never was."}, "top_sources": {"type": "array", "items": {"type": "object", "properties": {"category": {"type": "string"}, "percentage": {"type": "number"}, "label": {"type": ["string", "null"]}, "sanctioned": {"type": "boolean"}}}}}}, "counterparties": {"type": "array", "items": {"type": "object"}, "description": "Block `counterparties`. Top counterparties, up to `top_n`, each with `address`, `label`, `category`, `tokens[]` (per-token sent/received and tx counts), `sent_usd`, `received_usd`, `usd_coverage`, `tx_count`, `first_block`, `last_block`. Self-loops are dropped. Use POST /v1/counterparties for the full picture."}, "counterparties_meta": {"type": "object", "description": "Block `counterparties`. What the list is a subset OF. The window is scanned and ranked, and only the top `detailed` of it are resolved in full, `ranked_by` value, so small transfers (a mixer deposit, a dust payment) can fall out of a list that looks complete. Walk /v1/address-transactions when completeness matters.", "properties": {"requested": {"type": "integer"}, "returned": {"type": "integer"}, "candidate_window": {"type": "integer", "description": "Counterparties ranked."}, "detailed": {"type": "integer", "description": "Of those, how many were resolved in full."}, "in_window": {"type": "integer"}, "ranked_by": {"type": "string", "enum": ["value"]}, "complete": {"type": "boolean", "description": "True only when nothing was cut."}}}, "counterparties_truncated": {"type": "object", "description": "Block `counterparties`. Present only when the STORE cut its own scan: there may be larger counterparties than the ones listed."}, "behavioral_signals": {"type": "array", "description": "Block `counterparties`. Patterns read off the counterparties, e.g. `mixer_adjacent`.", "items": {"type": "object", "properties": {"kind": {"type": "string"}, "severity": {"type": "string"}, "description": {"type": "string"}, "evidence": {"type": "object"}}}}, "inferred": {"type": "object", "description": "Block `counterparties`. Only for an address nobody has labelled: a heuristic reading of its flows (e.g. that it behaves as an exchange deposit address). Our inference, not a claim by a source."}, "mixer_interaction": {"type": "object", "description": "Block `counterparties`. The address took part in a mixing / coinjoin transaction: contact, not an attribution."}, "peel_chain": {"type": "object", "description": "Block `counterparties`. bitcoin only: the address is a step of a peel chain."}, "incidents": {"type": "array", "items": {"$ref": "#/components/schemas/IncidentLink"}, "description": "Block `incidents` (opt-in). Documented incidents this address appears in, at most a handful; `incidents_total` says how many exist."}, "incidents_total": {"type": "integer", "description": "Block `incidents`. How many incidents link this address in total."}, "degraded": {"type": ["object", "null"], "description": "A backing store did not answer, so this entity is INCOMPLETE. A low score on a degraded entity is not evidence of a clean address.", "properties": {"reason": {"type": "string"}, "calls": {"type": "array", "items": {"type": "string"}}, "causes": {"type": "array", "items": {"type": "string"}}}}}}, "Axes": {"type": "object", "description": "What is claimed about the address, one winner per QUESTION. Always all seven keys; a key whose value is `null` means nobody made a claim on that question, which is not the same as 'known to be nothing'.\n\nClaims on different axes never compete: an address can be an exchange (`business`), sanctioned (`legal_status`), frozen by an issuer (`asset_control`) and a member of a hub (`membership`) all at once, and every one of those is reported. Only claims on the SAME axis compete, and the source with the higher `source_rank` wins.\n\nAn axis is an attribution, not a risk figure: the price of the address is `risk`. Key order in the object carries no meaning.", "required": ["account_type", "business", "actor", "membership", "legal_status", "asset_control", "structure"], "additionalProperties": false, "properties": {"account_type": {"type": ["object", "null"], "description": "What the address is technically: contract, proxy, token, multisig, wallet, burn, … null = no claim on this axis.", "properties": {"category": {"type": "string", "description": "The winning category on this axis, lower case, from GET /v1/categories (e.g. `cex`, `sanction`, `group-hub`)."}, "source": {"type": ["string", "null"], "description": "The feed or pass that made the claim, named as stored, e.g. `ofac:sdn_advanced`, `entities-discoverer`, `publicaml:clustering`. null means the stored claim never carried a source, not that one is hidden."}, "source_rank": {"type": ["integer", "null"], "description": "How far we trust that source, 0-100. It is what decided the winner when several sources answered the SAME axis; it is never compared across axes."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in the source on a 0..1 scale. Absent when the source has no trust row."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not comparable with `source_confidence`, which is ours about the source."}, "applies_to": {"type": "string", "enum": ["cluster"], "description": "Present for an external attribution inherited from a cluster."}, "group_id": {"type": "string", "description": "Cluster that supplied an inherited attribution; also identifies membership groups."}, "basis": {"type": "string", "enum": ["attribution", "derived", "brand"], "description": "`attribution`: the source stated this category and answers for it. `derived`: we read it off the source's own wording (e.g. `deposit` from a label 'Binance Deposit'); the source never said it. `brand` (`business` axis only): nobody said what KIND of business this is, but the address resolves to a platform, so the axis carries `brand` with `category: null`."}}}, "business": {"type": ["object", "null"], "description": "What the address does: cex, dex, defi, bridge, mixer, gambling, deposit, … null = no claim on this axis.", "properties": {"category": {"type": "string", "description": "The winning category on this axis, lower case, from GET /v1/categories (e.g. `cex`, `sanction`, `group-hub`)."}, "source": {"type": ["string", "null"], "description": "The feed or pass that made the claim, named as stored, e.g. `ofac:sdn_advanced`, `entities-discoverer`, `publicaml:clustering`. null means the stored claim never carried a source, not that one is hidden."}, "source_rank": {"type": ["integer", "null"], "description": "How far we trust that source, 0-100. It is what decided the winner when several sources answered the SAME axis; it is never compared across axes."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in the source on a 0..1 scale. Absent when the source has no trust row."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not comparable with `source_confidence`, which is ours about the source."}, "applies_to": {"type": "string", "enum": ["cluster"], "description": "Present for an external attribution inherited from a cluster."}, "group_id": {"type": "string", "description": "Cluster that supplied an inherited attribution; also identifies membership groups."}, "basis": {"type": "string", "enum": ["attribution", "derived", "brand"], "description": "`attribution`: the source stated this category and answers for it. `derived`: we read it off the source's own wording (e.g. `deposit` from a label 'Binance Deposit'); the source never said it. `brand` (`business` axis only): nobody said what KIND of business this is, but the address resolves to a platform, so the axis carries `brand` with `category: null`."}, "role": {"type": "string", "enum": ["hot_wallet", "cold_wallet", "deposit", "gas_supplier", "treasury", "custody"], "description": "Which address of the business this is. Refines `category`, never replaces it."}, "role_source": {"type": ["string", "null"], "description": "Who said so about the role."}, "brand": {"type": "object", "description": "The platform (brand) this address belongs to, resolved from its own label or, failing that, from its cluster's binding (`basis`). Refines `category` like `role` does — 'whose exchange' is the same question as 'is it an exchange' — so it is not an eighth axis. Present only when the address resolves to a brand. When nobody made a `business` claim the axis is still filled: `{category: null, basis: \"brand\", brand}`.\n\nIts `risk_score` is part of the address's own risk: see `risk.breakdown.brand_direct`. The full record (licences, KYC, every ground) is at GET /v1/brands/{name_key}, linked in `detail`.", "properties": {"entity_id": {"type": "integer"}, "name_key": {"type": "string", "description": "The key to ask /v1/brands for."}, "display_name": {"type": ["string", "null"]}, "risk_score": {"type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "0..100, worst component wins; null = no component at all (no country and no designation), which is NOT 0. The threshold for 'high risk' and the full rule are in brand_risk_policy."}, "reason": {"type": ["object", "null"], "description": "The WORST component — the one the score came from, and the one field that says whether it was a sanction or a jurisdiction: {kind: designation, count, status, score} or {kind: jurisdiction, code, list, status, valid_from, score}. null when there is no component."}, "designated": {"type": "boolean", "description": "The ORGANISATION itself carries an active sanction. It reaches the address score through `risk_score` (breakdown `brand_direct`), not through this flag."}, "jurisdiction": {"type": "object", "properties": {"domicile_country": {"type": ["string", "null"], "description": "Country of REGISTRATION."}, "management_country": {"type": ["string", "null"], "description": "Country of ACTUAL CONTROL — a different fact, filled only on a regulator's or court's finding."}, "licence_countries": {"type": "array", "items": {"type": "string"}, "description": "Issuing countries of the licences and registrations valid on as_of."}, "unchecked": {"type": "array", "items": {"type": "string"}, "description": "Countries of this venue that have NO row for as_of in the FATF or EU list. Empty means everything the venue has was checked; a non-empty list means risk_score is a lower bound and names who could raise it. This replaces a bare completeness flag — the flag itself is in the full record."}}}, "as_of": {"type": "string", "format": "date", "description": "The date the verdict was computed for."}, "basis": {"type": ["string", "null"], "enum": ["label", "cluster", null], "description": "What resolved the brand: `label` — the address's own label, `cluster` — the binding of its cluster."}, "detail": {"type": "string", "description": "Path of this brand's full record: /v1/brands/{name_key}."}}}}}, "actor": {"type": ["object", "null"], "description": "Who is behind it: individual, fund, dao, government, state-actor, scammer, hacker, … null = no claim on this axis.", "properties": {"category": {"type": "string", "description": "The winning category on this axis, lower case, from GET /v1/categories (e.g. `cex`, `sanction`, `group-hub`)."}, "source": {"type": ["string", "null"], "description": "The feed or pass that made the claim, named as stored, e.g. `ofac:sdn_advanced`, `entities-discoverer`, `publicaml:clustering`. null means the stored claim never carried a source, not that one is hidden."}, "source_rank": {"type": ["integer", "null"], "description": "How far we trust that source, 0-100. It is what decided the winner when several sources answered the SAME axis; it is never compared across axes."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in the source on a 0..1 scale. Absent when the source has no trust row."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not comparable with `source_confidence`, which is ours about the source."}, "applies_to": {"type": "string", "enum": ["cluster"], "description": "Present for an external attribution inherited from a cluster."}, "group_id": {"type": "string", "description": "Cluster that supplied an inherited attribution; also identifies membership groups."}, "basis": {"type": "string", "enum": ["attribution", "derived", "brand"], "description": "`attribution`: the source stated this category and answers for it. `derived`: we read it off the source's own wording (e.g. `deposit` from a label 'Binance Deposit'); the source never said it. `brand` (`business` axis only): nobody said what KIND of business this is, but the address resolves to a platform, so the axis carries `brand` with `category: null`."}}}, "membership": {"type": ["object", "null"], "description": "Which group the address belongs to. `group-entity`: a named owner. `group-cospend`: one owner proven by co-spending (bitcoin), identity unknown. `group-hub`: addresses that pay into one convergence point, owner unknown. `group-community`: addresses that transact with each other often; proves NOTHING about ownership. `group-cex-origin`: funded from the same exchange withdrawals. Do not put one name on a whole group unless it is `group-entity`. null = no claim on this axis.", "properties": {"category": {"type": "string", "description": "The winning category on this axis, lower case, from GET /v1/categories (e.g. `cex`, `sanction`, `group-hub`)."}, "source": {"type": ["string", "null"], "description": "The feed or pass that made the claim, named as stored, e.g. `ofac:sdn_advanced`, `entities-discoverer`, `publicaml:clustering`. null means the stored claim never carried a source, not that one is hidden."}, "source_rank": {"type": ["integer", "null"], "description": "How far we trust that source, 0-100. It is what decided the winner when several sources answered the SAME axis; it is never compared across axes."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in the source on a 0..1 scale. Absent when the source has no trust row."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not comparable with `source_confidence`, which is ours about the source."}, "applies_to": {"type": "string", "enum": ["cluster"], "description": "Present for an external attribution inherited from a cluster."}, "group_id": {"type": "string", "description": "The cluster id, e.g. `entity:eth:binance` or `unnamed:eth:0x…`. Pass it to POST /v1/entities or /v1/cluster-mappings."}, "basis": {"type": "string", "enum": ["attribution", "derived", "brand"], "description": "`attribution`: the source stated this category and answers for it. `derived`: we read it off the source's own wording (e.g. `deposit` from a label 'Binance Deposit'); the source never said it. `brand` (`business` axis only): nobody said what KIND of business this is, but the address resolves to a platform, so the axis carries `brand` with `category: null`."}, "members": {"type": ["integer", "null"], "description": "How many addresses the group holds."}, "is_anchor": {"type": "boolean", "description": "true when this address is the group's own convergence point rather than a member."}}}, "legal_status": {"type": ["object", "null"], "description": "Legal status: sanction, terrorism, ransomware. The designation that proves it travels inside the claim. null = no claim on this axis.", "properties": {"category": {"type": "string", "description": "The winning category on this axis, lower case, from GET /v1/categories (e.g. `cex`, `sanction`, `group-hub`)."}, "source": {"type": ["string", "null"], "description": "The feed or pass that made the claim, named as stored, e.g. `ofac:sdn_advanced`, `entities-discoverer`, `publicaml:clustering`. null means the stored claim never carried a source, not that one is hidden."}, "source_rank": {"type": ["integer", "null"], "description": "How far we trust that source, 0-100. It is what decided the winner when several sources answered the SAME axis; it is never compared across axes."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in the source on a 0..1 scale. Absent when the source has no trust row."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not comparable with `source_confidence`, which is ours about the source."}, "applies_to": {"type": "string", "enum": ["cluster"], "description": "Present for an external attribution inherited from a cluster."}, "group_id": {"type": "string", "description": "Cluster that supplied an inherited attribution; also identifies membership groups."}, "basis": {"type": "string", "enum": ["attribution", "derived", "brand"], "description": "`attribution`: the source stated this category and answers for it. `derived`: we read it off the source's own wording (e.g. `deposit` from a label 'Binance Deposit'); the source never said it. `brand` (`business` axis only): nobody said what KIND of business this is, but the address resolves to a platform, so the axis carries `brand` with `category: null`."}, "designation": {"type": "object", "description": "The listing itself: which authority, which list, under which name, and when we last verified it.", "properties": {"authority": {"type": "string", "description": "e.g. OFAC, OFSI."}, "list": {"type": "string", "description": "e.g. SDN, UK Sanctions List."}, "jurisdiction": {"type": "string"}, "entity_name": {"type": "string", "description": "The name the list designates."}, "as_of": {"type": "string", "description": "Date of the list snapshot the listing was verified against."}, "feed": {"type": "string"}, "reference_url": {"type": "string"}, "verified_against": {"type": "string", "description": "The feed the listing was checked in."}, "also_listed_by": {"type": "array", "items": {"type": "object"}, "description": "Other authorities that list the same address, same fields."}}}, "jurisdictions": {"type": "array", "items": {"type": "string"}, "description": "US, EU, GB, UN. The union over every listing, not only the winning one."}}}, "asset_control": {"type": ["object", "null"], "description": "Whether an issuer has technically blocked the funds (`issuer-freeze`). A separate question from `legal_status`: a sanctioned address can move USDT freely, and a frozen one cannot move it whatever its legal status. null = no claim on this axis.", "properties": {"category": {"type": "string", "description": "The winning category on this axis, lower case, from GET /v1/categories (e.g. `cex`, `sanction`, `group-hub`)."}, "source": {"type": ["string", "null"], "description": "The feed or pass that made the claim, named as stored, e.g. `ofac:sdn_advanced`, `entities-discoverer`, `publicaml:clustering`. null means the stored claim never carried a source, not that one is hidden."}, "source_rank": {"type": ["integer", "null"], "description": "How far we trust that source, 0-100. It is what decided the winner when several sources answered the SAME axis; it is never compared across axes."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in the source on a 0..1 scale. Absent when the source has no trust row."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not comparable with `source_confidence`, which is ours about the source."}, "applies_to": {"type": "string", "enum": ["cluster"], "description": "Present for an external attribution inherited from a cluster."}, "group_id": {"type": "string", "description": "Cluster that supplied an inherited attribution; also identifies membership groups."}, "basis": {"type": "string", "enum": ["attribution", "derived", "brand"], "description": "`attribution`: the source stated this category and answers for it. `derived`: we read it off the source's own wording (e.g. `deposit` from a label 'Binance Deposit'); the source never said it. `brand` (`business` axis only): nobody said what KIND of business this is, but the address resolves to a platform, so the axis carries `brand` with `category: null`."}, "issuers": {"type": "array", "items": {"type": "string"}, "description": "EVERY issuer that froze this address (e.g. circle, tether), not only the one named in `source`."}, "assets": {"type": "array", "items": {"type": "object", "properties": {"token": {"type": "string"}, "issuer": {"type": "string"}, "state": {"type": "string", "description": "`frozen`, or the state it went to after a release."}, "frozen_at": {"type": ["string", "null"], "format": "date-time"}, "freeze_block": {"type": ["integer", "null"]}, "freeze_tx": {"type": ["string", "null"]}}}, "description": "One row per token and issuer, with the freezing transaction."}}}, "structure": {"type": ["object", "null"], "description": "The address's position in the shape of the graph, computed by us: hub, consolidation, sweep-intermediary, coinjoin. A POSITION, never an identity: an exchange deposit hub and a wallet drainer collecting from victims produce the same shape. Read `business` and `actor` for who it is. null = no claim on this axis.", "properties": {"category": {"type": "string", "description": "The winning category on this axis, lower case, from GET /v1/categories (e.g. `cex`, `sanction`, `group-hub`)."}, "source": {"type": ["string", "null"], "description": "The feed or pass that made the claim, named as stored, e.g. `ofac:sdn_advanced`, `entities-discoverer`, `publicaml:clustering`. null means the stored claim never carried a source, not that one is hidden."}, "source_rank": {"type": ["integer", "null"], "description": "How far we trust that source, 0-100. It is what decided the winner when several sources answered the SAME axis; it is never compared across axes."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in the source on a 0..1 scale. Absent when the source has no trust row."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not comparable with `source_confidence`, which is ours about the source."}, "applies_to": {"type": "string", "enum": ["cluster"], "description": "Present for an external attribution inherited from a cluster."}, "group_id": {"type": "string", "description": "Cluster that supplied an inherited attribution; also identifies membership groups."}, "basis": {"type": "string", "enum": ["attribution", "derived", "brand"], "description": "`attribution`: the source stated this category and answers for it. `derived`: we read it off the source's own wording (e.g. `deposit` from a label 'Binance Deposit'); the source never said it. `brand` (`business` axis only): nobody said what KIND of business this is, but the address resolves to a platform, so the axis carries `brand` with `category: null`."}, "method": {"type": "string", "description": "Which rule produced it, e.g. p1-hub-discovery."}, "anchor": {"type": "string", "description": "The convergence point the shape is keyed on."}}}}}, "Risk": {"type": "object", "description": "The price of the address. Present whenever the score was requested (`risk`, or `aml_score`, in `include`, or no `include` at all). ABSENT when it was not requested: absence means 'not asked', never 'no risk'.", "properties": {"score": {"type": ["number", "null"], "minimum": 0, "maximum": 100, "description": "Money-laundering risk, 0 (nothing known against it) to 100. A RISK figure, not a share of funds: an address that received 1% of its money from a sanctioned entity can score high, and the number never says how much of the balance is tainted.\n\nIt combines its parts by MAX, not by sum, so the terms in `breakdown` do not add up to it and should not be presented as if they do.\n\nnull when `scored` is false: withheld, NOT zero and NOT clean. And 0 for an address in `entities` is a different statement from an address listed in `not_found`, which we hold no data for."}, "level": {"type": ["string", "null"], "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL", null], "description": "The coarse band `score` falls in, for callers that branch rather than plot:\n\n  * `LOW`      — score below 25\n  * `MEDIUM`   — 25 to 59\n  * `HIGH`     — 60 to 89\n  * `CRITICAL` — 90 or above, OR the address is sanctioned, at any score\n\nThe sanctions override is deliberate: a designated address whose score was floored must still reach a consumer that only rejects the top band. The reason is in `axes.legal_status` and `breakdown.sanction_direct`."}, "scored": {"type": "boolean", "description": "false = no score was produced rather than one computed without its inputs; `score`, `level` and `breakdown` are then null and `unscorable_reason` says why: the risk store did not answer (transient), or the chain is label-only (Solana, permanent). `axes` and `label` are read separately and are complete. Branch on this, not on the number."}, "breakdown": {"oneOf": [{"$ref": "#/components/schemas/RiskBreakdown"}, {"type": "null"}]}, "unscorable_reason": {"type": "string", "enum": ["risk_store_unavailable", "chain_label_only"], "description": "Only with `scored: false`. `risk_store_unavailable`: OURS, not a property of the address — retry the same request. `chain_label_only`: the chain (today `SOL`) is served from attribution alone, there is no graph to score from; permanent, retrying does not help, and it is NOT a clean result."}, "unscorable_detail": {"type": "string", "description": "Plain-language form of the above."}}}, "CounterpartyLabel": {"type": "object", "description": "What we know about one address on the other side of a transfer.", "properties": {"address": {"type": "string"}, "label": {"type": ["string", "null"], "description": "Display name of the entity, e.g. 'Binance 14'."}, "category": {"type": ["string", "null"], "description": "Entity type: cex, dex, mixer, gambling, bridge, payment, …"}, "source": {"type": ["string", "null"], "description": "The feed behind the claim, named as it is stored — e.g. `ofac:sdn_advanced`, `arkham:sanctioned`, `chainabuse:chainabuse`. Every source is named: address answers withheld non-public feeds behind the literal `publicaml` until 2026-09-11, and no longer do. How much the claim is worth is `source_confidence`, published alongside. A null means the stored claim never carried a source at all — not that one is being hidden."}, "source_confidence": {"type": ["number", "null"], "description": "How much we trust whoever made this claim, 0..1. Derived from the source's tier: an official list answers 1.0, a vendor blocklist 0.5, a community report 0.25. Absent when no tier prices the source — that is a gap in our policy table, not a statement of trust, so it is not reported as confidence."}, "confidence": {"type": ["number", "null"], "description": "The source's OWN stated certainty, untouched. Not the same as source_confidence, which is ours about the source."}, "risk_score": {"type": "number"}, "sanctioned": {"type": "boolean"}, "sanction": {"type": "object", "properties": {"label": {"type": ["string", "null"]}, "source": {"type": ["string", "null"]}}}}}, "IncidentLink": {"type": "object", "description": "A documented incident this address appears in. Read `implicates_address` before presenting it as an accusation: a drained wallet is listed with role='victim' precisely so it is not mistaken for a participant.", "properties": {"slug": {"type": "string"}, "name": {"type": "string"}, "category": {"type": ["string", "null"]}, "status": {"type": "string", "enum": ["confirmed", "alleged", "disputed", "retracted"], "description": "Anything but 'confirmed' must stay visible to the reader."}, "stream": {"type": "string", "enum": ["curated", "community"], "description": "'curated' is an analyst-recorded investigation; 'community' is a user-filed report. Same shape, NOT the same grade of claim."}, "occurred_at": {"type": ["string", "null"]}, "summary": {"type": ["string", "null"]}, "link": {"type": "string", "enum": ["direct", "propagated"], "description": "'propagated' means the incident belongs to an origin whose risk reached here — it says nothing about this address."}, "role": {"type": ["string", "null"], "enum": ["perpetrator", "intermediary", "recipient", "victim", "mentioned", null], "description": "Who this address is to the incident. Only `perpetrator` and `intermediary` accuse: they set `implicates_address` and the link is inherited by whoever the address later pays. `recipient` records that funds of a known origin arrived here and is NOT an accusation — the holder may be a service or an OTC desk rather than a participant; it carries a score but implicates nobody. `victim` and `mentioned` write no seed. A `victim` is not scored for exposure originating from the same incident; see `aml_score_breakdown.policy_suppressed_sources`."}, "implicates_address": {"type": "boolean"}, "named_in_source": {"type": "boolean", "description": "false = no published source prints this address; the link is our attribution."}, "note": {"type": ["string", "null"], "description": "Why the link has the role it has, e.g. 'venue (Binance), named by the report but not accused'."}, "via": {"type": "object", "properties": {"address": {"type": "string"}, "hops": {"type": ["integer", "null"]}}}, "sources": {"type": "array", "items": {"type": "object", "properties": {"url": {"type": "string"}, "title": {"type": ["string", "null"]}, "publisher": {"type": ["string", "null"]}, "tier": {"type": "string", "enum": ["vendor-advisory", "law-enforcement", "security-firm", "media", "social", "internal"]}, "published_at": {"type": ["string", "null"]}, "updated_at": {"type": ["string", "null"], "description": "Advisories get revised after publication; null when never updated."}, "archive_url": {"type": ["string", "null"]}, "content_sha256": {"type": ["string", "null"], "description": "SHA-256 of the text we read, so a later edit is detectable."}, "names_addresses": {"type": "boolean"}, "excerpt": {"type": ["string", "null"]}}}}}}, "RiskBreakdown": {"type": "object", "description": "Why the score is what it is (`risk.score` on /v2/enrich). Combine by MAX, not by sum. Most sub-blocks appear only when they apply, so absence means 'did not fire', never 0.", "properties": {"direct_exposure": {"type": "number", "description": "Risk from this address's OWN labels and seeds."}, "indirect_exposure": {"type": "number", "description": "MAX over the propagated origins — not their sum."}, "behavioral_risk": {"type": ["number", "null"], "description": "null = no behavioural signal assessed; 0 = assessed and contributed nothing."}, "exposure_direction": {"type": "string", "enum": ["inbound", "outbound", "mixed"]}, "sanction_direct": {"type": "object", "description": "Present when the address is itself on a sanctions list. `score` is capped by the rank of `source`, like every claim: a listing by the authority itself (rank 100) keeps 100, a vendor's mirror of it (`arkham:sanctioned`, 90) gives 90, a sanction known only from a feed's label text (rank 50) gives 50. `uncapped_score` carries the severity before the cap.", "properties": {"label": {"type": "string"}, "score": {"type": "number", "description": "Severity capped by the rank of `source`."}, "uncapped_score": {"type": "number", "description": "The severity before the cap. Present only when the cap lowered it."}, "source": {"type": "string"}, "source_confidence": {"type": ["number", "null"]}, "designation": {"type": "object", "description": "The listing that proves it; same object as `axes.legal_status.designation`."}, "jurisdictions": {"type": "array", "items": {"type": "string"}, "description": "US, EU, GB, UN. Absent means the authority was never recorded, not that there is none."}}}, "brand_direct": {"type": "object", "description": "The address belongs to a platform (`axes.business.brand`) whose own risk score is above 0, and that score is part of this address's DIRECT exposure: an active designation of the organisation is 100, a lifted one 50, and the platform's jurisdiction counts in full (FATF black list 100, EU list 50, FATF grey list 40, weak-supervision licensing jurisdiction 20), worst component wins. So every address of a casino licensed only in Curaçao or Anjouan carries at least 20.\n\nCAPPED BY THE RANK OF THE LINK, like any claim (`min(severity, rank)`): the platform's score says how bad the PLATFORM is, and how sure we are that this address is the platform's is said by whoever linked them. Through the address's own label it is the best rank among the label sources that lead to the platform (an Arkham entity label 50, Arkham's sanctions list 90); through the cluster it is the rank of our clustering (50). When the cap bites, `uncapped_score` and `link_rank` say so. A designated exchange's hot wallet therefore scores 50 to 90 through this term, not 100.\n\nCombined with the other direct terms by MAX, not by sum: an address sanctioned itself AND belonging to a sanctioned platform is not the sum of the two. The entity floor never removes it — the floor only clears indirect exposure. Absent when the address has no platform, the platform's score is null (no data) or 0, or the cap leaves 0.", "properties": {"score": {"type": "number", "description": "What this term contributes: the platform's `risk_score` capped by `link_rank`, 0..100."}, "uncapped_score": {"type": "number", "description": "The platform's own `risk_score`. Present only when the cap lowered it."}, "link_rank": {"type": "integer", "minimum": 0, "maximum": 100, "description": "Rank of the source that linked this address to the platform (its label source, or our clustering for `basis: cluster`). Present only when it lowered the score."}, "name_key": {"type": "string", "description": "The key to ask /v1/brands for."}, "basis": {"type": "string", "enum": ["label", "cluster"], "description": "`label`: the address's own label names the platform. `cluster`: its cluster is bound to the platform."}, "reason": {"type": ["object", "null"], "description": "The worst component, same object as `axes.business.brand.reason`: {kind: designation, count, status, score} or {kind: jurisdiction, code, list, status, valid_from, score}."}, "detail": {"type": "string", "description": "Path of the platform's full record: /v1/brands/{name_key}."}}}, "identity_direct": {"type": "object", "description": "The address IS a mixer (attributed as one) and scores for that identity, not only for its exposure.", "properties": {"category": {"type": "string"}, "score": {"type": "number"}}}, "outbound_direct": {"type": "object", "description": "This address itself paid a designated counterparty, priced by that category at one hop. See `outbound_exposure` for the payments.", "properties": {"category": {"type": "string"}, "score": {"type": "number"}, "source": {"type": ["string", "null"]}, "source_confidence": {"type": ["number", "null"]}, "reason": {"type": "string"}}}, "frozen_direct": {"type": "object", "description": "Token issuer (Tether/Circle) has frozen this address.", "properties": {"issuers": {"type": "array", "items": {"type": "string"}}, "score": {"type": ["number", "null"], "description": "null when the freeze is recorded but not priced on its own."}, "scored_as": {"type": "string"}, "tokens": {"type": "array", "items": {"type": "string"}}}}, "entity_floor": {"type": "object", "description": "The score was forced to a known-clean floor because the address is identified infrastructure (an exchange hot wallet, a token contract). `raw_score` is what it would have been. Read this before reporting a 0.", "properties": {"reason": {"type": "string"}, "raw_score": {"type": "number"}}}, "incident_bonus": {"type": "number", "description": "Flat surcharge added because the incident canon names this address as a PARTICIPANT in a recorded incident — `incidents[].implicates_address` is true, i.e. role `perpetrator` or `intermediary`. Flat per address, not per incident: five reports about one address are still one claim. Victims, `recipient` and `mentioned` addresses never get it, and neither does anything carrying `entity_floor` or `non_wallet` — a community report naming the USDT contract describes a token's user base, not a wallet. Added on top of the other terms rather than combined by MAX, and capped with them at 100. Absent means it did not apply."}, "non_wallet": {"type": "object", "description": "Not a wallet at all: a mint/burn pseudo-address or a token contract. Both exposures forced to 0.", "properties": {"reason": {"type": "string"}, "suppressed_direct": {"type": "number"}, "suppressed_indirect": {"type": "number"}, "registry": {"type": "string"}}}, "contacts": {"type": "object", "description": "Real counterparties whose transferred value is below the materiality threshold: recorded on purpose, scored at zero on purpose. A dusting attack must not raise a victim's score.", "properties": {"count": {"type": "integer"}, "max_value_usd": {"type": "number"}, "threshold_usd": {"type": "number"}, "reason": {"type": "string"}}}, "sanctions_contact": {"type": "object", "description": "Sub-threshold contact with a designated address. Lifted out of `contacts` because it is reportable regardless of amount.", "properties": {"count": {"type": "integer"}, "max_value_usd": {"type": "number"}, "reason": {"type": "string"}}}, "propagated_sources": {"type": "array", "description": "At most 10 rows, ordered by (hops asc, score desc).", "items": {"type": "object", "description": "One risk origin that reached this address through the transaction graph. The money path is `src → … → via → this address` and only the two ends are returned; walk the middle with POST /v1/source-trace.", "properties": {"src": {"type": "string", "description": "The origin the risk started at."}, "src_label": {"type": ["string", "null"], "description": "Display name of the origin: its own label, else the title of the incident in which it is the recorded perpetrator, else null. Never the risk class — that is `category`."}, "category": {"type": ["string", "null"], "description": "sanction, mixer, hack, phishing, darknet, …"}, "source": {"type": ["string", "null"], "description": "Who made the accusation, e.g. 'ofac:sdn_advanced'."}, "source_rank": {"type": ["integer", "null"], "description": "How far we trust the source that made this accusation, 0-100. The score you see is `min(severity, source_rank)`, and the rank also bounds how far the claim travels: `ceil(min(base_hops, 8) * source_rank / 100)` hops. Replaced the old `source_tier` on 2026-09-22; feed tiers no longer exist."}, "source_kind": {"type": ["string", "null"], "description": "What kind of claimant this is: `feed` (an outside provider), `detector` (our own heuristic over graph shape) or `investigation` (our own case work)."}, "severity": {"type": ["number", "null"], "description": "What the ACCUSATION weighs on its own, before the rank bounds it. Keyed by category, not by source: a `hack` weighs the same whether it came from a court filing or a web form."}, "hops": {"type": "integer", "description": "How many TRANSFERS separate `src` from this address."}, "hops_economic": {"type": ["integer", "null"], "description": "The distance the policy CHARGED, which is what the score is taken from. It differs from `hops` for two independent reasons: on bitcoin the graph runs address → transaction → address, so one economic step is two hops; and a step through an address that passed the money straight on (one payer, one payee, almost nothing kept) is not charged at all — those steps are counted in `collapsed_hops`."}, "collapsed_hops": {"type": "integer", "description": "How many steps on the path were taken but NOT charged, because each went through an address that merely passed the money on. 0 for every ordinary finding. A high value next to a near-origin score is the honest shape of a layering chain: the money moved many times without changing hands."}, "source_confidence": {"type": ["number", "null"], "description": "Our trust in `source` on a 0..1 scale."}, "reference": {"type": ["string", "null"], "description": "A link or key for the claim at the origin, when the source gave one."}, "score": {"type": "number", "description": "Hop-decayed RISK contributed by this origin. Never a share of funds."}, "value_usd": {"type": ["number", "null"], "description": "How much money could have travelled this path, in USD: the value of the NARROWEST hop between `src` and this address, priced on the day that hop happened. It is a CEILING, not the amount received — a chain carries no more than its bottleneck, so $10M cannot have arrived through a $0.01 transfer. `null` means UNKNOWN (no hop on the path was priced; true of every entry propagated before 2026-08-06) and must not be read as 0, which means 'nothing priced moved'."}, "via": {"type": ["string", "null"], "description": "The address that actually paid this one. null at hops 0 and 1."}, "via_label": {"type": ["string", "null"], "description": "Display name of `via`, same rule as `src_label`."}, "direction": {"type": "string", "const": "inbound"}}}}, "indirect_contributors": {"type": "array", "description": "Labelled neighbours whose share of the incoming money contributes to `indirect_exposure`.", "items": {"type": "object", "properties": {"address": {"type": "string"}, "label": {"type": ["string", "null"]}, "category": {"type": ["string", "null"]}, "weight": {"type": "number"}, "inflow_pct": {"type": "number"}, "contribution": {"type": "number"}, "direction": {"type": "string"}}}}, "peel_taint": {"type": "object", "description": "bitcoin only: taint inherited from the ends of a peel (layering) chain this address sits on.", "properties": {"score": {"type": "number"}, "peel_id": {"type": "string"}, "hop": {"type": "integer"}, "depth": {"type": "integer"}, "origin_category": {"type": ["string", "null"]}, "endpoint_label": {"type": ["string", "null"]}}}, "suppressed_sources": {"type": "object", "description": "Stored origins dropped as stale: the origin was reclassified after propagation ran.", "properties": {"count": {"type": "integer"}, "reason": {"type": "string"}}}, "policy_suppressed_sources": {"type": "array", "description": "Stored exposure that did not score, grouped by category, with the reason. Two reasons exist. (1) The category, bounded by its source's rank, no longer reaches this far. (2) This address is recorded as a `victim` of an incident, and the exposure originates from that same incident: its perpetrator or intermediary addresses, or the incident's own seed (typically gas or a transfer from a drainer to its victim). The reason then reads `this address is recorded as a victim in incident <slug>; ...`, and the slug is the key for `GET /v1/incidents?slug=`. The victim role never removes the address's own seeds, sanction exposure or exposure from any other origin, and does not lift `entity_floor`. Recorded so a lower score than before can be explained.", "items": {"type": "object", "properties": {"count": {"type": "integer"}, "category": {"type": "string"}, "reason": {"type": "string"}}}}, "outbound_exposure": {"type": "object", "description": "Payments this address MADE to a designated counterparty. A different claim from everything above it, which measures what the address received — so it is reported here and deliberately does not enter the score. Present only when such a payment exists. A payment is one hop; `hops: 2` appears only with `via_venue_deposit: true`, meaning the money went through the venue's own deposit address.", "properties": {"count": {"type": "integer"}, "max_score": {"type": "number"}, "max_value_usd": {"type": ["number", "null"], "description": "The largest `value_usd` among the payments below. null when none of them is priced."}, "reason": {"type": "string"}, "items": {"type": "array", "items": {"type": "object", "properties": {"src": {"type": "string", "description": "The designated address that was paid."}, "source": {"type": ["string", "null"], "description": "Who claims that address, e.g. ofac:sdn_advanced."}, "category": {"type": ["string", "null"]}, "hops": {"type": "integer", "enum": [1, 2]}, "score": {"type": "number"}, "value_usd": {"type": ["number", "null"], "description": "Same measure and same caveat as `propagated_sources[].value_usd`: the narrowest hop of the payment path in USD, a ceiling, `null` = unknown."}, "via": {"type": ["string", "null"]}, "via_venue_deposit": {"type": ["boolean", "null"]}, "direction": {"type": "string", "enum": ["outbound"]}}}}}}}}, "IssuedFreeKey": {"type": "object", "required": ["api_key", "tier", "rate_limit_units", "window_sec", "created_at", "reissued"], "properties": {"api_key": {"type": "string", "description": "`tg_…`. Send it as `X-API-Key` on every call; it has no secret and cannot sign."}, "key_hint": {"type": "string", "description": "16-hex fingerprint, safe to log. The same id usage accounting groups by."}, "customer_id": {"type": "string", "description": "`tg-<telegram_user_id>`."}, "external_id": {"type": "string"}, "tier": {"type": "string", "enum": ["free", "pro", "enterprise"]}, "hmac": {"type": "boolean", "const": false}, "rate_limit_units": {"type": ["integer", "null"], "description": "Units (addresses) this key may spend per `window_sec`, metered per key across every endpoint. null = unmetered."}, "window_sec": {"type": "integer", "description": "Rate-limit window in seconds (3600 in production)."}, "created_at": {"type": "string", "format": "date-time"}, "reissued": {"type": "boolean", "description": "true when an existing active key was returned instead of a new one."}, "docs": {"type": "object", "properties": {"base_url": {"type": "string"}, "console": {"type": "string"}, "openapi": {"type": "string"}, "rate_limits": {"type": "string"}}}, "usage": {"type": ["object", "null"], "description": "How much of its quota this key has spent. `null` when the rate-limit store or the usage log could not be read; the key itself is still returned. The current-window numbers are read from the same counter the rate limiter charges, so they equal the `RateLimit-*` headers the key's own calls receive. Calling this endpoint never charges the user's key.", "required": ["window_sec", "units_limit", "units_used", "units_remaining", "reset_at", "used_24h", "used_30d", "last_used_at"], "properties": {"window_sec": {"type": "integer", "description": "Length of the quota window in seconds (3600 in production)."}, "units_limit": {"type": ["integer", "null"], "description": "Units per window. null = the key is not metered; then units_used, units_remaining and reset_at are null too."}, "units_used": {"type": ["integer", "null"], "description": "Units spent in the current window, rejected (429) requests included, as the limiter counts them."}, "units_remaining": {"type": ["integer", "null"], "description": "max(0, units_limit - units_used). Same number as `RateLimit-Remaining`."}, "reset_at": {"type": ["string", "null"], "format": "date-time", "description": "End of the current window, UTC. The window is fixed, not rolling: the whole quota comes back at this instant."}, "used_24h": {"type": "integer", "description": "Units spent in the last 24 hours, from usage accounting. Trails live traffic by up to ~15 s."}, "used_30d": {"type": "integer", "description": "Units spent in the last 30 days, from usage accounting."}, "last_used_at": {"type": ["string", "null"], "format": "date-time", "description": "Start of the minute (UTC) of the key's most recent request, or null if it was never used."}}}}}}, "securitySchemes": {"ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key", "description": "Public key identifier (`pk_…`). On reads it removes the rate limit and unlocks your own pushed labels. It CANNOT write. A key that is unknown, revoked or expired is refused with `401 invalid_api_key` rather than silently ignored."}, "HmacTimestamp": {"type": "apiKey", "in": "header", "name": "X-Timestamp", "description": "Unix milliseconds. Must be within 5 minutes of server time."}, "HmacSignature": {"type": "apiKey", "in": "header", "name": "X-Signature", "description": "Hex HMAC-SHA256 over `{timestamp}\\n{METHOD}\\n{path}?{query}\\n{body}` keyed with your secret. Required on every write. Sending X-Timestamp or X-Signature commits the request to this path — a bad signature fails outright rather than falling back to key-only auth."}}}, "security": [{"ApiKeyAuth": []}, {}]}