Cessio Docs

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.cc

REST 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[].hash from registration and the hash of 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 quote

price 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 settlement

The 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.

On this page