Skip to content

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

http
POST /v1/swap/build
Content-Type: application/json
x-api-key: YOUR_API_KEY

Body

FieldTypeRequiredDefaultDescription
inputMintstringYesBase58 mint or token symbol (e.g. "SOL", "USDC")
outputMintstringYesBase58 mint or token symbol
amountstringYesAmount in smallest units
slippageBpsnumberNo100Slippage tolerance (1-5000 bps)
userPublicKeystringYesBase58 public key of the signer
createAtastring | booleanNo"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).
createAtaIfMissingbooleanNoDeprecated alias for createAta: true"always", falsefalse.
excludePoolsstring[]No[]Pool addresses to skip when ranking (e.g. retry after a failure)
simulatebooleanNofalseRun 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:

  1. Builds the tx with setComputeUnitLimit(1_400_000) (the network max)
  2. Calls connection.simulateTransaction(tx, { sigVerify: false, replaceRecentBlockhash: true })
  3. On success, replaces the CU limit with consumedUnits × 1.1 and returns the smaller, cheaper-to-execute tx
  4. 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 returns 400 with 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

json
{
  "inputMint": "SOL",
  "outputMint": "USDC",
  "amount": "1000000000",
  "slippageBps": 100,
  "userPublicKey": "Fgz6qLgeibJPNkNdALqvrYQ9FauLanTKs15miEsAiHqh"
}

Full mint addresses also accepted:

json
{
  "inputMint": "So11111111111111111111111111111111111111112",
  "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
  "amount": "1000000000",
  "slippageBps": 100,
  "userPublicKey": "Fgz6qLgeibJPNkNdALqvrYQ9FauLanTKs15miEsAiHqh"
}

Response

200 OK

json
{
  "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)

http
POST /v1/swap/build
Accept: application/octet-stream
x-api-key: YOUR_API_KEY

Response:

  • body: raw unsigned VersionedTransaction bytes
  • headers:
    • x-quote-id
    • x-route-json (base64url-encoded JSON metadata envelope)
    • x-estimated-output
    • x-simulated-output
    • x-min-output
    • x-compute-units
    • x-simulated
    • x-venum-attestation (base64-encoded JSON attestation; present when attestation is enabled — see Response attestation)

x-route-json decodes to:

json
{
  "route": {
    "dex": "orca-whirlpool",
    "poolAddress": "HJPjoWUr...",
    "hops": []
  },
  "estimatedOutput": "134520000",
  "minOutput": "133174800",
  "computeUnits": 184302,
  "simulated": true
}

Response Fields

FieldTypeDescription
transactionstringBase64-encoded unsigned VersionedTransaction
quoteIdstringQuote identifier — required for submission (30s TTL)
routeobjectSelected route details
estimatedOutputstringExpected output in smallest units
minOutputstringMinimum output the user keeps after slippage
computeUnitsnumberAllocated compute units (real if simulated, hardcoded per-DEX otherwise)
simulatedbooleanWhether computeUnits was computed via simulateTransaction or estimated
attestationobject | nullDetached 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. attestation is null when 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 epoch

Every 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

  1. 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.
  2. Recompute sha256(transactionBytes) and assert it equals payload.transactionSha256.
  3. Assert the summary matches your request: payload.userPublicKey is your signer, the mints and inAmount are what you asked for, minOutput is acceptable, and payload.expiresAt > Date.now().
  4. Reconstruct the message above and verify the signature against your pinned signer. Reject on any failure, before signing.
ts
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

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

  1. SetComputeUnitLimit — sized per DEX type (200K-600K CU)
  2. Output ATA creation (per createAta — see Body) — idempotent createAssociatedTokenAccount for the output token only. "check" (default) includes it only when the output ATA is missing; "always"/true always includes it; false omits 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 of createAta.
  3. Native SOL wrap (when inputMint is SOL) — createAssociatedTokenAccountIdempotent for the user's WSOL account, then SystemProgram.transfer of amount lamports into it, then SyncNative so the SPL token balance reflects the deposit. No-op if the input is any other mint.
  4. Output WSOL ATA creation (when outputMint is SOL) — idempotent createAssociatedTokenAccount for the user's WSOL account so the swap has a valid output_token_account to write into. Without this, swaps to native SOL fail with AccountNotInitialized (Anchor 3012) on Raydium CLMM and similar DEXes.
  5. Swap instruction(s) — DEX-specific swap (may include tick/bin array init)
  6. Native SOL unwrap (when outputMint is SOL) — closeAccount on 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

StatusDescription
400Invalid request body
401Missing or invalid API key
400Invalid request body, or simulate: true and every candidate route failed simulation.
404No 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".
429Rate limit exceeded
503Blockhash not available (retry in a moment)

Rate Limit

TierLimit
free5 / min
starter30 / min
growth75 / min
pro150 / min