Skip to main content

Getting started with Gateway

By the end you've created a user, funded their wallet, bought a small amount of SOL for them, and found the fill in your organization's transactions. It takes about 15 minutes, plus the time your USDC transfer takes.

This guide trades real funds

We don't have a fully working sandbox yet, so every trade here is a real on-chain transaction. Keep the amounts small.

Before you begin​

Sign in to the developer console with your work email, then create your organization and an API key. The console downloads the key file; keep it out of your repo. Use a login that isn't also a personal True Markets trading account, because a trading account can't join an organization.

You also need a signer key. You hold it, and it signs your users' transactions. The script generates one, or the console's Users page can make a test one.

The code needs Node.js 18 or later, or Python 3.10 or later. Each step below is a part of one script, quickstart.ts or quickstart.py, that you run at the end.

Install, then point at your API key file
npm install tsx @turnkey/api-key-stamper @turnkey/crypto
export TM_KEY_FILE=path/to/your-api-key.json

1. Check you can reach the API​

This call needs no key. It returns every asset your users can trade. The script finds SOL on Solana in this list.

Find SOL in the asset catalog, no key needed
const { data: assets } = await get("/v1/gateway/assets");
const sol = assets.find(
(a: any) => a.symbol === "SOL" && a.chain === "solana",
);
Response200
{
"data": [
{
"id": "495e07ac-fa3e-4179-85b7-b1e8cece3dc4",
"symbol": "SOL",
"chain": "solana",
"venue": "defi",
"…": "…"
}
]
}

2. Mint an organization token​

The script signs the string {key_id}.{timestamp} with your API key and exchanges it for an organization token. Your servers send it in Authorization: Bearer on every call. It lasts an hour and there's no refresh token, so mint a new one before it expires.

The token also names your organization, and the script reads organization_id from it. The console's Overview shows the same id.

Sign the challenge and mint the token
const { key_id, private_key } = JSON.parse(
readFileSync(API_KEY_FILE, "utf8"),
);
const timestamp = Math.floor(Date.now() / 1000);
const signature = sign(
"sha256",
Buffer.from(`${key_id}.${timestamp}`),
{
key: createPrivateKey({ key: private_key, format: "jwk" }),
// r and s concatenated, which the API expects
dsaEncoding: "ieee-p1363",
},
).toString("base64url");

const minted = await post("/v1/auth/api-key/token", {
key_id,
timestamp,
signature,
});
token = minted.access_token;

// The token names your organization: no id to configure.
const claims = JSON.parse(
Buffer.from(token.split(".")[1], "base64url").toString(),
);
const organizationId = claims.tm.organization_id;
Response200keep access_token
{
"access_token": "eyJhbGciOi…",
"token_type": "Bearer",
"expires_in": "2026-10-01T18:11:27Z"
}

3. Create a user​

One call creates the user and their Solana and EVM wallets. external_ref_id is your own id for this customer, and signer_public_key is the public half of your signer key. Keep user_id: it goes in TM-On-Behalf-Of on every call for this user.

Sending the same external_ref_id again returns the same user, so the script is safe to rerun. A 503 means the wallets aren't ready yet, so the script sends the same request again.

This user only ever signs with the key in signer-key.json, so keep that file and treat this user as a test user.

Load or create your signer key
// The signer key signs every wallet transaction for the users you
// register it on. Keep this file: a user can only ever sign with
// the key it was created with.
if (!existsSync(SIGNER_KEY_FILE)) {
const { publicKey, privateKey } = generateP256KeyPair();
const file = {
signer_public_key: publicKey,
signer_private_key: privateKey,
};
writeFileSync(SIGNER_KEY_FILE, JSON.stringify(file), {
mode: 0o600,
});
}
const signer = JSON.parse(readFileSync(SIGNER_KEY_FILE, "utf8"));
const stamper = new ApiKeyStamper({
apiPublicKey: signer.signer_public_key,
apiPrivateKey: signer.signer_private_key,
});
const stamp = async (payload: string) =>
(await stamper.stamp(payload)).stampHeaderValue;
Create the user and their wallets
let user;
for (let attempt = 1; ; attempt++) {
try {
user = await post(
`/v1/account/organizations/${organizationId}/users`,
{
external_ref_id: EXTERNAL_REF_ID,
signer_public_key: signer.signer_public_key,
},
);
if (user.wallets.length) break;
} catch (err: any) {
// A 503 means the wallets aren't ready yet. The same
// request finishes them.
if (err.status !== 503 || attempt === 5) throw err;
}
await sleep(2000);
}
const userId = user.user_id;
const forUser = { "TM-On-Behalf-Of": userId };
const solanaAddress = user.wallets.find(
(w: any) => w.chain_family === "solana",
).address;
Response201keep user_id and the Solana address
{
"user_id": "9c1e7a52-4d3b-4f8e-a6b7-1c2d3e4f5a6b",
"external_ref_id": "quickstart_user_1",
"created_at": "2026-10-01T17:02:11Z",
"wallets": [
{ "address": "7Gk2…Qm9p", "chain_family": "solana" },
{ "address": "0x4a8f…c21e", "chain_family": "evm" }
]
}

