---
title: "Who an address transacted with, with amounts per token"
method: POST
path: "/v1/counterparties"
tags: ["Graph"]
---

# Who an address transacted with, with amounts per token

`POST /v1/counterparties`

## Request

- Base URL: `https://intelapi.publicaml.org`
- URL: `https://intelapi.publicaml.org/v1/counterparties`
- Auth: one of:
  - API key in header `X-API-Key`
  - none

Three modes, selected by which field you send. They do not combine.

1. `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.
2. `category` — 'did this wallet touch any exchange / mixer / casino'.
3. `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.

Self-loops are kept here, unlike in /v2/enrich.

## Request body

- object
  - `wallet_address` string, required: The wallet whose counterparties to look at. The address to look up, in its normal display form. * 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. * ethereum, bsc — `0x` + 40 hex characters, any case. * tron — base58 (`T…`, 34 characters); the `0x` 20-byte form is accepted too and is what the API stores and echoes.
  - `chain` 'bitcoin' | 'ethereum' | 'bsc' | 'tron', required: 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. Getting 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` string[]: Mode 1: candidate counterparty addresses to check.
  - `category` string: Mode 2: entity type to match — cex, mixer, gambling, bridge, …
  - `kinds` string[]: Mode 3: which classes of counterparty to return. Sending this field is what selects the mode; it is a 400 alongside `filter` or `category`.
  - `direction` 'in' | 'out' | 'both': `kinds` mode. 'in' (the default there) = who paid this address.
  - `rank_by` 'value' | 'tx_count': 'value' weighs token units with NO price attached, so a memecoin sender can outrank an ETH sender. Use 'tx_count' to avoid that.
  - `limit` integer
  - `offset` integer
  - `budget_sec` number: `kinds` mode: wall-clock budget for the degree probe.
  - `include` string[]: ['risk'] adds aml_score, risk_level and sanctioned to each row.
  - `since` union: Restrict to a time window. Epoch seconds or ISO-8601 ('2026-06-21').
    - integer
    - string
  - `until` union
    - integer
    - string

## Response `200`

Counterparties with per-token amounts.

- object
  - `wallet_address` string
  - `chain` string
  - `counterparties` object[]
    - `address` string
    - `label` string, nullable
    - `category` string, nullable
    - `tx_count` integer
    - `first_block` integer
    - `last_block` integer
    - `tokens` object[]: Per asset, in proper units (not wei). Sorted by max(sent, received).
      - `token` string: Contract address; empty string means the chain's native coin. EVM: bare hex with NO 0x prefix.
      - `symbol` string, nullable: 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` integer
      - `sent` number
      - `received` number
      - `sent_tx_count` integer
      - `received_tx_count` integer
    - `sanctioned` boolean
    - `aml_score` number, nullable: include: ['risk'] only.
    - `risk_level` 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL': 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: * `LOW` — score below 25 * `MEDIUM` — 25 to 59 * `HIGH` — 60 to 89 * `CRITICAL` — 90 or above, OR `sanctioned: true` at any score The 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` 'personal' | 'venue' | 'hub' | 'deposit_wallet' | 'contract' | 'mint_burn' | 'unknown': `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` string: `kinds` mode: the label or degree that decided it. 'non_propagating_address' on a mint_burn row.
    - `mint_burn` object: 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.
      - `received_is` 'mint'
      - `sent_is` 'burn'
      - `note` string
    - `value_raw` string: `kinds` mode. Decimal STRING — a uint256 does not survive a JS number.
    - `degree` object, nullable: Degree measured in a bounded window. A LOWER BOUND, never a lifetime degree.
  - `summary` object: `kinds` mode: count per kind.
  - `value_split` object: `kinds` mode: how the value divides across kinds, tokens at face value.
  - `coverage` object: What the scan actually managed to read.
  - `partial` boolean: true = a cap or budget cut the answer, so the list is a floor, not the whole set.
  - `request_id` string
  - `processing_time_ms` integer

## Other responses

- `400`: 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).
- `429`: One of two things, told apart by `error`: * `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. * `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.
- `500`: Server error.

---

[API](https://skmtc.dev/publicaml/apis/publicaml-intelligence-api.md) · [All operations](https://skmtc.dev/publicaml/apis/publicaml-intelligence-api/llms.txt) · [OpenAPI document](https://skmtc.dev/publicaml/apis/publicaml-intelligence-api/revisions/7c0c6315088c?raw)
