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 two reactions: an RFQ arrives, so quote it and sign what the quote returned; an allocation is requested, so sign that too. Only the second needs the socket.
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" };
/** 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)}`);
}
/** The allocations a taker's accept enqueued — nothing returns those. */
async function drainPending() {
const { actions } = await fetch(`${API}/tx/pending`, { headers: auth }).then((r) => r.json());
await signActions(actions);
}
async function quote(rfq) {
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()}`);
await signActions((await res.json()).actions); // anchors the quote — not optional
}
function connect() {
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 === "sign.requested") return void drainPending(); // allocations
if (frame.type === "resync") return void ws.close();
});
ws.on("close", () => setTimeout(connect, 1000)); // reconnect = fresh snapshot
}
connect();
setInterval(drainPending, 30_000); // safety net if a frame is missedInstall 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 parsed = wsEventSchema.safeParse(raw);
if (!parsed.success) return; // never crash, never log-spamA 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 two propose-dvp
actions it returned are signed, and a won trade still needs its allocate
signature. If the bot's signing path stalls, it quietly stops filling — every
quote is invisible and every win times out.
Alert on it. GET /maker/status answers in one call, and its three 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.serviceActivated) alert("settlement service not open — quotes will 409");
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=…"e=…
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.