Skip to content

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, and GET /config
  • POST /swap/quote and POST /verify-signature

The API-key protected read and helper routes are:

  • GET /orders, GET /orders/{order_id}, GET /fills, GET /fills/{order_id}, and GET /balances
  • GET /permit/metadata
  • transaction builders such as POST /approve, POST /deposit, POST /tx/send, POST /transfer, and POST /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:

Authorization: Bearer {api_key}:{api_secret}

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_secret is 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).

requests.post(
    f"{API}/api-keys/self-revoke",
    headers={
        "Content-Type":  "application/json",
        "Authorization": f"Bearer {api_key}:{api_secret}",
    },
    data=json.dumps({"api_key": api_key}),
    timeout=10,
)
await fetch(`${API}/api-keys/self-revoke`, {
  method: "POST",
  headers: {
    "Content-Type":  "application/json",
    "Authorization": `Bearer ${api_key}:${api_secret}`,
  },
  body: JSON.stringify({ api_key }),
});

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):

DOMAIN = {
    "name": "Sera",
    "version": "1",
    "chainId": 11155111,
    "verifyingContract": "0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e",
}
const DOMAIN = {
  name: "Sera",
  version: "1",
  chainId: 11155111,
  verifyingContract: "0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e",
};

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.

  • expiration must be strictly in the future.
  • expiration must 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-chain Order.uuid field.

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:

[255:252] executor_id | [251:124] full UUID4 bits | [123:12] group_id | [11:0] leg_id

Standalone Limit Orders

For a normal limit order:

  • leg_id = 0
  • group_id = first 112 bits of order_id
import uuid as _uuid

def uuid_string_to_int(s: str) -> int:
    return int(_uuid.UUID(s))

def encode_standalone_uuid(order_id: str, executor_id: int) -> str:
    raw   = uuid_string_to_int(order_id)
    group = raw >> 16
    return str((executor_id << 252) | (raw << 124) | (group << 12))
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_id is derived from order 0
  • leg_id increments 0, 1, 2, ...
def encode_vl_uuid(order_id: str, executor_id: int,
                  leg_id: int, group_order_id: str) -> str:
    raw   = uuid_string_to_int(order_id)
    group = uuid_string_to_int(group_order_id) >> 16
    return str((executor_id << 252) | (raw << 124) | (group << 12) | leg_id)
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_address is the market base token address.
  • to_address is the market quote token address.
  • bid spends to_address to buy from_address.
  • ask spends from_address to sell into to_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()
const CANCEL_ORDER_TYPES = {
  CancelOrder: [
    { name: "owner",   type: "address" },
    { name: "orderId", type: "uint256" },
  ],
};
const signature = await signer.signTypedData(DOMAIN, CANCEL_ORDER_TYPES, {
  owner: WALLET, orderId: BigInt(uuidInt),
});

CancelVLBatch

CANCEL_VL_BATCH_TYPES = {
    "CancelVLBatch": [
        {"name": "owner",     "type": "address"},
        {"name": "vlBatchId", "type": "string"},
    ]
}
const CANCEL_VL_BATCH_TYPES = {
  CancelVLBatch: [
    { name: "owner",     type: "address" },
    { name: "vlBatchId", type: "string"  },
  ],
};

Withdraw Signature

WITHDRAW_TYPES = {
    "WithdrawIntent": [
        {"name": "user",      "type": "address"},
        {"name": "tokens",    "type": "address[]"},
        {"name": "amounts",   "type": "uint256[]"},
        {"name": "recipient", "type": "address"},
        {"name": "deadline",  "type": "uint256"},
        {"name": "uuid",      "type": "uint256"},
    ]
}
const WITHDRAW_TYPES = {
  WithdrawIntent: [
    { name: "user",      type: "address"   },
    { name: "tokens",    type: "address[]" },
    { name: "amounts",   type: "uint256[]" },
    { name: "recipient", type: "address"   },
    { name: "deadline",  type: "uint256"   },
    { name: "uuid",      type: "uint256"   },
  ],
};

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.