How requests and signing work
Gateway uses two keys for two different jobs, and they never swap. Your organization API key proves who is calling. Your signer key approves each transaction that moves funds out of a user's wallet.
Which key does what
| Organization API key | Signer key | |
|---|---|---|
| Proves | that the request comes from your organization | that you approve one wallet transaction |
| Comes from | the developer console | you generate it; the console can make a test one |
| Its public half lives | on your organization | on each user, as signer_public_key |
| Signs | the string {key_id}.{timestamp} | each payload an order, cancel or transfer returns |
| Sent as | signature on POST /v1/auth/api-key/token | signatures[] on an execute call |
A signature made with the wrong key is rejected, so keep the two apart in your code.
Authenticate a request
To sign in, sign the string {key_id}.{timestamp} with your organization API key and exchange the signature for an organization token at POST /v1/auth/api-key/token. The token lasts an hour and has no refresh token, so sign in again before it expires.
- Use the current Unix time in seconds. It must be within 30 seconds of ours.
- Sign with ES256, which is ECDSA over P-256 with SHA-256.
- Send
randsconcatenated, base64url-encoded, assignature.
Send the token as Authorization: Bearer on every call. A call that acts for one of your users adds TM-On-Behalf-Of with their user_id. Calls about your organization as a whole, such as creating users or reading organization transactions, leave the header out.
Getting started, step 2 has the signing code.
- TypeScript
- Python
- curl
const balances = await get("/v1/gateway/balances", forUser);
balances = get("/v1/gateway/balances", for_user)
curl -s https://api.truemarkets.co/v1/gateway/balances \
-H "Authorization: Bearer $ORG_TOKEN" \
-H "TM-On-Behalf-Of: $USER_ID"
Sign a wallet transaction
Anything that moves funds, such as an order, a cancel on-chain or a transfer, comes back with unsigned payloads. Each payload is one wallet transaction, and nothing moves until you sign it and execute. You can sign with any P-256 library:
- Take each
payloadstring exactly as the response returned it. Don't parse or re-serialize it. - Sign it with ECDSA over P-256 and SHA-256, using your signer key. Encode the signature as DER, then as hex.
- Put it in a JSON object with your compressed public key, as 66 hex characters, and the scheme
SIGNATURE_SCHEME_TK_API_P256. - Base64url-encode that JSON. The result is one entry in
signatures[], in the same order aspayloads. - Post
signaturesto the execute call withauth_type: "api_key".
The wallets run on Turnkey, which checks this signature and adds our approval before the transaction reaches the chain. Signer keys covers where to keep the key.
{
"publicKey": "02a1b2c3…7e8f90",
"signature": "3045022100…",
"scheme": "SIGNATURE_SCHEME_TK_API_P256"
}
- TypeScript
- Python
import { createECDH, createPrivateKey, sign } from "node:crypto";
import { readFileSync } from "node:fs";
// A signer key file with hex keys, the format the console's
// Users page downloads.
const { signer_private_key } = JSON.parse(
readFileSync(process.env.SIGNER_KEY_FILE!, "utf8"),
);
const ecdh = createECDH("prime256v1");
ecdh.setPrivateKey(Buffer.from(signer_private_key, "hex"));
const point = ecdh.getPublicKey(); // 0x04 || x || y
const key = createPrivateKey({
format: "jwk",
key: {
kty: "EC",
crv: "P-256",
d: Buffer.from(signer_private_key, "hex").toString(
"base64url",
),
x: point.subarray(1, 33).toString("base64url"),
y: point.subarray(33).toString("base64url"),
},
});
const publicKey = ecdh.getPublicKey("hex", "compressed");
export function stamp(payload: string): string {
const signature = sign(
"sha256",
Buffer.from(payload),
key,
).toString("hex");
const json = JSON.stringify({
publicKey,
signature,
scheme: "SIGNATURE_SCHEME_TK_API_P256",
});
return Buffer.from(json).toString("base64url");
}
import base64
import json
import os
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
# A signer key file with hex keys, the format the console's
# Users page downloads.
with open(os.environ["SIGNER_KEY_FILE"]) as f:
signer = json.load(f)
key = ec.derive_private_key(
int(signer["signer_private_key"], 16), ec.SECP256R1()
)
public_key = (
key.public_key()
.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.CompressedPoint,
)
.hex()
)
def stamp(payload: str) -> str:
signature = key.sign(
payload.encode(), ec.ECDSA(hashes.SHA256())
)
envelope = json.dumps(
{
"publicKey": public_key,
"signature": signature.hex(),
"scheme": "SIGNATURE_SCHEME_TK_API_P256",
}
)
return (
base64.urlsafe_b64encode(envelope.encode())
.decode()
.rstrip("=")
)
Download stamp.ts or stamp.py.
Or use a Turnkey library
| Language | Library | Call |
|---|---|---|
| TypeScript | @turnkey/api-key-stamper | new ApiKeyStamper({ apiPublicKey, apiPrivateKey }).stamp(payload) |
| Go | github.com/tkhq/go-sdk/v2 | NewAPIKeyStamper(privateKey).Stamp(ctx, payload) |
| Python | turnkey-api-key-stamper | ApiKeyStamper(ApiKeyStamperConfig(api_public_key, api_private_key)).stamp(payload) |
| Rust | turnkey_api_key_stamper | see the crate docs |
Each one returns the same base64url stamp. Pass the payload string exactly as you received it.
Getting started, step 5 uses the TypeScript and Python ones end to end.
Next: Signer keys