Cessio Docs
Guides

Building a bot

The unattended quote loop, and the idioms that keep it alive

The Quickstart drives one trade by hand. This page turns that into a process that runs unattended: it assumes you already have signer.mjs, an API key, and a funded party.

The desk hosts no bot of its own — quoting is entirely yours.

The loop

Everything the bot does is one reaction: an RFQ arrives, so quote it and sign what the quote returned. The socket delivers the RFQs (and keeps you invitable); for a maker, GET /tx/pending is only the recovery path after a restart. (A party that also takes signs more: whatever the desk queues for a trade it accepted — see The sign queue.)

import WebSocket from "ws";
import { loadPrivateKey, signHashB64 } from "./signer.mjs";

const API = process.env.API_URL ?? "https://api.devnet.cessio.cc";
const KEY = process.env.API_KEY;                      // mk_… from registration
const privateKey = loadPrivateKey(process.env.PARTY_KEY_PEM);
const SPREAD_BPS = 50;                                // 0.5% around reference
const PRICES = { "cbtc/usdcx": 97000, "ceth/usdcx": 3500 };

const auth = { "X-API-Key": KEY, "content-type": "application/json" };
const live = new Set();                               // rfqIds holding a live quote of ours

/** Sign a list of actions and submit them in ONE request. */
async function signActions(actions) {
  if (actions.length === 0) return;
  const res = await fetch(`${API}/tx/execute`, {
    method: "POST",
    headers: auth,
    body: JSON.stringify({
      signatures: actions.map((a) => ({ actionId: a.id, signature: signHashB64(privateKey, a.hash) })),
    }),
  }).then((r) => r.json());
  for (const r of res.results) if (r.status !== "executed") console.warn(`unsigned: ${JSON.stringify(r)}`);
}

/** Anything still waiting on us from before a restart — the recovery path. */
async function drainPending() {
  const { actions } = await fetch(`${API}/tx/pending`, { headers: auth }).then((r) => r.json());
  await signActions(actions);
}

async function quote(rfq) {
  if (live.has(rfq.rfqId)) return;                    // the reconnect snapshot replays RFQs we quote
  const ref = PRICES[`${rfq.base}/${rfq.quote}`];
  if (ref === undefined) return;                      // a pair we don't price
  const factor = rfq.direction === "SELL" ? 1 - SPREAD_BPS / 1e4 : 1 + SPREAD_BPS / 1e4;
  const res = await fetch(`${API}/maker/quotes`, {
    method: "POST",
    headers: auth,
    // .toFixed keeps float noise out of the wire format — see "Prices are strings"
    // validUntil omitted: the desk caps its default at the RFQ deadline.
    body: JSON.stringify({ rfqId: rfq.rfqId, price: (ref * factor).toFixed(2) }),
  });
  if (!res.ok) return console.warn(`quote rejected: ${res.status} ${await res.text()}`);
  live.add(rfq.rfqId);
  await signActions((await res.json()).actions);      // anchors the quote — not optional
}

async function connect() {
  // What we already quote, read BEFORE the snapshot replays `rfq.created`:
  // quoting a live quote again would replace it for nothing.
  live.clear();
  try {
    const incoming = await fetch(`${API}/rfq/incoming`, { headers: auth }).then((r) => r.json());
    for (const r of incoming) if (r.open && r.myQuote?.status === "pending") live.add(r.rfqId);
  } catch (e) {
    console.warn(`rfq/incoming unreadable, re-quoting the snapshot: ${e}`);
  }
  const ws = new WebSocket(`${API.replace(/^http/, "ws")}/maker/stream`, {
    headers: { "X-API-Key": KEY },
  });
  ws.on("message", (data) => {
    let frame;
    try {
      frame = JSON.parse(String(data));
    } catch {
      return;                                         // malformed frames are ignored
    }
    if (frame.type === "rfq.created") return void quote(frame.payload);
    if (frame.type === "quote.status" && frame.payload.status !== "pending") return void live.delete(frame.payload.rfqId);
    if (frame.type === "resync") return void ws.close();
    // Everything else — trade progress and the like — is informational.
  });
  ws.on("close", () => setTimeout(connect, 1000));    // reconnect = fresh snapshot
}

await drainPending();                                 // recover anything a restart left behind
await connect();

Install ws in the bot project before running this example. Browser-native WebSocket cannot set the X-API-Key upgrade header, so it cannot authenticate the maker stream.

That is a complete maker. The rest of this page is what keeps it running.

Ignore what you don't understand

Frames are routed by party, not by socket kind, and the event set can grow — your maker socket may carry types you have never heard of. Validate, then skip:

const HANDLED = new Set(["rfq.created", "quote.status", "resync"]);
if (!HANDLED.has(frame?.type)) return;   // never crash, never log-spam

A bot that dies on one unexpected frame loses every RFQ until someone restarts it.

Keep no state you cannot rebuild

Track RFQs in a plain Map<rfqId, RfqDto> per API key and clear it on disconnect — the reconnect snapshot rebuilds it, and resync is just a disconnect you were told about. No database, no drift. The snapshot rules are in Subscribe to RFQs.

Sign, or you never traded

POST /maker/quotes returning 200 means nothing until the allocate actions it returned are signed. If the bot's signing path stalls, it quietly stops filling — every quote stays invisible to takers and dies at its validUntil.

Alert on it. GET /maker/status answers in one call, and its two live fields are exactly the ways a bot dies silently:

const s = await fetch(`${API}/maker/status`, { headers: auth }).then((r) => r.json());
if (!s.invitable) alert("stream is down — no RFQ will ever reach us");
if (s.pendingActions > 0) alert(`${s.pendingActions} unsigned actions — signing path stalled`);

Price around a reference, mind the direction

Where the number comes from is your business — your own book, an exchange feed, a pool. A spread around a reference, signed by the RFQ's direction, is the common shape:

// below reference when the taker sells (you buy), above when the taker buys.
const factor = direction === "SELL" ? 1 - spreadBps / 10_000 : 1 + spreadBps / 10_000;

If you have no feed of your own, GET /maker/reference-price?base=…&quote=… returns the desk's advisory reference (CoinGecko, cached server-side). It answers {"price": null} whenever it has nothing, it is not a price the desk will hold you to, and polling it gains nothing over the cache. A serious maker brings its own source.

Set validUntil only when you mean it

Omit it and the desk uses its own quote lifetime, already capped at the RFQ deadline — which is what most bots want. Send it when your pricing has a real window, and keep it under rfq.deadline; an explicit value past the deadline is a 409, because silently shortening a commitment you stated would be worse.

Log rejected quotes, don't throw

A non-ok response to POST /maker/quotes is business as usual — someone else was faster, the deadline hit, the pair moved. Log it with the response text and keep serving other RFQs.

Prices are strings

Money crosses the wire as a decimal string ("97300.0"), never a float — past ~15 significant digits a float silently rounds, and the ledger will not accept what you meant. Do your arithmetic in whatever precision you like; format to a decimal string at the edge.

On this page