POST /v1/swap/build
Build an unsigned swap transaction. You sign it client-side, then submit via POST /v1/swap.
By default Venum adds no platform swap fee — the route output is yours in full. If you want to charge your own integrator fee, append your own transfer to the unsigned transaction client-side — Venum doesn't manage integrator fees.
/v1/swap/build returns JSON by default. If you send Accept: application/octet-stream, it returns raw unsigned transaction bytes instead.
Internally runs the same routing engine as POST /v1/quote: direct pools, 2-hop and 3-hop routes through every tracked token, freshness gating (60 s cutoff), freshness-weighted scoring, and the same exact-CL re-quote validation against on-chain tick state. The selected route is built as either a single swap instruction (direct) or N chained swap instructions sharing intermediate ATAs (multi-hop).
Build pass behavior
Two policies apply only to /v1/swap/build (and not /v1/quote), because they're driven by what the user can actually sign and land:
- Short-route bias. Within each confidence tier, 1-hop and 2-hop routes are tried before 3-hop. Stock wallets submit transactions without lookup tables, so the 1232-byte size limit is hard, and 3-hop concentrated-liquidity routes routinely overflow it. The bias makes the build pass try shorter routes first so it finds something that fits.
- No PDA init on the user's behalf. The build pass refuses any route that would require initializing a tick-array (Orca/Raydium CLMM) or bin-array (Meteora DLMM) PDA. Each one is ~0.07 SOL of rent and would silently quote a tiny user out of their own funds. When a candidate hits this, the build pass skips it and falls through to the next route — which almost always exists through pools with already-initialized arrays at zero marginal cost. If no route satisfies this, the endpoint returns
404.
If a candidate's serialized tx exceeds the 1232-byte limit, the build pass also falls through to the next candidate instead of returning 500. The combination of these fallbacks makes /v1/swap/build resilient to the long tail of "this route exists but the dApp can't actually sign it" cases.
Request
POST /v1/swap/build
Content-Type: application/json
x-api-key: YOUR_API_KEYBody
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
inputMint | string | Yes | — | Base58 mint or token symbol (e.g. "SOL", "USDC") |
outputMint | string | Yes | — | Base58 mint or token symbol |
amount | string | Yes | — | Amount in smallest units |
slippageBps | number | No | 100 | Slippage tolerance (1-5000 bps) |
userPublicKey | string | Yes | — | Base58 public key of the signer |
createAta | string | boolean | No | "check" | Output-ATA creation policy: "check" (default — server RPC-checks, includes the idempotent create only if missing), "always"/true (always include, no check), false (never — you've confirmed it exists). Recommended: check the output ATA yourself and pass "always"/true or false for the leanest tx + no server RPC. The input ATA is never created (you must already hold the input). |
createAtaIfMissing | boolean | No | — | Deprecated alias for createAta: true → "always", false → false. |
excludePools | string[] | No | [] | Pool addresses to skip when ranking (e.g. retry after a failure) |
simulate | boolean | No | false | Run simulateTransaction to compute exact CU usage and bubble pre-signature errors. Adds ~100-200 ms latency. See Simulation below. |
Simulation
When simulate: true, the build pass:
- Builds the tx with
setComputeUnitLimit(1_400_000)(the network max) - Calls
connection.simulateTransaction(tx, { sigVerify: false, replaceRecentBlockhash: true }) - On success, replaces the CU limit with
consumedUnits × 1.1and returns the smaller, cheaper-to-execute tx - On failure (
simulate.value.err), that candidate route is dropped and the build pass falls through to the next viable route. If every candidate fails, the endpoint returns400with simulation details/logs.
The simulated field in the response indicates whether the returned computeUnits came from a real sim (true) or a per-DEX heuristic (false).
Example
{
"inputMint": "SOL",
"outputMint": "USDC",
"amount": "1000000000",
"slippageBps": 100,
"userPublicKey": "Fgz6qLgeibJPNkNdALqvrYQ9FauLanTKs15miEsAiHqh"
}Full mint addresses also accepted:
{
"inputMint": "So11111111111111111111111111111111111111112",
"outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"amount": "1000000000",
"slippageBps": 100,
"userPublicKey": "Fgz6qLgeibJPNkNdALqvrYQ9FauLanTKs15miEsAiHqh"
}Response
200 OK
{
"transaction": "AQAAAA...base64...==",
"quoteId": "q_3f4a8b2c0d6e1f7a8b2c0d6e1f7a8b2c",
"route": {
"dex": "orca-whirlpool",
"poolAddress": "HJPjoWUr...",
"outputAmount": "134520000"
},
"estimatedOutput": "134520000",
"minOutput": "133174800",
"computeUnits": 184302,
"simulated": true,
"attestation": {
"version": "v1",
"domain": "venum-swap-attestation",
"signer": "Atte5t1ng1111111111111111111111111111111111",
"algorithm": "ed25519",
"signature": "base64-ed25519-signature==",
"signedAt": 1718900000000,
"payload": {
"quoteId": "q_3f4a8b2c0d6e1f7a8b2c0d6e1f7a8b2c",
"transactionSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"userPublicKey": "Fgz6qLgeibJPNkNdALqvrYQ9FauLanTKs15miEsAiHqh",
"inputMint": "So11111111111111111111111111111111111111112",
"outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"inAmount": "1000000000",
"minOutput": "133174800",
"feeAccount": null,
"platformFeeBps": 0,
"venumFeeBps": 0,
"expiresAt": 1718900030000
}
}
}200 OK (binary mode)
POST /v1/swap/build
Accept: application/octet-stream
x-api-key: YOUR_API_KEYResponse:
- body: raw unsigned
VersionedTransactionbytes - headers:
x-quote-idx-route-json(base64url-encoded JSON metadata envelope)x-estimated-outputx-simulated-outputx-min-outputx-compute-unitsx-simulatedx-venum-attestation(base64-encoded JSON attestation; present when attestation is enabled — see Response attestation)
x-route-json decodes to:
{
"route": {
"dex": "orca-whirlpool",
"poolAddress": "HJPjoWUr...",
"hops": []
},
"estimatedOutput": "134520000",
"minOutput": "133174800",
"computeUnits": 184302,
"simulated": true
}Response Fields
| Field | Type | Description |
|---|---|---|
transaction | string | Base64-encoded unsigned VersionedTransaction |
quoteId | string | Quote identifier — required for submission (30s TTL) |
route | object | Selected route details |
estimatedOutput | string | Expected output in smallest units |
minOutput | string | Minimum output the user keeps after slippage |
computeUnits | number | Allocated compute units (real if simulated, hardcoded per-DEX otherwise) |
simulated | boolean | Whether computeUnits was computed via simulateTransaction or estimated |
attestation | object | null | Detached Ed25519 signature over the transaction bytes + summary, for tamper detection. null when attestation isn't enabled. See Response attestation. |
Integrator fees
Venum doesn't manage integrator fees. If you want to take your own cut, append your own output-token transfer to the unsigned transaction before signing — you control the rate and the destination, with no referral program or per-mint claim account to manage.
Response attestation
Each response carries a detached Ed25519 attestation signed by Venum over the exact transaction bytes plus the critical summary (mints, amounts, minOutput, the signer/destination wallet, quoteId, and an expiry). Because you sign the raw transaction Venum returns, verifying this attestation against Venum's pinned public key lets you detect a response tampered in transit — a swapped destination, widened slippage, a redirected output — before the wallet signs.
Venum signs; you verify. Verification is optional but strongly recommended for production, especially if you don't independently re-derive and inspect every instruction.
attestationisnullwhen attestation isn't enabled for the deployment you're calling.
The signed message
The signature covers a newline-joined UTF-8 message, in this exact field order (all values come from attestation.payload):
venum-swap-attestation:v1
<quoteId>
<transactionSha256> # lowercase hex SHA-256 of the transaction bytes
<userPublicKey>
<inputMint>
<outputMint>
<inAmount>
<minOutput>
<feeAccount or ""> # always empty — retained in the v1 format
<platformFeeBps> # always 0 — retained in the v1 format
<venumFeeBps>
<expiresAt> # ms epochEvery field is base58, hex, or a decimal integer, so the join is unambiguous. The leading venum-swap-attestation:v1 tag is a domain separator — the signing key only ever signs messages of this shape, never raw transaction bytes.
Verifying
- Pin Venum's signer key once, from
GET /v1/attestation/pubkey, into your build. Do not fetch-and-trust it at request time — a hop that can tamper the swap response can tamper that endpoint too. - Recompute
sha256(transactionBytes)and assert it equalspayload.transactionSha256. - Assert the summary matches your request:
payload.userPublicKeyis your signer, the mints andinAmountare what you asked for,minOutputis acceptable, andpayload.expiresAt > Date.now(). - Reconstruct the message above and verify the
signatureagainst your pinnedsigner. Reject on any failure, before signing.
import { createHash, createPublicKey, verify as edVerify } from 'node:crypto'
import { PublicKey } from '@solana/web3.js'
const PINNED_SIGNER = 'PASTE_FROM_/v1/attestation/pubkey' // pin this as a constant
function verifyAttestation(att: any, transactionBytes: Uint8Array): boolean {
if (att?.signer !== PINNED_SIGNER) return false
if (att.payload.expiresAt <= Date.now()) return false
if (att.payload.transactionSha256 !== createHash('sha256').update(transactionBytes).digest('hex')) return false
const p = att.payload
const msg = Buffer.from([
`${att.domain}:${att.version}`,
p.quoteId, p.transactionSha256, p.userPublicKey, p.inputMint, p.outputMint,
p.inAmount, p.minOutput, p.feeAccount ?? '', String(p.platformFeeBps),
String(p.venumFeeBps), String(p.expiresAt),
].join('\n'), 'utf8')
const spki = Buffer.concat([
Buffer.from('302a300506032b6570032100', 'hex'),
Buffer.from(new PublicKey(PINNED_SIGNER).toBytes()),
])
const key = createPublicKey({ key: spki, format: 'der', type: 'spki' })
return edVerify(null, msg, key, Buffer.from(att.signature, 'base64'))
}In binary mode, the attestation is the base64-decoded JSON of the x-venum-attestation header; verify it the same way against the raw bytes.
Binary Example
const response = await fetch('https://api.venum.dev/v1/swap/build', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': API_KEY,
'accept': 'application/octet-stream',
},
body: JSON.stringify({
inputMint: 'SOL',
outputMint: 'USDC',
amount: '1000000000',
userPublicKey: wallet.publicKey.toBase58(),
}),
})
const quoteId = response.headers.get('x-quote-id')!
const routeJson = response.headers.get('x-route-json')!
const routeMeta = JSON.parse(Buffer.from(routeJson, 'base64url').toString('utf8'))
const unsignedBytes = new Uint8Array(await response.arrayBuffer())
const tx = VersionedTransaction.deserialize(unsignedBytes)Transaction Contents
The unsigned transaction contains, in order:
SetComputeUnitLimit— sized per DEX type (200K-600K CU)- Output ATA creation (per
createAta— see Body) — idempotentcreateAssociatedTokenAccountfor the output token only."check"(default) includes it only when the output ATA is missing;"always"/truealways includes it;falseomits it. The input ATA is never created — you must already hold the input to swap it, so its account exists (a SOL input is wrapped in step 3). Intermediate ATAs on multi-hop routes are always created, independent ofcreateAta. - Native SOL wrap (when
inputMintis SOL) —createAssociatedTokenAccountIdempotentfor the user's WSOL account, thenSystemProgram.transferofamountlamports into it, thenSyncNativeso the SPL token balance reflects the deposit. No-op if the input is any other mint. - Output WSOL ATA creation (when
outputMintis SOL) — idempotentcreateAssociatedTokenAccountfor the user's WSOL account so the swap has a validoutput_token_accountto write into. Without this, swaps to native SOL fail withAccountNotInitialized(Anchor 3012) on Raydium CLMM and similar DEXes. - Swap instruction(s) — DEX-specific swap (may include tick/bin array init)
- Native SOL unwrap (when
outputMintis SOL) —closeAccounton the WSOL ATA, returning all SOL (the swap output + the temporary rent) to the user's main account.
Recommended: check the output ATA in your own wallet state and pass createAta: false when it exists (or "always"/true when it doesn't) — this skips the server-side existence RPC and keeps the transaction smallest, which matters for the 1232-byte limit on multi-hop routes. The default "check" is the safe choice when you can't check yourself. The native-SOL wrap/unwrap and output-WSOL-ATA steps are unconditional — they're driven by the input/output mint.
Errors
| Status | Description |
|---|---|
400 | Invalid request body |
401 | Missing or invalid API key |
400 | Invalid request body, or simulate: true and every candidate route failed simulation. |
404 | No pools found, no valid quote, or every candidate route was filtered or failed validation/build constraints. Treat as "skip this opportunity", not "retry the API". |
429 | Rate limit exceeded |
503 | Blockhash not available (retry in a moment) |
Rate Limit
| Tier | Limit |
|---|---|
| free | 5 / min |
| starter | 30 / min |
| growth | 75 / min |
| pro | 150 / min |
