CryptoTaxEdge classification API
app.cryptotaxedge.com/v1/classify-batch
Up to 100 transactions per call. Returns one result per tx; your tx_id is echoed back as id.
app.cryptotaxedge.com/v1/classify
Single transaction by hash. Same result shape, one record.
app.cryptotaxedge.com/quota-status
Read-only usage readout for your key's pool; never consumes quota. Optional ?hash= reports whether re-running that hash is free.
mcp.cryptotaxedge.com · classify_batch
Optional: the same tool over JSON-RPC for agent-native stacks. Most integrations use the REST endpoints above.
The full machine-readable contract is published as an OpenAPI 3.1 spec: request and response schemas, the closed treatment enum, and every error code, ready for codegen and validation.
Each request carries a bearer token. Create your own API key in Settings → API keys: sign in (free, no credit card), click “Create API key”, and copy the token. Developer keys include 250 classifications a month on the free tier (~25,000 on Pro); the same key works on both the REST API and the MCP server. For partner integrations with volume pricing, contact us.
Authorization: Bearer YOUR_API_KEY
A valid key is required on every request; there is no anonymous access to the public API. Rate limit is 60 requests per minute per key, and a single batch request carries up to 100 transactions, so a partner steadily fills up to 6,000 transactions a minute. tx_id is yours: we echo it back as id on each batch result so you can correlate to your records. chain is optional and auto-detected when omitted. We never return source or vendor internals. Quota counts distinct successful classifications only: re-running a hash you already classified is always free, and failed or review-routed results never consume quota. Check your pool anytime with GET /quota-status (free, never consumes quota).
Two known-good Ethereum mainnet hashes you can drop straight into a request. Re-runs are free, so testing costs you nothing:
0x280710a5673a8ff1af7f623ddb5148417ac36539228d0eec8edb8a57b19383c2
0x9b5693fc24d2e31c59317f53f7ca77b3d570ad86999bd22a4fc53a7d06a48da6
curl -X POST https://app.cryptotaxedge.com/v1/classify-batch \
-H 'Authorization: Bearer YOUR_PARTNER_KEY' \
-H 'Content-Type: application/json' \
-d '{
"hashes": [
{ "tx_id": "row_1", "hash": "0x280710a5673a8ff1af7f623ddb5148417ac36539228d0eec8edb8a57b19383c2", "chain": "ethereum" },
{ "tx_id": "row_2", "hash": "0x9b5693fc24d2e31c59317f53f7ca77b3d570ad86999bd22a4fc53a7d06a48da6", "chain": "ethereum" }
]
}'
Agent-native stacks can call the same classify_batch tool over MCP (JSON-RPC 2.0) at mcp.cryptotaxedge.com, same parameters, same key. MCP setup →
policy parameterA handful of treatments are genuinely two-sided: the same on-chain event has two defensible tax positions, and which one a firm posts is a house call, not a fact the chain settles. Send an optional policy object and the returned verdict reflects your firm's chosen position for those treatments, applied on the wire so every downstream system reads one answer. Four dimensions are governed today:
lpLP entry and exit. disposal or non_taxable.wrapWrap and unwrap of the same underlying. non_taxable or disposal.lstLiquid-staking token mint. disposal or non_taxable.stakingStaking-reward receipt. income or defer.{
"hashes": [
{ "tx_id": "row_1", "hash": "0x280710a5673a8ff1af7f623ddb5148417ac36539228d0eec8edb8a57b19383c2", "chain": "ethereum" }
],
"policy": {
"lp": "non_taxable",
"wrap": "non_taxable",
"lst": "disposal",
"staking": "income"
}
}
Strictly additive. Omit policy (or any single key) and that treatment falls back to the platform default, byte-identical to a policy-free call. Unknown keys and invalid values are ignored, never an error. Policy never overrides a transaction we flagged for review: it resolves a grey treatment, never papers over a classification we are not sure of. The grey_area block still names the position not taken.
{
"api_version": "v1",
"hash": "0x280710a5673a8ff1af7f623ddb5148417ac36539228d0eec8edb8a57b19383c2",
"chain": "ethereum",
"category": "swap",
"treatment": "disposal",
"taxable": true,
"confidence": 77,
"needs_review": false,
"ledger_action": "Trade",
"protocol": null,
"description": "Token swap; a taxable disposition under IRC 1001.",
"review_note": null,
"grey_area": null,
"assets": {
"sent": [ { "symbol": "stETH", "amount": "0.002297", "decimals": 18, "action": "transferred",
"token_address": "0xae7a...fe84", "to": "0x89c6...f818" } ],
"received": [ { "symbol": "USDC", "amount": "3.723749", "decimals": 6, "action": "received",
"token_address": "0xa0b8...eb48", "from": "0x1231...4eae" } ],
"gas": null
}
}
Complex transactions additionally return a per-leg breakdown plus a memo, so you can post the correct number of taxable events for a single on-chain transaction.
{
"category": "swap",
"treatment": "disposal",
"confidence": 88,
"parent": { "category": "swap", "description": "Flash-loan arbitrage: borrow, two swap legs, repay." },
"legs": [
{ "leg_index": 0, "role": "flash_borrow", "category": "borrow", "taxable": false },
{ "leg_index": 1, "role": "arb_swap_1", "category": "swap", "taxable": true },
{ "leg_index": 2, "role": "arb_swap_2", "category": "swap", "taxable": true },
{ "leg_index": 3, "role": "flash_repay", "category": "repay", "taxable": false }
],
"memo": "Borrow and repay are non-events; recognize gain or loss on the two swap legs."
}
treatment is a closed set of five values you can switch on exhaustively: disposal, income, non_taxable, expense, needs_review. Anything the engine cannot map to one of those five is clamped to needs_review, so a partner switch statement never sees an out-of-contract value. confidence is an integer from 0 to 100.
An uncertain transaction is never a silent wrong non-taxable. When the engine cannot reach a confident verdict it returns needs_review: true with taxable: null and a review_note explaining why: a deliberate deferral for a human to resolve, never a forced verdict, never a guess dressed as an answer, and never billed. A taxable of false means a positive non-taxable finding; a taxable of null always travels with needs_review: true and means undetermined. Treat the two as distinct in your ledger.
This is the differentiator: the engine flags what it is unsure of rather than committing a wrong number to the books.
confidence is a routing signal, not an accuracy percentage. It reflects how strongly independent evidence agreed on the verdict, and its job is to decide what gets served, what gets hedged, and what gets flagged. Do not read 77 as "77% likely correct".
The score lands in bands that track corroboration:
95+Full agreement: every consulted source (or a verified rule with on-chain evidence) reached the same verdict.80 to 94Majority agreement, or a rule adjudicated with supporting evidence.60 to 79Single-source: one source decoded the transaction and nothing contradicted it.<60Sources disagreed or the signal was weak. These route to review rather than ship as verdicts.Because the bands track corroboration rather than complexity, a trivially simple transfer can sit at 60: only one source decoded it, so it is single-source even though the shape is obvious. A more complex swap that three sources agree on scores 95. Both numbers are honest; neither is a quality grade on the transaction itself.
You do not need to invent your own cutoff: anything the engine is not prepared to stand behind already ships needs_review: true. A verdict with needs_review: false is one the engine serves as authoritative, at any confidence. If your workflow wants a stricter posture on top of that, sampling single-source results (the 60 to 79 band) is the sensible dial; the MCP surface applies an additional review floor at 70 for hosts that want it prebuilt. When needs_review is true, taxable is always null; treatment may still name the engine's leaning, and it is a pointer for the reviewer, not a verdict to post.
When a transaction has more than one defensible treatment, the response adds a grey_area block: note is the basis for the split, and alternate carries the position not taken so your accountant can see and switch it. When a policy is supplied, the top-level verdict reflects the firm's chosen position and alternate shows the road not taken. grey_area is null on transactions with a single settled treatment.
{
"category": "liquidity_add",
"treatment": "non_taxable",
"taxable": false,
"needs_review": false,
"grey_area": {
"note": "LP contribution/withdrawal: no LP-specific IRS guidance; general property/realization principle governs.",
"alternate": {
"treatment": "disposal",
"taxable": true,
"label": "Disposition view (LP entry/exit as a crypto-to-crypto disposal)"
}
}
}
Every error uses one envelope, { "error": { "code", "message" } }, with standard HTTP semantics. Codes are stable; messages are human-readable and can change.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
invalid_request |
400 | Body is not a JSON object, hashes is missing or empty, or a batch exceeds 100 items. | Fix the request. Do not retry unchanged. |
invalid_hash |
400 | A hash is not a 0x-prefixed 64-hex EVM hash or a base58 Solana signature. On batch calls the message names the offending index. | Fix the hash. Do not retry unchanged. |
unauthorized |
401 | Missing, malformed, or revoked API key. | Check the Authorization header and the key in Settings. Do not retry unchanged. |
quota_exceeded |
402 | The classification pool for your key (monthly) is exhausted. Re-running an already-classified hash never hits this: repeats are always free. | Check GET /quota-status, wait for the reset, or upgrade. Key on the HTTP 402 status. |
tx_not_found |
404 | The hash is well-formed but was not found on the searched chain. If you pinned a chain, the engine already probed other supported chains before answering (see chain_corrected). | Verify the hash; or omit chain to auto-detect. |
chain_not_detected |
404 | The hash matched no supported chain. It may be mistyped or from an unsupported network. | Verify the hash and network; supported chains are listed at GET /chains. |
tx_not_yet_mined |
404 | The transaction is in the mempool but not yet mined, so no verdict exists yet. | Retry after the transaction is included in a block. |
rate_limited |
429 | Over 60 requests per minute on this key. | Back off with jitter and retry. Prefer batching (100 per call) over request fan-out. |
internal_error |
500 | An engine fault, logged on our side. | Retry shortly; contact us if it persists. |
(upstream reason) |
502 | An upstream data source failed before a verdict was reached; the code carries the failure reason. Never shipped as a 200 verdict. | Retry shortly. Treat as transient. |
Batch semantics: /v1/classify-batch returns HTTP 200 with one result per input; an item that could not be classified carries an error string on that item (for example tx_not_retrievable) instead of failing the whole call. The single endpoint maps the same failures to the HTTP statuses above. Treat any item with error set as not classified, and never as a verdict.
Every field the /v1 wire carries, verified against live responses. Fields marked "only" are conditional; everything else is always present. Unknown future fields are additive: ignore what you do not use.
| Field | Type | Description | Example |
|---|---|---|---|
api_version |
string | Contract version echo. Always "v1" on this surface. | "v1" |
id |
string | Batch results only: your tx_id echoed back so you can correlate results to your records. A single classify response omits it. | "row_1" |
hash |
string | The transaction hash that was classified. | "0x2807...83c2" |
chain |
string | Chain slug the classification was served from. Can differ from your request when chain_corrected is present. | "ethereum" |
category |
string | Canonical transaction type. Open, additively growing vocabulary; branch exhaustive logic on treatment instead. | "swap" |
treatment |
enum | Closed five-value tax treatment: disposal, income, non_taxable, expense, needs_review. Safe to switch on exhaustively. | "disposal" |
taxable |
bool | null | Tri-state. true = taxable event; false = a positive non-taxable finding; null = undetermined, always paired with needs_review: true. Never coerce null to false. | true |
confidence |
integer | 0 to 100. Corroboration and routing signal, not an accuracy percentage; see the confidence section above. | 77 |
needs_review |
boolean | True when the engine defers to a human instead of guessing. Route to a review queue, never to the books. | false |
ledger_action |
string | null | Suggested ledger label for posting. | "Trade" |
protocol |
string | null | Protocol display name when identified. | "Aave V3" |
description |
string | null | Plain-English reasoning behind the verdict. | "Token swap; a taxable disposition..." |
review_note |
string | null | Set only when needs_review is true: why the transaction was flagged and what a reviewer should check. | null |
grey_area |
object | null | Non-null only when more than one treatment is defensible: { note, alternate: { treatment, taxable, label } }. See the grey-area section. | null |
assets |
object | Net asset movements: { sent: [legs], received: [legs], gas: leg | null }. Leg fields below. | { "sent": [...], ... } |
chain_corrected |
object | Present only when you pinned a chain, the tx was not there, and the engine found and classified it on another chain: { requested, detected }. | { "requested": "polygon", "detected": "ethereum" } |
parent |
object | Multi-step transactions only: the whole-transaction verdict summary. | { "category": "swap", ... } |
legs |
array | Multi-step transactions only: per-leg breakdown (leg_index, role, category, taxable) so you post the right number of events. | [ { "leg_index": 0, ... } ] |
memo |
string | Multi-step transactions only: plain-English posting memo for the whole transaction. | "Borrow and repay are non-events; ..." |
error |
string | Batch results only: the failure reason for this one item (a batch call returns HTTP 200 with per-item errors). Treat any item with error set as not classified. | "tx_not_retrievable" |
assets.sent[], assets.received[], assets.gas)| Field | Type | Description | Example |
|---|---|---|---|
symbol |
string | On-chain token symbol exactly as the contract reports it. Never rewritten, so your matching logic can key on it. | "USDC" |
amount |
string | Human-readable decimal amount, as a string. Token amounts overflow floating point; do not parse as a float where exactness matters. | "3.723749" |
decimals |
integer | null | Token decimals used to render the amount. | 6 |
action |
string | What happened on this leg. | "transferred", "received" |
token_address |
string | Contract address of the token, lowercase hex. Present on legs decoded from event logs. | "0xa0b8...eb48" |
token_name |
string | Token display name when metadata resolved one. | "Aave Ethereum USDC" |
to / from |
string | Counterparty address: to on sent legs, from on received legs. | "0x89c6...f818" |
token_type |
string | Present on NFT legs. When set, amount is a token count, not a fungible balance. | (NFT legs only) |
symbol_collision |
boolean | True when this symbol matches a canonical token on the same chain at a DIFFERENT contract (e.g. a vault share reusing a major stablecoin symbol). symbol itself is never altered. | true |
display_symbol |
string | Display-only disambiguated symbol, present only when symbol_collision is true. Render this; keep matching on symbol. | "USDC (Varlamore USDC Growth)" |
inferred |
boolean | True when the amount was inferred rather than read from an event log (native-coin proceeds emit no log). The review_note names the method. | true |
inferred_basis |
string | Inference method when inferred is true. | "balance_delta" |
Deprecated aliases (MCP surface only): the MCP tools historically used the field names tx_type, tax_category, reasoning, source, and authoritative. These are now deprecated aliases of category, treatment, description, and the needs_review flag (authoritative is exactly needs_review: false, never an independent stamp). They are kept for one release for back-compat and will be removed in the next minor version; migrate to the canonical names. None of these aliases ever appear on REST /v1.
category is the canonical transaction type. Its core is the stable set below; the vocabulary grows as new on-chain patterns are named (for example wrap, reward, liquid_staking_mint), always additively, so treat an unfamiliar category gracefully rather than as an error. For an exhaustive switch, branch on treatment (the closed five-value set), not category. ledger_action carries the suggested ledger label.
swap transfer income_receipt fee_payment borrow repay collateral_supply collateral_withdraw liquidity_add liquidity_remove unclassifiedThe full taxonomy, with each category's default treatment, the house position on grey-area categories, and the named alternatives, is published at cryptotaxedge.com/taxonomy.
The contract is versioned in the path (/v1/) and echoed in every response as api_version. Within v1 we only make additive changes: new optional fields may appear, but existing fields are never renamed or removed and the treatment enum never gains a value without a version bump. A breaking change ships as /v2 with v1 kept live through a published deprecation window. Build against the fields you use and ignore unknown ones, and the contract will not break under you.
- Create an API key in Settings, API keys and send it as
Authorization: Beareron every call. - Batch your transactions (up to 100 per call) to
POST /v1/classify-batch, each with your owntx_id; usePOST /v1/classifyfor one-offs. - Optionally send a
policyobject to apply your firm's house positions on the wire. - Correlate each result to your record by the echoed
id, then post fromtreatment,taxable, andledger_action. Multi-step transactions carry a per-leg breakdown so you post the right number of events. - Route any result with
needs_review: trueto a human queue rather than to the books; surfacegrey_areawhere present. - Handle errors from the
{ error: { code, message } }envelope (full table above). Retrytx_not_yet_minedafter the transaction mines and back off onrate_limited. - Generate a typed client from the OpenAPI 3.1 spec if your stack supports it, and poll
GET /quota-statusto surface remaining quota in your own UI.