API reference

One base URL, JSON in and out, bearer keys. Everything the dashboard shows is available here, including the evidence behind every decision. No SDK needed; the examples use curl and fetch.

base url
https://clearlist.xyz/api/v1

Authentication

Create keys in the dashboard. Live keys start with cl_live_, test keys with cl_test_. Test keys run against the same lists and policy; their decisions are tagged mode: "test" so you can filter them out. Send the key as a bearer token, or in an x-api-key header.

curl
curl https://clearlist.xyz/api/v1/lists \
  -H "Authorization: Bearer cl_test_…"

Keys are stored hashed; a lost key is revoked and replaced, not recovered.

POST /screen

Evaluates one subject against the lists, public labels, counterparty exposure, your policy and your allowlist. Persists the decision and returns it with status 201.

POST/api/v1/screen
subjectSubjectRequired. One of the four subject shapes below. Shorthand: put the subject fields at the top level instead of under subject.
idempotency_keystringOptional, up to 128 chars. Same key within the org returns the original decision instead of screening again.
metadataobjectOptional, up to 20 string pairs. Echoed back on the decision and visible in the dashboard. Put your user id here.
curl
curl https://clearlist.xyz/api/v1/screen \
  -H "Authorization: Bearer cl_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": { "type": "address", "address": "0x098b716b8aaf21512996dc57eb0615e2383e2f96" },
    "idempotency_key": "withdrawal_91f2",
    "metadata": { "user_id": "usr_4421", "flow": "withdrawal" }
  }'
fetch (TypeScript)
const res = await fetch("https://clearlist.xyz/api/v1/screen", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CLEARLIST_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    subject: { type: "address", address: withdrawalAddress },
    idempotency_key: withdrawalId,
    metadata: { user_id: user.id, flow: "withdrawal" },
  }),
});
const decision = await res.json();

if (decision.outcome === "block") {
  return reject(decision.user_message);        // safe to show
}
if (decision.outcome === "review") {
  await holdForReview(withdrawalId, decision.id); // an analyst clears or confirms it
}

Subject types

Addresses are normalised (EVM lower-cased, bech32 lower-cased, base58 as-is) and the chain is detected from the format when you do not pass one. Names are matched against every alias on every list; a strong name match on its own produces review, never block, unless a date of birth, nationality or identifier corroborates it, or your policy says otherwise.

subjects
// Address: chain auto-detected from the format when omitted.
{ "type": "address", "address": "0x098b…2f96", "chain": "ethereum", "exposure": true }

// Solana address: base58 is detected; exposure runs through Helius or any Solana RPC.
{ "type": "address", "address": "6wjq…mr4U", "exposure": true }

// Name: corroborating identifiers raise a strong match from review to block.
{ "type": "name", "name": "Viktor Bout", "dob": "1967-01-13",
  "nationality": "RU", "country": "AE", "id_number": "…", "entity_type": "individual" }

// Country: ISO 3166-1 alpha-2. ip is informational in v1.
{ "type": "country", "country": "IR", "ip": "203.0.113.7" }

// Transaction: both sides screened; exposure on both unless exposure:false.
{ "type": "transaction", "chain": "solana",
  "from": "7xKX…", "to": "9aBc…",
  "amount": "250.00", "asset": "USDC", "tx_hash": "…", "exposure": true }

The decision

The response is the full record: outcome, reasons with evidence, raw matches, exposure results, the exact list batches used, and the policy version. A sample block decision for the address above:

