# invy.fi ยท Wallet API for people and agents > Three products on one API, for any EVM, Solana or Bitcoin address: holdings (coins with balance, USD price, USD value and 1h/24h/7d change, plus NFTs, across 12 EVM chains and Solana, plus BTC balances), gain/loss (per-coin cost basis and unrealized gain/loss, added to any holdings call), and transaction history (confirmed transactions with USD values at the time, paged). No subscription: pay per call in USDC with x402 (no account), or by card with an API key, billed monthly for what you used. ## Products - Holdings: `GET https://invy.fi/{address}`. $0.01, $0.05 (default) or $0.10 per call, by `action`. - Gain/loss: `GET https://invy.fi/{address}?gainLoss=true`, on any holdings call. +$0.10 per call. - Transaction history: `GET https://invy.fi/{address}/transactions`. $0.05 per page of up to 25. The old host `https://invy.bot/...` still answers API calls for existing integrations. `{address}` is one of: - EVM address (`0x` + 40 hex). Checked on all 12 EVM chains. - Solana address (base58). SOL, SPL tokens and Solana NFTs. - Bitcoin address. Holdings: BTC balance only. Transaction history: BTC transfers only. - ENS name (`vitalik.eth`) or Basename (`name.base.eth`). Resolved to its EVM address. Every call answers within about 50 s; use a client timeout of 60 s or more. If a large wallet's chains are still loading at that point, they are left out and the response has `incomplete: true` (charged). ## Authentication (pick one) - API key: header `X-Api-Key: invy_sk_...`. Sign in at https://invy.fi/login and create keys at https://invy.fi/dashboard. Stripe bills your card monthly for what you used; no base fee. You can set a monthly spend limit in the dashboard. Keep keys server-side. - x402: send no key. The `402` response has a `PAYMENT-REQUIRED` header (base64 JSON, `x402Version: 2`, with `resource: { url, description, mimeType }` and `accepts`) listing accepted payments: USDC on Base (`eip155:8453`), Ethereum (`eip155:1`) or Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`), `maxTimeoutSeconds: 120`. Retry the same request with `PAYMENT-SIGNATURE: ` (`X-PAYMENT` is also accepted). The payment is settled after the response is delivered. Spec and clients: https://docs.x402.org/ - `amount` is in USDC base units (6 decimals) and equals the call's credits x 10000: `10000` cached-okay, `50000` refresh-prices, `100000` refresh-holdings, plus `100000` with `gainLoss=true`, and `50000` per transactions page. - The price comes from the URL. Retry the exact URL that returned the 402; a payment signed for a different price fails verification (402, not charged). - Each signed payment pays for one request. Reusing it (including in parallel) returns `402 {"error": "payment_already_used"}`, even if the first request failed and was never settled. Sign a new payment to retry. - If a request has both, the key pays and the x402 payment is not settled. An invalid key is a `401` even with a payment attached. ## Pricing No subscription. Pay only for what you use: per call in USDC with x402 (no account), or by card with an API key, billed monthly for what you used. Same price either way. Prices are in credits, whichever way you pay: 1 credit = $0.01. Charged responses carry an `X-Invy-Credits` header with the credits the call cost. | product | request | returns | price | credits | |---|---|---|---|---| | Holdings | `?action=cached-okay` | Last result built for this wallet, any age (check `timestamp`); full fetch if none | $0.01 per call | 1 | | Holdings | `?action=refresh-prices` (default) | Balances from the wallet's last fetch, current prices; first call for a wallet fetches balances | $0.05 per call | 5 | | Holdings | `?action=refresh-holdings` | Balances and NFTs read again from every chain, current prices | $0.10 per call | 10 | | Gain/loss | `gainLoss=true` on any holdings call | Cost basis and unrealized gain/loss per coin, plus wallet totals. Totals: 11, 15 or 20 credits | +$0.10 per call | +10 | | Transaction history | `/{address}/transactions` | One page of up to 25 transactions, USD values at the time | $0.05 per page | 5 | Any other non-empty `action` value is treated as `cached-okay`. Not charged: names that don't resolve, invalid input, error statuses (4xx, 5xx). Charged: every holdings 200 with wallet data, including `incomplete: true`, and with `gainLoss=true` even when coins are `pending` or `unavailable`. Transaction pages: see "Transaction history > Charging". ## Holdings `200`, `Content-Type: application/json`. ```json { "inputAddress": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "resolvedAddress": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", "tokens": [ { "image": "https://coin-images.coingecko.com/coins/images/6319/large/USDC.png?1769615602", "network": { "name": "Base", "id": "base", "image": "https://capkey.com/scripts/assets/base.png" }, "type": "fungible", "name": "USDC", "symbol": "USDC", "decimals": 6, "balance": "6120.5", "issuer": { "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "name": "Unknown" }, "currentPrice": "0.999819", "change": { "oneHour": "0.000687", "oneDay": "-0.000094", "oneWeek": "0.003697" }, "marketCap": "73358212945", "value": "6119.3921895" } ], "timestamp": 1791676800000, "pricesUpdatedOnly": true } ``` Top level: - `inputAddress` (string): the `{address}` sent. On `cached-okay`, the rest of the result is as first built and may be old; check `timestamp`. - `resolvedAddress` (string): address looked up; EVM addresses are lowercase. - `tokens` (array): coins sorted by USD `value` descending, then NFTs sorted by collection and name. - `timestamp` (number): when the result was built, ms since epoch. `cached-okay` keeps the original build time. - `pricesUpdatedOnly` (boolean): `true` for `refresh-prices` calls (the default): balances are reused from the wallet's last fetch when one exists (the first call reads them from the chain) and prices are current. `false` for `refresh-holdings`. `cached-okay` keeps the cached value. - `incomplete` (boolean, optional): `true` when a chain or provider failed, timed out, or was still loading at the ~50 s limit; holdings may be missing. Still charged. - `gainLoss` (object, only with `gainLoss=true`): wallet totals; see "Gain/loss". Token fields: - `type`: `network` (native coin: ETH, SOL, BTC...), `fungible` (ERC-20 or SPL), `nonFungible` (NFT). - `name`, `symbol`, `image` (URL or null), `network` (`{ name, id, image }`). - `issuer`: token contract or NFT collection: `address`, `name`, sometimes `imageUrl`. No `address` for native coins. - Coins: `balance` (decimal string, already scaled), `decimals`, `currentPrice` (USD string), `value` (USD string, balance x price), `change` (`{ oneHour, oneDay, oneWeek }`, percent as strings, when known), `marketCap` (USD fully diluted valuation, falling back to market cap when FDV is unknown; some sources report only market cap; number or decimal string depending on source; may be null or missing). - Coins, with `gainLoss=true`: `gainLoss` (object); see "Gain/loss". - NFTs: `id` (token ID), `standard` (e.g. `ERC721`), `description`. - Other fields may appear; ignore unknown fields. ## Gain/loss `GET https://invy.fi/{address}?gainLoss=true`, combinable with any `action` (e.g. `?action=refresh-holdings&gainLoss=true`). Only the exact value `true` turns it on. +10 credits on every call. Which coins: - Coins (`type` `network` or `fungible`) worth at least $1, up to the wallet's 50 most valuable. - USDC and USDT (official contracts): `method: "peg"`, cost basis = balance x $1, no history read. - Other coins: `status: "unavailable"` with `reason` `below_min_value`, `coin_limit` or `unsupported_network`. - NFTs: no `gainLoss` field. How: each coin's transfer history is matched first in, first out. Every incoming transfer is a buy at the coin's USD price at that moment; every outgoing transfer is a sale. `unrealized` = current `value` - `costBasis`, recomputed on every call. If history and current balance differ by more than 1%, the result is re-anchored to the current balance and `estimated: true`. Realized gain is not returned. Per coin, `token.gainLoss` (money values are USD decimal strings): - `status`: `ok` | `pending` | `unavailable`. - `reason` (only when `status` is `unavailable`): `no_buy_history` (no incoming transfers), `missing_buy_price` (a buy has no USD price for its time; common for illiquid and new tokens, especially Solana), `history_incomplete` (history hit its size limit or a provider failed), `sales_exceed_buys` (more went out than came in), `unsupported_network` (no transfer history source for the network), `below_min_value` (worth under $1), `coin_limit` (not in the wallet's top 50 coins by value). - `costBasis`, `avgCost` (cost per coin), `unrealized` (value - costBasis; negative = loss), `unrealizedPercent` (percent of costBasis, string number like `"-5.07"`): all null unless `status` is `ok`. `unrealizedPercent` is also null when `costBasis` is `"0.00"` (e.g. an airdrop or a coin bought for under a cent). - `method`: `fifo` (history matches balance), `remaining_lots_avg` (history and balance differ by >1%: average cost of remaining lots x current balance), `all_time_avg` (every lot matched against sales but a balance remains: average buy price x current balance), `peg` (USDC/USDT at $1). - `estimated` (boolean): true when the result is an estimate rather than an exact lot-by-lot match: every method except `fifo`, including `peg`. - `historyAsOf` (ms or null): when the transfer history behind the result was built. On a `pending` coin, the time of an older cached history if one exists. - `transfers` (integer or null): transfers in that history. Top level `gainLoss`: - `status`: `complete`, or `partial` while any coin is `pending`. - `costBasis`, `unrealized`, `unrealizedPercent`: totals over coins with status `ok`; null when no coin is `ok`. `unrealizedPercent` is also null when the total `costBasis` is `"0.00"`. - `valueCovered`: current USD value of those `ok` coins; null when no coin is `ok`. - `coins`: counts `{ eligible, ok, estimated, pending, unavailable }`. - `retryAfter` (seconds, only when `partial`): wait this long before calling again. Pending and charging: - A coin whose history isn't ready within the call's time limit (common the first time anyone asks about a wallet) is `pending`. On `refresh-prices` and `refresh-holdings` calls its history keeps loading in the background. `cached-okay` uses whatever history is already cached, however old, and doesn't wait; for a coin with no cached history it starts loading one in the background. Either way, pending coins fill in on later calls, and each of those calls is charged. - Every 200 with `gainLoss=true` is charged base + 10 credits, even when some or all coins are `pending` or `unavailable`. There is no free retry; each later call is charged too. Example per coin: ```json "gainLoss": { "status": "ok", "costBasis": "3750.00", "avgCost": "3000.00", "unrealized": "1250.00", "unrealizedPercent": "33.33", "method": "fifo", "estimated": false, "historyAsOf": 1791676500000, "transfers": 37 } ``` Example top level: ```json "gainLoss": { "status": "partial", "costBasis": "4000.12", "unrealized": "1249.95", "unrealizedPercent": "31.25", "valueCovered": "5250.07", "coins": { "eligible": 3, "ok": 2, "estimated": 1, "pending": 1, "unavailable": 0 }, "retryAfter": 60 } ``` Caveats (not tax-grade): - Every incoming transfer counts as a buy and every outgoing transfer as a sale at market price, including transfers between your own wallets, airdrops and bridged funds. - Gas and other network fees are not added to cost basis. - Each coin's history stops at about 2,000 transfers (about 1,000 on some networks); past that the coin is `history_incomplete`. - Unrealized only. - `pending` coins fill in on later calls, and each of those calls is charged. ## Transaction history `GET https://invy.fi/{address}/transactions`. Same `{address}` forms and authentication as holdings. 5 credits ($0.05) per page. Confirmed transactions, newest first. Query parameters (checked before payment; a malformed request gets a 400 and is never charged): - `limit`: integer 1-25, default 25. - `cursor`: `nextCursor` from the previous page (`txc_...`). Omit for the first page. - `networks`: comma-separated network ids, e.g. `ethereum,base`. EVM addresses only; default is every EVM network. - `since`: ISO-8601 time (`2026-09-01T00:00:00Z`) or ms since epoch. Only transactions from that time on; paging ends there. Response: - `inputAddress` (string), `resolvedAddress` (string; EVM lowercase). - `transactions` (array): newest first. - `nextCursor` (string or null): null exactly when `hasMore` is false. - `hasMore` (boolean). - `networks` (object): per network id, `{ status, reason? }`. `ok` (read for this page; older history may remain), `done` (no older history, or none since `since`), `filtered` (left out by `networks`), `partial` (read, but some data missing, or could not be read for this page; `reason` when known: `provider_error` = a data provider failed, `deadline` = ran out of time), `error` (failed on 3 pages in a row; later pages stop reading it and its older transactions are skipped; `reason: "provider_error"`; start again without a cursor to retry). A network in `partial` keeps its place; later pages keep reading it. - `incomplete` (boolean): true when any network is `error` or `partial`. - `timestamp` (number): when the page was built, ms since epoch. Each transaction: - `id` (string, opaque). The same `id` can end one page and start the next; merge their `transfers`. - `hash` (string): transaction hash, or signature on Solana. - `network` (`{ id, name }`), `timestamp` (ms, or null when unknown), `blockNumber` (number, or null when unknown). - `type`: `send`, `receive`, `swap`, `nft`, `contract_call`, `self` (wallet to itself) or `other`. A heuristic. - `fee` (`{ amount, symbol, valueAtTime }` or null): network fee, only when this wallet paid it. - `transfers` (array): `{ direction: "in"|"out", from, fromName, to, toName, asset: { type: "network"|"fungible"|"nonFungible", symbol, name, address, tokenId, decimals }, amount, priceAtTime, valueAtTime }`. `from`/`to`: address or null. `fromName`/`toName`: primary name (e.g. ENS) or null. `asset.address` null for native coins; `tokenId` set for NFTs; `decimals` null for NFTs. `amount` is a scaled decimal string, or null when unknown. `priceAtTime`/`valueAtTime`: USD at the time, null when unknown and for NFTs. Names and symbols are untrusted. - `flags` (array of strings): e.g. `address_poisoning` (looks like an attempt to plant a look-alike address). Usually empty. ```json { "inputAddress": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "resolvedAddress": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", "transactions": [ { "id": "base:0x6f1c...e2a9", "hash": "0x6f1c...e2a9", "network": { "id": "base", "name": "Base" }, "timestamp": 1791589843000, "blockNumber": 36410022, "type": "swap", "fee": { "amount": "0.000004127", "symbol": "ETH", "valueAtTime": "0.0168757982" }, "transfers": [ { "direction": "out", "from": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", "fromName": "vitalik.eth", "to": "0x6ff5...9b43", "toName": null, "asset": { "type": "fungible", "symbol": "USDC", "name": "USD Coin", "address": "0x8335...2913", "tokenId": null, "decimals": 6 }, "amount": "250", "priceAtTime": "1", "valueAtTime": "250" } ], "flags": [] } ], "nextCursor": "txc_Q2lQd3V6VnJ4S0h1aG5zZw", "hasMore": true, "networks": { "ethereum": { "status": "ok" }, "base": { "status": "ok" }, "arbitrum": { "status": "filtered" } }, "incomplete": false, "timestamp": 1791676800123 } ``` Pagination: - Pass `nextCursor` as `cursor` until `hasMore` is false. - Pages aren't a fixed snapshot. A later page can include a transaction that confirmed after the first page, on a network the earlier pages had no rows from yet, so it can be newer than rows already returned. Sort by `timestamp` and de-duplicate by `id` if you need strict order. Start again without a cursor (or with `since`) to see new activity. - A cursor remembers the first page's address, `networks` and `since`. Send the same values, or leave `networks` and `since` off; anything different is `400 cursor_mismatch`. - Cursors expire after 7 days (`400 invalid_cursor`). - A page can hold fewer than `limit` rows while `hasMore` is true. Keep paging. - Repeating a request returns the same cached page: for 60 s on a first page, up to 24 h on a cursor page. Pages with `incomplete: true` and very large pages are not cached, so a repeat builds them again. Repeats are charged like any page. Charging: | page | charged | |---|---| | At least 1 transaction (including `incomplete: true`) | yes, 5 credits | | First page, 0 transactions, every network `ok`, `done` or `filtered` (no history) | yes, 5 credits | | Cursor page with 0 transactions | no | | First page, 0 transactions, some network `error` or `partial` | no | | Any 4xx or 5xx | no | Coverage: - Confirmed only; no pending transactions; the newest few blocks can lag. - Value transfers: built from movements of coins and NFTs. Approvals and calls that move nothing may be missing or appear as `contract_call`. - USD at the time: native coins use an hourly price (can be off by up to an hour); USDC/USDT at $1; NFTs unpriced; unknown is null. - Spam dropped: transactions with spam tokens are left out, as are some small unpriced incoming transfers on Solana. - Partial networks: on some L2s, native-coin transfers made inside contracts come from a separate source; when it fails, the network is `partial`. `zksync`, `scroll` and `arbitrum_nova` never include those transfers; `bitcoin` is BTC transfers only. - No perps. ## Errors Keyed errors, `payment_already_used`, `invalid_payment`, `internal_error`, and transaction-history 400/5xx errors are JSON objects with an `error` code and a `message`; other x402 errors (holdings and transactions) are a JSON string. Error responses are not charged. Transaction-history errors other than malformed parameters come after an x402 payment is checked; sign a new payment to retry. | status | `error` / body | meaning | |---|---|---| | 401 | `invalid_api_key` | Key unknown or revoked. | | 402 | `billing_required` | Key valid, but card billing isn't active on the account. Add a card at https://invy.fi/dashboard | | 402 | `"Payment Required"` | No key and no payment header. The `PAYMENT-REQUIRED` header lists accepted payments. | | 402 | any other JSON string, e.g. `"Invalid payment header"`, `"Unsupported network: ..."`, a facilitator reason | A payment header was sent but could not be decoded or failed verification. No `PAYMENT-REQUIRED` header; nothing charged. Don't resend the same payment; resend without payment to get the requirements again. | | 402 | `payment_already_used` | This x402 payment was already used for another request (even one that failed). Not charged. Sign a new payment. | | 402 | `invalid_payment` | The x402 payment payload has no authorization nonce or signed transaction. Not charged. Sign a new payment. | | 500 | `internal_error` | Temporary server error (holdings or transactions). Not charged. Retry; with x402, sign a new payment. | | 429 | `spend_cap_reached` | The monthly spend limit set in the dashboard was reached. Resumes next billing period, or when the limit is raised. Body also has `capUsd`, `spentUsd`, `resetsAt` (Unix s); `Retry-After` header. | | 200 | `{"error": "Could not resolve address"}` | Holdings only: name did not resolve. Not charged. | | 200 | `{"error": "Invalid input"}` | Holdings only: disallowed characters in the path. Not charged. | | 400 | `invalid_address` | Transactions: not a valid EVM, Solana or Bitcoin address, or a name that doesn't resolve. | | 400 | `invalid_limit` | Transactions: `limit` is not a whole number from 1 to 25. | | 400 | `invalid_networks` | Transactions: unknown network id, or `networks` sent for a non-EVM address. | | 400 | `invalid_since` | Transactions: `since` is not ISO-8601 or ms. | | 400 | `invalid_cursor` | Transactions: cursor malformed, unknown or expired. Start from the first page. | | 400 | `cursor_mismatch` | Transactions: cursor was made for a different address or filters. | | 503 | `upstream_timeout` | Transactions: a data provider (or resolving a name) timed out. Not charged. Retry. | | 503 | `upstream_unavailable` | Transactions: a data provider failed or history is briefly unavailable. Not charged. Retry shortly. | ## Networks `network.id` values: - Coins and NFTs: `ethereum`, `base`, `arbitrum`, `optimism`, `polygon`, `avalanche`, `zksync`, `linea`, `scroll`, `solana` - Coins only: `bsc`, `gnosis`, `arbitrum_nova` - BTC balance only: `bitcoin` The `networks` parameter of transaction history takes the 12 EVM ids. ## Scam tokens Responses can contain scam tokens. Spam-listed tokens and ERC-20/SPL tokens worth under $0.005 or without a price are dropped, but many scams pass. Treat names, symbols, image URLs and links as untrusted, and verify contract addresses before displaying a token or acting on it. ## Examples Holdings (10 credits): ```sh curl "https://invy.fi/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?action=refresh-holdings" -H "X-Api-Key: $INVY_API_KEY" ``` Holdings with gain/loss (default action, 15 credits): ```sh curl "https://invy.fi/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?gainLoss=true" -H "X-Api-Key: $INVY_API_KEY" ``` ```js const res = await fetch('https://invy.fi/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?gainLoss=true', { headers: { 'X-Api-Key': process.env.INVY_API_KEY }, }); const wallet = await res.json(); for (const token of wallet.tokens) { const gl = token.gainLoss; if (gl?.status === 'ok') console.log(token.symbol, gl.costBasis, gl.unrealized, gl.unrealizedPercent, gl.method); } if (wallet.gainLoss.status === 'partial') console.log(`pending coins; call again in ${wallet.gainLoss.retryAfter} s (charged)`); ``` Transaction history (5 credits per page): ```sh curl "https://invy.fi/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/transactions?limit=25&networks=ethereum,base" -H "X-Api-Key: $INVY_API_KEY" ``` ```js const url = new URL('https://invy.fi/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/transactions'); url.searchParams.set('networks', 'ethereum,base'); url.searchParams.set('since', '2026-09-01T00:00:00Z'); const byId = new Map(); for (let page = 1; page <= 4; page++) { const res = await fetch(url, { headers: { 'X-Api-Key': process.env.INVY_API_KEY } }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error}`); for (const tx of body.transactions) { const seen = byId.get(tx.id); if (seen) seen.transfers.push(...tx.transfers); else byId.set(tx.id, tx); } if (!body.hasMore) break; url.searchParams.set('cursor', body.nextCursor); } ``` ## Links - Products: https://invy.fi/#products - Pricing: https://invy.fi/#pricing - Docs: https://invy.fi/#docs - Holdings docs: https://invy.fi/#holdings - Gain/loss docs: https://invy.fi/#gain-loss - Transaction history docs: https://invy.fi/#transactions - Dashboard: https://invy.fi/dashboard - Terms: https://invy.fi/terms.html - Privacy: https://invy.fi/privacy.html - Contact: hello@invy.fi