Skip to content

Swap API

POST /v1/swap/build turns a pool, an input token, and an amount into the exact transactions a wallet needs to swap on Uniswap v4: approvals when needed, then a single UniversalRouter call. HookKit never signs, never holds funds, and never submits transactions. The output goes to sender.

POST /v1/swap/build
Field Required Notes
poolId one of PoolId of an indexed pool
currency0, currency1, fee, tickSpacing, hooks one of The pool key, for any pool
tokenIn no Input token address (or use zeroForOne, default true)
amount yes Exact input amount, decimal string in token units
sender yes The wallet that signs, pays, and receives the output
slippageBps no 1 to 5000. Default 50 (0.5%)
hookData no Bytes passed to the hook. Default 0x

Swaps are exact-input. HookKit quotes the swap with the V4Quoter (running the hook), then sets minAmountOut = expectedAmountOut * (1 - slippageBps / 10000).

Swapping 25 USDG for ETH from a wallet that has never used Permit2:

Terminal window
curl -X POST https://api.hookkit.dev/v1/swap/build \
-H "content-type: application/json" \
-d '{
"poolId": "0x387bf619da4d3fb62bb276482693dba1b9b3520f573cabdfe033384a24125982",
"tokenIn": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"amount": "25",
"sender": "0x1111111111111111111111111111111111111111",
"slippageBps": 50
}'
{
"chainId": 4663,
"poolId": "0x387bf619da4d3fb62bb276482693dba1b9b3520f573cabdfe033384a24125982",
"sender": "0x1111111111111111111111111111111111111111",
"tokenIn": { "address": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", "symbol": "USDG", "name": "Global Dollar", "decimals": 6 },
"tokenOut": { "address": "0x0000000000000000000000000000000000000000", "symbol": "ETH", "name": "Ether", "decimals": 18 },
"amountIn": "25",
"expectedAmountOut": "0.010760328858221029",
"minAmountOut": "0.010706527213929923",
"slippageBps": 50,
"deadline": 1793273323,
"transactions": [
{
"to": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"data": "0x095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3ffff...ffff",
"value": "0",
"description": "Approve Permit2 to spend the input token (one-time)"
},
{
"to": "0x000000000022D473030F116dDEE9F6B43aC78BA3",
"data": "0x87517c450000000000000000000000005fc5360d0400a0fd4f2af552add042d716f1d168...",
"value": "0",
"description": "Allow the Universal Router to pull the input token via Permit2 (30 days)"
},
{
"to": "0x204FAca1764B154221e35c0d20aBb3c525710498",
"data": "0x3593564c...",
"value": "0",
"description": "Swap through the Uniswap v4 Universal Router"
}
],
"note": "Unsigned. Send the transactions in order from `sender`; HookKit never signs or holds funds."
}
  1. ERC-20 approve(Permit2, max): only when the token’s allowance to Permit2 is too low. One-time per token.
  2. Permit2.approve(token, UniversalRouter, amount, expiration): only when the router’s Permit2 allowance is too low or expired. Valid for 30 days.
  3. UniversalRouter.execute with one V4_SWAP command running SWAP_EXACT_IN_SINGLE, SETTLE_ALL, and TAKE_ALL. The output is sent to sender, and the swap reverts if it would receive less than minAmountOut or if deadline (20 minutes from the build) has passed.

HookKit reads the sender’s current allowances when building, so steps 1 and 2 are left out when they are already in place. When tokenIn is native ETH there is nothing to approve: you get only the swap, with value set to the input amount.

{
"amountIn": "0.01",
"expectedAmountOut": "23.204353",
"minAmountOut": "23.088331",
"transactions": [
{
"to": "0x204FAca1764B154221e35c0d20aBb3c525710498",
"data": "0x3593564c...",
"value": "10000000000000000",
"description": "Swap through the Uniswap v4 Universal Router"
}
]
}

Send the transactions in order, waiting for each to be mined, from the same account you passed as sender.

import { createWalletClient, createPublicClient, defineChain, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { HookKit } from "@hookkit/sdk"
const robinhoodChain = defineChain({
id: 4663,
name: "Robinhood Chain",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.mainnet.chain.robinhood.com"] } },
})
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
const wallet = createWalletClient({ account, chain: robinhoodChain, transport: http() })
const client = createPublicClient({ chain: robinhoodChain, transport: http() })
const hookkit = new HookKit()
const swap = await hookkit.swap.build({
poolId: "0x387bf619da4d3fb62bb276482693dba1b9b3520f573cabdfe033384a24125982",
tokenIn: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", // USDG
amount: "25",
sender: account.address,
slippageBps: 50,
})
console.log(`Swapping ${swap.amountIn} ${swap.tokenIn.symbol}, min out ${swap.minAmountOut} ${swap.tokenOut.symbol}`)
for (const tx of swap.transactions) {
const hash = await wallet.sendTransaction({
to: tx.to,
data: tx.data,
value: BigInt(tx.value),
})
const receipt = await client.waitForTransactionReceipt({ hash })
if (receipt.status !== "success") throw new Error(`${tx.description} failed: ${hash}`)
console.log(tx.description, hash)
}

In a browser, use your wallet connector’s WalletClient (wagmi, RainbowKit, Privy, and so on) instead of a private key; the loop is the same.

A hook runs inside your swap. Check the pool’s hook first:

  • GET /v1/pools/:poolId returns hook.capabilities for the pool’s hook.
  • GET /v1/hooks/:address adds bytecode and proxy checks.

See Reading a hook’s risk profile. The minAmountOut bound protects you against a hook that returns less than quoted, but not against approvals you grant to other contracts.

Status Code When
400 bad_request Missing sender, bad amount, tokenIn not in the pool
404 not_found poolId not indexed (pass the key) or pool not initialized
422 no_route The quote reverted, so no swap can be built