201 Created
{
  "id": "dec_8f3k2m9q1x7p4v6n0b5c2",
  "org_id": "org_demo",
  "outcome": "block",
  "reasons": [
    {
      "code": "address.sanctioned",
      "severity": "critical",
      "summary": "The address 0x098b…2f96 appears on the OFAC SDN list under the DPRK3 program (Lazarus Group), batch published 2026-09-30.",
      "evidence": {
        "list": "OFAC_SDN",
        "batch_id": "batch_2026_09_30_sdn",
        "entity_id": "OFAC_SDN:27307",
        "primary_name": "LAZARUS GROUP",
        "programs": [
          "DPRK3"
        ],
        "asset_code": "ETH",
        "address": "0x098b716b8aaf21512996dc57eb0615e2383e2f96",
        "source_url": "https://sanctionssearch.ofac.treas.gov/Details.aspx?id=27307"
      }
    },
    {
      "code": "exposure.direct",
      "severity": "high",
      "summary": "2 of 184 scanned transactions interact directly with a sanctioned counterparty (hop 1).",
      "evidence": {
        "chain": "ethereum",
        "provider": "etherscan",
        "scanned_txs": 184,
        "hits": [
          {
            "counterparty": "0xa0e1c89ef1a489c9c7de96311ed5ce5d32c20e4b",
            "hop": 1,
            "direction": "out",
            "tx_count": 2,
            "via": "OFAC_SDN:27307"
          }
        ]
      }
    }
  ],
  "subject": {
    "type": "address",
    "address": "0x098b716b8aaf21512996dc57eb0615e2383e2f96",
    "chain": "ethereum"
  },
  "matches": {
    "addresses": [
      {
        "address": "0x098b716b8aaf21512996dc57eb0615e2383e2f96",
        "chain": "ethereum",
        "list": "OFAC_SDN",
        "batch_id": "batch_2026_09_30_sdn",
        "entity_id": "OFAC_SDN:27307",
        "primary_name": "LAZARUS GROUP",
        "programs": [
          "DPRK3"
        ]
      }
    ],
    "names": [],
    "labels": []
  },
  "exposure": [
    {
      "status": "ok",
      "chain": "ethereum",
      "address": "0x098b716b8aaf21512996dc57eb0615e2383e2f96",
      "provider": "etherscan",
      "scanned_txs": 184,
      "counterparties": 61,
      "hits": [
        {
          "counterparty": "0xa0e1c89ef1a489c9c7de96311ed5ce5d32c20e4b",
          "hop": 1,
          "direction": "out",
          "tx_count": 2,
          "first_seen": "2026-08-14T09:12:00Z",
          "last_seen": "2026-09-02T17:40:00Z",
          "via": {
            "address": "0xa0e1c89ef1a489c9c7de96311ed5ce5d32c20e4b",
            "chain": "ethereum",
            "list": "OFAC_SDN",
            "batch_id": "batch_2026_09_30_sdn",
            "entity_id": "OFAC_SDN:27307",
            "primary_name": "LAZARUS GROUP",
            "programs": [
              "DPRK3"
            ]
          }
        }
      ],
      "computed_at": "2026-10-06T14:02:11Z",
      "cached": false
    }
  ],
  "lists": {
    "OFAC_SDN": {
      "batch_id": "batch_2026_09_30_sdn",
      "published_at": "2026-09-30T00:00:00Z",
      "fetched_at": "2026-10-06T06:00:03Z",
      "entry_count": 17842
    },
    "OFAC_CONS": {
      "batch_id": "batch_2026_09_30_cons",
      "published_at": "2026-09-30T00:00:00Z",
      "fetched_at": "2026-10-06T06:00:05Z",
      "entry_count": 512
    },
    "EU_FSF": {
      "batch_id": "batch_2026_10_03_eu",
      "published_at": "2026-10-03T00:00:00Z",
      "fetched_at": "2026-10-06T06:00:09Z",
      "entry_count": 5231
    },
    "UK_OFSI": {
      "batch_id": "batch_2026_10_02_uk",
      "published_at": "2026-10-02T00:00:00Z",
      "fetched_at": "2026-10-06T06:00:12Z",
      "entry_count": 4790
    },
    "UN_SC": {
      "batch_id": "batch_2026_09_28_un",
      "published_at": "2026-09-28T00:00:00Z",
      "fetched_at": "2026-10-06T06:00:14Z",
      "entry_count": 1123
    }
  },
  "policy_version": 3,
  "user_message": "We can't process this transaction. Contact support with reference dec_8f3k2m9q1x7p4v6n0b5c2.",
  "latency_ms": 31,
  "created_at": "2026-10-06T14:02:11Z",
  "idempotency_key": "withdrawal_91f2",
  "metadata": {
    "user_id": "usr_4421",
    "flow": "withdrawal"
  }
}

