A small trading bot has three jobs: observe a market, decide whether a condition is met, and submit a transaction. Keeping those jobs separate makes failures easier to understand. This tutorial uses TypeScript, Jupiter's current Swap v2 API, and optional Helius event notifications.
Start with a read-only monitor. Jupiter's public swap flow uses Solana mainnet liquidity; it is not a devnet swap simulator. Use mocks for Jupiter responses and devnet/localnet for your own program tests. Only test actual swaps with an explicitly funded mainnet development wallet.
Project setup
Use a maintained Node.js LTS release and install:
mkdir solana-bot
cd solana-bot
npm init -y
npm install @solana/web3.js@1 bs58 dotenv
npm install --save-dev typescript @types/node tsx
Create .env and add it to .gitignore:
JUPITER_API_KEY=your_api_key
TARGET_MINT=token_mint_to_observe
TAKER_ADDRESS=your_public_wallet_address
# PRIVATE_KEY is only needed by a separate, explicitly enabled execution step.
Get the API key from Jupiter Portal. A configured RPC from Helius, QuickNode, or Triton is useful for independent transaction inspection. This example lets Jupiter manage swap submission rather than maintaining its own RPC resend loop.
Read prices without inventing a zero
The Jupiter Price API is a market-data endpoint, not an executable quote. A missing price must not become a zero-dollar buy signal.
// src/price.ts
import "dotenv/config";
export async function getTokenPrice(mint: string): Promise<number> {
const key = process.env.JUPITER_API_KEY;
if (!key) throw new Error("Set JUPITER_API_KEY");
const url = new URL("https://api.jup.ag/price/v3");
url.searchParams.set("ids", mint);
const response = await fetch(url, {
headers: { "x-api-key": key },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error("Price HTTP " + response.status);
const body = await response.json() as Record<string, { usdPrice?: unknown }>;
const price = body[mint]?.usdPrice;
if (typeof price !== "number" || !Number.isFinite(price) || price <= 0) {
throw new Error("No usable price for this mint");
}
return price;
}
Keep the source's observation time when your strategy needs age checks. A successful request is not proof of liquidity or a guarantee that a trade can fill at the displayed price.
Request an order with Swap v2
The Swap API now uses /swap/v2. Its assembled-transaction flow is GET /order followed by POST /execute. The older Metis /swap/v1 documentation is marked superseded; new code should not copy the former keyless lite-api setup.
This module requests an order and exposes a separate execution function:
// src/swap.ts
import "dotenv/config";
import { Keypair, VersionedTransaction } from "@solana/web3.js";
const base = "https://api.jup.ag/swap/v2";
export interface Order {
requestId: string;
transaction: string | null;
inputMint: string;
outputMint: string;
inAmount: string;
outAmount: string;
slippageBps?: number;
errorCode?: number;
errorMessage?: string;
}
function headers() {
const key = process.env.JUPITER_API_KEY;
if (!key) throw new Error("Set JUPITER_API_KEY");
return { "x-api-key": key, "Content-Type": "application/json" };
}
export async function getOrder(
inputMint: string, outputMint: string, amount: string, taker: string,
): Promise<Order> {
if (!/^[1-9][0-9]*$/.test(amount)) throw new Error("Use positive base units");
const url = new URL(base + "/order");
url.search = new URLSearchParams({
inputMint, outputMint, amount, taker,
maxSupportedTransactionVersion: "0",
}).toString();
const response = await fetch(url, {
headers: headers(), signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error("Order HTTP " + response.status);
const order = await response.json() as Order;
if (!order.requestId || !order.transaction || order.errorCode != null) {
throw new Error(order.errorMessage || "No executable order");
}
if (order.inputMint !== inputMint || order.outputMint !== outputMint
|| order.inAmount !== amount) {
throw new Error("Order differs from requested swap");
}
return order;
}
export async function executeApprovedOrder(order: Order, signer: Keypair) {
if (!order.transaction) throw new Error("Order has no transaction");
const tx = VersionedTransaction.deserialize(
Buffer.from(order.transaction, "base64"),
);
tx.sign([signer]);
const response = await fetch(base + "/execute", {
method: "POST",
headers: headers(),
signal: AbortSignal.timeout(30_000),
body: JSON.stringify({
requestId: order.requestId,
signedTransaction: Buffer.from(tx.serialize()).toString("base64"),
}),
});
if (!response.ok) {
throw new Error("Execute outcome needs reconciliation: HTTP " + response.status);
}
const result = await response.json() as {
status?: string; signature?: string; code?: number;
};
if (result.status !== "Success" || !result.signature) {
throw new Error("Swap not confirmed successful: " + JSON.stringify(result));
}
return result.signature;
}
The code requests transaction version 0 to match web3.js v1. The order's amount is an integer string in the input token's base units: lamports only when the input is SOL. Read the returned fees, expected output, slippage, and transaction instructions before approving an order. The illustrated field checks are not a full transaction-policy validator.
Jupiter handles landing in the /execute flow. Do not overwrite its blockhash or change the returned message. See the order/execute contract for current optional parameters and fee accounting.