Quickstart
From nothing to a settled trade — register, 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.
Four steps: register → 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 holds the same actions again, as your
recovery path — and, while a trade you accepted settles, anything the desk
queues for it after the accept.
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 thehashof every action a call returns — 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 WS = process.env.WS_URL ?? "wss://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})`);You can check the key — and everything else that has to be true before you
can trade — at any point (export API_KEY=mk_… for the shell lines):
curl -H "X-API-Key: $API_KEY" $API_URL/maker/status
# {"partyId":"…","hint":"acme-mm-…","invitable":false,"pendingActions":0}A 401 {"error":"unknown party or api key"} means the key is wrong.
invitable and pendingActions are the live preconditions for trading — the
open stream of step 3 is what turns invitable true.
Setup: the signing helper
Every step below signs the actions a call returned. This one helper signs any list of them in a single request, and every 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);
}No polling, no waiting. See The sign queue for the recovery path and the rules that bite.
2. Fund
The desk issues no tokens: you fund your party by sending instruments to your
partyId from any token-standard wallet. On DevNet the desk also runs a
faucet — GET /faucet says whether it is on and what it hands out,
POST /faucet draws.
Every deposit — faucet or not, Canton Coin included — then waits in
GET /wallet/incoming until you accept it. Accepting returns 202 with the
actions to sign — same key, same helper:
const faucet = await fetch(`${API}/faucet`, { headers: { "X-API-Key": API_KEY } }).then((r) => r.json());
if (faucet.enabled) await fetch(`${API}/faucet`, { method: "POST", headers: { "X-API-Key": API_KEY } });
// Transfers reach the inbox within seconds of being sent.
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: a quote locks what it moves the moment you sign it, and
one your free holdings cannot cover is refused with 409 INSUFFICIENT_FUNDS.
The desk fee is paid in the pair's quote asset, so keep at least the fee
free in that asset even on RFQs where you receive it.
3. 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}/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.
Once a taker invites you, quote it. GET /rfq/incoming also keeps RFQs that
closed in the last 10 minutes (open: false, with your quote's outcome), so
pick an open one without a live quote of yours (none yet, or one that expired or
was revoked):
const incoming = await fetch(`${API}/rfq/incoming`, {
headers: { "X-API-Key": API_KEY },
}).then((r) => r.json());
const rfq = incoming.find((r) => r.open && r.myQuote?.status !== "pending");
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); // one allocation per asset of the quoteprice 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 one allocate action per
allocation of the quote (typically two: the asset you send, the asset you
receive), and until all are signed your quote is unanchored: the taker cannot see it,
and it dies at its validUntil. 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.
4. Settle
Nothing to do: your signed allocations are the quote. If the taker accepts,
the taker signs its own side, the desk adds its fee receipt and settles every
leg in one Canton transaction. Your stream carries trade.step
(allocated), then quote.status won and trade.settled with the Canton
updateId and receipt contract ids as on-ledger proof. If another maker's
quote wins you get quote.status lost; if the taker's side falls through,
trade.failed — nothing moved, your quote is revoked, and its allocations are
released.
The one habit worth adding is recovery: after a restart or a timed-out call, sign whatever is still waiting on you.
const { actions } = await fetch(`${API}/tx/pending`, {
headers: { "X-API-Key": API_KEY },
}).then((r) => r.json());
await signActions(API_KEY, actions);That is the whole loop; everything else in these docs is detail.
Taking liquidity
The same key trades the other side. Request quotes from makers that are online
right now (GET /desk lists them), accept the best one, and sign what the
accept returns — the same helper again:
const { makers } = await fetch(`${API}/desk`, { headers: { "X-API-Key": API_KEY } }).then((r) => r.json());
const created = await fetch(`${API}/rfq`, {
method: "POST",
headers: { "content-type": "application/json", "X-API-Key": API_KEY },
body: JSON.stringify({
direction: "SELL", qty: "0.01", base: "cbtc", quote: "usdcx",
invitedMakers: makers.filter((m) => m !== registered.hint), ttlSeconds: 120,
}),
}).then((r) => r.json());
// A quote reaches you once its allocations have landed, a few seconds after the
// RFQ: as `quote.created` on the stream, or — polled here — in GET /rfq/mine.
let best;
for (let i = 0; i < 30 && best === undefined; i++) {
await new Promise((r) => setTimeout(r, 2000));
const mine = await fetch(`${API}/rfq/mine`, { headers: { "X-API-Key": API_KEY } }).then((r) => r.json());
best = mine
.find((r) => r.rfqId === created.rfqId)
?.quotes.filter((q) => q.status === "pending")
.sort((a, b) => Number(b.price) - Number(a.price))[0]; // SELL: the highest price wins
}
if (best === undefined) throw new Error("no quote within a minute");
const res = await fetch(`${API}/quote/${best.quoteId}/accept`, {
method: "POST",
headers: { "X-API-Key": API_KEY },
});
const accepted = await res.json();
if (res.status !== 202) throw new Error(`accept refused: ${accepted.error}`); // e.g. 409 INSUFFICIENT_FUNDS
await signActions(API_KEY, accepted.actions); // your side of the settlementThe outcome arrives as trade.settled or trade.failed. Until it does, keep
signing what arrives for the trade — sign.requested, or the /tx/pending
drain above: an allocation the desk could not queue at accept time, or a fresh
one for an action that expired, reaches you only that way. On trade.failed
nothing moved, the quote is revoked and the RFQ stays open until its deadline —
accept another quote. An accept refused up front (409 INSUFFICIENT_FUNDS)
leaves the quote acceptable: top up and accept it again.
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 immediately and any
// stream it opened is closed with 4401: reconnect with the new one.Lose 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.