The TypeScript shape, verbatim from the service:

types
type Outcome = "allow" | "review" | "block";

interface Decision {
  id: string;                 // dec_…
  org_id: string;
  outcome: Outcome;
  reasons: Reason[];          // empty when outcome is allow with nothing found
  subject: Subject;           // echoed, normalised
  matches: {
    addresses: AddressMatch[];
    names: NameMatch[];
    labels: LabelMatch[];
  };
  exposure: ExposureResult[] | null;
  lists: Partial<Record<ListCode, ListVersion>>; // exact batches used
  policy_version: number;
  user_message: string;       // safe to show your end user; never names the entry
  latency_ms: number;
  created_at: string;         // ISO 8601
  idempotency_key?: string | null;
  metadata?: Record<string, string> | null;
}

interface Reason {
  code: ReasonCode;
  severity: "info" | "low" | "medium" | "high" | "critical";
  summary: string;            // one plain-English sentence
  evidence: Record<string, unknown>; // list, entity_id, batch_id, counterparty, hop, score…
}

type ReasonCode =
  | "address.sanctioned" | "address.labeled"
  | "name.strong_match" | "name.strong_match_corroborated" | "name.possible_match"
  | "country.sanctioned" | "country.restricted"
  | "exposure.direct" | "exposure.indirect" | "exposure.unavailable"
  | "allowlist.hit" | "policy.override";

type ListCode = "OFAC_SDN" | "OFAC_CONS" | "EU_FSF" | "UK_OFSI" | "UN_SC";

interface ListVersion {
  batch_id: string;
  published_at: string | null; // as stated by the publisher
  fetched_at: string;
  entry_count: number;
}

interface NameMatch {
  entity_id: string; list: ListCode; batch_id: string;
  matched_alias: string; primary_name: string;
  score: number;              // 0..1
  programs: string[]; entity_type: string;
  corroborated_by: string[];  // e.g. ["dob", "nationality"]
  source_url: string | null;
}

interface AddressMatch {
  address: string; chain: Chain | null; list: ListCode; batch_id: string;
  entity_id: string; primary_name: string; programs: string[];
}

interface LabelMatch {
  address: string; chain: Chain; label: string;
  category: "mixer" | "hack" | "scam" | "darknet" | "ransomware"
          | "sanctioned_service" | "exchange" | "bridge" | "protocol" | "other";
  source: string; source_url: string | null; confidence: number;
}

interface ExposureResult {
  status: "ok" | "unavailable" | "skipped";
  chain: Chain; address: string; provider: string | null;
  depth: 1 | 2;
  scanned_txs: number; counterparties: number;
  hits: ExposureHit[];
  paths: Array<{ hops: string[]; via: AddressMatch | LabelMatch; value_usd: number | null; tx_count: number }>;
  inbound_flagged_pct: number | null;   // share (0..100) of priced inbound value from flagged counterparties
  outbound_flagged_pct: number | null;
  flagged_value_usd: number | null;     // across direct flagged counterparties, where estimable
  flagged_tx_count: number;
  values: { inbound_usd: number | null; outbound_usd: number | null; native_symbol: string | null;
            native_price_usd: number | null; priced_counterparties: number };
  hop2?: { candidates: number; scanned: number; max: number; terminal: number; cached: number };
  computed_at: string; cached: boolean; note?: string;
}

interface ExposureHit {
  counterparty: string; hop: 1 | 2; direction: "in" | "out" | "both";
  tx_count: number; first_seen: string | null; last_seen: string | null;
  via: AddressMatch | LabelMatch;
  path: string[];                 // [subject, counterparty] or [subject, intermediate, counterparty]
  value_usd: number | null;       // hop 1: both sides; hop 2: the smaller leg of the path
  value_in?: ValueSide; value_out?: ValueSide;   // hop 1 only
  intermediate?: string;          // hop 2 only
}

