Integrating the Tirio swap API with viem: price, quote, check, send
A working TypeScript integration of Tirio's public swap API: /price while the user types, /quote before signing, the checks to run, then send.
Tirio's API is the same one the tirio.io app runs on. It is public, needs no key and returns two things an integrator needs: fast indicative prices while a user types, and complete, simulated swap transactions ready for their wallet to sign. This guide builds a working integration in TypeScript with viem, step by step. Every request and field below was checked against the live API and its OpenAPI description.
The basics
- Base URL:
https://api.tirio.io, with the chain in the path:/bsc/...for BNB Chain. - Every endpoint is a GET. Errors come back as
{ "error": "<code>", "message": "<text>" }. - Amounts are raw integer strings in the token's smallest unit. 1 BNB is
1000000000000000000. - The native coin is the zero address.
- Rate limit: 10 requests a second per IP with a burst of 30. Over that you get 429 with a
Retry-Afterheader. - Fees: quotes from the API carry no Tirio fee. You can add your own partner fee, covered in our partner fee guide. On sales of an exact amount whose output token has no transfer tax, the Router keeps any output above the quote up to 1 % of it, the same rule as in the app.
The setup, with a wallet client for signing, a public client for reading and a small helper for the API:
import { createPublicClient, createWalletClient, custom, http, type Address, type Hex } from "viem";
import { bsc } from "viem/chains";
const wallet = createWalletClient({ chain: bsc, transport: custom(window.ethereum!) });
const publicClient = createPublicClient({ chain: bsc, transport: http() });
const [account] = await wallet.getAddresses();
const API = "https://api.tirio.io";
const ROUTER: Address = "0x000000000927913A80FCBC84E6e1F8f8a9614f2E";
const NATIVE: Address = "0x0000000000000000000000000000000000000000";
const USDT: Address = "0x55d398326f99059fF775485246999027B3197955";
async function tirio<T>(path: string, params: Record<string, string>): Promise<T> {
const response = await fetch(`${API}/bsc/${path}?${new URLSearchParams(params)}`);
const body = await response.json();
if (!response.ok) throw new Error(`${body.error}: ${body.message}`);
return body as T;
}Step 1: /price while the user types
/price routes an amount exactly as a quote would, but skips the simulation and returns no transaction, which makes it fast and cheap. It is the number to show as the user edits the amount. The Tirio app waits 400 ms after the last keystroke before asking:
type Price = {
amountOut: string;
priceImpactBps: number | null;
gasEstimate: number;
gasPrice: string;
block: number;
route: { paths: { sharePpm: number; amountIn: string; amountOut: string }[] };
};
let timer: ReturnType<typeof setTimeout> | undefined;
function onAmountChange(amountIn: string) {
clearTimeout(timer);
timer = setTimeout(async () => {
const price = await tirio<Price>("price", { tokenIn: NATIVE, tokenOut: USDT, amountIn });
render(price);
}, 400);
}render stands for your own UI update. priceImpactBps is null when it could not be measured, and block tells you which state the pools were read at. Never send a transaction based on /price. It has none.
Step 2: /quote when the user reviews
When the user is ready, ask for a quote. It routes the amount, builds the Router transaction and simulates it at the latest block before returning it.
type Quote = {
amountOut: string;
minOut: string;
simulated: boolean;
approval?: { spender: Address; permit2: boolean; required: string; actual: string };
permit?: { kind: "eip2612" | "permit2"; signatureOffset: number; spender: Address };
tx: { from?: Address; to: Address; data: Hex; value: string; gas: number };
};
const quote = await tirio<Quote>("quote", {
tokenIn: NATIVE,
tokenOut: USDT,
amountIn,
slippageBps: "50",
recipient: account,
sender: account,
});The fields that matter most:
| Field | Meaning |
|---|---|
amountOut | Expected output, the simulated one when simulated is true |
minOut | The minimum the Router enforces, from your slippageBps |
simulated | true when the full transaction ran successfully at the latest block |
tx | to, data, value and gas for the wallet |
approval, permit | Present for token inputs that need an approval or can be signed instead |
Passing sender is optional but worth it. With it, the quote fills tx.from, reports issues such as an insufficient balance, and for a token input tells you whether the wallet must approve first (approval) or can sign a permit instead (permit). Our permit guide shows how to handle both. Native BNB needs neither.
Step 3: check the transaction before the wallet sees it
Treat the API like any remote service. Pin the Router address in your code, and decode the calldata to confirm it says what you asked for. The Tirio app does this on every quote.
import { decodeFunctionData, parseAbi } from "viem";
const routerAbi = parseAbi([
"struct SwapParams { address tokenIn; address tokenOut; uint256 amountIn; uint256 minOut; uint256 expectedOut; address recipient; address inputTo; uint256 deadline; address partner; uint16 partnerFeeBps; uint8 flags; }",
"function swap(SwapParams p, bytes route, bytes permit) payable returns (uint256 amountOut, uint256 amountInUsed)",
]);
if (quote.tx.to.toLowerCase() !== ROUTER.toLowerCase()) throw new Error("unexpected target");
const { args } = decodeFunctionData({ abi: routerAbi, data: quote.tx.data });
const [params] = args;
if (params.recipient.toLowerCase() !== account.toLowerCase()) throw new Error("unexpected recipient");
if (params.amountIn !== BigInt(amountIn)) throw new Error("unexpected amount");
if (params.minOut !== BigInt(quote.minOut)) throw new Error("unexpected minimum");/health also reports the Router address, which is useful to notice a mismatch, but the address in your code should be the source of truth.
Step 4: simulate, send and wait
Run the exact transaction once more from the user's account with eth_call, then send it.
const transaction = { account, to: quote.tx.to, data: quote.tx.data, value: BigInt(quote.tx.value) };
await publicClient.call(transaction);
const hash = await wallet.sendTransaction({ ...transaction, gas: BigInt(quote.tx.gas) });
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("the swap reverted");If the final check fails on chain, for example because the price moved beyond the slippage you allowed, the transaction reverts as a whole: no tokens move and only gas is spent. A quote is valid until its deadline, 20 minutes by default, but prices move every block, so request a fresh quote right before sending rather than reusing an old one.
We ran these steps against production on 2026-10-01, short of signing: a 0.01 BNB to USDT quote decoded to exactly the requested recipient, amount and minimum, and its eth_call from a funded address returned the expected output.
Step 5: handle errors properly
error | Status | What to do |
|---|---|---|
invalidRequest | 400 | Fix the parameter named in message |
unsupportedToken | 400 | The token cannot be traded through Tirio, see message |
noRoute | 404 | No route for this pair and amount. Try a smaller amount |
rateLimited | 429 | Wait for Retry-After seconds, then retry |
unavailable | 503 | Quotes are paused for a moment. Retry shortly |
internal | 500 | Retry |
A production checklist
Before you ship, make sure your integration does the following:
- Pins the Router address in code and rejects any quote whose
tx.todiffers. - Shows the minimum received next to the expected output, so users see what the contract enforces.
- Quotes again right before sending. Prices move every block, and the app refreshes its quote every 15 seconds while the user reviews.
- Handles both approval paths for token inputs: an
approvalto send first, or apermitto sign and splice in. - Simulates from the user's account before opening the wallet, and turns a revert into a readable message.
- Checks the receipt status instead of assuming success.
- Respects the rate limit by debouncing
/priceand honouringRetry-After.
Useful extras
maxHopsandmaxPathslimit route length and splitting.includeDexesandexcludeDexestake adapter ids from/bsc/protocolsto route only through, or around, specific venues.amountOutinstead ofamountInasks for an exact output. The response then carriesmaxIn, and for a native inputtx.valueis that maximum, with the unused part refunded in the same transaction. See exact-in vs exact-out.sourcetags your integration in the API's logs. It never reaches the chain.
The full parameter and field reference is in the API documentation.