Authentication¶
Network & Compatibility
| Resource | Value |
|---|---|
| API base URL | https://api.testnet.sera.cx/api/v1 |
| Chain | Sepolia (chainId 11155111) |
| Sera contract | 0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e |
| Vault contract | 0x3c7945840bAE0d7e7f3824Ebccef1962629250F0 |
| SOR contract | 0x83c1368110B640A729f3810De5FBe94b99aa5668 |
Signing primitives. Every trading mutation is an EIP-712 typed-data signature against the Sera domain. Deposits that take the permit path use the ERC-2612 Permit extension — supported by USDC, EURC, EURT, and most modern stablecoins, not all ERC-20s; call GET /permit/metadata to check support before signing. API-key management uses an EIP-712 ManageApiKey payload.
Tested clients. Python eth_account >= 0.10 + requests; TypeScript ethers v6 (signer.signTypedData). Browser wallets confirmed working with EIP-712 typed data: MetaMask, Rabby, Frame, Coinbase Wallet, Trust, Rainbow. Safe multisigs work via EIP-1271 (the message is verified on-chain rather than via ecrecover).
Address casing. Read endpoints (/balances, /orders, /fills) treat owner_address as case-sensitive — pass the lowercase form. EIP-712 signed payloads accept EIP-55 checksum addresses.
Sera uses two authentication modes:
- API keys for account reads and transaction-building helpers.
- EIP-712 signatures for trading, cancellation, withdrawal, and API-key management.
In practice, the public utility routes are:
GET /health,GET /system/time,GET /tokens,GET /markets, andGET /configPOST /swap/quoteandPOST /verify-signature
The API-key protected read and helper routes are:
GET /orders,GET /orders/{order_id},GET /fills,GET /fills/{order_id}, andGET /balancesGET /permit/metadata- transaction builders such as
POST /approve,POST /deposit,POST /tx/send,POST /transfer, andPOST /transfer/send
Setup¶
Every code snippet on this page assumes the following imports and constants. Pick a tab and reuse it across the rest of the page.
import json, time
import requests
from eth_account import Account
from eth_account.messages import encode_typed_data
API = "https://api.testnet.sera.cx/api/v1"
PRIVATE_KEY = "0x...your wallet key..."
WALLET = Account.from_key(PRIVATE_KEY).address
DOMAIN = {
"name": "Sera",
"version": "1",
"chainId": 11155111,
"verifyingContract": "0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e",
}
import { Wallet, TypedDataDomain, ZeroAddress } from "ethers";
const API = "https://api.testnet.sera.cx/api/v1";
const PRIVATE_KEY = "0x...your wallet key...";
const signer = new Wallet(PRIVATE_KEY);
const WALLET = signer.address;
const DOMAIN: TypedDataDomain = {
name: "Sera",
version: "1",
chainId: 11155111,
verifyingContract: "0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e",
};
API Keys¶
API keys are read-only credentials sent as:
Endpoint summary:
| Method | Path | Signed With |
|---|---|---|
POST | /api-keys | EIP-712 body payload (action=create) |
GET or POST | /api-keys or /api-keys/list | EIP-712 query parameters or body payload (action=list) |
DELETE or POST | /api-keys or /api-keys/revoke | EIP-712 query parameters or body payload (action=revoke_<api_key>) |
POST | /api-keys/revoke-all | EIP-712 body payload (action=revoke_all) |
POST | /api-keys/self-revoke | Bearer credentials of the key being revoked |
POST | /api-keys/verify | None (the key being verified is the body) |
Create An API Key¶
API keys are created by signing an EIP-712 ManageApiKey message.
MANAGE_API_KEY_TYPES = {
"ManageApiKey": [
{"name": "owner", "type": "address"},
{"name": "action", "type": "string"},
{"name": "timestamp", "type": "uint256"},
]
}
timestamp = int(time.time())
message = {"owner": WALLET, "action": "create", "timestamp": timestamp}
signable = encode_typed_data(DOMAIN, MANAGE_API_KEY_TYPES, message)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
r = requests.post(
f"{API}/api-keys",
headers={"Content-Type": "application/json"},
data=json.dumps({
"owner_address": WALLET,
"action": "create",
"timestamp": timestamp,
"signature": "0x" + signature.lstrip("0x"),
"label": "Trading bot",
}),
timeout=10,
)
api_key, api_secret = (lambda b: (b["api_key"], b["api_secret"]))(r.json())
const MANAGE_API_KEY_TYPES = {
ManageApiKey: [
{ name: "owner", type: "address" },
{ name: "action", type: "string" },
{ name: "timestamp", type: "uint256" },
],
};
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(DOMAIN, MANAGE_API_KEY_TYPES, {
owner: WALLET, action: "create", timestamp,
});
const response = await fetch(`${API}/api-keys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
owner_address: WALLET,
action: "create",
timestamp,
signature,
label: "Trading bot",
}),
});
const { api_key, api_secret } = await response.json();
Notes:
- The signed timestamp must be within 5 minutes of server time.
- A wallet can hold up to 10 active API keys.
api_secretis returned only once. Store it securely.
List API Keys¶
timestamp = int(time.time())
message = {"owner": WALLET, "action": "list", "timestamp": timestamp}
signable = encode_typed_data(DOMAIN, MANAGE_API_KEY_TYPES, message)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
keys = requests.get(
f"{API}/api-keys",
params={
"owner_address": WALLET,
"action": "list",
"timestamp": timestamp,
"signature": "0x" + signature.lstrip("0x"),
},
timeout=10,
).json()
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(DOMAIN, MANAGE_API_KEY_TYPES, {
owner: WALLET, action: "list", timestamp,
});
const url = new URL(`${API}/api-keys`);
url.searchParams.set("owner_address", WALLET);
url.searchParams.set("action", "list");
url.searchParams.set("timestamp", String(timestamp));
url.searchParams.set("signature", signature);
const keys = await fetch(url).then(r => r.json());
If your client prefers a JSON body over signed query parameters, POST /api-keys/list accepts the same owner_address, action, timestamp, and signature fields in the request body.
Revoke An API Key¶
api_key_to_revoke = "sera_..."
action = f"revoke_{api_key_to_revoke}"
timestamp = int(time.time())
signable = encode_typed_data(
DOMAIN, MANAGE_API_KEY_TYPES,
{"owner": WALLET, "action": action, "timestamp": timestamp},
)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
requests.delete(
f"{API}/api-keys",
params={
"owner_address": WALLET,
"api_key": api_key_to_revoke,
"action": action,
"timestamp": timestamp,
"signature": "0x" + signature.lstrip("0x"),
},
timeout=10,
)
const apiKeyToRevoke = "sera_...";
const action = `revoke_${apiKeyToRevoke}`;
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(DOMAIN, MANAGE_API_KEY_TYPES, {
owner: WALLET, action, timestamp,
});
const url = new URL(`${API}/api-keys`);
url.searchParams.set("owner_address", WALLET);
url.searchParams.set("api_key", apiKeyToRevoke);
url.searchParams.set("action", action);
url.searchParams.set("timestamp", String(timestamp));
url.searchParams.set("signature", signature);
await fetch(url, { method: "DELETE" });
If your client prefers a JSON body over signed query parameters, POST /api-keys/revoke accepts the same fields plus api_key in the request body.
Revoke All API Keys¶
Revoke every active API key for a wallet in a single signature. The action is the literal string revoke_all. Useful after a suspected leak, device loss, or as part of a wallet-rotation playbook — one wallet popup instead of N.
timestamp = int(time.time())
signable = encode_typed_data(
DOMAIN, MANAGE_API_KEY_TYPES,
{"owner": WALLET, "action": "revoke_all", "timestamp": timestamp},
)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
r = requests.post(
f"{API}/api-keys/revoke-all",
headers={"Content-Type": "application/json"},
data=json.dumps({
"owner_address": WALLET,
"action": "revoke_all",
"timestamp": timestamp,
"signature": "0x" + signature.lstrip("0x"),
}),
timeout=10,
)
# 200: {"status":"ok","revoked_api_keys":["sera_...","sera_..."],"count":2}
# 200: {"status":"ok","revoked_api_keys":[],"count":0} # no active keys
# 409: signature already used (replay) — re-sign with a fresh timestamp
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(DOMAIN, MANAGE_API_KEY_TYPES, {
owner: WALLET, action: "revoke_all", timestamp,
});
const res = await fetch(`${API}/api-keys/revoke-all`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
owner_address: WALLET,
action: "revoke_all",
timestamp,
signature,
}),
});
// 200: { "status": "ok", "revoked_api_keys": ["sera_...", "sera_..."], "count": 2 }
// 200: { "status": "ok", "revoked_api_keys": [], "count": 0 } // no active keys
// 409: signature already used (replay) — re-sign with a fresh timestamp
Self-Revoke (Bearer-Authenticated)¶
Revoke the API key whose credentials you are currently using, without a wallet signature. Authenticate with Authorization: Bearer {api_key}:{api_secret}; the body's api_key must equal the bearer's api_key (you can only revoke the key you are signed in as — to revoke a sibling key, use DELETE /api-keys with a wallet signature).
Verify An API Key¶
Confirm that an api_key/api_secret pair is valid without consuming rate-limit budget or side-effecting any state. Returns the owner address on success.
r = requests.post(
f"{API}/api-keys/verify",
headers={"Content-Type": "application/json"},
data=json.dumps({"api_key": api_key, "api_secret": api_secret}),
timeout=10,
)
# 200: {"valid": True, "owner_address": "0x..."}
# 401: {"detail": "Invalid api_key or api_secret"}
# 503: {"detail": "Service temporarily unavailable; please retry"}
const res = await fetch(`${API}/api-keys/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key, api_secret }),
});
// 200: { "valid": true, "owner_address": "0x..." }
// 401: { "detail": "Invalid api_key or api_secret" }
// 503: { "detail": "Service temporarily unavailable; please retry" }
EIP-712 Domain¶
The public API verifies signatures against the Sepolia Sera contract domain (already shown in Setup):
Use GET /config to fetch the live chain_id, sera_address, vault_address, and sor_address for the current deployment instead of hardcoding them.
Expiration Rules¶
Signed order payloads require expiration, and swap quote requests use the same bounded future timestamp for the signed Intent they return.
expirationmust be strictly in the future.expirationmust be no more than 365 days minus the 300-second clock-skew guard from current server time.- Missing, zero, past, or far-future values are rejected before the request reaches matching or settlement.
Use GET /system/time to derive these timestamps and leave a small buffer instead of signing right at the edge.
UUID Binding For Order Requests¶
Limit orders now carry two linked identifiers:
order_id: a UUID4 string used as the human-readable order ID.uuid_int: the decimal uint256 embedded in the signed on-chainOrder.uuidfield.
The API rejects requests where uuid_int does not match the composite encoding of order_id shown below. Fetch the live executor_id from GET /health, then generate uuid_int using the composite layout below:
Standalone Limit Orders¶
For a normal limit order:
leg_id = 0group_id = first 112 bits of order_id
function uuidStringToBigInt(uuid: string): bigint {
return BigInt(`0x${uuid.replace(/-/g, "")}`);
}
function encodeStandaloneUuid(orderId: string, executorId: number): string {
const raw = uuidStringToBigInt(orderId);
const group = raw >> 16n;
return ((BigInt(executorId) << 252n) | (raw << 124n) | (group << 12n)).toString();
}
Example valid pair:
{
"order_id": "00000000-0000-4000-8000-000000000001",
"uuid_int": "6427948336465191935941739505432058208337171677044006212075520"
}
Virtual Liquidity Batches¶
For VL batches:
- all siblings share the same
group_id group_idis derived from order 0leg_idincrements0, 1, 2, ...
function encodeVlUuid(
orderId: string, executorId: number,
legId: number, groupOrderId: string,
): string {
const raw = uuidStringToBigInt(orderId);
const group = uuidStringToBigInt(groupOrderId) >> 16n;
return ((BigInt(executorId) << 252n) | (raw << 124n) | (group << 12n) | BigInt(legId)).toString();
}
Order Signature¶
The signed on-chain Order struct is:
ORDER_TYPES = {
"Order": [
{"name": "user", "type": "address"},
{"name": "expiration", "type": "uint48"},
{"name": "feeBps", "type": "uint48"},
{"name": "recipient", "type": "address"},
{"name": "fromToken", "type": "address"},
{"name": "toToken", "type": "address"},
{"name": "fromAmount", "type": "uint256"},
{"name": "toAmount", "type": "uint256"},
{"name": "initialDepositAmount", "type": "uint256"},
{"name": "uuid", "type": "uint256"},
]
}
const ORDER_TYPES = {
Order: [
{ name: "user", type: "address" },
{ name: "expiration", type: "uint48" },
{ name: "feeBps", type: "uint48" },
{ name: "recipient", type: "address" },
{ name: "fromToken", type: "address" },
{ name: "toToken", type: "address" },
{ name: "fromAmount", type: "uint256" },
{ name: "toAmount", type: "uint256" },
{ name: "initialDepositAmount", type: "uint256" },
{ name: "uuid", type: "uint256" },
],
};
In POST /orders requests, the API uses pair identity fields rather than direct spend/receive fields:
from_addressis the market base token address.to_addressis the market quote token address.bidspendsto_addressto buyfrom_address.askspendsfrom_addressto sell intoto_address.
Fetch these token addresses from GET /tokens and display-oriented pair labels from GET /markets. For production order placement, call POST /orders/preview before wallet signature and sign the returned eip712_order. Preview applies the public market precision grid and returns the exact raw fromAmount / toAmount values that the final POST /orders submission must match.
Example:
order_data = {
"user": WALLET,
"expiration": int(time.time()) + 86_400,
"feeBps": 0,
"recipient": "0x0000000000000000000000000000000000000000",
"fromToken": "0x...",
"toToken": "0x...",
"fromAmount": 1_085_000_000,
"toAmount": 1_000_000_000,
"initialDepositAmount": 0,
"uuid": int(uuid_int),
}
signable = encode_typed_data(DOMAIN, ORDER_TYPES, order_data)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
const orderData = {
user: WALLET,
expiration: Math.floor(Date.now() / 1000) + 86_400,
feeBps: 0,
recipient: ZeroAddress,
fromToken: "0x...",
toToken: "0x...",
fromAmount: 1_085_000_000n,
toAmount: 1_000_000_000n,
initialDepositAmount: 0n,
uuid: BigInt(uuidInt),
};
const signature = await signer.signTypedData(DOMAIN, ORDER_TYPES, orderData);
Intent Signature For Swaps¶
POST /swap/quote returns route_params. Sign them exactly as returned.
INTENT_TYPES = {
"Intent": [
{"name": "taker", "type": "address"},
{"name": "inputToken", "type": "address"},
{"name": "outputToken", "type": "address"},
{"name": "maxInputAmount", "type": "uint256"},
{"name": "minOutputAmount", "type": "uint256"},
{"name": "recipient", "type": "address"},
{"name": "initialDepositAmount", "type": "uint256"},
{"name": "uuid", "type": "uint256"},
{"name": "deadline", "type": "uint48"},
]
}
signable = encode_typed_data(DOMAIN, INTENT_TYPES, quote["route_params"])
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
const INTENT_TYPES = {
Intent: [
{ name: "taker", type: "address" },
{ name: "inputToken", type: "address" },
{ name: "outputToken", type: "address" },
{ name: "maxInputAmount", type: "uint256" },
{ name: "minOutputAmount", type: "uint256" },
{ name: "recipient", type: "address" },
{ name: "initialDepositAmount", type: "uint256" },
{ name: "uuid", type: "uint256" },
{ name: "deadline", type: "uint48" },
],
};
const signature = await signer.signTypedData(DOMAIN, INTENT_TYPES, quote.route_params);
Cancel Signatures¶
CancelOrder¶
CancelOrder.orderId is the composite uuid_int, not the UUID string.
CANCEL_ORDER_TYPES = {
"CancelOrder": [
{"name": "owner", "type": "address"},
{"name": "orderId", "type": "uint256"},
]
}
signable = encode_typed_data(
DOMAIN, CANCEL_ORDER_TYPES,
{"owner": WALLET, "orderId": int(uuid_int)},
)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
CancelVLBatch¶
Withdraw Signature¶
Verify A Signature Before Submission¶
r = requests.post(
f"{API}/verify-signature",
headers={"Content-Type": "application/json"},
data=json.dumps({
"owner_address": WALLET,
"side": "bid",
"amount": "1000",
"price": "1.085",
"from_address": EURC_ADDRESS,
"to_address": USDC_ADDRESS,
"order_id": "00000000-0000-4000-8000-000000000001",
"uuid_int": "6427948336465191935941739505432058208337171677044006212075520",
"signature": signature,
"expiration": int(time.time()) + 86_400,
}),
timeout=10,
)
const response = await fetch(`${API}/verify-signature`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
owner_address: WALLET,
side: "bid",
amount: "1000",
price: "1.085",
from_address: EURC_ADDRESS,
to_address: USDC_ADDRESS,
order_id: "00000000-0000-4000-8000-000000000001",
uuid_int: "6427948336465191935941739505432058208337171677044006212075520",
signature,
expiration: Math.floor(Date.now() / 1000) + 86_400,
}),
});
Clock Synchronization¶
Use GET /system/time for expiration and deadline calculations, GET /health for the live executor_id used in uuid_int generation, and GET /config for the live EIP-712 contract addresses.