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 authenticate private reads, transaction helpers and self-revocation. They do not replace the wallet signatures required for trading or signed cancellations. Send credentials 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()
signature = "0x" + signature.removeprefix("0x")
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.removeprefix("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()
signature = "0x" + signature.removeprefix("0x")
keys = requests.get(
f"{API}/api-keys",
params={
"owner_address": WALLET,
"action": "list",
"timestamp": timestamp,
"signature": "0x" + signature.removeprefix("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()
signature = "0x" + signature.removeprefix("0x")
requests.delete(
f"{API}/api-keys",
params={
"owner_address": WALLET,
"api_key": api_key_to_revoke,
"action": action,
"timestamp": timestamp,
"signature": "0x" + signature.removeprefix("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()
signature = "0x" + signature.removeprefix("0x")
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.removeprefix("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" }
API-Key Fields and Results¶
POST /api-keys additionally accepts optional label (trimmed, maximum 64 characters). A successful create returns HTTP 200 with api_key, the one-time plaintext api_secret, nullable label and string create_time.
List responses use { "keys": [...] }; each item contains integer id, owner_address, api_key, nullable label and create_time. They do not return api_secret. Single-key and self-revocation return { "status": "ok" }; a missing/already-revoked key can return 404. Revoke-all returns status, revoked_api_keys and count, including a successful zero count.
Create and revoke-all reject reuse of the same signed (owner, action, timestamp) with 409. Changing only the signature encoding does not create a fresh approval. After an ambiguous create response, list keys before creating another; the lost plaintext secret cannot be recovered by listing. Key-cap exhaustion also returns 409 and requires revoking an existing key first.
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.
Example:
health_response = requests.get(f"{API}/health", timeout=10)
health_response.raise_for_status()
order_id = str(_uuid.uuid4())
uuid_int = encode_standalone_uuid(order_id, int(health_response.json()["executor_id"]))
market_response = requests.get(f"{API}/markets", timeout=10)
market_response.raise_for_status()
market = next(m for m in market_response.json()["markets"]
if {m["base_symbol"], m["quote_symbol"]} == {"USDC", "EURC"})
signed_order_request = {
"owner_address": WALLET, "side": "bid", "amount": "1000", "price": "1",
"from_address": market["base_address"], "to_address": market["quote_address"],
"order_id": order_id, "uuid_int": str(uuid_int),
"expiration": int(time.time()) + 3600,
}
preview_response = requests.post(f"{API}/orders/preview",
json={**signed_order_request, "order_type": "limit"}, timeout=10)
preview_response.raise_for_status()
preview = preview_response.json()
signed_order_request.update(amount=preview["normalized_amount"], price=preview["normalized_price"])
order_data = preview["eip712_order"]
signable = encode_typed_data(DOMAIN, preview["eip712_types"], order_data)
raw_signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
order_signature = "0x" + raw_signature.removeprefix("0x")
const healthResponse = await fetch(`${API}/health`);
if (!healthResponse.ok) throw new Error(`health: ${healthResponse.status}`);
const orderId = crypto.randomUUID();
const uuidInt = encodeStandaloneUuid(orderId, (await healthResponse.json()).executor_id);
const marketsResponse = await fetch(`${API}/markets`);
if (!marketsResponse.ok) throw new Error(`markets: ${marketsResponse.status}`);
const market = (await marketsResponse.json()).markets.find((m: any) =>
[m.base_symbol, m.quote_symbol].sort().join("/") === "EURC/USDC");
if (!market) throw new Error("No active USDC/EURC market");
const signedOrderRequest = {
owner_address: WALLET, side: "bid", amount: "1000", price: "1",
from_address: market.base_address, to_address: market.quote_address,
order_id: orderId, uuid_int: uuidInt.toString(),
expiration: Math.floor(Date.now() / 1000) + 3600,
};
const previewResponse = await fetch(`${API}/orders/preview`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...signedOrderRequest, order_type: "limit" }),
});
if (!previewResponse.ok) throw new Error(`preview: ${previewResponse.status}`);
const preview = await previewResponse.json();
signedOrderRequest.amount = preview.normalized_amount;
signedOrderRequest.price = preview.normalized_price;
const orderData = preview.eip712_order;
const orderSignature = await signer.signTypedData(DOMAIN, preview.eip712_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()
signature = "0x" + signature.removeprefix("0x")
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()
signature = "0x" + signature.removeprefix("0x")
CancelVLBatch¶
Withdraw Signature¶
Verify A Signature Before Submission¶
const response = await fetch(`${API}/verify-signature`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...signedOrderRequest, signature: orderSignature }),
});
if (!response.ok) throw new Error(`verify: ${response.status}`);
console.log((await response.json()).valid);
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.
CancelAll¶
POST /orders/cancel-all requires both an API key and this signed message. Use the current Sera EIP-712 domain from GET /config.
import secrets
from eth_account import Account
from eth_account.messages import encode_typed_data
CANCEL_ALL_TYPES = {
"CancelAll": [
{"name": "owner", "type": "address"},
{"name": "nonce", "type": "uint256"},
{"name": "deadline", "type": "uint256"},
]
}
nonce = secrets.randbits(256) or 1
deadline = int(time.time()) + 300
message = {"owner": WALLET, "nonce": nonce, "deadline": deadline}
signable = encode_typed_data(DOMAIN, CANCEL_ALL_TYPES, message)
signed = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signature = "0x" + signed.removeprefix("0x")
const CANCEL_ALL_TYPES = {
CancelAll: [
{ name: "owner", type: "address" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const nonce = BigInt("0x" + crypto.randomUUID().replace(/-/g, ""));
const deadline = Math.floor(Date.now() / 1000) + 300;
const signature = await signer.signTypedData(DOMAIN, CANCEL_ALL_TYPES, {
owner: WALLET,
nonce: BigInt(nonce),
deadline: BigInt(deadline),
});
Use a fresh positive uint256 nonce for each distinct cancellation. Send its decimal string and the integer deadline in the request body, plus the matching lowercase owner in the query string. Reusing a consumed nonce returns 409. Individual Order and VL cancellations retain their existing types.
For ordinary order entry, preview supplies eip712_order and eip712_types; sign those values unchanged. Exact Output uses the existing Intent type with its own returned signing_domain and stored quote_uuid.
Signature Encoding¶
Use the wallet's standard 65-byte ECDSA signature for order placement, VL placement, Swap/Exact Output execution and /verify-signature. Order placement, VL placement, regular Swap execution and /verify-signature accept 65–8192 bytes; Exact Output execution accepts 65–4096 bytes. A 64-byte EIP-2098 signature can be recognized by lower-level code but is rejected by these intent signature fields. Permit fields separately accept the 64/65-byte encodings; Exact Output permit signatures have a 4096-byte upper limit. Contract-wallet signatures must satisfy both the route's length checks and ERC-1271 validation.
When formatting Python signatures, remove only the literal 0x prefix with removeprefix("0x"). lstrip("0x") also removes leading zero digits and can corrupt a valid signature. The examples above preserve those bytes.