4. Fund the Solana wallet​

The script prints the user's Solana address and waits. Send about 3 USDC to it on Solana. The order spends 2 of it, and we pay the network fees.

Send it on Solana

USDC sent on any other network won't reach this wallet.

Wait until the wallet holds enough USDC
const usdcAvailable = async () => {
const { data } = await get("/v1/gateway/balances", forUser);
const usdc = data.find(
(b: any) => b.symbol === "USDC" && b.chain === "solana",
);
return Number(usdc?.available ?? 0);
};
if ((await usdcAvailable()) < Number(ORDER_USDC)) {
console.log(
`… send about 3 USDC on Solana to ${solanaAddress}`,
);
while ((await usdcAvailable()) < Number(ORDER_USDC))
await sleep(10_000);
}

5. Place the order and execute​

The script places a market buy for 2 USDC of SOL. The response carries the quote and the payloads, the unsigned wallet transactions to sign. The script signs each one with your signer key and executes right away.

A quote lasts about two minutes, so doing it in one go keeps it from expiring. If execute still returns quote_stale, run the script again.

Execute usually returns complete. A buy that first moves funds between chains returns pending, and the script reads the order until it's final. If order_id comes back empty, no order was created, and quote.issues says why.

Place the order, sign the payloads, execute
const order = await post(
"/v1/gateway/orders",
{
asset_id: sol.id,
qty: ORDER_USDC,
qty_unit: "quote",
side: "buy",
type: "market",
},
forUser,
);
// An empty order_id means no order was created, and
// quote.issues says why.
if (!order.order_id)
throw new Error(
`no order: ${JSON.stringify(order.quote?.issues)}`,
);

// Sign each payload and execute straight away, before the
// quote expires.
const signatures = await Promise.all(
order.payloads.map((p: any) => stamp(p.payload)),
);
const executed = await post(
`/v1/gateway/orders/${order.order_id}/execute`,
{ signatures, auth_type: "api_key" },
forUser,
);
Response200from execute
{ "status": "complete" }

6. See the fill​

The script reads the order back for the user, then finds the same fill in your organization's transactions, which list every user's orders and transfers. That call has no TM-On-Behalf-Of, because it already covers all your users. The row's status reads completed, the word the transactions list uses for anything finished.

Read the order, then find it in your transactions
let filled = executed;
while (
!["complete", "canceled", "failed"].includes(filled.status)
) {
await sleep(2000);
filled = await get(
`/v1/gateway/orders/${order.order_id}`,
forUser,
);
}
console.log(
`✓ order ${filled.status} ` +
`${filled.executed_qty ?? ""} SOL for ${ORDER_USDC} USDC`,
);

// Your organization's transactions cover every user, so this
// call has no header.
const feed = await get(
`/v1/gateway/organizations/${organizationId}/transactions` +
"?type=order",
);
const row = feed.data.find((t: any) => t.id === order.order_id);

Run it​

Run the script. You can run it again: it reuses the same user, and each run buys another 2 USDC of SOL while the wallet has funds.

Run the script
npx tsx quickstart.ts
Example output
✓ token expires 2026-10-01T18:11:27Z
✓ user 9c1e7a52-4d3b-4f8e-a6b7-1c2d3e4f5a6b solana 7Gk2…Qm9p
… send about 3 USDC on Solana to 7Gk2…Qm9p
✓ funded
✓ order complete 0.0102 SOL for 2 USDC
✓ found in your organization's transactions (completed)

Where to go next​

Every error response includes a request_id. Include it when you email [email protected], so we can find the request.