Authentication & Request Signing
Senticore is non-custodial: an order only moves your balance if it carries a signature that recovers to a key your account authorized. Authentication therefore has two independent layers, and it matters that you keep them apart:
- Action signature — proves intent. The cryptographic signature over the canonical action, bound to the deployment (chain + contract). This is what the matching engine verifies before it touches the book.
- Transport credential — proves who is connecting. The API key / session that is allowed to submit on the account (HMAC headers, a FIX/FIXP logon, or a BSL session).
A single market-maker order involves both: a per-action signature (or, on the session lanes, a session that stands in for it) and a transport credential.
Everything on this page is a wire specification. The TypeScript SDK is a convenience, not a dependency — the golden vectors below let you verify a signer you write in Rust, Go, Python, C++, or anything else, byte-for-byte against the engine.
Which scheme do I use?
| Scheme | Proves | Used by |
|---|---|---|
| blake3 canonical action hash (v2) + secp256k1 | Action intent | The primary path — all REST/SDK trading actions, BSL per-order signing |
EIP-712 typed Order | Action intent (alt.) | Only the MM cancel-replace place-leg (/api/v1/mm/orders/replace) |
EIP-191 personal_sign (text) | Wallet/owner intent | Agent authorization, admin actions, cancel-by-id leg |
HMAC-SHA256 (v2) / Ed25519 (v3) SC-* headers | Transport identity | Machine REST clients, MM order entry |
| FIX/FIXP logon | Transport identity | FIX 4.4 (553/554), FIXP (Negotiate credentials) |
blake3-keyed WS token (spws1.…) | Transport identity | Private WebSocket / drop-copy |
The rest of this page specifies each one precisely enough to reimplement.
1. The primary path — blake3 canonical action hash (v2)
This is the signature inside every SignedAction you submit to POST /api/v1/trading/actions
(retail) or the MM/BSL order-entry lanes. Reproduce it exactly and you can sign in any language.
1.1 The canonical payload
You sign an ActionPayload. Its canonical encoding is compact JSON in struct-declaration order
(not alphabetical), snake_case keys:
{"account":"0x…","nonce":4810,"nonce_reservation_id":null,"ts":1765500000000,"action":{ … }}
Rules that an independent implementation must match:
- Compact JSON — no insignificant whitespace.
- Field order is fixed:
account,nonce,nonce_reservation_id, (client_order_idif present),ts,action. nonce_reservation_idis always present, serialized asnull(reservations are deprecated — see §4).client_order_idis omitted entirely when absent, and binds the hash when present.accountand order/cancel ids are0x-prefixed lowercase hex (account = 20 bytes, order id = 32 bytes).- Enums are exact strings:
Side→"Bid"/"Ask";Book→"YES"/"NO";time_in_force→"gtc"/"ioc"/"fok"/"post_only";stp_mode→"cancel_maker"/"cancel_taker"/"reject"/"skip_self". - The
actionis an externally-tagged enum: a spot limit order is{"SpotPlaceOrder":{ "market":7,"side":"Bid","price":998400,"qty":1000, … }}. Leg/order field order ismarket, (bookfor outcome markets),side,price,qty, then the flagsstp_mode,time_in_force,is_market,reduce_only,expires_at.
price and qty are integer micro-units (1e6 scale): price = 998400 means 0.998400.
1.2 The v2 hash (chain-bound)
The signing digest binds the action to this deployment, so a signature can never be replayed onto another chain or contract:
domain = "SENTICORE/ACTION_PAYLOAD/v2" (ASCII, no null terminator)
preimage =
chain_id as 8 bytes, big-endian
|| verifying_contract as 20 raw bytes (the EVM address, NOT keccak-wrapped)
|| canonical_json(ActionPayload) (the bytes from §1.1)
hash = blake3( domain || preimage ) (32-byte output)
blake3(domain || preimage) means: feed the domain ASCII into the hasher first, then the preimage
bytes, then read 32 bytes.
For production (Arbitrum One): chain_id = 42161. The verifying_contract is the live
StateCommitment address — read it from GET /api/v1/bsl/connectivity or the
Smart Contracts → Addresses page rather than hard-coding it, so you never
drift from a redeployment.
An older v1 domain (SENTICORE/ACTION_PAYLOAD/v1) signed the same JSON but without the
8+20-byte chain prefix. Guarded production deployments are configured for v2.
A v1 migration flag can exist during cutover windows, but clients must not
depend on it. Sign v2 by reading actionSigning.chainId and
actionSigning.verifyingContract from GET /api/v1/bsl/connectivity.
With the TypeScript SDK, pass the bundle-derived chain binding into every local signing call:
import { actionChainBindingFromConnectivityBundle, signAction } from "@sentico-labs/sdk";
const bundle = await fetch(`${API_BASE_URL}/api/v1/bsl/connectivity`).then((r) => r.json());
const chainBinding = actionChainBindingFromConnectivityBundle(bundle);
const signed = signAction(payload, privateKey, { chainBinding });
1.3 The signature format
- secp256k1, 65 bytes =
r (32) ‖ s (32) ‖ v (1). - The recovery byte
vis accepted as{0, 1, 27, 28}. - Two preimage modes are both accepted, so use whichever your stack makes easy:
- raw — sign the 32-byte blake3 hash directly (best for server-side signers).
- personal — sign
keccak256("\x19Ethereum Signed Message:\n32" || hash32), i.e. wrap the hash with EIP-191personal_sign(best for browser wallets /eth_sign).
- On the wire the signature is:
"signature": { "scheme": "EcdsaSecp256k1", "bytes": [0, 1, 2] }
SDK helpers may accept a hex signature as input, but the JSON wire object sends
bytes as a byte array.
The engine recovers the address and accepts the action iff it is the account itself, the account's vault owner, or a delegated signer the account authorized.
1.4 A complete request
POST /api/v1/trading/actions
{
"payload": {
"account": "0x1111111111111111111111111111111111111111",
"nonce": 4810,
"nonce_reservation_id": null,
"ts": 1765500000000,
"action": {
"SpotPlaceOrder": {
"market": 7, "side": "Bid", "price": 998400, "qty": 1000,
"stp_mode": null, "time_in_force": "post_only",
"is_market": false, "reduce_only": false, "expires_at": null
}
}
},
"signature": { "scheme": "EcdsaSecp256k1", "bytes": [119, 138, 155, 180] }
}
The success response is a SubmitResponse (HTTP 200) — see
Error Model for the exact shape and the reject codes.
Golden vectors
Verify your signer against these before you send anything live. They are produced by the engine's
own test suite. The hashes below pin chain_id = 8453 and verifying_contract = 0xCC…CC as test
inputs — substitute the production chain_id (42161) and the live contract to get production
hashes; the canonical JSON and the procedure are identical.
Vector 1 — SpotPlaceOrder (post-only bid)
Canonical JSON:
{"account":"0x1111111111111111111111111111111111111111","nonce":4810,"nonce_reservation_id":null,"ts":1765500000000,"action":{"SpotPlaceOrder":{"market":7,"side":"Bid","price":998400,"qty":1000,"stp_mode":null,"time_in_force":"post_only","is_market":false,"reduce_only":false,"expires_at":null}}}
| Output | Value |
|---|---|
| v1 hash | c8d02209196c492de5b39c90d7efd356548784ddd464603913b59afab911b42f |
v2 hash (chainId 8453, contract 0xCC…CC) | 26d8533a5150e7a8bf6537824715562eddb55a0c019a0b23635533a1d4e7e084 |
| derived order id | 0x52401b1d6de155089120a39ccd8ca52e3b5daaf090f090c5a0705b53b914d57e |
signature (key 0x…01, raw mode, v1 hash) | 0x778a9bb4e285beb1386319b23433ffd0941a05050017beb5d25c28d47c5d8ad33845ab9bdc8d8fe4b9a9b3c058d27bc38973f7d0870435898707d00061babecf01 |
Vector 2 — Cancel
{"account":"0x1111111111111111111111111111111111111111","nonce":4811,"nonce_reservation_id":null,"ts":1765500000001,"action":{"Cancel":{"order_id":"0x2222222222222222222222222222222222222222222222222222222222222222"}}}
| Output | Value |
|---|---|
| v1 hash | aecabe7c50eaa0a1a6f59b75687b64dce6f96fcaef509319051baff0e78eb38a |
v2 hash (8453, 0xCC…CC) | 2f49fecdfa23487b9f3c8d0566c1a4f9ab21d0955c5f061f63780cf25e88a543 |
If your implementation reproduces these byte-for-byte, it is correct.
2. EIP-712 typed Order (cancel-replace place-leg only)
The MM cancel-replace endpoints (POST /api/v1/mm/orders/replace, /cancel-replace) accept a
standard EIP-712 typed-data signature for the place leg, so you can sign with off-the-shelf
wallet libraries.
domain = {
name: "SentipredictSpotCLOB",
version: "1",
chainId: <42161 in production>,
verifyingContract: <settlement contract>
}
Order(uint256 marketId, address trader, uint8 side,
uint256 price, uint256 qty, uint64 expiry, uint64 nonce)
sideis the packed code:0 = (YES,Bid),1 = (YES,Ask),2 = (NO,Bid),3 = (NO,Ask); spot uses Bid/Ask via the same 0/1 convention.traderis the account address.- The cancel leg of the same request is signed with EIP-191 over the text
"Senticore CancelById\norderId:…\ntrader:…", not EIP-712.
expiry unitsIn the EIP-712 struct, expiry is hashed as the raw number you pass, but the order request treats
it as seconds and normalizes to milliseconds downstream. Pass seconds, and sign the same seconds
value.
This path is intentionally narrow. For everything else, use the blake3 v2 path in §1. See also EIP-712 Signing.
3. Transport credentials
3.1 Machine REST — SC-* HMAC / Ed25519
Programmatic REST clients (including MM order entry) authenticate the connection with replay- protected headers. This is separate from the action signature in the body.
SC-Auth-Version: 2
SC-Key: <apiKeyId> # the spk_… value
SC-Nonce: <strictly increasing u64>
SC-Timestamp: <unix ms, ±30s skew>
SC-Passphrase: <spp_… passphrase> # omit for Ed25519 (v3)
SC-Signature: 0x<hmac-sha256 hex>
The HMAC is computed over this exact string (literal \n separators):
SENTICORE-HMAC-V2
sc-key:<apiKeyId>
sc-nonce:<nonce>
sc-timestamp:<ms>
method:<UPPERCASE METHOD>
path:<request path, NO query string>
query:<canonical query: split on &, drop empties, sort, rejoin; "" if none>
body-sha256:<lowercase hex sha256 of the raw request body>
SC-Signature = "0x" || hex(HMAC_SHA256(key = your decrypted apiSecret, msg = the string above)),
compared in constant time. Clock skew tolerance is 30 s; each
(apiKeyId, SC-Nonce) pair is single-use for machine transport auth
(replay → 409). This is separate from the signed action payload.nonce, which
is scoped to the engine account and described in the next section.
Ed25519 (SC-Auth-Version: 3) is identical except the first line is SENTICORE-ED25519-V1, the
signature is a 64-byte Ed25519 signature over the same string, and there is no passphrase. The
server stores only your public key — Ed25519 is the recommended machine scheme.
3.2 FIX 4.4 logon
Username(553) = <apiKeyId>
Password(554) = <apiSecret>:<apiPassphrase> # single colon split on the first ':'
Account(1) = <engine account hex>
TargetSubID(57) = order_entry | drop_copy
Ed25519 credentials are not accepted for FIX logon. See FIX → Session.
3.3 FIXP (binary-FIX) negotiate
Authenticate once in the FIXP Negotiate message with the credentials field:
apiKeyId:apiSecret:apiPassphrase:accountHex
The session is then the authenticated identity (no per-order signing). See Binary-FIX → Conformance.
3.4 Private WebSocket token
POST /api/v1/ws/token (authenticated by your SC-* headers or a delegated wallet signature)
returns a bearer token spws1.<payload>.<sig>:
{ "ok": true, "account": "0x…", "scope": "private_ws:read",
"tokenType": "Bearer", "token": "spws1.…", "ttlMs": 180000 }
TTL defaults to 180 s, clamped to [60 s, 300 s]. Present it in the first WS frame as an auth
message — not a query parameter or header:
{ "method": "auth", "id": 1, "token": "spws1.…" }
See WebSocket → Private channels.
4. The nonce model
Senticore uses a 256-wide sliding window per account — designed for pipelining, so you never have to serialize order submission waiting for acks.
State per account: a floor (the lowest still-open nonce) and a set of consumed nonces inside the
window. The accept rule for an incoming nonce n:
| Condition | Result |
|---|---|
n < floor | reject nonce_below_floor — permanent, never retry |
n ≥ floor + 256 | reject nonce_outside_window — too far ahead |
n already consumed | reject nonce_replayed |
| otherwise | accept; floor then advances over the consumed contiguous prefix |
What this means in practice:
- Pipelining is first-class. Submit nonces out of order anywhere in
[floor, floor+256)without waiting for acks and without reserving anything. - Up to 256 nonces in flight. If you leave a gap at the bottom,
floorwon't advance, so anything≥ floor+256is rejected until you fill the gap. - Nonces are per-account, monotonic in the simple case. Every reject carries
nonceFloor,nonceWindow, andnextUsableNonceso you can resynchronize.
nonce_reservation_id must stay present-as-null in the signed JSON (for hash compatibility),
but its value must be null. The legacy reservation endpoint still exists for backward compatibility;
new integrations should ignore it.
5. Delegated agents & API-key onboarding
You don't sign every order with the cold wallet. Instead the wallet authorizes a delegated agent key once, and that key signs (or transports) thereafter — with scopes, expiry, and revocation.
Create: POST /api/agents/create. The owner wallet EIP-191-signs a typed authorization message
(app SentiPredict, the agent public key, scopes, policy, a single-use nonce, issued/expiry). The
response returns, once:
| Credential | Format | Used as |
|---|---|---|
| API key id | spk_<16 hex> | SC-Key / FIX 553 / FIXP apiKeyId |
| API secret | sps_<64 hex> | HMAC signing key / FIX 554 secret |
| API passphrase | spp_<32 hex> | SC-Passphrase / FIX 554 passphrase |
Ed25519 agents get an spk_ id but no secret/passphrase — the private key never leaves you.
Scopes:
| Scope | Allows |
|---|---|
trade | Place orders, RFQ |
cancel | Cancels, conditional deletes |
quote | Batch place/cancel/amend + MM order entry |
mint_redeem | Split/merge inventory actions |
read | Private reads + private WS token |
drop_copy | Drop-copy stream |
Lifetime & rotation: API agents up to 180 d, institutional agents 90 d, browser sessions
30 d, and mobile sessions 24 h. The web trading desk requests slightly below the 30 d browser
cap so normal wallet-signing latency cannot push the authorization over the server limit.
Rotate via POST /api/agents/{id}/rotate (old credential stays valid 24 h for overlap).
Revoke an agent (/api/agents/revoke), a single credential, or — in an emergency — the owner wallet
can revoke everything (POST /api/v1/trading/accounts/owner-revoke).
Full flow and request bodies: API Agents.
When creating an agent, omit authorization.accountIdHex unless you already have the internal
32-byte trading-account id. The owner wallet (20 bytes) goes in userWalletAddress; putting it
into accountIdHex is rejected.
Permissioning summary
| Surface | Action signature | Transport credential |
|---|---|---|
| HTTP public market data | — | none |
HTTP trading (/trading/actions) | blake3 v2 (or delegated) | none / signed body |
| HTTP MM/BSL order entry | blake3 v2 per order | SC-* HMAC on an institutional_agent |
| FIX 4.4 | — (session stands in) | FIX logon 553/554/1 |
| BSL Direct TCP | blake3 v2 per order (AuthSidecar) | BSL session |
| Binary-FIX / FIXP | — (session stands in) | FIXP Negotiate credentials |
| WebSocket private | — | spws1.… token in auth frame |