What you’ll build
- A wallet’s total PnL: realized, unrealized, fees and external flows
- Per-token breakdowns for up to 100 tokens in one call
- The same token’s performance compared across chains
- PnL for a specific time range
- Handling for edge cases like the 503 bootstrap and airdrops
Time
~10 minutes
You need
- A Zerion API key
- Node.js 18+ (for native fetch) or any HTTP client
Endpoints
How PnL is calculated
Zerion API uses FIFO (first in, first out): it matches the earliest purchases against the earliest sales. This is the most common standard for tax reporting.Response fields
When you add token filters (
fungible_ids or fungible_implementations), the response also includes a breakdown object with per-token stats like average_buy_price, average_sell_price, and gain percentages.
The resource type in the response is
wallet_pnl (not pnl).Steps
1
Portfolio-level PnL
Fetch a wallet’s overall PnL across all tokens and chains.
GET /v1/wallets/{address}/pnl?currency=usd2
Batch PnL for specific tokens
Pass up to 100 tokens in one request to get a per-token breakdown. There are two filtering modes:Example response (Option B: Per-token breakdown fields:Each entry in the breakdown object (whether
Option A:
fungible_ids (cross-chain)Aggregates a token’s PnL across all chains. Use it for portfolio-level token views where the chain doesn’t matter.breakdown.by_id):fungible_implementations (chain-specific)Tracks PnL per chain deployment, using chain:contract_address pairs. Use it to compare the same token across L1s and L2s, or when you need chain-level precision.Native chain tokens use
chain: with an empty address after the colon: ethereum: for ETH, base: for Base ETH, polygon: for MATIC, solana: for SOL.by_id or by_implementation) contains:3
Time-range PnL
Scope PnL to a time window with the You can combine
since and till parameters, as Unix timestamps in milliseconds. Omit till to measure up to now.PnL is pre-computed at six marks: now, 1 day ago, 1 week ago, 1 month ago, 1 year ago and the beginning of the year. Ranges on these marks return in under 200 ms at p99. This example uses the beginning of the year:since/till with token filters.4
Filter by chain
Scope PnL to specific chains with Use it when your app is chain-specific (for example, a Base-only wallet) or to compare PnL across L2s.
filter[chain_ids].GET /v1/wallets/{address}/pnl?currency=usd&filter[chain_ids]=ethereum,baseZerion API ignores
filter[chain_ids] when you use fungible_implementations. The chain is already part of each implementation string.5
Full working example
Save as
pnl-dashboard.mjs and run node pnl-dashboard.mjs. It prints the output shown at the top of this page and retries 503 responses while a cold wallet bootstraps.pnl-dashboard.mjs
Edge cases and operational notes
503 + Retry-After (cold wallets)
503 + Retry-After (cold wallets)
For wallets that haven’t been queried before, or wallets with long transaction histories, the first request may return a
503 with a Retry-After header while PnL is being bootstrapped. This is non-billable. Retry after the indicated delay (usually a few seconds). Once bootstrapped, subsequent requests are fast: under 200 ms for basic PnL queries without a breakdown. Building a breakdown adds 150–300 ms depending on the number of tokens. Snapshots are retained for ~1 month; after that, a re-bootstrap may be triggered.A 503 always carries a Retry-After header. If you get an error without one, retrying is not the answer. See the next two entries.422 (wallets too large to calculate)
422 (wallets too large to calculate)
Wallets with more than 1 million actions are too large for PnL to be calculated, and return Don’t fold this into your
422 with no Retry-After header:503 retry loop. The helper above only recurses on 503, so a 422 surfaces to the caller instead. Don’t cache it against the address either: the same wallet can return 503 again later if its cached state is rebuilt.400 (addresses Zerion doesn't track)
400 (addresses Zerion doesn't track)
Zerion only calculates PnL for addresses that behave like user wallets. Zerion rejects contract addresses that aren’t recognized smart-contract wallets, burn addresses, and high-volume addresses such as exchange hot wallets before doing any work:Smart-contract wallets are fine: Safe, Coinbase Smart Wallet and ERC-4337 accounts all work. See Error handling for the full rule.
Excluded tokens
Excluded tokens
Tokens without reliable price data are omitted from breakdown calculations rather than erroring. They won’t appear in the
breakdown object.Airdrop cost basis
Airdrop cost basis
Tokens received as “pure mints” (from the zero address or a token contract with no payment beyond gas) are assigned zero cost basis. However, many airdrops are actually distributed from a non-zero address, which causes them to be misclassified as purchases at the market price at the time of receipt. Keep this in mind when reviewing cost basis data for airdropped tokens.
Base asset format
Base asset format
Native chain tokens use
chain: with an empty address after the colon: ethereum: for ETH, base: for Base ETH, polygon: for MATIC, solana: for SOL.Supported currencies
Supported currencies
16 options via the
currency param: usd, eur, gbp, btc, eth, krw, jpy, aud, cad, inr, nzd, try, zar, cny, chf, rub.Batch limits
Batch limits
Max 100 tokens per request for either
fungible_ids or fungible_implementations. For larger portfolios, paginate with multiple calls.Next steps
- Combine with wallet positions to fetch current holdings, then batch those token IDs into PnL for a complete dashboard.
- Add balance charts to show portfolio value over time alongside PnL.
- Set up webhooks to trigger PnL recalculations when new transactions land.