How to Build a Wallet App with Venum API
This guide shows how to build a non-custodial Solana wallet app on top of the Venum API — the read side (balances, portfolio value, token metadata, live prices, charts) and the action side (in-wallet swaps).
A wallet is mostly a data product. The hard part is not signing transactions — the wallet adapter does that — it is turning a pubkey into a trustworthy screen: what do I hold, what is it worth, is it up or down today, and is this token real or an impersonator. Venum gives you each of those as an endpoint.
What Your Wallet Needs
- Token balances for a connected pubkey
- A USD total and a 24h change number
- Token names, symbols, decimals, and logos
- Live prices while the app is open
- A price chart per token
- A token search that doesn't surface scams above real assets
- Swap and send actions
- Transaction status feedback
The Venum endpoints involved:
| Need | Endpoint |
|---|---|
| Balances | GET /v1/balances/:wallet |
| Portfolio value | GET /v1/portfolio/value/:wallet |
| 24h change + per-asset breakdown | GET /v1/portfolio/pnl/:wallet (+ batch + SSE variants) |
| Token metadata + risk signals | GET /v1/tokens/:mint |
| Token logos | GET /v1/tokens/:mint/logo |
| Token search | GET /v1/tokens/search |
| Live prices | GET /v1/stream/prices (SSE) |
| Charts | GET /v1/chart/:mint |
| Swap | POST /v1/quote → POST /v1/swap/build → POST /v1/swap |
| Status | GET /v1/tx/:signature |
Recommended Architecture
Same shape as the swap app guide: a thin backend in front of the Venum API.
Wallet UI (browser / mobile webview)
-> Your backend
-> Venum API- Your API key stays server-side.
- You can cache and reshape responses per screen.
- Two exceptions can go straight from the client: the logo proxy (no auth, drops into
<img>) and charts (anonymous access supported).
The wallet stays non-custodial throughout: your backend only ever sees pubkeys and signed transactions, never keys.
Step 1: The Holdings List
Start with balances. One call returns SPL + Token-2022 holdings with symbols and decimals already resolved:
// /api/wallet/:address/balances
const res = await fetch(
`https://api.venum.dev/v1/balances/${address}`,
{ headers: { 'x-api-key': process.env.VENUM_API_KEY! } }
);
const { balances } = await res.json();
// [{ mint, symbol, decimals, amount, uiAmount }, ...]Notes that matter for a wallet UI:
- SOL is always present, even at zero — render it first.
- Zero-balance token accounts are already filtered out; you don't need to hide empty ATAs yourself.
amountis a raw base-10 string,uiAmountis human-readable — useamountfor math,uiAmountfor display.
Try it against a real wallet:
curl https://api.venum.dev/v1/balances/Fgz6qLgeibJPNkNdALqvrYQ9FauLanTKs15miEsAiHqh \
-H 'x-api-key: YOUR_API_KEY'Step 2: The Headline Number
Every wallet opens on a total. Use the lean value endpoint for that first paint:
// fast: current total only
const { valueNowUsd } = await venum(`/v1/portfolio/value/${address}`);Then hydrate the "up/down today" tile and per-asset changes with the full PnL endpoint:
const pnl = await venum(`/v1/portfolio/pnl/${address}`);
// { valueNowUsd, value24hAgoUsd, changeUsd, changePct, assets: [...] }Two things to get right in the UI:
- This is value change, not trading PnL — a deposit reads as gain, a withdrawal as loss. Label it "24h change", not "profit". See the portfolio reference for the exact semantics.
- Check
stats.truncated: for very active wallets the 24h-ago figure is a partial reconstruction — show the change as approximate rather than exact.
Assets that can't be priced come back with priceNow: null and contribute $0 to totals. Show them at the bottom of the holdings list with a quantity but no fiat value — don't hide them.
Multi-wallet views
If your app supports multiple accounts (most wallets do), don't loop. Batch up to 20 wallets in one call:
curl "https://api.venum.dev/v1/portfolio/pnl?wallets=WALLET_A,WALLET_B" \
-H 'x-api-key: YOUR_API_KEY'Or better, use the streaming batch so account rows render progressively instead of waiting for the slowest wallet:
const es = new EventSource(
`/api/portfolio/stream?wallets=${wallets.join(',')}` // your backend proxies with the key
);
es.addEventListener('pnl', (e) => renderRow(JSON.parse(e.data)));
es.addEventListener('wallet-error', (e) => markRetryable(JSON.parse(e.data).wallet));
es.addEventListener('done', () => es.close());One failing wallet never ends the stream — it arrives as wallet-error and you can retry it individually.
Step 3: Token Metadata, Logos, and Risk Signals
For each mint in the holdings list, resolve display metadata:
const token = await venum(`/v1/tokens/${mint}`);
// { name, symbol, decimals, logoURI, logoProxyURI, description,
// priceUsd, liquidityUsd, updateAuthority, mintAuthority, freezeAuthority, ... }For icons, prefer logoProxyURI — a Venum-hosted, CORS-open, non-redirecting copy of the token image. Raw logoURI values live on arbitrary hosts that redirect, 403 non-browser clients, or disappear; the proxy doesn't, needs no auth, and drops straight into <img>:
<img src="https://api.venum.dev/v1/tokens/So11111111111111111111111111111111111111112/logo" />The same response carries the raw on-chain signals a wallet should surface on the token detail screen:
mintAuthority—nullmeans supply is fixed; a live authority can inflate supply.freezeAuthority—nullmeans renounced; a live one can freeze holder accounts.updateAuthority— the metadata issuer's key, the strongest on-chain issuer anchor. For tokens claiming to be equities or majors, an impersonator can fake a name but not the issuer's signing key.liquidityUsd— USD capital actually held on-chain in the token's deepest USD-paired pool, verified against the vault's real balance. Pool-claimed depth is never trusted, so this can't be inflated by a dust trade.
You don't need to build a scoring model on day one — just badge the red flags (live freeze authority, dust liquidity) and you're ahead of most wallet UIs.
Handle errors per the documented contract: 404 means the mint definitively isn't a token (terminal — cache it), 503 means transient (retry — the token may exist). Never render a token as "not found" off a 503.
Step 4: Token Search That Resists Impersonators
The receive screen and swap token picker both need search. GET /v1/tokens/search does free-text over symbol, name, and description across every token Venum has pool coverage for — and ranks by text relevance, then vault-verified liquidity, so the real token tops the clones sharing its symbol:
const { results } = await venum(`/v1/tokens/search?q=${query}&limit=10`);Wallet-relevant behavior:
- Pasting a full mint address resolves it directly, even for a token not yet indexed — support this in the same input field.
lowLiquidity: truerows (under $1,000 verified) always sort below every liquid row. In a wallet UI, badge them and require an explicit tap-through — this is your scam filter.poolCountis market breadth (clones typically have 1);updateAuthorityis on search rows too, so you can verify issuers before the user even opens the detail page.- Token-2022 embedded metadata is indexed, so tokenized equities are searchable by name.
Do not use priceUsd alone as a legitimacy signal — for a thin listing the pool is the market, so a manipulated pool can display any price. Depth is the signal; price is just a number.
Step 5: Live Prices While the App Is Open
Poll-free price updates via SSE. Subscribe to exactly the mints in the user's holdings (up to 50 per connection):
const mints = balances.map((b) => b.mint).join(',');
const es = new EventSource(
`https://api.venum.dev/v1/stream/prices?tokens=${mints}&apiKey=YOUR_API_KEY`
);
es.addEventListener('price', (e) => {
const frame = JSON.parse(e.data);
updatePrice(frame.mint, frame.priceUsd, frame.change24h);
});Key your client state on frame.mint — it's stable regardless of whether you subscribed by symbol or mint, so no reverse lookup is needed. Any indexed mint works, not just the curated catalog.
If you don't need per-tick updates, frame.confidence === 'confirmed' frames are enough; optimistic frames are lower-latency pre-confirmation updates you can opt into for a livelier tape.
Reconnect with backoff when the stream drops, and resubscribe from current holdings (they may have changed while offline). Open streams count against the SSE connection limit on your plan — one shared connection per client, not one per token.
Step 6: Charts
The token detail screen wants a price chart. GET /v1/chart/:mint returns USD OHLCV candles and supports anonymous access, so it can be called from the client directly:
const chart = await fetch(
`https://api.venum.dev/v1/chart/${mint}?range=1d`
).then((r) => r.json());Drive the UI off the status field, never off candles.length:
ok— render candles.warming— data is being refreshed; render a placeholder and poll again after theRetry-Afterheader. A deliberate placeholder is fine — the anti-pattern is inferring one from absent data.unavailable— no candle data exists for this token's pools; show "no chart" rather than an error.
A tracked token always returns 200 with one of these — 404 is reserved for unknown tokens.
Step 7: Swap Inside the Wallet
An in-wallet swap is the same quote → build → sign → submit lifecycle as a standalone swap app, so follow Build a Swap App for the full flow. Two additions are worth wiring into a wallet specifically:
Pin the displayed quote. Pass the quoteId from /v1/quote (plus the user's slippage as maxDriftBps) into /v1/swap/build. If the market moved beyond tolerance between display and build, you get 409 QUOTE_MOVED with the old and new outputs — show the new price and let the user re-confirm instead of silently filling at a worse rate. That's the difference between a wallet users trust and one they screenshot angrily.
const build = await venum('/v1/swap/build', {
method: 'POST',
body: { inputMint, outputMint, amount, slippageBps, userPublicKey, quoteId, maxDriftBps: slippageBps },
});
if (build.status === 409 && build.code === 'QUOTE_MOVED') {
return showRequote(build.newOutput, build.driftBps);
}Verify what you sign. Build responses are Ed25519-signed (attestation), so your backend — or a security-minded user — can verify the unsigned transaction and its stated minOutput came from Venum unmodified before the wallet prompt appears. The signing pubkey is pinned and published in the docs.
Submission, retries, and idempotency are covered by the reliability guide — the short version: resending the same signed transaction is safe, branch on error code not prose, and rebuild when a quote expires.
Step 8: Activity and Status
After any action, track the signature:
const status = await venum(`/v1/tx/${signature}`);Show the signature, an explorer link, and a readable failure state — the same status state machine as the swap guide.
For a balance-over-time sparkline on the wallet home screen, there's GET /v1/history/balance and its SSE variant. Historical reconstruction is expensive, so these endpoints are consumer-funded — the request includes your own RPC URL for the history scan. Treat it as an optional power feature, not a first-render dependency.
Putting It Together: First Paint Order
The difference between a wallet that feels instant and one that feels broken is load order:
balances+portfolio/valuein parallel — render the holdings list and total immediately.- Token metadata/logos per mint — cache aggressively; logos are effectively immutable via the proxy.
portfolio/pnl— hydrate the 24h tile when it lands (it's heavier; the docs list its lower rate limits).- Open the price stream — flip static prices to live.
- Charts and history — only on the token detail screen, on demand.
Cache metadata by mint in your backend and reuse it across all your users — token names don't change per wallet.
Production Checklist
- API key server-side only; logo proxy and charts are the only client-direct calls
- Key all client state on
mint, never onsymbol(symbols collide; that's the impersonator problem) - Badge
lowLiquiditytokens and live freeze authorities - Label the 24h number as value change, not trading profit; respect
truncated - Use the streaming batch for multi-account views
- Handle
503 + Retry-Afteras retry,404as terminal, on metadata lookups - Reconnect SSE with backoff; resubscribe from current holdings
- Pin swaps to the displayed
quoteId; surfaceQUOTE_MOVEDas a re-confirm, not an error - Throttle proactively on
X-RateLimit-Remaininginstead of reacting to429
Minimal App Structure
src/
api/
balances.ts
portfolio.ts
token-meta.ts
search.ts
swap.ts // quote + build + submit proxies
components/
HoldingsList.tsx
PortfolioHeader.tsx
TokenDetail.tsx
TokenSearch.tsx
SwapSheet.tsx
lib/
prices-stream.ts
wallet.ts
format.tsStart with the holdings list and the headline number. Everything else layers on.
Next Steps
- Portfolio API reference for batch, streaming, and the exact PnL semantics
- Token Search and Token Metadata for the full field lists
- Build a Swap App for the complete swap lifecycle
- Reliability & Retries for idempotency and rate-limit headers
- SSE Overview for stream limits and reconnect behavior
