Signing
Every mutating order action is authorized by a per-action signature over a deterministic action payload. This signature authorizes the order — who may move which funds — and is independent of the transport credential.
This page covers the per-action trading signature that authorizes the order. That is distinct from SC-* machine auth, which authorizes the HTTP request (HMAC or Ed25519 over a request signing string). A request can carry valid SC-* machine auth and still be rejected if the embedded action signature is missing or invalid. For request authentication and the SC-* headers, see Authentication.
This page was previously published at /integration/eip712-signing. Despite that
name, the trading action path is not EIP-712 typed data: it is a blake3 hash
over a canonical JSON payload, described below.
Flow
- Build the action payload in canonical field order.
- Encode it as canonical JSON bytes.
- Compute
blake3("SENTICORE/ACTION_PAYLOAD/v1" || canonical_bytes). - Sign the 32-byte hash with the wallet key.
- Submit
{ payload, signature }through Order Entry or Trading HTTP.
Steps 1–3 are fully local. A client that reproduces the canonical bytes itself
does not need the POST /actions/hash round trip — see
Computing the hash locally.
Hash definition
signing_hash = blake3( "SENTICORE/ACTION_PAYLOAD/v1" || canonical_bytes )
blake3with default parameters, 32-byte output.- The domain string is prepended as raw ASCII bytes (no length prefix).
canonical_bytesis the canonical JSON encoding of the action payload, UTF-8, defined below.
Any change to the canonical encoding ships under a new domain string
(.../v2); v1 vectors stay valid for the lifetime of the v1 domain.
Signing schemes
Sign the 32-byte hash with the wallet key. The backend accepts:
- raw ECDSA over the 32-byte hash (
scheme: "EcdsaSecp256k1"), and - EIP-191
personal_signover the same 32-byte hash.
The submitted signature is 65 bytes in EVM order: r || s || v. Recovery byte
v may be 0/1 or 27/28.
API agents and FIX credentials authorize an integration lane or delegated signer, but they do not remove the action-level authorization requirement. Every mutating order action must still be signed by the account owner or an authorized delegated signer. See Authentication for the credential models.
Canonical JSON encoding
The signing preimage is exact. Reproduce these rules byte-for-byte:
- No whitespace anywhere.
- Field order is the declaration order given in the tables below, NOT
alphabetical. This is the single most common integration mistake: the
canonicalPayloadechoed byPOST /actions/hashis alphabetically sorted for display; hashing that JSON produces a different, invalid hash. - Field names are the canonical snake_case wire names.
- Optional fields are always present, serialized as
nullwhen absent — with one exception:client_order_idis omitted entirely when absent (it is never emitted asnull). It appears in the canonical bytes only when you set it. - Account ids:
0x+ 40 lowercase hex chars. Order ids:0x+ 64 lowercase hex chars. - Integers are serialized as bare JSON numbers (no quotes). Quantities and
prices are u64 atomic units; use a big-integer type in languages where
2^53is a concern. - Enums:
sideis"Bid"/"Ask",bookis"YES"/"NO";time_in_force(gtc,ioc,fok,post_only),stp_mode(cancel_maker,cancel_taker,reject,skip_self),trigger_direction,conditional_kindare snake_case strings. - The action is externally tagged:
{"<Variant>":{...}}. The outcome place-order variant is canonically taggedPlaceOrder(the public request aliasOutcomePlaceOrderis input-only and never part of the canonical bytes).
Envelope field order
| # | Field | Type |
|---|---|---|
| 1 | account | hex(20) |
| 2 | nonce | u64 |
| 3 | nonce_reservation_id | string | null |
| 4 | client_order_id | string (omitted when absent) |
| 5 | ts | u64 (unix ms) |
| 6 | action | tagged object |
client_order_id is optional. When present it appears here, between
nonce_reservation_id and ts, and changes both the signing hash and the
derived OrderId. When absent it is omitted from the bytes entirely (it is the
one optional field that is not serialized as null), so the golden vectors
below — none of which set it — remain byte-identical. A client that never sets
client_order_id does not need to change anything.
Variant field order
| Variant | Fields in canonical order |
|---|---|
SpotPlaceOrder | market, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at |
PlaceOrder (outcome) | market, book, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at |
Cancel | order_id |
AmendOrder | order_id, new_qty |
SpotQuoteReplace | market, legs[] |
QuoteReplace | market, legs[] |
| spot leg | cancel_order_id, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at |
| outcome leg | cancel_order_id, book, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at |
AmendOrder is quantity-only by design (it keeps time priority). Use
SpotQuoteReplace / QuoteReplace to reprice; do not send new_price. Quote
replace is atomic at engine level: if any leg is rejected the engine rolls back
the full group. A spot leg with cancel_order_id and qty: 0 is cancel-only; a
leg without cancel_order_id is place-only. Replacing an order creates a new
child order derived from the parent action and leg index, and does not
preserve the canceled order's queue priority.
Computing the hash locally
The two-step wallet flow (POST /actions/hash, then submit) costs one extra
HTTP round trip per action. The submit endpoint recomputes the signing hash from
the submitted payload; there is no server-side state between the two calls. A
client that reproduces the canonical bytes locally can therefore skip
/actions/hash entirely and submit signed actions in one round trip.
The TypeScript SDK ships this as canonicalActionPayloadJson(),
actionSigningHashHex(), deriveOrderIdHex(), and signAction() (the
@sentico-labs/sdk signing module). The rules above are the normative spec for
any other language.
import {
actionSigningHashHex,
deriveOrderIdHex,
signAction,
type LocalActionPayload,
} from "@sentico-labs/sdk";
const payload: LocalActionPayload = {
account: "0x1111111111111111111111111111111111111111",
nonce: 4810,
ts: 1765500000000,
action: {
kind: "SpotPlaceOrder",
market: 7,
side: "Bid",
price: 998400,
qty: 1000,
timeInForce: "post_only",
},
};
const signedAction = signAction(payload, process.env.SENTICORE_PRIVATE_KEY!);
console.log({
signingHash: actionSigningHashHex(payload),
derivedOrderId: deriveOrderIdHex(payload),
});
await client.orderEntry.submitSignedAction(signedAction, {
idempotencyKey: "client-order-4810",
});
deriveOrderIdHex(payload) uses
blake3("SENTICORE/ORDER_ID/v1" || canonical_bytes) for place orders. For
quote-replace place legs, use deriveQuoteReplaceOrderIdHex(payload, legIndex).
Because client_order_id is part of canonical_bytes when present, setting it
changes the derived OrderId. It influences the derived id only; it is not
stored in order state or the state_root.
The SDK can hash BigInt values above 2^53-1, but submit-ready JSON objects
reject values that JavaScript would round. Keep public-beta price/quantity atoms
inside JS safe integers for the JSON path, or use a raw encoder that preserves
the decimal bytes exactly.
Golden vectors
These vectors are asserted byte-for-byte by the backend test suite
(senticore-types) and the TypeScript SDK test suite. Validate your
implementation against all three before going live.
Vector 1: SpotPlaceOrder
Canonical bytes:
{"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}}}
Signing hash:
0xc8d02209196c492de5b39c90d7efd356548784ddd464603913b59afab911b42f
Vector 2: Cancel
Canonical bytes:
{"account":"0x1111111111111111111111111111111111111111","nonce":4811,"nonce_reservation_id":null,"ts":1765500000001,"action":{"Cancel":{"order_id":"0x2222222222222222222222222222222222222222222222222222222222222222"}}}
Signing hash:
0xaecabe7c50eaa0a1a6f59b75687b64dce6f96fcaef509319051baff0e78eb38a
Vector 3: SpotQuoteReplace (one replace leg, reserved nonce)
Canonical bytes:
{"account":"0x1111111111111111111111111111111111111111","nonce":4812,"nonce_reservation_id":"res-1","ts":1765500000002,"action":{"SpotQuoteReplace":{"market":7,"legs":[{"cancel_order_id":"0x2222222222222222222222222222222222222222222222222222222222222222","side":"Bid","price":998500,"qty":1189,"stp_mode":null,"time_in_force":"post_only","is_market":false,"reduce_only":false,"expires_at":null}]}}}
Signing hash:
0x0b635be460cf6d9ae3a9fe11c1b5d5176c942e9b6139f88dac142baa1818584c
API input vs canonical signing casing
Local signing must use the canonical snake_case, declaration-order encoding above. The API input envelopes accepted by the HTTP endpoints are more lenient: the 0.1.3 parsers accept documented camelCase aliases and canonicalize them internally. Do not hash camelCase examples directly.
| Family | API input fields |
|---|---|
Public place-order envelopes: SpotPlaceOrder, OutcomePlaceOrder | marketId, timeInForce, isMarket, reduceOnly, expiresAt |
Raw advanced envelopes: Cancel, AmendOrder, QuoteReplace, SpotQuoteReplace, PlaceAlgoOrder, PlaceConditionalOrder | market, order_id, new_qty, time_in_force, is_market, reduce_only, expires_at |
For deterministic client code, prefer the SDK LocalActionPayload shape or the
canonical snake_case field order. Unknown fields are not a compatibility
contract; clients must not rely on the server retaining or interpreting
undeclared fields.
Hash-and-submit (compatibility flow)
POST /api/v1/trading/actions/hash stays available as a debugging and reference
endpoint; compare its hash against your local result during integration. The
flow is:
POST /api/v1/trading/actions/hashwith an unsigned payload.- Sign the returned
hashexactly — do not reserialize the returnedpayloadand hash it yourself. The response JSON is for transport/debugging; it is not the canonical local-signing preimage. - Submit
{ payload, signature }(signaturescheme: "EcdsaSecp256k1",bytes).
POST /actions/hash can fill nonce: 0 for its returned reference payload (it
replaces 0 with the current account nonce when no nonce_reservation_id is
present). That convenience does not apply to locally signed actions: never
submit a locally signed action with nonce: 0 unless 0 is actually the next
usable nonce.
Use Idempotency-Key on submit requests that may be retried.
Action envelopes
Examples below use API input casing. For local signing, apply the canonical encoding rules above.
Spot place order
{
"account": "0x1111111111111111111111111111111111111111",
"nonce": 0,
"ts": 1781190000000,
"action": {
"SpotPlaceOrder": {
"marketId": 1,
"side": "Bid",
"price": 1000000,
"qty": 100000,
"stpMode": "cancel_maker",
"timeInForce": "post_only",
"isMarket": false,
"reduceOnly": false,
"expiresAt": null
}
}
}
Spot orders do not carry book or outcome.
Prediction place order
{
"account": "0x1111111111111111111111111111111111111111",
"nonce": 0,
"ts": 1781190000000,
"action": {
"OutcomePlaceOrder": {
"marketId": 10,
"outcome": "YES",
"side": "Bid",
"price": 520000,
"qty": 100000,
"timeInForce": "gtc"
}
}
}
book is accepted as a deprecated alias for outcome on prediction place
orders. The canonical signing tag for this variant is PlaceOrder.
Cancel
{
"account": "0x1111111111111111111111111111111111111111",
"nonce": 0,
"ts": 1781190000000,
"action": {
"Cancel": {
"order_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}
orderId is accepted as an alias.
Amend quantity
{
"account": "0x1111111111111111111111111111111111111111",
"nonce": 0,
"ts": 1781190000000,
"action": {
"AmendOrder": {
"order_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"new_qty": 50000
}
}
}
Amend only allows quantity reduction while keeping priority. Do not send
new_price; use quote replace for repricing.
Spot quote replace
{
"account": "0x1111111111111111111111111111111111111111",
"nonce": 0,
"ts": 1781190000000,
"action": {
"SpotQuoteReplace": {
"market": 1,
"legs": [
{
"cancel_order_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"side": "Bid",
"price": 999900,
"qty": 100000,
"time_in_force": "post_only",
"is_market": false,
"reduce_only": false
},
{
"side": "Ask",
"price": 1000100,
"qty": 100000,
"time_in_force": "post_only"
}
]
}
}
}
Spot quote legs do not carry book.
Prediction quote replace
{
"account": "0x1111111111111111111111111111111111111111",
"nonce": 0,
"ts": 1781190000000,
"action": {
"QuoteReplace": {
"market": 10,
"legs": [
{
"cancel_order_id": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"book": "YES",
"side": "Bid",
"price": 510000,
"qty": 100000,
"time_in_force": "post_only"
},
{
"book": "NO",
"side": "Ask",
"price": 480000,
"qty": 100000,
"time_in_force": "post_only"
}
]
}
}
}
Algo order
{
"account": "0x1111111111111111111111111111111111111111",
"nonce": 0,
"ts": 1781190000000,
"action": {
"PlaceAlgoOrder": {
"market": 10,
"book": "YES",
"side": "Bid",
"total_qty": 1000000,
"limit_price": 510000,
"time_in_force": "gtc",
"strategy": {
"kind": "twap",
"slice_qty": 100000,
"interval_ms": 5000
}
}
}
}
Supported strategies are iceberg, twap, and vwap. CancelAlgoOrder cancels
a placed algo parent.
Conditional order
{
"account": "0x1111111111111111111111111111111111111111",
"nonce": 0,
"ts": 1781190000000,
"action": {
"PlaceConditionalOrder": {
"market": 10,
"book": "YES",
"side": "Ask",
"qty": 100000,
"limit_price": 700000,
"trigger_price": 690000,
"trigger_direction": "at_or_above",
"conditional_kind": "take_profit",
"reduce_only": true,
"signed_trigger_order": {
"payload": {
"account": "0x1111111111111111111111111111111111111111",
"nonce": 43,
"ts": 1781190000000,
"action": {
"OutcomePlaceOrder": {
"marketId": 10,
"outcome": "YES",
"side": "Ask",
"price": 700000,
"qty": 100000,
"reduceOnly": true
}
}
},
"signature": {
"scheme": "EcdsaSecp256k1",
"bytes": [1, 2, 3]
}
}
}
}
}
The nested signed_trigger_order must already be signed. The outer conditional
action controls storage and trigger conditions. CancelConditionalOrder cancels
a placed conditional.
Nonces
Each signed action carries a nonce that is part of the signing hash; the server
cannot rewrite it after signing. The signing bytes and the v1 domain are
unchanged by the nonce rule — existing golden vectors and locally-signing
clients keep working.
A signed nonce n is accepted if and only if it is inside the account's replay
window and has not already been used:
nonceFloor <= n < nonceFloor + nonceWindow(currentlynonceWindow = 256),- and
nhas not already been consumed or is not already in-flight.
nonceFloor is the lowest still-acceptable nonce (the next strict nonce).
Gaps and out-of-order submission are allowed: you may pipeline any unused
nonces in the open window in any order, without waiting for an ack or
pre-reserving a range. A strict, sequential client (nonceFloor, nonceFloor+1, …) is just the simplest valid case.
Reject classes (machine-readable code, with discovery fields):
nonce_below_floor—n < nonceFloor: permanently stale/consumed. Pick a fresh in-window nonce; do not retry the same value.nonce_outside_window—n >= nonceFloor + nonceWindow: too far ahead. Retry after the floor advances (fill your lower nonces first).nonce_replayed—nis in-window but already used/in-flight. Pick a different unused in-window nonce.nonce_mismatch— legacy/uncategorized nonce reject.
Every nonce reject carries nonceFloor and nonceWindow so you can recompute
the valid open interval, plus nextUsableNonce (= nonceFloor) as a
convenience hint.
{
"accepted": false,
"ok": false,
"seq": null,
"derivedOrderId": null,
"error": "nonce 4600 is below replay window; nonceFloor=4810 nonceWindow=256",
"code": "nonce_below_floor",
"nextUsableNonce": 4810,
"nonceFloor": 4810,
"nonceWindow": 256
}
Reservations are deprecated
Nonce reservations existed only to escape the old strict-contiguous rule. Under
the windowed model any unused in-window nonce is accepted, so reservations are
no longer required. The reserve endpoint and nonce_reservation_id remain
accepted for backward compatibility but are deprecated — new integrations should
omit nonce_reservation_id (keep it absent or null) and simply pick in-window
nonces. If a payload includes a reservation id without a valid reservation
token, the action is rejected.
See also
- Authentication — request-level SC-* machine auth (HMAC / Ed25519), wallet vs delegated credentials, and scopes.
- Order Entry — submitting signed action
batches via
POST /api/v1/order-entry/ordersand the compact lane. - SDKs — the
signAction()and hashing helpers.