HTTP Check API
Last updated: 2026-07-28 ยท Base URL: https://w4check.com/api/v1/check
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"
}
address(string, required) โ a BTC, ETH, or TRX wallet address. Same validator the Telegram/checkcommand uses โ BTC P2PKH/P2SH/bech32/bech32m, ETH0x+ 40 hex chars, or TRXT+ 33 base58 chars.- Request body is capped at 10 KB โ exactly 10,240 bytes (
10 * 1024). Live-verified at the boundary: a 10,240-byte body is accepted (not rejected for size), a 10,241-byte body gets a 413 (see ยง4). - No other fields are read or accepted.
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.
- Default limit: 20 requests/minute per key on
w4check.com(operator-configurable in general โ this is the live, verified value for this instance). Live-verified against the real, unmodified default (no env override): 21 consecutive requests from one key returned 200 twenty times and 429 on the 21st. - Window: fixed 60-second windows (not a sliding window).
- Breaching the limit returns
429with aRetry-Afterheader giving the seconds until the current window resets (always โฅ 1). - Rotating your key does not reset your quota. The limiter tracks both your key hash and your underlying Telegram account, so rotating to a fresh key inherits whatever budget is left in the current window for your account โ it is not a way to get a free extra 20 requests.
- Two different keys (different Telegram accounts) have fully independent budgets.
- The limiter is in-memory only โ a bot process restart resets every key's counter to zero. This is a quota reset, not a security gap.
- An oversized body (e.g. a ~20 KB payload, well over the 10 KB cap) sent by an already-rate-limited key still returns 429, not 413 โ the limiter runs first, before the body is read at all.
6. Known limitations
- Single endpoint.
POST /v1/checkis the only route. No batch/bulk endpoint, no list/history endpoint, no key-management endpoint (key issuance/rotation is Telegram-only, per ยง1). - No webhooks / no async mode. Every request blocks until the full check pipeline finishes (sanctions lists, GoPlus, blockchain data fetch, behavioral analysis, PDF generation) โ there is no "submit now, fetch result later" flow.
- PDF delivery is inline base64, not a URL. The generated PDF is embedded directly in the JSON response as
pdf_base64. There is no signed/temporary download link โ every response includes the full PDF bytes (base64-inflated, ~33% larger than the raw file). - No interactive API docs.
/docs,/redoc, and/openapi.jsonare intentionally disabled (they carry no auth of their own in FastAPI's defaults, and would otherwise leak the full route/param schema to anyone who can reach the port). This document is the only reference. - No self-service key management via HTTP. Keys can only be issued/rotated through the Telegram bot (ยง1) โ there is no equivalent HTTP endpoint.
- One key per Telegram account, no scoping, no expiry.
- This hosted instance is already enabled and reachable at the base URL above. If you're running your own checker-bot deployment instead of using
w4check.com, note that the HTTP API is off by default until the operator turns it on, and exposing the port beyond localhost is a separate infrastructure decision. - Rate-limit counters do not survive a restart (see ยง5) โ a caller can get up to one extra window's worth of requests right after an operator restarts the process.