interface ValueSide {
  native: string | null;          // whole units, e.g. "0.25" SOL
  tokens: Array<{ mint_or_contract: string; symbol?: string; amount: string; decimals: number | null }>;
  usd_estimate: number | null;    // stables at 1:1 + native at a cached spot price; null when unpriceable
}

// Derived from the chain registry; see "Supported chains" and GET /api/v1/chains.
type Chain = "solana" | "ethereum" | "base" | "bsc" | "robinhood" | "arbitrum"
           | "optimism" | "polygon" | "avalanche" | "arc" | "linea" | "scroll"
           | "zksync" | "blast" | "mantle" | "gnosis" | "celo" | "cronos"
           | "sonic" | "berachain" | "unichain" | "worldchain" | "abstract" | "apechain"
           | "opbnb" | "sei" | "hyperevm" | "monad" | "polygon-zkevm" | "fraxtal"
           | "taiko" | "moonbeam" | "ethereum-classic" | "bitcoin" | "tron" | "litecoin"
           | "bitcoin-cash" | "bitcoin-sv" | "bitcoin-gold" | "dogecoin" | "dash" | "zcash"
           | "verge" | "monero" | "xrp" | "other";

How outcomes combine: every reason carries the outcome your policy assigns to it and the decision takes the worst one. An allowlist hit for the subject downgrades the outcome to allow and is itself reported as a reason, so nothing is silently suppressed.

Batch

POST/api/v1/screen/batchup to 100 subjects, same order back

Body is { "subjects": Subject[], "metadata"?: {…} }. Each subject is screened and persisted as its own decision; the response is an array of decisions in the same order. Counts as one screen per subject.

curl
curl https://clearlist.xyz/api/v1/screen/batch \
  -H "Authorization: Bearer cl_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "subjects": [
    { "type": "address", "address": "0x098b716b8aaf21512996dc57eb0615e2383e2f96" },
    { "type": "name", "name": "Viktor Bout", "dob": "1967" },
    { "type": "country", "country": "KP" }
  ] }'

Decisions

GET/api/v1/decisionsnewest first, keyset cursor
outcomeallow | review | blockFilter by outcome.
typeaddress | name | country | transactionFilter by subject type.
since, untilISO 8601Created-at window.
cursorstringFrom the previous page's next_cursor.
limit1..100Default 50.

Returns { data: Decision[], next_cursor: string | null }.

GET/api/v1/decisions/:id
GET/api/v1/decisions/:id/evidencethe evidence file

The evidence file is the decision plus the complete list records behind every match: the entity, all its aliases and identifiers, its designated addresses, and the list batch metadata (publisher URL, checksum, published and fetched dates). It is what you attach when a bank or partner asks why a decision was made.

Reviews

Any decision with outcome review opens a review automatically. Resolving one records a separate resolution; the decision itself is never modified. Dashboard users can do the same with one click.

GET/api/v1/reviews?status=open
POST/api/v1/reviews/:id/resolve
statuscleared | confirmedCleared means false positive. Confirmed means the match is real.
resolutionstringRequired. Your note; it becomes part of the evidence.
allowlistbooleanWhen clearing an address or name subject, also add it to the allowlist.

Allowlist

Your false-positive suppression. Entries are an address, a normalised name, or a specific list entity id (to suppress one entry while keeping the name screened against everything else). A hit on an allowlisted subject still appears as an allowlist.hit reason with the original match attached.

GET/api/v1/allowlist
POST/api/v1/allowlist
subject_typeaddress | name | entityWhat the value is.
valuestringAddress, name or entity id (e.g. OFAC_SDN:27307). Normalised server-side.
reasonstringRequired. Written into every evidence export that touches the entry.
expires_atISO 8601Optional expiry.
DELETE/api/v1/allowlist/:id

Policy

The per-organisation rules: name thresholds, what each address label category does, country block and review lists, and exposure settings. Saving bumps the version; every decision records the version it ran under.

