HTTP Check API

A minimal, single-endpoint HTTP API for compliance-team integrations that need to run a checker-bot address check programmatically instead of via Telegram.

This doc covers the one endpoint that exists: POST /v1/check. Every example request/response shown below was captured by actually running the real checker-bot code through a real request/response cycle.

1. Obtaining an API key

API keys are issued through the Telegram bot, not through the HTTP API itself (there is no self-service signup endpoint). DM the bot and send:

/apikey

First time: a new key is generated and shown to you once, in the reply:

๐Ÿ”‘ Your API key has been created:

w4c_live_<32 random URL-safe characters>

Save it now โ€” it will not be shown again. Run /apikey to view a masked version
later, or /apikey rotate to replace it.

(w4c_live_ prefix, 9 chars, + a 32-character random suffix โ€” 41 characters total. Verified by generating 20 fresh keys live and measuring every suffix: all 20 came out exactly 32 chars, matching secrets.token_urlsafe(24)'s deterministic output length for a byte count that's a multiple of 3.)

Copy it somewhere safe immediately โ€” the raw key is never shown or stored anywhere else. checker-bot only ever persists a salted SHA-256 hash of it, so if you lose it, the only recovery path is rotating to a new one.

Subsequent calls (no args): since you already have a key, /apikey instead shows a masked view:

๐Ÿ”‘ Your API key: w4c_live_****<last 4 chars>
Issued: 2026-07-28 12:00 UTC
Run /apikey rotate to replace it.

