Partner Integration · API contract

CryptoTaxEdge classification API

You own the ledger, the chart of accounts, and the close workflow. You send transactions; we return the canonical category, tax treatment, confidence, and per-leg reasoning. Classifications are informational only, not tax advice; verify results with a qualified tax professional before filing.
Endpoints
POST 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.
POST app.cryptotaxedge.com/v1/classify Single transaction by hash. Same result shape, one record.
GET 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 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.

Authentication

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).

Try it: sample transactions

Two known-good Ethereum mainnet hashes you can drop straight into a request. Re-runs are free, so testing costs you nothing:

Token swap ethereum
0x280710a5673a8ff1af7f623ddb5148417ac36539228d0eec8edb8a57b19383c2
Multi-step token-for-token exchange → category "swap".
Lending withdrawal ethereum
0x9b5693fc24d2e31c59317f53f7ca77b3d570ad86999bd22a4fc53a7d06a48da6
Withdraw collateral from a lending protocol → category "collateral_withdraw".
Request
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 →

Firm policy: optional policy parameter

A 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.

Response: simple transaction
{
  "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
  }
}
Response: multi-step transaction (flash loan, batch NFT, bridge, DCA)

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 and the honesty rule

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: what the number means

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.

Grey-area transparency

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)"
    }
  }
}
Errors

Every error uses one envelope, { "error": { "code", "message" } }, with standard HTTP semantics. Codes are stable; messages are human-readable and can change.

CodeHTTPMeaningWhat 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.

Field reference

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.

FieldTypeDescriptionExample
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"
Asset legs (assets.sent[], assets.received[], assets.gas)
FieldTypeDescriptionExample
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 vocabulary

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 unclassified

The 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.

Versioning and stability

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.

Integration guide
  1. Create an API key in Settings, API keys and send it as Authorization: Bearer on every call.
  2. Batch your transactions (up to 100 per call) to POST /v1/classify-batch, each with your own tx_id; use POST /v1/classify for one-offs.
  3. Optionally send a policy object to apply your firm's house positions on the wire.
  4. Correlate each result to your record by the echoed id, then post from treatment, taxable, and ledger_action. Multi-step transactions carry a per-leg breakdown so you post the right number of events.
  5. Route any result with needs_review: true to a human queue rather than to the books; surface grey_area where present.
  6. Handle errors from the { error: { code, message } } envelope (full table above). Retry tx_not_yet_mined after the transaction mines and back off on rate_limited.
  7. Generate a typed client from the OpenAPI 3.1 spec if your stack supports it, and poll GET /quota-status to surface remaining quota in your own UI.