Using the API

Base URL https://api.slowdesk.io (on the tailnet, http://100.61.104.169:8470; on a laptop, http://localhost:8470) Homepage GET / in a browser — the feed with start, pause and stop Docs GET /docs — these pages, rendered Vocabulary GET /api — every route Full reference api.md

This page is for a person or a program that wants to use the service: read scores, pull a day of tape, or play one back. It assumes nothing about how the service is built.

0. You do not need a key to look

Reading scores, listing and pulling tape, and playing a day back are public. Open the homepage and press Start. The one limit: a replay without a key ends after five minutes of wall clock, with an end frame that says so and names the tape instant to reconnect from. Everything in sections 3 through 5 below works with no Authorization header at all.

A key does two things: lifts the five-minute cap, and unlocks score — the routes that spend vendor time or disk.

To run it yourself: docker compose -f compose.yml -f compose.dev.yml up --build --wait in the repo brings the whole thing up on http://localhost:8470 from the seed — real rides, their scores, and a slice of a real AAPL day — with a dev key pm_dev_laptop_only_000000000000000000000000 that has score. Everything below works against it; swap the base URL.

1. Get a key (when you need one)

Keys are minted by an operator on the box and handed to you once:

# on the box, in /opt/postmortem
docker compose run --rm api node bin/api-keys.ts create --name "evan laptop"
docker compose run --rm api node bin/api-keys.ts create --name "lab-k sweep" --scopes read,replay,score
  pm_3kq9vX2Lw8QeR1tHnJ7cYbA0dPfKsM4uZ6gE5iT9xW2o

  id 1 · evan laptop · scopes read,replay
  This is the only time the key is shown. Store it now.

A key has scopes:

scope lets you without a key
read read stored scores, job status, the tape's manifest and objects yes
replay open the replay socket yes, five minutes at a time
score start a scoring job, score a ride on demand, warm the replay cache — anything that spends vendor time or disk no

The default is read,replay. list and revoke ID are the other two commands; a revoked key stops working within a minute.

2. Send it

export PM_KEY=pm_…
curl -s https://api.slowdesk.io/me -H "Authorization: Bearer $PM_KEY"
{ "key": { "id": 1, "name": "evan laptop", "prefix": "pm_3kq9vX2L…", "scopes": ["read", "replay"] } }

Three ways, first one wins:

where when
Authorization: Bearer pm_… REST. Use this.
X-API-Key: pm_… REST, for a client that cannot set Authorization
?key=pm_… in the URL WebSockets only — a browser cannot set headers on an upgrade

Without a key, read and replay routes answer as if you were anonymous and /me says so; a score route answers 401 with a sentence naming the scope. With a key that lacks the scope you get 403 naming it. A key that is presented is always checked: a typo is a 401, never a silent downgrade.

3. Read scores

curl -s "https://api.slowdesk.io/scores?from=2026-09-01&to=2026-09-03&limit=50" -H "Authorization: Bearer $PM_KEY"
curl -s https://api.slowdesk.io/scores/137 -H "Authorization: Bearer $PM_KEY"

Each score is the three lines — mechanical, realised, delta — with the evidence: which door the plan reached first and when, MFE, MAE, capture, and unscoreable with a reason when the ride could not be scored honestly. The newest version of each ride's score is returned; scores are never edited.

To score afresh (needs score):

curl -s -X POST https://api.slowdesk.io/score/range -H "Authorization: Bearer $PM_KEY" \
  -H "content-type: application/json" -d '{"from":"2026-09-02","to":"2026-09-02"}'
# -> 202 {"jobId":"…"}; then listen on wss://api.slowdesk.io/ws?key=… or poll /jobs/:id

4. Pull a day of tape

What is on file:

curl -s https://api.slowdesk.io/ticks/manifest.json -H "Authorization: Bearer $PM_KEY" \
  | jq '.symbols.AAPL | keys'

One symbol-day — trades, quotes and status, gzipped NDJSON, the same bytes as the S3 object, in the recorder's shape:

curl -s -o AAPL-2026-09-02.ndjson.gz \
  "https://api.slowdesk.io/ticks/day=2026-09-02/symbol=AAPL/ticks.ndjson.gz" \
  -H "Authorization: Bearer $PM_KEY"
gunzip -c AAPL-2026-09-02.ndjson.gz | head -3

The day's own summary and sha256, to check what you got:

curl -s "https://api.slowdesk.io/ticks/day=2026-09-02/symbol=AAPL/manifest.json" -H "Authorization: Bearer $PM_KEY"

A day that is not on file is 404. A day is either whole or absent; there are no partial days.

5. Play a day back

wss://api.slowdesk.io/replay?symbols=AAPL,SPY&day=2026-09-02&from=09:25&to=16:05&speed=1&key=pm_…
param
symbols comma list; merged by timestamp
day ET session date
from, to HH:MM or HH:MM:SS New York, or an ISO instant. Default 04:00–20:01. Set from — at 1x from the top, the open is five and a half hours away
speed 1 real time · 10 ten times · 0 as fast as you can read

What you receive, in order:

  1. {"type":"hello", …} — the vocabulary.
  2. {"type":"loading","symbol":"AAPL"} — once per symbol, only the first time that day is played on this box (it is fetched from S3 into a local cache).
  3. {"type":"snapshot","symbol":"AAPL","at":"…","quote":{…},"trade":{…},"status":{…}} — the last quote, trade and status standing at from. Start your book from this.
  4. {"type":"ready","from":"…","to":"…","speed":1,"days":[…]} — the clock is pinned: from is now.
  5. Rows. Each has channel (trade | quote | status) and no type. Same columns as the file.
  6. {"type":"end","sent":123456,"lastTs":"…","counts":{"AAPL":{"trade":…,"quote":…,"status":…}}} — then the server closes. When from..to spans the whole day, counts equals the manifest's, so a client can check what it received without a second call.

Control, any time after ready: send {"op":"pause"}, {"op":"resume"}, {"op":"speed","speed":10}. Each is acknowledged with {"type":"clock",…} and re-pins the clock at that tape instant, so nothing is skipped or replayed.

Node:

import WebSocket from "ws";
const ws = new WebSocket(`wss://api.slowdesk.io/replay?symbols=AAPL&day=2026-09-02&from=09:25&speed=1&key=${process.env.PM_KEY}`);
ws.on("message", (d) => {
  const m = JSON.parse(d);
  if (m.type === "snapshot") book = m.quote;
  else if (m.channel === "quote") book = m;
  else if (m.channel === "trade") onTrade(m);
  else if (m.type === "end") ws.close();
});

Browser: the same, with new WebSocket(url); the key goes in ?key=.

Warm the cache before a session so loading never shows (needs score):

curl -s -X POST https://api.slowdesk.io/ticks/warm -H "Authorization: Bearer $PM_KEY" \
  -H "content-type: application/json" -d '{"symbols":["AAPL","SPY"],"day":"2026-09-02"}'

6. Etiquette and limits

Errors

code meaning
401 a score route with no key; or a malformed, unknown or revoked key on any route
403 the key lacks the scope the route needs; the message names it
404 a ride, score, job or symbol-day that is not there
400 a parameter is wrong; the message says which
503 no store is configured (laptop mode)