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:
{"type":"hello", …}— the vocabulary.{"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).{"type":"snapshot","symbol":"AAPL","at":"…","quote":{…},"trade":{…},"status":{…}}— the last quote, trade and status standing atfrom. Start your book from this.{"type":"ready","from":"…","to":"…","speed":1,"days":[…]}— the clock is pinned:fromis now.- Rows. Each has
channel(trade|quote|status) and notype. Same columns as the file. {"type":"end","sent":123456,"lastTs":"…","counts":{"AAPL":{"trade":…,"quote":…,"status":…}}}— then the server closes. Whenfrom..tospans the whole day,countsequals 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
- A replay at
speed=0sends ~1M rows for a liquid day as fast as you read them. Read them. - There is no rate limit yet. There is a log. A key is a name.
- Keys in URLs land in the ingress log. Use the header from anything that can send one.
- Nothing here writes to the desk's ledger, and no key can make it.
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) |