Skip to main content

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:

  1. 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.
  2. 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.

You are never locked into an SDK

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?

SchemeProvesUsed by
blake3 canonical action hash (v2) + secp256k1Action intentThe primary path — all REST/SDK trading actions, BSL per-order signing
EIP-712 typed OrderAction intent (alt.)Only the MM cancel-replace place-leg (/api/v1/mm/orders/replace)
EIP-191 personal_sign (text)Wallet/owner intentAgent authorization, admin actions, cancel-by-id leg
HMAC-SHA256 (v2) / Ed25519 (v3) SC-* headersTransport identityMachine REST clients, MM order entry
FIX/FIXP logonTransport identityFIX 4.4 (553/554), FIXP (Negotiate credentials)
blake3-keyed WS token (spws1.…)Transport identityPrivate 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_id if present), ts, action.
  • nonce_reservation_id is always present, serialized as null (reservations are deprecated — see §4). client_order_id is omitted entirely when absent, and binds the hash when present.
  • account and order/cancel ids are 0x-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 action is an externally-tagged enum: a spot limit order is {"SpotPlaceOrder":{ "market":7,"side":"Bid","price":998400,"qty":1000, … }}. Leg/order field order is market, (book for outcome markets), side, price, qty, then the flags stp_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.

Use v2 for production signing

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 v is 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-191 personal_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}}}
OutputValue
v1 hashc8d02209196c492de5b39c90d7efd356548784ddd464603913b59afab911b42f
v2 hash (chainId 8453, contract 0xCC…CC)26d8533a5150e7a8bf6537824715562eddb55a0c019a0b23635533a1d4e7e084
derived order id0x52401b1d6de155089120a39ccd8ca52e3b5daaf090f090c5a0705b53b914d57e
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"}}}
OutputValue
v1 hashaecabe7c50eaa0a1a6f59b75687b64dce6f96fcaef509319051baff0e78eb38a
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)
  • side is 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.
  • trader is 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 units

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

ConditionResult
n < floorreject nonce_below_floor — permanent, never retry
n ≥ floor + 256reject nonce_outside_window — too far ahead
n already consumedreject nonce_replayed
otherwiseaccept; 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, floor won't advance, so anything ≥ floor+256 is rejected until you fill the gap.
  • Nonces are per-account, monotonic in the simple case. Every reject carries nonceFloor, nonceWindow, and nextUsableNonce so you can resynchronize.
Reservations are deprecated

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:

CredentialFormatUsed as
API key idspk_<16 hex>SC-Key / FIX 553 / FIXP apiKeyId
API secretsps_<64 hex>HMAC signing key / FIX 554 secret
API passphrasespp_<32 hex>SC-Passphrase / FIX 554 passphrase

Ed25519 agents get an spk_ id but no secret/passphrase — the private key never leaves you.

Scopes:

ScopeAllows
tradePlace orders, RFQ
cancelCancels, conditional deletes
quoteBatch place/cancel/amend + MM order entry
mint_redeemSplit/merge inventory actions
readPrivate reads + private WS token
drop_copyDrop-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.

Onboarding hint

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

SurfaceAction signatureTransport credential
HTTP public market datanone
HTTP trading (/trading/actions)blake3 v2 (or delegated)none / signed body
HTTP MM/BSL order entryblake3 v2 per orderSC-* HMAC on an institutional_agent
FIX 4.4— (session stands in)FIX logon 553/554/1
BSL Direct TCPblake3 v2 per order (AuthSidecar)BSL session
Binary-FIX / FIXP— (session stands in)FIXP Negotiate credentials
WebSocket privatespws1.… token in auth frame

Next