GET/api/v1/policy
PUT/api/v1/policyfull replacement, validated
default policy
{
  "name": {
    "min_score": 0.8,
    "review_threshold": 0.85,
    "block_threshold": 0.93,
    "block_on_strong_uncorroborated": false
  },
  "address": {
    "sanctioned": "block",
    "labeled": {
      "mixer": "review",
      "hack": "review",
      "darknet": "review",
      "ransomware": "block",
      "scam": "review",
      "sanctioned_service": "block"
    }
  },
  "country": {
    "block": [
      "CU",
      "IR",
      "KP",
      "SY"
    ],
    "review": [
      "RU",
      "BY",
      "VE",
      "MM",
      "AF",
      "YE",
      "LY",
      "SD",
      "SO",
      "IQ"
    ]
  },
  "exposure": {
    "enabled": true,
    "direct_sanctioned": "block",
    "direct_labeled": "review",
    "indirect": "allow",
    "ignore_categories": [
      "exchange",
      "bridge",
      "protocol"
    ],
    "max_txs": 200
  }
}

exposure also accepts depth (1 or 2, default 1) and hop2_max_counterparties (default 15, max 50); see Exposure below.

Exposure

Counterparty exposure answers “who has this address transacted with” and weighs the answer by value. It runs on transaction subjects (both sides), on address subjects that pass exposure: true, and on its own through the endpoint below. Every result carries the same shape (ExposureResult above): the direct counterparties that are sanctioned or publicly labeled, how much moved between them and the subject, and the share of the subject’s inbound and outbound value that touched flagged addresses.

depth 1defaultThe subject's recent transactions (up to policy.exposure.max_txs, 200 by default) are read and every distinct counterparty is checked against the government lists and the public labels. Synchronous, 2.5 s budget.
depth 2opt inAfter hop 1, the most valuable clean counterparties (not flagged, not an exchange, bridge or protocol, not a program or contract) are each scanned one hop, up to hop2_max_counterparties of them, inside an 8 s overall budget. Hits found there are hop-2 hits with the path subject → intermediate → flagged. Best effort: when the budget runs out, the note says scanned_hop2: n of m and the result is partial.
value_usdestimateUSD stables (USDC, USDT, DAI, USDe, PYUSD, USDS, FDUSD) at 1:1 matched by mint or contract address, never by symbol; native SOL, ETH, BNB, POL and AVAX at a spot price cached for ten minutes from a public source. Other tokens are listed but not priced. When nothing on a side could be priced the estimate is null, and taint percentages are over the priced portion only.
inbound_flagged_pct0..100 | nullPriced inbound value that came directly from flagged counterparties, as a share of all priced inbound value. null when no inbound value could be priced. outbound_flagged_pct is the mirror.
flagged_value_usdnumber | nullSum of both sides across the direct flagged counterparties, where estimable. flagged_tx_count is the matching transaction count.
pathsarrayOne entry per flagged counterparty per route: hops, the list entry or label it was reached through, the value and the transaction count. For hop 2 the value and count are the smaller leg of the path, an upper bound on what could have flowed along it.
caching6 hResults are cached per address and chain for six hours. A hop-2 scan reuses cached hop-1 scans of its intermediates, and a depth-1 result resumes into depth 2 without rescanning hop 1. Pass fresh: true to bypass.

In a decision, hop-1 hits become exposure.direct reasons (value-aware when values were readable: “sent $12,400 across 3 transactions to …”) with the action your policy sets for sanctioned and labeled counterparties; hop-2 hits become exposure.indirect reasons under policy.exposure.indirect (allow by default, reported at info severity) with the full path and all routes in the evidence. Every exposure reason also carries the subject’s taint figures in evidence.taint.

POST/api/v1/exposurestandalone; nothing is persisted as a decision
addressstringRequired. Normalised server-side.
chainChainOptional; detected from the address format when omitted.
depth1 | 2Optional; defaults to policy.exposure.depth, then 1.
freshbooleanOptional; skip the cache.
curl
curl https://clearlist.xyz/api/v1/exposure \
  -H "Authorization: Bearer cl_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "address": "73GiQ6TE3Tjtx5yFXTCppMQof6Gqam93pD4fJ5NweApz", "chain": "solana", "depth": 2 }'