(An extra Last rotated: ... line appears once you've rotated at least once.)

Rotating a key (invalidates the old one immediately):

/apikey rotate
๐Ÿ”„ Your API key has been rotated. Your old key no longer works.

New key:
w4c_live_<new random string>

Save it now โ€” it will not be shown again.

/apikey only works in a DM with the bot. Sent in a group chat, it replies Please DM me to manage your API key. and does nothing else.

One key per Telegram account. There is no key-scoping, no expiry, and no way to list/delete a key other than rotating it.

2. Authentication

Send the key on every request, in one of two headers:

Header Format Priority
Authorization Bearer <key> Checked first. If present, it is authoritative โ€” even if malformed, the request is rejected without falling back to X-API-Key.
X-API-Key <key> (raw, no Bearer prefix) Only used if Authorization is absent.
curl -s https://w4check.com/api/v1/check \
  -H "Authorization: Bearer w4c_live_pmsM8GsOM9usMVdi6yk-CJKOd9dTwaiP" \
  -H "Content-Type: application/json" \
  -d '{"address": "0x742d35cc6634c0532925a3b844bc9e7595f2bd38"}'

equivalently:

curl -s https://w4check.com/api/v1/check \
  -H "X-API-Key: w4c_live_pmsM8GsOM9usMVdi6yk-CJKOd9dTwaiP" \
  -H "Content-Type: application/json" \
  -d '{"address": "0x742d35cc6634c0532925a3b844bc9e7595f2bd38"}'

A missing key, an unrecognized key, and a rotated-away key all return the exact same 401 โ€” deliberately, so a response can never be used to probe whether a key exists, is malformed, or was rotated. See ยง4 for the exact body.

3. POST /v1/check

Request

{
  "address": "0x742d35cc6634c0532925a3b844bc9e7595f2bd38"
}

Response โ€” 200 OK

The full serialized AggregatedResult (the same object the Telegram bot builds for a /check reply) as JSON, snake_case field names, plus one extra field: pdf_base64 โ€” the generated PDF report, base64-encoded, inline in the response body (no separate download URL).

Real captured example (address_type=eth, a clean address, no OFAC/UK/EU/GoPlus match, pdf_base64 truncated for readability):

{
  "risk_level": "CLEAN",
  "address": "0x742d35cc6634c0532925a3b844bc9e7595f2bd38",
  "address_type": "eth",
  "timestamp": "2026-07-28T14:56:47.877072+00:00",
  "ofac": { "flagged": false, "entity_name": null, "entity_uid": null },
  "uk": { "flagged": false, "entity_name": null, "entity_id": null },
  "eu": { "flagged": false, "entity_name": null, "entity_id": null },
  "goplus": { "flagged": false, "flags": [], "skipped": false, "unavailable": false, "reason": null },
  "goplus_caveat": null,
  "blockchain": { "balances": [], "operations": [], "skipped": true, "unavailable": false, "reason": "..." },
  "blockchain_caveat": "Behavioral wallet activity metrics are not available for ETH in this report.",
  "risk_items": [
    {
      "risk_id": "sanctions_ofac_sdn",
      "risk_name": "OFAC SDN",
      "group": "sanctions",
      "group_name": "SANCTIONS & WATCHLISTS",
      "detected": false,
      "status": "clean",
      "short_explanation": "Not found on OFAC SDN list"
    },
    "... 40 more entries, one per individual risk check (3 OFAC/UK/EU sanctions + 17 GoPlus categories + 3 GraphSense address-typology checks + 2 fraud-dataset checks + 1 USDT issuer blacklist + 15 behavioral checks = 41 risk_items total, each shaped exactly like the item above) ..."
  ],
  "risk_details": [
    "... 38 entries (NOT 1:1 with risk_items โ€” the 3 direct OFAC/UK/EU sanctions items have no separate risk_details entry, since their entity_name is already inline on the risk_items object itself), each { risk_id, risk_name, group, detail_text } โ€” long-form descriptions for the flagged-or-informational risk_items above ..."
  ],
  "sources": [
    { "name": "OFAC SDN", "last_updated": "2026-07-28T14:56:47.874387+00:00", "kind": "refreshed" },
    { "name": "UK Sanctions List", "last_updated": "2026-07-28T14:56:47.874391+00:00", "kind": "refreshed" },
    { "name": "EU Sanctions List", "last_updated": "2026-07-28T14:56:47.874393+00:00", "kind": "refreshed" }
  ],
  "mixing_services": { "flagged": false, "entity_name": null, "detection_method": "address_list", "matched_txid": null },
  "darkmarket": { "flagged": false, "entity_name": null },
  "exchange_no_kyc": { "flagged": false, "entity_name": null },
  "zenodo_stolen_crypto": { "flagged": false, "entity_name": null },
  "conti_ransomware_payments": { "flagged": false, "entity_name": null, "provenance": null, "provenance_description": null },
  "usdt_blacklist": { "flagged": false, "status": null, "chain": "ethereum", "tx_hash": null, "block_number": null, "skipped": false, "history": [] },
  "counterparty_flags": {},
  "stablecoin_exposure": null,
  "stablecoin_decision": { "decision": "INCONCLUSIVE", "reasons": ["No stablecoin lens could evaluate: authenticity skipped, exposure absent, behavioral skipped"] },
  "stablecoin_behavioral": { "source": "behavioral_stablecoin", "check_results": [], "risks": [], "level": "clean", "skipped": true, "reason": "Insufficient stablecoin data: 0 non-dust operations (minimum 10 required)", "stablecoin_op_count": 0, "analyzed_op_count": 0, "dust_excluded_count": 0, "known_exchange_typology": false },
  "btc_cluster_matches": [],
  "cluster_graph": null,
  "pdf_base64": "JVBERi0xLjQKJZOMi54gUmVwb3J0TGFiIEdlbmVy...<116692 base64 chars total>"
}

sources[*].last_updated above are genuine, independently-timestamped datetime.now(UTC) calls (one per dataset) from a real capture โ€” not a single hand-set value copy-pasted three times.

risk_level is one of "HIGH", "MEDIUM", "CLEAN", "ERROR". A direct OFAC/UK/EU match forces HIGH โ€” real captured example (same address, mocked OFAC match, all other fields identical in shape to the CLEAN example above):

{
  "risk_level": "HIGH",
  "timestamp": "2026-07-28T14:56:47.946649+00:00",
  "ofac": { "flagged": true, "entity_name": "TEST SANCTIONED ENTITY", "entity_uid": "TEST-0001" },
  "risk_items": [
    {
      "risk_id": "sanctions_ofac_sdn",
      "risk_name": "OFAC SDN",
      "group": "sanctions",
      "group_name": "SANCTIONS & WATCHLISTS",
      "detected": true,
      "status": "high",
      "short_explanation": "FLAGGED โ€” TEST SANCTIONED ENTITY (UID: TEST-0001)"
    },
    "... 40 more entries, same shape/count as the CLEAN example above (41 risk_items total either way โ€” this field's flagged entry count is dependent on the address, but the array LENGTH is not) ..."
  ],
  "stablecoin_decision": { "decision": "STOP", "reasons": ["Wallet is itself a direct OFAC/UK/EU sanctions match"] },
  "pdf_base64": "JVBERi0xLjQKJZOMi54gUmVwb3J0TGFiIEdlbmVy...<117108 base64 chars total>"
}

Decoding pdf_base64:

python3 -c "import json, base64, sys; d = json.load(open('response.json')); open('report.pdf', 'wb').write(base64.b64decode(d['pdf_base64']))"

A checker being unavailable is not an error. If GoPlus or the blockchain provider (Alchemy/TronGrid) is temporarily down, the response is still 200 with goplus.unavailable / blockchain.unavailable set true and a reason string โ€” identical to what a Telegram /check reply shows as a caveat. Only genuinely unexpected failures return a 5xx (see ยง4).

Field reference (top level)

Field Type Notes
risk_level string enum HIGH / MEDIUM / CLEAN / ERROR
address, address_type string address_type is "btc" / "eth" / "trx"
timestamp string ISO-8601, UTC
ofac, uk, eu object {flagged, entity_name, entity_uid/entity_id}
goplus object {flagged, flags[], skipped, unavailable, reason}
blockchain object or null balances/operations, or {skipped|unavailable, reason}
risk_items array one entry per individual risk check (41 total today โ€” live-counted)
risk_details array long-form descriptions for a SUBSET of risk_items (38 of the 41 โ€” the 3 direct OFAC/UK/EU sanctions items have no separate entry), keyed by risk_id
sources array per-data-source freshness/provenance
mixing_services, darkmarket, exchange_no_kyc, zenodo_stolen_crypto, conti_ransomware_payments, usdt_blacklist object individual dataset-check results
stablecoin_decision object or null {decision: STOP|NEEDS_REVIEW|PASS|INCONCLUSIVE, reasons[]} โ€” presentation-only, never feeds risk_level
pdf_base64 string base64-encoded PDF report

This is a growing, additive schema โ€” new optional fields have been added over time (e.g. cluster_graph, stablecoin_exposure) and existing fields are not removed. Treat unknown fields as forward-compatible and ignorable.

4. Errors

Every non-200 response โ€” auth failure, bad input, rate limit, or an unexpected server error โ€” uses one consistent envelope:

{ "error": { "code": "<short_machine_code>", "message": "<human string>" } }

Never a stack trace, exception class name, or the offending input echoed back.

Status code When Real captured body
401 unauthorized Missing key, malformed Authorization header, unknown key, or a rotated-away key. Identical response for all four cases โ€” a caller cannot distinguish "no key" from "wrong key" from "key I used to have". {"error": {"code": "unauthorized", "message": "Invalid or missing API key."}}
400 bad_request Body isn't valid JSON, isn't a JSON object, or is missing/has a non-string address. {"error": {"code": "bad_request", "message": "Malformed request body."}}
400 invalid_address address is present and a string, but doesn't match any supported BTC/ETH/TRX format. {"error": {"code": "invalid_address", "message": "Unsupported address format. Supported: BTC (P2PKH, P2SH, bech32, bech32m), ETH, and TRX."}}
413 payload_too_large Request body exceeds 10 KB (checked via Content-Length first, then while streaming โ€” never buffers an unbounded body). {"error": {"code": "payload_too_large", "message": "Request body too large."}}
429 rate_limited Per-API-key rate limit exceeded (see ยง5). Response also carries a Retry-After header (seconds). {"error": {"code": "rate_limited", "message": "Rate limit exceeded. Try again later."}}
500 internal_error Genuinely unexpected failure โ€” a checker call raising outside its own error handling, or the mandatory audit/PDF storage write failing. A failed storage write is a hard block: you get a 500, never a 200 with un-persisted results. {"error": {"code": "internal_error", "message": "An unexpected error occurred."}}
404 http_error Any path other than /v1/check (a framework-level routing miss, not one of this API's own error paths โ€” still wrapped in the same envelope). {"error": {"code": "http_error", "message": "Not Found"}}
405 http_error /v1/check hit with a method other than POST (e.g. GET) โ€” another framework-level case, same generic http_error code/envelope as 404. {"error": {"code": "http_error", "message": "Method Not Allowed"}}

The 404 and 405 rows above are Starlette's own generic messages, passed through the framework's fallback error handler rather than one of this API's own named error codes โ€” expect the message text to be whatever Starlette's default is, not a checker-bot-authored string, and don't rely on it being byte-stable across a framework upgrade the way the other rows' messages are.

Ordering matters: auth is checked before the rate limit, and the rate limit is checked before the request body is even read โ€” an over-limit request gets a 429 immediately, regardless of what (if anything) is in its body.

GET /docs, /redoc, and /openapi.json all return a plain 404 โ€” this API deliberately ships with no interactive docs UI or machine-readable OpenAPI spec (see ยง6).

5. Rate limiting

Enforced per API key, before your request body is processed.

6. Known limitations