Cessio Docs

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.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 hashestopology[].hash from registration and every action hash from /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 swap

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 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 immediately

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