Quickstart
From nothing to a settled trade — register, activate, fund, quote, settle
This is the entire maker path, over the API, with no browser and no
desk-operator step: you generate an Ed25519 party key, self-register with
it, and from then on sign every ledger effect yourself. The signer itself
needs no package beyond Node's built-in node:crypto; the authenticated
stream uses the Node ws package.
Five steps: register → activate → fund → quote → settle. One rule ties
them together: if a call cannot take effect until you sign, it returns the
actions to sign in its own response. You sign their hashes and submit them —
all of them, in one request — to POST /tx/execute. The
sign queue is what remains: your recovery path, and how
the desk reaches you with work no call of yours produced.
Setup: base URL
This desk runs on DevNet. Export these once and every snippet below is copy-pasteable as written:
export API_URL=https://api.devnet.cessio.cc
export WS_URL=wss://api.devnet.cessio.ccREST calls and the WebSocket upgrade authenticate with the X-API-Key header.
Browser-native WebSocket cannot set that upgrade header and is not a maker
authentication client; use a Node client such as ws for the stream.
Setup: the signer
Two signing conventions matter and are easy to mix up:
- Prepared-transaction hashes —
topology[].hashfrom registration and every actionhashfrom/tx/pending— are base64 of RAW bytes. Decode the base64 first, sign those bytes, then base64-encode the signature. - The challenge string used for key rotation is signed as UTF-8 text directly — do not base64-decode it first.
// signer.mjs — the only crypto this bot needs, node:crypto only.
import { generateKeyPairSync, sign as edSign, createPrivateKey } from "node:crypto";
export function generateKeypair() {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const spki = publicKey.export({ type: "spki", format: "der" });
return {
// raw 32-byte Ed25519 public key, base64 — what the API expects
publicKeyB64: spki.subarray(spki.length - 32).toString("base64"),
// PKCS8 PEM — THIS is your party's identity. Save it; there is no recovery.
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
};
}
export const loadPrivateKey = (pem) => createPrivateKey(pem);
/** Sign a base64 HASH: decode to raw bytes first, sign those, re-encode. */
export function signHashB64(privateKey, hashB64) {
return edSign(null, Buffer.from(hashB64, "base64"), privateKey).toString("base64");
}
/** Sign a plain UTF-8 string — only for the /maker/challenge rotation flow. */
export function signTextB64(privateKey, text) {
return edSign(null, Buffer.from(text, "utf8"), privateKey).toString("base64");
}1. Register
POST /maker/register/start needs no API key — it is how you get one. It
returns a fresh party id and one or more hashes to sign; sign each with the
key that becomes your party's permanent signing key, then
POST /maker/register/complete with the signatures in the same order.
import { generateKeypair, loadPrivateKey, signHashB64 } from "./signer.mjs";
const API = process.env.API_URL ?? "https://api.devnet.cessio.cc";
const { publicKeyB64, privateKeyPem } = generateKeypair();
const privateKey = loadPrivateKey(privateKeyPem);
// Persist privateKeyPem to disk/secrets now — losing it loses the party.
const start = await fetch(`${API}/maker/register/start`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ displayName: "Acme MM", publicKey: publicKeyB64 }),
}).then((r) => r.json());
// { registrationId, partyId, topology: [{ hash, description }] }
const signatures = start.topology.map((t) => signHashB64(privateKey, t.hash));
const registered = await fetch(`${API}/maker/register/complete`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ registrationId: start.registrationId, signatures }),
}).then((r) => r.json());
// { partyId, hint, apiKey }
const API_KEY = registered.apiKey; // shown ONCE — store it now, the desk keeps only its hash
console.log(`registered as ${registered.hint} (${registered.partyId})`);register/complete also hands back actions — the one-time activation of
your settlement service, ready to sign (step 2). Separately, and only on DevNet,
it starts the TransferPreapproval that lets funds reach you in the background
(step 3); that one is best-effort and has a manual re-drive.
You can check the key — and everything else that has to be true before you can trade — at any point with:
curl -H "X-API-Key: $API_KEY" $API_URL/maker/status
# {"partyId":"…","hint":"acme-mm-…","invitable":false,
# "serviceActivated":false,"pendingActions":1}A 401 {"error":"unknown party or api key"} means the key is wrong. The three
booleans are the preconditions for trading, and step 2 and step 4 below are what
turn them true.
2. Activate
The activation came back with your API key. This one helper signs any list of actions in a single request, and every later step reuses it unchanged:
async function signActions(apiKey, actions) {
if (actions.length === 0) return;
const signatures = actions.map((a) => ({
actionId: a.id,
signature: signHashB64(privateKey, a.hash),
}));
const { results } = await fetch(`${API}/tx/execute`, {
method: "POST",
headers: { "content-type": "application/json", "X-API-Key": apiKey },
body: JSON.stringify({ signatures }),
}).then((r) => r.json());
// Partial success is ordinary — check per action, not per response.
for (const r of results) if (r.status !== "executed") console.warn(r);
}
await signActions(API_KEY, registered.actions);That is it — no polling, no waiting. See The sign queue for what still arrives unasked, and the rules that bite.
3. Fund
The desk issues no tokens and has no faucet: you fund your party from the
outside, by sending the instrument to your partyId from any token-standard
wallet. On DevNet the receive preapproval from step 1 must have landed before
a transfer can even be sent to you — if funding seems stuck, re-drive it with
POST /maker/preapproval (idempotent, a no-op on the local sandbox).
Deposits then wait in GET /wallet/incoming until you accept them. Accepting
returns 202 with the actions to sign — same key, same helper:
const { transfers } = await fetch(`${API}/wallet/incoming`, {
headers: { "X-API-Key": API_KEY },
}).then((r) => r.json());
for (const t of transfers) {
const { actions } = await fetch(`${API}/wallet/incoming/${t.cid}/accept`, {
method: "POST",
headers: { "X-API-Key": API_KEY },
}).then((r) => r.json());
await signActions(API_KEY, actions);
}
console.log(await fetch(`${API}/wallet/holdings`, { headers: { "X-API-Key": API_KEY } }).then((r) => r.json()));Fund before you quote: the desk checks holdings when a taker accepts, not when you quote, and an underfunded win fails the trade.
4. Quote
An open GET /maker/stream socket authenticated by X-API-Key is required
to be invitable:
the desk offers a taker your hint only while that socket is connected. A maker
with no open stream is never invited and simply receives no RFQs — no error,
nothing to catch, GET /rfq/incoming just stays empty. Keep the stream open
even if you drive everything off REST. Use a Node client that can attach the
upgrade header (for example pnpm add ws):
import WebSocket from "ws";
const makerStream = new WebSocket(`${WS_URL}/maker/stream`, {
headers: { "X-API-Key": API_KEY },
});Browser-native WebSocket cannot set X-API-Key on the upgrade request and
therefore cannot authenticate a maker stream. See
Subscribe to RFQs.
const [rfq] = await fetch(`${API}/rfq/incoming`, {
headers: { "X-API-Key": API_KEY },
}).then((r) => r.json());
const quote = await fetch(`${API}/maker/quotes`, {
method: "POST",
headers: { "content-type": "application/json", "X-API-Key": API_KEY },
body: JSON.stringify({ rfqId: rfq.rfqId, price: "97300.0" }),
}).then((r) => r.json());
await signActions(API_KEY, quote.actions); // the fee proposal, then the swapprice is the amount of the quote instrument per 1 unit of the base
instrument, as a positive decimal string. validUntil is optional: omitted, the
desk gives your quote its default lifetime, capped at the RFQ's deadline. Send
it only when you want a specific window — one past the deadline is a 409, the
desk will not silently shorten what you stated on purpose.
Signing is not optional. quote.actions holds two propose-dvp actions,
and until both are signed your quote is unanchored: the taker cannot see it,
and accepting it answers 409 RETRY_LATER. Watch for quote.anchored on your
stream to know it went live. See
Quotes live on the ledger for why quoting is a
ledger write rather than a database row.
5. Settle
If the taker accepts, allocation actions (purpose: "allocate") appear on
GET /tx/pending. This is the one thing no call of yours returns — nobody
asked for it, the desk enqueued it when the taker accepted. So here, and only
here, you poll (or drive it off sign.requested on the stream):
async function drainPending() {
const { actions } = await fetch(`${API}/tx/pending`, {
headers: { "X-API-Key": API_KEY },
}).then((r) => r.json());
await signActions(API_KEY, actions);
}
setInterval(drainPending, 2000);Your stream then carries quote.status (won / lost / expired),
trade.step progress, and finally trade.settled with the Canton updateId
and receipt contract ids as on-ledger proof — or trade.failed if a leg came
up short. That is the whole loop; everything else in these docs is detail.
Key rotation
If you lose the API key, recover it by proving you still hold the party key. Remember: this challenge is signed as UTF-8 text, not as a hash.
import { signTextB64 } from "./signer.mjs";
const { challenge } = await fetch(`${API}/maker/challenge`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ partyId: registered.partyId }),
}).then((r) => r.json());
const { apiKey: newApiKey } = await fetch(`${API}/maker/api-key/rotate`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
partyId: registered.partyId,
challenge,
signature: signTextB64(privateKey, challenge),
}),
}).then((r) => r.json());
// newApiKey — shown ONCE, the old key stops working immediatelyLose the party key itself and nothing can be recovered — there is no operator override, by design.
Next
Turn this into something that runs unattended:
Building a bot has the streaming quote loop built on
the same signer.mjs, plus the idioms that keep it alive.