Skip to Content
IntegrationHTTP API

HTTP API Reference

All routes live under veil-landing/app/api/**/route.ts and run on the Node runtime. The Neon HTTP driver (@neondatabase/serverless) is used for DB access; no WebSocket pool is opened per request.

Conventions

  • Request bodies are JSON. Bad JSON returns 400 {"error":"bad json"}.
  • All authenticated endpoints accept {actor, nonce, signature, ...payload}. See Authorization Model for the handshake.
  • Responses are JSON. Errors are {"error": "<short reason>"}.
  • Numeric on-chain quantities are returned as strings to preserve u64/u128 fidelity.

Auth

POST /api/auth/nonce

Issue a single-use signed-message nonce. TTL: 5 minutes.

Request

{ "pubkey": "<base58 wallet>", "action": "<scoped action, e.g. \"add_admin:<pubkey>:<role>\">" }

Response

{ "nonce": "<32-hex-char nonce>", "message": "Veil admin auth\nAction: <action>\nNonce: <nonce>", "expiresAt": "2026-04-25T08:39:31.950Z" }

The wallet signs the exact message bytes (UTF-8) with ed25519 detached signature. The base58-encoded signature goes into the protected endpoint’s body as signature.

Source: app/api/auth/nonce/route.ts.


Admin

GET /api/admin/me?pubkey=<wallet>

Read-only role lookup for UI gating. Not authoritative — the same role is re-checked server-side on every authenticated endpoint.

Response

{ "role": "super_admin" | "pool_admin" | null }

GET /api/admin/allowlist

Public list of currently active admins (revoked admins excluded).

Response

{ "admins": [ { "pubkey": "...", "role": "super_admin", "label": "bootstrap", "added_by": "system", "created_at": "2026-04-25T08:26:29.249Z", "revoked_at": null } ] }

POST /api/admin/allowlist

Add a wallet to the allowlist. Requires super_admin signed nonce. The signed action is add_admin:<pubkey>:<role>.

Request

{ "actor": "<super_admin pubkey>", "nonce": "<from /api/auth/nonce>", "signature": "<base58 ed25519 sig over the canonical message>", "pubkey": "<wallet to add>", "role": "pool_admin" | "super_admin", "label": "<optional human label>" }

Response on success

{ "ok": true }

Failure cases

StatuserrorCause
400missing fieldsOne of actor/nonce/signature/pubkey absent
400invalid rolerole ∉ {pool_admin, super_admin}
401bad signatureed25519 verify failed
401nonce invalid or expiredNonce already consumed or > 5 min old
401not authorizedActor not in pool_admins (or revoked)
401super_admin requiredActor is pool_admin, not super_admin

GET /api/admin/audit?actor=<pubkey>&action=<action>&limit=50

Read the admin audit log. Public on devnet/localnet; on mainnet the response is gated behind a signed nonce (same actor / nonce / signature headers as the allowlist endpoints) and otherwise returns 403 forbidden.

Filters: actor (admin pubkey), action (e.g. init_pool, update_pool, pause, collect_fees, add_admin, revoke_admin). limit defaults 50, max 200.

Response

{ "entries": [ { "id": 42, "actor": "...", "action": "update_pool", "target": "...pool...", "details": { "field": "value" }, "created_at": "..." } ] }

DELETE /api/admin/allowlist

Soft-revoke a wallet (sets revoked_at = now()). Requires super_admin signed nonce. Signed action: revoke_admin:<pubkey>.

Request

{ "actor": "<super_admin pubkey>", "nonce": "...", "signature": "...", "pubkey": "<wallet to revoke>" }

The endpoint rejects actor == pubkey to prevent self-lockout.


Pools

GET /api/pools

Cached pool index (refreshed via /api/pools/sync).

Response

{ "pools": [ { "pool_address": "...", "token_mint": "...", "symbol": "USDC" | null, "authority": "...", "vault": "...", "pool_bump": 254, "authority_bump": 255, "vault_bump": 0, "paused": false, "total_deposits": "0", "total_borrows": "0", "accumulated_fees": "0", "ltv_wad": "750000000000000000", "liquidation_threshold_wad": "800000000000000000", "liquidation_bonus_wad": "50000000000000000", "protocol_liq_fee_wad": "100000000000000000", "reserve_factor_wad": "100000000000000000", "close_factor_wad": "500000000000000000", "base_rate_wad": "10000000000000000", "optimal_util_wad": "800000000000000000", "slope1_wad": "40000000000000000", "slope2_wad": "750000000000000000", "flash_fee_bps": 9, "oracle_price": null, "oracle_conf": null, "oracle_expo": null, "pyth_price_feed": null, "created_by": "...", "init_signature": "...", "last_synced_at": "...", "created_at": "..." } ] }

POST /api/pools/init

Register an on-chain pool that the actor just initialised. Requires the actor to be on the allowlist with role pool_admin or super_admin. Signed action: init_pool:<token_mint>.

Request

{ "actor": "...", "nonce": "...", "signature": "...", "pool_address": "<derived PDA>", "token_mint": "...", "symbol": "USDC" | null, "authority": "<actor>", "vault": "<derived ATA>", "pool_bump": 254, "authority_bump": 255, "vault_bump": 0, "init_signature": "<solana tx sig>" }

The endpoint is idempotent on pool_address (ON CONFLICT DO NOTHING). A row in audit_log is written regardless of conflict outcome.

POST /api/pools/sync

Refresh a pool’s cached row from chain. Public.

Request

{ "pool_address": "<PDA>", "symbol": "USDC" }

The server calls getAccountInfo, decodes the bytes via the same TS decoder used by the dApp (lib/veil/state.ts::decodeLendingPool), and upserts the result into pools.