200 OK (addresses abbreviated)
{
  "status": "ok",
  "chain": "solana",
  "address": "73GiQ6TE3Tjtx5yFXTCppMQof6Gqam93pD4fJ5NweApz",
  "provider": "helius",
  "depth": 2,
  "scanned_txs": 100,
  "counterparties": 38,
  "hits": [
    {
      "counterparty": "HXk3…mixr",
      "hop": 1,
      "direction": "out",
      "tx_count": 3,
      "first_seen": "2026-09-12T08:41:10.000Z",
      "last_seen": "2026-09-30T19:02:44.000Z",
      "via": {
        "address": "HXk3…mixr",
        "chain": "solana",
        "label": "Mixer pool",
        "category": "mixer",
        "source": "public-labels",
        "source_url": "https://…",
        "confidence": 0.9
      },
      "path": [
        "73Gi…eApz",
        "HXk3…mixr"
      ],
      "value_usd": 12400,
      "value_in": {
        "native": null,
        "tokens": [],
        "usd_estimate": null
      },
      "value_out": {
        "native": "4.2",
        "tokens": [
          {
            "mint_or_contract": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
            "symbol": "USDC",
            "amount": "11800",
            "decimals": 6
          }
        ],
        "usd_estimate": 12400
      }
    },
    {
      "counterparty": "0x098b…2f96",
      "hop": 2,
      "direction": "in",
      "tx_count": 1,
      "first_seen": "2026-09-02T17:40:00.000Z",
      "last_seen": "2026-09-02T17:40:00.000Z",
      "via": {
        "address": "0x098b…2f96",
        "chain": null,
        "list": "OFAC_SDN",
        "batch_id": "batch_2026_09_30_sdn",
        "entity_id": "OFAC_SDN:27307",
        "primary_name": "LAZARUS GROUP",
        "programs": [
          "DPRK3"
        ]
      },
      "path": [
        "73Gi…eApz",
        "9aBc…int1",
        "0x098b…2f96"
      ],
      "value_usd": 950,
      "intermediate": "9aBc…int1"
    }
  ],
  "paths": [
    {
      "hops": [
        "73Gi…eApz",
        "HXk3…mixr"
      ],
      "via": {
        "label": "Mixer pool",
        "category": "mixer"
      },
      "value_usd": 12400,
      "tx_count": 3
    },
    {
      "hops": [
        "73Gi…eApz",
        "9aBc…int1",
        "0x098b…2f96"
      ],
      "via": {
        "list": "OFAC_SDN",
        "entity_id": "OFAC_SDN:27307"
      },
      "value_usd": 950,
      "tx_count": 1
    }
  ],
  "inbound_flagged_pct": 0,
  "outbound_flagged_pct": 38.4,
  "flagged_value_usd": 12400,
  "flagged_tx_count": 3,
  "values": {
    "inbound_usd": 2210.5,
    "outbound_usd": 32291.7,
    "native_symbol": "SOL",
    "native_price_usd": 142.86,
    "priced_counterparties": 31
  },
  "hop2": {
    "candidates": 22,
    "scanned": 11,
    "max": 15,
    "terminal": 4,
    "cached": 3
  },
  "computed_at": "2026-10-06T14:02:11.000Z",
  "cached": false,
  "note": "ignored per policy.exposure.ignore_categories: exchange (2), protocol (3); scanned_hop2: 11 of 15 (budget exhausted)"
}
GET/api/v1/exposure/:chain/:addressthe last computed result, any age; 404 if never computed

Each POST counts as one exposure in your usage; the GET does not. Both need a plan that includes exposure (402 otherwise). Limits worth knowing: hop 1 is synchronous and bounded by max_txs; hop 2 is best effort within its budget and never expands exchanges, bridges, protocols, programs or contracts; values are estimates for ranking and explanation, not accounting; EVM chains need the Etherscan v2 key and Solana works keyless over a public RPC (fewer transactions per scan) or with Helius; Bitcoin, Tron and the other screening-only chains return status: "unavailable".

