Local Action Signing
The two-step wallet flow (POST /actions/hash, then POST /actions) 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() (see @sentico-labs/sdk signing module). This page is
the normative spec for any other language.
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.
Sign the 32-byte hash with the wallet key (raw ECDSA over the hash, or
personal_sign; both are accepted).
Canonical JSON encoding
- 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. - 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^53 is 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 | ts | u64 (unix ms) |
| 5 | action | tagged object |
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 |
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
TypeScript example
import { actionSigningHashHex } from "@sentico-labs/sdk";
const hash = actionSigningHashHex({
account: "0x1111111111111111111111111111111111111111",
nonce: 4810,
ts: 1765500000000,
action: {
kind: "SpotPlaceOrder",
market: 7,
side: "Bid",
price: 998400,
qty: 1000,
timeInForce: "post_only",
},
});
// sign `hash` with the wallet, then POST /api/v1/trading/actions directly.
Notes
POST /actions/hashstays available as a debugging and reference endpoint; compare itshashagainst your local result during integration.- Nonces: local signing means the server no longer fills
nonce: 0for you. Track the account nonce locally and resynchronize from the structurednextUsableNoncefield on nonce rejections (see Error Model), or use nonce range reservations. - Any change to the canonical encoding will ship under a new domain string
(
.../v2);v1vectors stay valid for the lifetime of the v1 domain.