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.
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.
- TypeScript
- Python
npm install tsx @turnkey/api-key-stamper @turnkey/crypto
export TM_KEY_FILE=path/to/your-api-key.json
pip install requests cryptography turnkey-api-key-stamper
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.
- TypeScript
- Python
- curl
const { data: assets } = await get("/v1/gateway/assets");
const sol = assets.find(
(a: any) => a.symbol === "SOL" && a.chain === "solana",
);
assets = get("/v1/gateway/assets")["data"]
sol = next(
a
for a in assets
if a["symbol"] == "SOL" and a["chain"] == "solana"
)
curl -s https://api.truemarkets.co/v1/gateway/assets
{
"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.
- TypeScript
- Python
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;
with open(API_KEY_FILE) as f:
api_key = json.load(f)
jwk = api_key["private_key"]
private_key = ec.EllipticCurvePrivateNumbers(
b64url_int(jwk["d"]),
ec.EllipticCurvePublicNumbers(
b64url_int(jwk["x"]),
b64url_int(jwk["y"]),
ec.SECP256R1(),
),
).private_key()
timestamp = int(time.time())
der = private_key.sign(
f"{api_key['key_id']}.{timestamp}".encode(),
ec.ECDSA(hashes.SHA256()),
)
# The API expects r and s concatenated.
r, s = decode_dss_signature(der)
signature = (
base64.urlsafe_b64encode(
r.to_bytes(32, "big") + s.to_bytes(32, "big")
)
.rstrip(b"=")
.decode()
)
minted = post(
"/v1/auth/api-key/token",
{
"key_id": api_key["key_id"],
"timestamp": timestamp,
"signature": signature,
},
)
token = minted["access_token"]
# The token names your organization: no id to configure.
claims = json.loads(
base64.urlsafe_b64decode(token.split(".")[1] + "==")
)
organization_id = claims["tm"]["organization_id"]
{
"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.
- TypeScript
- Python
// 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;
# 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 not os.path.exists(SIGNER_KEY_FILE):
key = ec.generate_private_key(ec.SECP256R1())
file = {
"signer_public_key": key.public_key()
.public_bytes(Encoding.X962, PublicFormat.CompressedPoint)
.hex(),
"signer_private_key": key.private_numbers()
.private_value.to_bytes(32, "big")
.hex(),
}
descriptor = os.open(
SIGNER_KEY_FILE, os.O_WRONLY | os.O_CREAT, 0o600
)
with os.fdopen(descriptor, "w") as f:
json.dump(file, f)
with open(SIGNER_KEY_FILE) as f:
signer = json.load(f)
stamper = ApiKeyStamper(
ApiKeyStamperConfig(
api_public_key=signer["signer_public_key"],
api_private_key=signer["signer_private_key"],
)
)
def stamp(payload):
return stamper.stamp(payload).stamp_header_value
- TypeScript
- Python
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;
for attempt in range(1, 6):
try:
user = post(
f"/v1/account/organizations/{organization_id}/users",
{
"external_ref_id": EXTERNAL_REF_ID,
"signer_public_key": signer[
"signer_public_key"
],
},
)
if user["wallets"]:
break
except ApiError as err:
# A 503 means the wallets aren't ready yet. The same
# request finishes them.
if err.status != 503 or attempt == 5:
raise
time.sleep(2)
user_id = user["user_id"]
for_user = {"TM-On-Behalf-Of": user_id}
solana_address = next(
w["address"]
for w in user["wallets"]
if w["chain_family"] == "solana"
)
{
"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.
USDC sent on any other network won't reach this wallet.
- TypeScript
- Python
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);
}
def usdc_available():
balances = get("/v1/gateway/balances", for_user)["data"]
usdc = next(
(
b
for b in balances
if b["symbol"] == "USDC"
and b["chain"] == "solana"
),
None,
)
return float(usdc["available"]) if usdc else 0.0
if usdc_available() < float(ORDER_USDC):
print(
f"… send about 3 USDC on Solana to {solana_address}"
)
while usdc_available() < float(ORDER_USDC):
time.sleep(10)
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.
- TypeScript
- Python
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,
);
order = post(
"/v1/gateway/orders",
{
"asset_id": sol["id"],
"qty": ORDER_USDC,
"qty_unit": "quote",
"side": "buy",
"type": "market",
},
for_user,
)
# An empty order_id means no order was created, and
# quote.issues says why.
if not order.get("order_id"):
raise RuntimeError(
f"no order: {order.get('quote', {}).get('issues')}"
)
# Sign each payload and execute straight away, before the
# quote expires.
signatures = [stamp(p["payload"]) for p in order["payloads"]]
executed = post(
f"/v1/gateway/orders/{order['order_id']}/execute",
{
"signatures": signatures,
"auth_type": "api_key",
},
for_user,
)
{ "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.
- TypeScript
- Python
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);
filled = executed
while filled["status"] not in (
"complete",
"canceled",
"failed",
):
time.sleep(2)
filled = get(
f"/v1/gateway/orders/{order['order_id']}", for_user
)
print(
f"✓ order {filled['status']} "
f"{filled.get('executed_qty', '')} SOL for {ORDER_USDC} USDC"
)
# Your organization's transactions cover every user, so this
# call has no header.
feed = get(
f"/v1/gateway/organizations/{organization_id}/transactions"
"?type=order"
)
row = next(
(t for t in feed["data"] if t["id"] == order["order_id"]),
None,
)
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.
- TypeScript
- Python
npx tsx quickstart.ts
python quickstart.py
✓ 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
- How requests and signing work: the two keys, what each signs, and signing in any language.
- Signer keys: decide where the key lives before you create real users.
- Place orders for a user: limit orders, listing and canceling.
Every error response includes a request_id. Include it when you email [email protected], so we can find the request.