Monitors

Enrol an address or a name and it is rescreened whenever any list publishes a new batch. If the outcome changes you get a monitor.changed webhook and a new decision. Monitored subjects count toward your plan’s monitoring allowance (see pricing); the rescans themselves are not metered. Monitoring is available on paid plans.

GET/api/v1/monitors?status=&outcome=&cursor=&limit=
POST/api/v1/monitors
body
{ "subject": { "type": "address", "address": "0x…" }, "metadata": { "customer_id": "cus_123" } }
GET/api/v1/monitors/:id
POST/api/v1/monitors/:id/pause
POST/api/v1/monitors/:id/resume
DELETE/api/v1/monitors/:id

Webhooks

Signed POSTs to your endpoint. Events: decision.created, monitor.changed, review.resolved, lists.refreshed. Deliveries time out after 10 seconds and retry with backoff (1 min, 5 min, 30 min, 2 h, 12 h). Respond 2xx to acknowledge.

GET/api/v1/webhooks
POST/api/v1/webhooksreturns the secret exactly once
body
{ "url": "https://api.example.com/clearlist", "events": ["decision.created", "monitor.changed"] }
POST/api/v1/webhooks/:id/test
GET/api/v1/webhooks/:id/deliveries
DELETE/api/v1/webhooks/:id

Every delivery carries clearlist-signature, clearlist-event and clearlist-delivery-id headers. The body is { id, event, created_at, data }. Verify the signature against the raw body:

verify.ts (no dependencies)
import { createHmac, timingSafeEqual } from "node:crypto";