Response

{ "ok": true, "pool_address": "..." }

Errors: 400 bad pubkey, 404 pool account not found.


Positions

GET /api/positions/[user]

Cached positions for a wallet, sorted by last_synced_at DESC.

Response

{ "positions": [ { "position_address": "...", "pool_address": "...", "owner": "<user>", "deposit_shares": "0", "borrow_principal": "0", "deposit_idx_snap": "1000000000000000000", "borrow_idx_snap": "1000000000000000000", "health_factor_wad": "...", "last_synced_at": "..." } ] }

Position rows are populated by indexers / liquidator bots, not by the dApp flow today. The schema and endpoints exist so an indexer can write here and the liquidator UI can scan unhealthy positions efficiently.

GET /api/positions/[user]/detail

Enriched per-position view: joins positionspools ⨯ recent tx_log, and computes deposit/debt token values, principal+interest split, supply/borrow APYs, and the account-level health factor across cross-collateralized pools.

Response (per position)

{ "positions": [ { "pool_address": "...", "symbol": "USDC", "deposit_shares": "0", "borrow_principal": "1500000000", "deposit_tokens": "0", "borrow_debt": "1500004153", "original_deposit": "0", "principal": "1500000000", "interest": "4153", "supply_apy": 2.8, "borrow_apy": 5.5, "borrow_txs": [{ "signature": "...", "amount": "...", "created_at": "..." }], "health_factor_wad": "625004153360933801" } ] }

The health_factor_wad is the account-level HF (Σ collateral × liq_threshold ÷ Σ debt) replicated on every row, not per-pool. This is the same value used by /api/positions/unhealthy.

POST /api/positions/sync

Refresh a single (user, pool) position from on-chain into the DB. Reads the on-chain UserPosition and LendingPool accounts via the server-side RPC (serverRpcUrl() from lib/network.ts — never user-supplied), recomputes the HF, and upserts. The dApp calls this after every successful action so the indexed views stay current.

Request

{ "pool_address": "...", "user": "..." }

Response

{ "ok": true, "exists": true, "position_address": "..." }

If the position no longer exists on-chain, the row is removed from the DB and exists returns false. Both accounts must be owned by the Veil program; otherwise the call returns 400 invalid.

GET /api/positions/unhealthy?pool=<pool>&limit=50

Return all positions belonging to wallets whose account-level HF is below 1.0 WAD (i.e. liquidatable). Sorted by HF ascending so the most underwater positions come first. Both deposit and borrow rows for an unhealthy owner are returned so a liquidator UI can show the cross-collateral picture.

limit defaults to 50 and is clamped to 200. Filtering by pool keeps only debt rows in the named pool.

Response

{ "positions": [ { "position_address": "...", "pool_address": "...", "owner": "...", "deposit_shares": "5000000", "borrow_principal": "0", "health_factor_wad": "625004153360933801", "account_health_factor_wad": "625004153360933801" } ] }

The HF math uses pools.oracle_price, oracle_expo, liquidation_threshold_wad, and decimals columns. decimals must be populated correctly per pool — the setup scripts pass it on INSERT; legacy rows that defaulted to 9 will miscompute USD amounts by 10× (BTC) or 1000× (USDC/USDT) and leave underwater positions invisible to this endpoint.


Transactions

GET /api/transactions?wallet=<pubkey>&pool=<pool>&limit=50

Append-only tx log. Filters: wallet, pool, limit (max 200, default 50).

Response

{ "transactions": [ { "id": 42, "signature": "...", "pool_address": "...", "wallet": "...", "action": "deposit" | "withdraw" | "borrow" | "repay" | "liquidate" | "flash" | "init" | "update_pool" | "pause" | "resume" | "collect_fees" | "update_oracle", "amount": "1000000", "status": "pending" | "confirmed" | "failed", "error_msg": null, "created_at": "..." } ] }

POST /api/transactions

Append a confirmed (or failed) transaction. Idempotent on signature (ON CONFLICT DO UPDATE with the new status). Called by the dApp after the wallet returns a tx signature.

Request

{ "signature": "...", "wallet": "...", "action": "deposit", "pool_address": "...", "amount": "1000000", "status": "confirmed", "error_msg": null }

Health

GET /api/health

System liveness probe. Returns DB latency, the current RPC slot, and counts of pools / tx_log rows under the active cluster. Used by uptime checks and as the first-debug step when something looks wrong end-to-end.

Response

{ "status": "ok", "timestamp": "2026-05-02T17:00:00.000Z", "checks": { "database": { "ok": true, "latencyMs": 38 }, "rpc": { "ok": true, "slot": 452800123 }, "counts": { "pools": 5, "tx_log": 142 } } }

If any sub-check fails, status: "degraded" is returned with 200 OK so a caller can distinguish “infra reachable but unhealthy” from “veil API itself down”.


Error code mapping

API errors map to short, redacted strings to avoid leaking implementation detail. The set is closed:

Returned errorHTTPWhere it comes from
bad json400req.json() rejected
missing fields400required fields absent
pubkey and action required400nonce endpoint
invalid pubkey400length/charset check
invalid role400role validation
bad pubkey: <reason>400new PublicKey() threw
cannot revoke yourself400self-lockout guard
pool account not found404getAccountInfo returned null
pool_address required400sync endpoint
signature, wallet, action required400tx log endpoint
bad signature401TweetNaCl verify failed
nonce invalid or expired401auth_nonces row absent or stale
not authorized401actor not in pool_admins
super_admin required401role mismatch

Source: app/api/**/route.ts, lib/auth/admin.ts.

Last updated on