Skip to content

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

  1. Token balances for a connected pubkey
  2. A USD total and a 24h change number
  3. Token names, symbols, decimals, and logos
  4. Live prices while the app is open
  5. A price chart per token
  6. A token search that doesn't surface scams above real assets
  7. Swap and send actions
  8. Transaction status feedback

The Venum endpoints involved:

NeedEndpoint
BalancesGET /v1/balances/:wallet
Portfolio valueGET /v1/portfolio/value/:wallet
24h change + per-asset breakdownGET /v1/portfolio/pnl/:wallet (+ batch + SSE variants)
Token metadata + risk signalsGET /v1/tokens/:mint
Token logosGET /v1/tokens/:mint/logo
Token searchGET /v1/tokens/search
Live pricesGET /v1/stream/prices (SSE)
ChartsGET /v1/chart/:mint
SwapPOST /v1/quotePOST /v1/swap/buildPOST /v1/swap
StatusGET /v1/tx/:signature

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:

ts
// /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.
  • amount is a raw base-10 string, uiAmount is human-readable — use amount for math, uiAmount for display.

Try it against a real wallet:

bash
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:

ts
// 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:

ts
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:

bash
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:

ts
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:

ts
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>:

html
<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:

  • mintAuthoritynull means supply is fixed; a live authority can inflate supply.
  • freezeAuthoritynull means 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:

ts
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: true rows (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.
  • poolCount is market breadth (clones typically have 1); updateAuthority is 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):

ts
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:

ts
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 the Retry-After header. 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.

ts
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:

ts
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:

  1. balances + portfolio/value in parallel — render the holdings list and total immediately.
  2. Token metadata/logos per mint — cache aggressively; logos are effectively immutable via the proxy.
  3. portfolio/pnl — hydrate the 24h tile when it lands (it's heavier; the docs list its lower rate limits).
  4. Open the price stream — flip static prices to live.
  5. 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 on symbol (symbols collide; that's the impersonator problem)
  • Badge lowLiquidity tokens 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-After as retry, 404 as terminal, on metadata lookups
  • Reconnect SSE with backoff; resubscribe from current holdings
  • Pin swaps to the displayed quoteId; surface QUOTE_MOVED as a re-confirm, not an error
  • Throttle proactively on X-RateLimit-Remaining instead of reacting to 429

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

Start with the holdings list and the headline number. Everything else layers on.

Next Steps