// Header: clearlist-signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, `${t}.${rawBody}`)>
export function verifyWebhookSignature(secret: string, header: string | null, rawBody: string, toleranceSec = 300) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(parts.v1 ?? "", "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Next.js route handler
export async function POST(req: Request) {
  const raw = await req.text();                       // verify the raw body, not a re-serialised one
  if (!verifyWebhookSignature(process.env.CLEARLIST_WEBHOOK_SECRET!, req.headers.get("clearlist-signature"), raw)) {
    return new Response("bad signature", { status: 401 });
  }
  const event = JSON.parse(raw);                      // { id, event, created_at, data }
  return new Response("ok");
}

Lists

GET/api/v1/lists

Status of every source: current batch id, published and fetched timestamps, entry and address counts. Sources: OFAC SDN, OFAC Consolidated (non-SDN), EU Financial Sanctions File, UK OFSI Consolidated List, UN Security Council Consolidated List. Fetched from the publishers every six hours; a refresh is rejected if the parsed count drops below half of the previous batch, so a broken upstream file never empties a list.

GET/api/v1/labels?address=public chain labels for an address
GET/api/v1/healthno auth; database and list freshness

Supported chains

45 chains, 32 of them EVM. Two levels of coverage. Screening (is this exact address on a government list, or in the public labels) works on every chain, because designated addresses are strings. Exposure (who has this address transacted with) needs an indexer: every EVM chain below runs through the Etherscan v2 API, Solana through Helius or a Solana RPC. Bitcoin, Tron, the UTXO chains, Monero and XRP are screening-only today; a screen on them completes with exposure[].status = "unavailable" rather than failing.

GET/api/v1/chainsno auth; the registry below as JSON

Pass chain on an address subject to pin it; omit it and the chain is detected from the address format. EVM addresses are matched against designations on any EVM chain, since the same key controls the same address everywhere. The registry is the single source of truth for the engine, the dashboard and this page.

ChainidFamilyScreeningExposure
SolanasolanaSolanaLists + labelsHelius or Solana RPC
EthereumethereumEVM1Lists + labelsEtherscan v2
BasebaseEVM8453Lists + labelsEtherscan v2
BNB Smart ChainbscEVM56Lists + labelsEtherscan v2
Robinhood ChainrobinhoodEVM4663Lists + labelsEtherscan v2
Arbitrum OnearbitrumEVM42161Lists + labelsEtherscan v2
OP MainnetoptimismEVM10Lists + labelsEtherscan v2
PolygonpolygonEVM137Lists + labelsEtherscan v2
Avalanche C-ChainavalancheEVM43114Lists + labelsEtherscan v2
ArcarcEVM5042Lists + labelsEtherscan v2
LinealineaEVM59144Lists + labelsEtherscan v2
ScrollscrollEVM534352Lists + labelsEtherscan v2
ZKsync ErazksyncEVM324Lists + labelsEtherscan v2
BlastblastEVM81457Lists + labelsEtherscan v2
MantlemantleEVM5000Lists + labelsEtherscan v2
GnosisgnosisEVM100Lists + labelsEtherscan v2
CeloceloEVM42220Lists + labelsEtherscan v2
CronoscronosEVM25Lists + labelsEtherscan v2
SonicsonicEVM146Lists + labelsEtherscan v2
BerachainberachainEVM80094Lists + labelsEtherscan v2
UnichainunichainEVM130Lists + labelsEtherscan v2
World ChainworldchainEVM480Lists + labelsEtherscan v2
AbstractabstractEVM2741Lists + labelsEtherscan v2
ApeChainapechainEVM33139Lists + labelsEtherscan v2
opBNBopbnbEVM204Lists + labelsEtherscan v2
Sei EVMseiEVM1329Lists + labelsEtherscan v2
HyperEVMhyperevmEVM999Lists + labelsEtherscan v2
MonadmonadEVM143Lists + labelsEtherscan v2
Polygon zkEVMpolygon-zkevmEVM1101Lists + labelsEtherscan v2
FraxtalfraxtalEVM252Lists + labelsEtherscan v2
TaikotaikoEVM167000Lists + labelsEtherscan v2
MoonbeammoonbeamEVM1284Lists + labelsEtherscan v2
Ethereum Classicethereum-classicEVMLists + labelsNot yet
BitcoinbitcoinBitcoinLists + labelsNot yet
TrontronTronLists + labelsNot yet
LitecoinlitecoinUTXOLists + labelsNot yet
Bitcoin Cashbitcoin-cashUTXOLists + labelsNot yet
Bitcoin SVbitcoin-svUTXOLists + labelsNot yet
Bitcoin Goldbitcoin-goldUTXOLists + labelsNot yet
DogecoindogecoinUTXOLists + labelsNot yet
DashdashUTXOLists + labelsNot yet
ZcashzcashUTXOLists + labelsNot yet
VergevergeUTXOLists + labelsNot yet
MoneromoneroOtherLists + labelsNot yet
XRP LedgerxrpOtherLists + labelsNot yet

Errors

Every non-2xx response is the same envelope with a stable code:

4xx / 5xx
{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed.",
    "details": [
      {
        "path": [
          "subject",
          "address"
        ],
        "message": "Address is too short."
      }
    ]
  }
}
400 validation_errorBody or query failed validation; details lists each issue.
400 invalid_jsonBody is not JSON.
401 unauthenticatedNo key sent.
401 invalid_keyKey malformed, unknown or revoked.
404 not_foundNo such resource in your organisation.
409 already_resolvedReview was already cleared or confirmed.
429 rate_limitedPer-key limit hit; see retry-after.
500 internal_errorOur fault. The request is safe to retry.

Exposure never fails a screen: when a chain provider is slow or down, the decision completes with exposure[].status = "unavailable" and an exposure.unavailable info reason, so you can decide whether to hold.

Rate limits

Per key, by plan: 300 on Free, 600 on Startup, 2,000 on Growth, 10,000 on Foundation & Enterprise requests per minute (token bucket). Every response carries x-ratelimit-remaining; a 429 carries retry-after in seconds. Address screens typically return in under 50 ms; exposure adds up to 2.5 s when it has to hit a chain provider.

Idempotency

Pass idempotency_key on a screen (your withdrawal id, your session id) and a retry returns the original decision rather than creating a second one. Keys are scoped to your organisation and never expire. Without a key, each call is a new decision, which is what you want for periodic re-checks.

Need a key? Create an organisation and you will have one in under a minute.