{
  "openapi": "3.1.0",
  "info": {
    "title": "CryptoTaxEdge classification API",
    "version": "1.0.0",
    "summary": "Per-transaction crypto tax classification: category, treatment, confidence, and per-leg reasoning.",
    "description": "Send blockchain transaction hashes; receive the canonical category, a closed five-value tax treatment, a confidence signal, review routing, and per-leg asset movements.\n\nContract guarantees:\n- Within v1 changes are additive only: fields are never renamed or removed, and the treatment enum never gains a value without a version bump. Ignore unknown fields.\n- An uncertain transaction is never a silent wrong answer: needs_review true always travels with taxable null and a review_note.\n- Errors always use one envelope: { \"error\": { \"code\", \"message\" } }.\n\nHuman-readable documentation: https://dashboard.cryptotaxedge.com/api\nCategory and treatment taxonomy: https://cryptotaxedge.com/taxonomy",
    "contact": {
      "name": "CryptoTaxEdge",
      "email": "hello@cryptotaxedge.com",
      "url": "https://dashboard.cryptotaxedge.com/api"
    },
    "termsOfService": "https://cryptotaxedge.com/legal.html"
  },
  "servers": [
    {
      "url": "https://app.cryptotaxedge.com",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/v1/classify": {
      "post": {
        "operationId": "classifyTransaction",
        "summary": "Classify one transaction by hash",
        "description": "Classifies a single on-chain transaction. chain is optional and auto-detected when omitted; if you pin a chain and the transaction is found on a different supported chain, the response ships the real classification plus a chain_corrected note instead of a 404. Rate limit: 60 requests per minute per key.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "hash"
                ],
                "properties": {
                  "hash": {
                    "type": "string",
                    "description": "0x-prefixed 64-hex EVM transaction hash, or a base58 Solana signature.",
                    "examples": [
                      "0x280710a5673a8ff1af7f623ddb5148417ac36539228d0eec8edb8a57b19383c2"
                    ]
                  },
                  "chain": {
                    "type": "string",
                    "description": "Optional chain slug (auto-detected when omitted). Supported chains are listed at GET /chains.",
                    "examples": [
                      "ethereum"
                    ]
                  },
                  "policy": {
                    "$ref": "#/components/schemas/Policy"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Classification verdict.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Classification"
                }
              }
            }
          },
          "400": {
            "description": "Malformed body or hash. Fix the request; do not retry unchanged. Codes: invalid_request, invalid_hash.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key. Codes: unauthorized.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "402": {
            "description": "Classification pool exhausted for the period. Repeats of already-classified hashes remain free; check GET /quota-status. Codes: quota_exceeded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "description": "The hash is well-formed but no verdict exists: not found on the searched chain(s), matched no supported chain, or still in the mempool. Never returned as a 200 needs_review. Codes: tx_not_found, chain_not_detected, tx_not_yet_mined.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "description": "Over the per-key rate limit. Back off and retry. Codes: rate_limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "Engine fault, logged for review. Retry shortly. Codes: internal_error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "502": {
            "description": "An upstream data source failed before a verdict was reached; the code carries the engine failure reason. Retry shortly. Upstream failures are never shipped as verdicts. Codes: upstream failure reasons.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/v1/classify-batch": {
      "post": {
        "operationId": "classifyBatch",
        "summary": "Classify up to 100 transactions in one call",
        "description": "Same per-item result shape as /v1/classify. The call returns HTTP 200 with one result per input; an item that could not be classified carries an error string on that item rather than failing the whole call. Your tx_id is echoed back as id on each result for correlation. The wrong-chain rescue (chain_corrected) also runs per item.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "hashes"
                ],
                "properties": {
                  "hashes": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "type": "object",
                      "required": [
                        "hash"
                      ],
                      "properties": {
                        "tx_id": {
                          "type": "string",
                          "description": "Your correlation id, echoed back as id on the matching result. Optional but strongly recommended."
                        },
                        "hash": {
                          "type": "string",
                          "description": "0x-prefixed 64-hex EVM transaction hash, or a base58 Solana signature."
                        },
                        "chain": {
                          "type": "string",
                          "description": "Optional chain slug (auto-detected when omitted)."
                        }
                      }
                    }
                  },
                  "policy": {
                    "$ref": "#/components/schemas/Policy"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "One result per input transaction, in input order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "api_version",
                    "count",
                    "results"
                  ],
                  "properties": {
                    "api_version": {
                      "type": "string",
                      "const": "v1"
                    },
                    "count": {
                      "type": "integer",
                      "description": "Number of results returned."
                    },
                    "results": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Classification"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Malformed body, empty or oversized hashes array, or an invalid hash (the message names the offending index). Fix the request; do not retry unchanged. Codes: invalid_request, invalid_hash.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key. Codes: unauthorized.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "402": {
            "description": "Classification pool exhausted for the period. Key on the HTTP 402 status. Repeats of already-classified hashes remain free. Codes: quota_exceeded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "description": "Over the per-key rate limit. Back off and retry. Codes: rate_limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "Engine fault, logged for review. Retry shortly. Codes: internal_error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/quota-status": {
      "get": {
        "operationId": "quotaStatus",
        "summary": "Read the caller's remaining classification quota",
        "description": "Read-only and free: never consumes quota. Anonymous callers get their daily pool; a Bearer developer key reports the key owner's monthly pool; partner-tier keys report kind \"partner\" (metered on the billing rail instead). Optional ?hash= adds a repeat verdict for that hash (repeat true means re-running it is free).",
        "security": [
          {
            "bearerAuth": []
          },
          {}
        ],
        "parameters": [
          {
            "name": "hash",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional transaction hash to check for repeat (free re-run) status."
          }
        ],
        "responses": {
          "200": {
            "description": "Quota readout for the resolved identity.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuotaStatus"
                }
              }
            }
          },
          "429": {
            "description": "Over the per-caller rate limit for this endpoint. Back off and retry. Codes: rate_limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "503": {
            "description": "Quota ledger temporarily unreadable. Fails to \"unavailable\", never to a fake zero. Body: { \"error\": \"quota_status_unavailable\" }.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "const": "quota_status_unavailable"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key from Settings > API keys at https://dashboard.cryptotaxedge.com/settings#api (free Developer tier available). Sent as Authorization: Bearer YOUR_API_KEY on every request."
      }
    },
    "schemas": {
      "Classification": {
        "type": "object",
        "description": "One classified transaction. Identical shape for a single /v1/classify response and each entry of a /v1/classify-batch results array (batch entries additionally carry id, and carry error instead of an HTTP error status when that one item failed).",
        "required": [
          "api_version",
          "hash",
          "chain",
          "category",
          "treatment",
          "taxable",
          "confidence",
          "needs_review",
          "ledger_action",
          "protocol",
          "description",
          "review_note",
          "grey_area",
          "assets"
        ],
        "properties": {
          "api_version": {
            "type": "string",
            "const": "v1",
            "description": "Contract version echo."
          },
          "id": {
            "type": "string",
            "description": "Batch results only: your tx_id echoed back for correlation. A single /v1/classify response omits it."
          },
          "hash": {
            "type": [
              "string",
              "null"
            ],
            "description": "The transaction hash that was classified."
          },
          "chain": {
            "type": [
              "string",
              "null"
            ],
            "description": "Chain slug the classification was served from. Can differ from the requested chain when chain_corrected is present.",
            "examples": [
              "ethereum"
            ]
          },
          "category": {
            "type": "string",
            "description": "Canonical transaction type. Open vocabulary that only grows additively; treat unfamiliar values gracefully and branch exhaustive logic on treatment instead. Full reference: https://cryptotaxedge.com/taxonomy",
            "examples": [
              "swap",
              "collateral_withdraw"
            ]
          },
          "treatment": {
            "type": "string",
            "enum": [
              "disposal",
              "income",
              "non_taxable",
              "expense",
              "needs_review"
            ],
            "description": "Closed five-value tax treatment enum, safe to switch on exhaustively. Anything the engine cannot map to the first four values is clamped to needs_review; the enum never gains a value without a version bump."
          },
          "taxable": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Tri-state. true = taxable event; false = a POSITIVE non-taxable finding; null = undetermined and ALWAYS accompanied by needs_review true. Never coerce null to false."
          },
          "confidence": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Corroboration/routing signal, not an accuracy percentage. Bands reflect source agreement: 95+ full agreement, 80 to 94 majority agreement or an adjudicated rule, 60 to 79 single-source, below 60 disagreement (routed to review). A trivially simple transaction can sit at 60 simply because only one source decoded it."
          },
          "needs_review": {
            "type": "boolean",
            "description": "True when the engine deliberately defers to a human instead of guessing. Route these to a review queue, never to the books. When true, taxable is null and review_note says why."
          },
          "ledger_action": {
            "type": [
              "string",
              "null"
            ],
            "description": "Suggested ledger label for posting.",
            "examples": [
              "Trade",
              "Remove from Pool"
            ]
          },
          "protocol": {
            "type": [
              "string",
              "null"
            ],
            "description": "Protocol display name when identified.",
            "examples": [
              "Aave V3"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Plain-English reasoning for the verdict."
          },
          "review_note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set only when needs_review is true: why the transaction was flagged."
          },
          "grey_area": {
            "type": [
              "object",
              "null"
            ],
            "description": "Present (non-null) only when the transaction has more than one defensible tax treatment. The top-level treatment/taxable reflect the chosen (house or policy-selected) position; this block names the basis and the position not taken.",
            "properties": {
              "note": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The basis for the split: why more than one position is defensible."
              },
              "alternate": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "The defensible position NOT taken, so a reviewer can see and switch it.",
                "properties": {
                  "treatment": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The alternate treatment value."
                  },
                  "taxable": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "description": "Taxability under the alternate position."
                  },
                  "label": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Human-readable name of the alternate position."
                  }
                }
              }
            }
          },
          "assets": {
            "type": "object",
            "description": "Net asset movements for the transaction.",
            "required": [
              "sent",
              "received",
              "gas"
            ],
            "properties": {
              "sent": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/AssetLeg"
                }
              },
              "received": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/AssetLeg"
                }
              },
              "gas": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/AssetLeg"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Gas paid, same shape as a leg, or null when not attributable to the viewing perspective."
              }
            }
          },
          "chain_corrected": {
            "type": "object",
            "description": "Present only when the caller pinned a chain, the transaction was not found there, and the engine located and classified it on another supported chain instead. The top-level chain field names the chain that actually served.",
            "required": [
              "requested",
              "detected"
            ],
            "properties": {
              "requested": {
                "type": "string",
                "description": "The chain the caller asked for.",
                "examples": [
                  "polygon"
                ]
              },
              "detected": {
                "type": "string",
                "description": "The chain the transaction was actually found and classified on.",
                "examples": [
                  "ethereum"
                ]
              }
            }
          },
          "parent": {
            "type": "object",
            "description": "Multi-step transactions only: the whole-transaction verdict summary (category, description, and related roll-up fields).",
            "additionalProperties": true
          },
          "legs": {
            "type": "array",
            "description": "Multi-step transactions only: per-leg breakdown so you can post the correct number of taxable events for one on-chain transaction. Each leg carries leg_index, role, category, and taxable.",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "memo": {
            "type": "string",
            "description": "Multi-step transactions only: plain-English posting memo for the whole transaction."
          },
          "error": {
            "type": "string",
            "description": "Batch results only: the failure reason for this one item (for example tx_not_retrievable). A batch call returns HTTP 200 with per-item errors; the single endpoint maps the same failures to HTTP statuses. Treat any item with error set as not classified."
          }
        }
      },
      "AssetLeg": {
        "type": "object",
        "description": "One asset movement (a leg) inside the transaction, from the perspective of the transaction as a whole.",
        "required": [
          "symbol",
          "amount",
          "action"
        ],
        "properties": {
          "symbol": {
            "type": "string",
            "description": "On-chain token symbol, exactly as the contract reports it. Never rewritten, so downstream systems can key on it.",
            "examples": [
              "USDC",
              "stETH"
            ]
          },
          "amount": {
            "type": "string",
            "description": "Human-readable decimal amount as a string (token amounts overflow IEEE 754 doubles; never parse as a float if you need exactness).",
            "examples": [
              "3.723749"
            ]
          },
          "decimals": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Token decimals used to render the amount, or null when unknown.",
            "examples": [
              6
            ]
          },
          "action": {
            "type": "string",
            "description": "What happened to the asset on this leg, e.g. transferred or received.",
            "examples": [
              "transferred",
              "received"
            ]
          },
          "token_address": {
            "type": "string",
            "description": "Contract address of the token, lowercase hex. Present on legs decoded from event logs.",
            "examples": [
              "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
            ]
          },
          "token_name": {
            "type": "string",
            "description": "Token display name when the metadata lookup resolved one.",
            "examples": [
              "Aave Ethereum USDC"
            ]
          },
          "to": {
            "type": "string",
            "description": "Recipient address on sent legs."
          },
          "from": {
            "type": "string",
            "description": "Sender address on received legs."
          },
          "token_type": {
            "type": "string",
            "description": "Present on NFT legs. When set, amount is a token count, not a fungible balance."
          },
          "symbol_collision": {
            "type": "boolean",
            "description": "True when this leg's symbol matches a canonical token on the same chain at a DIFFERENT contract address (e.g. a vault share token that reuses a major stablecoin symbol). The symbol field itself is never altered; use display_symbol for rendering."
          },
          "display_symbol": {
            "type": "string",
            "description": "Display-only disambiguated symbol, present only when symbol_collision is true.",
            "examples": [
              "USDC (Varlamore USDC Growth)"
            ]
          },
          "inferred": {
            "type": "boolean",
            "description": "True when the leg amount was inferred rather than read directly from an event log (native-coin proceeds have no log). The review_note names the inference method."
          },
          "inferred_basis": {
            "type": "string",
            "description": "Inference method when inferred is true.",
            "examples": [
              "balance_delta"
            ]
          }
        }
      },
      "GreyArea": {
        "type": [
          "object",
          "null"
        ],
        "description": "Present (non-null) only when the transaction has more than one defensible tax treatment. The top-level treatment/taxable reflect the chosen (house or policy-selected) position; this block names the basis and the position not taken.",
        "properties": {
          "note": {
            "type": [
              "string",
              "null"
            ],
            "description": "The basis for the split: why more than one position is defensible."
          },
          "alternate": {
            "type": [
              "object",
              "null"
            ],
            "description": "The defensible position NOT taken, so a reviewer can see and switch it.",
            "properties": {
              "treatment": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The alternate treatment value."
              },
              "taxable": {
                "type": [
                  "boolean",
                  "null"
                ],
                "description": "Taxability under the alternate position."
              },
              "label": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Human-readable name of the alternate position."
              }
            }
          }
        }
      },
      "ChainCorrected": {
        "type": "object",
        "description": "Present only when the caller pinned a chain, the transaction was not found there, and the engine located and classified it on another supported chain instead. The top-level chain field names the chain that actually served.",
        "required": [
          "requested",
          "detected"
        ],
        "properties": {
          "requested": {
            "type": "string",
            "description": "The chain the caller asked for.",
            "examples": [
              "polygon"
            ]
          },
          "detected": {
            "type": "string",
            "description": "The chain the transaction was actually found and classified on.",
            "examples": [
              "ethereum"
            ]
          }
        }
      },
      "Policy": {
        "type": "object",
        "description": "Optional per-firm house positions for the genuinely two-sided treatments. Strictly additive: omitting the object (or any key) 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 flagged needs_review.",
        "properties": {
          "lp": {
            "type": "string",
            "enum": [
              "disposal",
              "non_taxable"
            ],
            "description": "LP entry and exit."
          },
          "wrap": {
            "type": "string",
            "enum": [
              "non_taxable",
              "disposal"
            ],
            "description": "Wrap and unwrap of the same underlying."
          },
          "lst": {
            "type": "string",
            "enum": [
              "disposal",
              "non_taxable"
            ],
            "description": "Liquid-staking token mint and redeem."
          },
          "staking": {
            "type": "string",
            "enum": [
              "income",
              "defer"
            ],
            "description": "Staking-reward receipt."
          }
        }
      },
      "QuotaStatus": {
        "type": "object",
        "description": "Read-only usage readout for the caller's quota identity. Never consumes quota. With a Bearer key the key's pool is reported; anonymous callers get the per-caller daily pool. Partner-tier keys get { \"kind\": \"partner\", \"metered\": \"billing-rail\" } instead of a counter.",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "anon",
              "dev",
              "partner"
            ],
            "description": "Which quota identity answered."
          },
          "used": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Distinct successful classifications consumed this period. Null when the optional ?hash= short-circuits to a repeat verdict."
          },
          "limit": {
            "type": "integer",
            "description": "Pool size for this identity and period."
          },
          "remaining": {
            "type": [
              "integer",
              "null"
            ],
            "description": "limit minus used, floored at 0."
          },
          "repeat": {
            "type": "boolean",
            "description": "Only meaningful with ?hash=. True means that hash was already counted this period, so re-running it is free."
          },
          "exceeded": {
            "type": "boolean",
            "description": "True when the pool is exhausted. Repeats of already-classified hashes still serve free."
          },
          "resets": {
            "type": "string",
            "enum": [
              "daily",
              "monthly"
            ],
            "description": "Anonymous pools reset daily; keyed developer pools reset monthly."
          },
          "metered": {
            "type": "string",
            "description": "Partner-tier keys only: \"billing-rail\" (usage is metered on the partner billing rail, not a pool)."
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable error code. See the error table in the docs for the full list and the recommended integrator action per code."
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation, safe to log and to show a developer."
              }
            }
          }
        }
      }
    }
  }
}