cinder — API reference

everything the web UI does, over plain HTTP and one WebSocket endpoint
← Send / receive P2P file transfer →
cinder is an ephemeral, identity-free relay — in-memory only, nothing written to disk. A value (up to 4KB) is stored under a 256-bit key; possession of the key is the entire access-control model — no accounts, no auth headers, no sessions. Everything below is the complete public surface: five HTTP endpoints and one WebSocket endpoint, nothing else. The web UI at / is just one client built on this — anything it can do, this API can do, including things it doesn't expose (time-locked release, WebSocket push).

The response envelope

Every endpoint except /status answers with the same JSON shape, and always with HTTP 200 — see "no leaking through the transport" below.
// present fields vary by outcome — status is always there
{
  "status": "ok",                 // see the outcome table below
  "data": "base64-of-the-value",  // only when status == "ok" and this was a read
  "key": "…",                     // only on POST (server-generated) and WS pushes
  "host_id": "cinder-1",          // only on a successful write
  "epoch": 1234567890,            // only on a successful write — see /status
  "owner_token": "…",             // only on a successful write — save this to delete later
  "meta": { "read_count": 0 }     // only on /meta
}
No leaking through the transport: whether a key exists, is locked, expired, or already burned is only ever distinguishable in this JSON body — never via HTTP status code (every key-dependent outcome is 200 OK), and cinder makes no guarantee about response size or timing being outcome-independent either. Only transport-level problems unrelated to any specific key — a bad method, a malformed key, a body over the size limit — use a non-200 status, and those fire identically no matter what the key is or whether it exists.

Keys & values

Keys are 256-bit random values, base64url-encoded with no padding — always exactly 43 characters from [A-Za-z0-9_-]. Anything else is rejected before it ever looks at key state. Values are opaque bytes, up to 4096 bytes (4KB) — cinder doesn't interpret them at all; encryption, text encoding, file formats are entirely a client concern.
Request bodies (PUT/POST) are raw bytes, sent as-is — no base64, no JSON wrapper. Response bodies encode the value as standard base64 in the data field, since JSON can't carry arbitrary bytes directly.
PUT/v1/{key}
Write a value under a key you choose. Body = the raw value (≤4KB).
Query paramValuesDefaultMeaning
modeburn | ttlttlburn = delivered exactly once, to whichever read (GET or WS push) gets there first; ttl = readable repeatedly until it expires
ttlseconds, 1–60480021600 (6h)hard expiry backstop — applies regardless of mode; nothing lives forever by omission
write_once0 | 10if set, this entry can never be replaced by a later PUT to the same key (returns conflict) until it expires or is deleted — independent of mode, which governs reads, not writes
release_atunix timestampunsetreads return locked until this time; must be strictly before the TTL expiry or the write is rejected
subscribable0 | 10if set, a waiting WebSocket subscriber gets this value pushed immediately on write, before any GET can see it — see WS /v1/subscribe
A plain PUT to an existing live key that wasn't written with write_once fully replaces it — value, flags, TTL, owner_token, everything. A PUT to a key that was written write_once and hasn't expired always returns conflict, regardless of what flags this new PUT carries.
# write once (burn on the first read), expires in 1 hour
curl -X PUT "http://localhost:8080/v1/<43-char-key>?mode=burn&ttl=3600" \
  --data-binary "the secret value"
{"status":"ok","host_id":"cinder-1","epoch":1789824322,"owner_token":"qGeETPGdvD0bs44qaJkjewfVrkHWi9KbqqwLyA_0nRY"}
POST/v1/
Same as PUT, but cinder generates a random 256-bit key for you. Same query params, same flags. The response includes the generated key.
curl -X POST "http://localhost:8080/v1/?ttl=86400" --data-binary "hello"
{"status":"ok","key":"9k8oPMQW2J7zSMAvSAF6N5j0hBvNQpv4yzN-C7JgHws","host_id":"cinder-1","epoch":1789824322,"owner_token":"…"}
GET/v1/{key}
Read a value. For a burn-mode key, this is the one delivery — a second GET (or a WS push beating it) afterward sees burned. For a ttl-mode key, reads any number of times until expiry.
curl "http://localhost:8080/v1/<43-char-key>"
{"status":"ok","data":"aGVsbG8="}
A read attempted before release_at returns locked and doesn't count against your read budget — it never attempted a real delivery.
GET/v1/{key}/meta
Check a key's read count without ever consuming it — always non-mutating, even for a burn-mode entry. Useful to check "has anyone read this yet?" without racing your own real read.
curl "http://localhost:8080/v1/<43-char-key>/meta"
 {"status":"ok","meta":{"read_count":0}}
DELETE/v1/{key}
Delete a key immediately, regardless of its remaining TTL or write_once flag. Requires the owner_token from when it was written, passed as a header — never in the URL, to keep it out of server/proxy logs.
curl -X DELETE "http://localhost:8080/v1/<43-char-key>" \
  -H "Owner-Token: <the owner_token from the write>"
A wrong or missing token returns not_found — indistinguishable from the key never having existed, so a caller without the right token learns nothing either way.
GET/status
This host's own honestly self-reported state — not wrapped in the envelope, since it carries no per-key outcome. Unmetered (doesn't draw from any budget), since it's what you need most while rate-limited.
{"uptime_seconds":42,"epoch":1789824322,"entry_count":3,"last_restart":"2026-09-20T00:27:55Z"}
epoch changes across a process restart (in-memory storage means a restart loses everything) — if you cached an epoch from a write and it no longer matches, that key is gone regardless of what its TTL said. This is how cinder tells the truth about durability instead of implying a guarantee it can't keep.
WS/v1/subscribe
Push delivery instead of polling GET. Open the WebSocket, then send JSON text frames to manage watches — up to 5 per connection, 50 total per IP.
// client → server, to start watching a key
{"subscribe": "<43-char-key>"}

// client → server, to stop (frees the slot without closing the connection)
{"unsubscribe": "<43-char-key>"}

// server → client, when that key is written with subscribable=1
{"status": "ok", "key": "<43-char-key>", "data": "base64…"}
BehaviorDetail
Never retroactivesubscribing to a key that already holds a value does not push it — only a write that happens after you subscribe triggers a push. Always follow the pattern below.
Single-shota subscription resolves on the next write to that key (whether or not it wins delivery) and is then removed — send a fresh subscribe to watch again
burn modeonly the first subscriber (by subscribe order) gets the push; it's the one delivery — a later GET sees burned
ttl modepush doesn't consume anything — every current subscriber gets it, and GET keeps working normally afterward
Budgeta push counts against your read budget the same as a GET would, but is never blocked by an empty budget — you asked for nothing at push time, so delivery isn't gated on it
The correct pattern (race-free regardless of who's online first)
1. Open the WebSocket, send {"subscribe": key}
2. Immediately also GET the key once
   — covers the case it was already written before you subscribed
3. If step 2 returned "ok", you're done; otherwise wait on the socket
   for the push (it will arrive if/when the write happens)
See examples/natrendezvous in the repo for a complete, real two-peer client implementing exactly this pattern for NAT-traversal signaling.

Outcome reference

The value of status in every envelope response.
statusMeaningWhere it appears
okthe request succeededeverywhere
not_foundno live entry at this key (never existed, already deleted, or a wrong Owner-Token on DELETE)GET, meta, DELETE
expiredthe entry's TTL passedGET, meta
lockedread attempted before release_atGET
burnedalready delivered once (burn mode) by an earlier GET or pushGET
conflictPUT targeted a live write_once entry; or WS subscribe repeated an already-active key on the same connectionPUT, WS
too_largerequest body exceeded 4096 bytesPUT, POST
budget_exceededa rate limit was hit — see Rate limits below; also reused for the global storage-capacity ceiling on brand-new keyseverywhere
invalid_keymalformed key in a WS subscribe/unsubscribe message (the REST endpoints reject a malformed key as a plain HTTP 400 instead, before any envelope is built)WS

Rate limits

Abuse thresholds, not fair-use rations — generous enough that no legitimate caller should ever notice them. Continuously-refilling token buckets, not fixed windows, so there's no herd effect at a reset boundary. Operator-tunable via server flags; defaults shown.
PoolDefaultDraws from it
Read600/min per IPGET, /meta, a WS push (accounting only — never blocks delivery)
Write60/min per IPPUT, POST, DELETE
Subscription slots50 per IP, 5 per connectionWS subscribe (fixed, not operator-tunable)
Storage capacity100,000 entries (~400MB at the 4KB cap)only brand-new keys — overwriting an existing live key is never blocked by this
/status is unmetered entirely — it's what you need to check while rate-limited.

Quickstart

A complete round trip: write, read, delete — against a locally running server.
# 1. write a burn-after-read value, save the owner_token from the response
curl -X POST "http://localhost:8080/v1/?mode=burn" --data-binary "top secret"

# 2. read it back (base64-decode "data" to get the original bytes)
curl "http://localhost:8080/v1/<key-from-step-1>"

# 3. reading it again now returns {"status":"burned"} — already delivered
curl "http://localhost:8080/v1/<key-from-step-1>"

# 4. or, instead of waiting for it to be read/expire, delete it early
curl -X DELETE "http://localhost:8080/v1/<key>" \
  -H "Owner-Token: <owner_token-from-step-1>"

P2P file transfer protocol

Not a new server API — cinder gains zero new endpoints for this. The page at /transfer uses cinder purely as a signaling side-channel: two peers exchange a WebRTC handshake through the five endpoints above (plus WS /v1/subscribe), then the actual files move directly browser-to-browser over a RTCDataChannel, encrypted end-to-end by WebRTC itself (DTLS) — cinder never sees file content, only a few hundred bytes of handshake. Everything below is a client-side convention layered on top of the API already documented above, not part of cinder's own contract — documented here so a compatible peer could be built in any language without reverse-engineering /transfer's JS.

Key derivation

Both peers agree on one shared secret out-of-band (any string). Every cinder key this protocol uses is derived from it:
deriveKey(secret, label) = base64url( SHA256(secret + "|" + label) )
Same scheme as examples/natrendezvous in the repo. Implemented as plain SHA-256, deliberately not crypto.subtle — that API needs a secure context (HTTPS or literally localhost), which would break the moment two different machines talk to each other over plain HTTP, the normal case here.

Chunking a blob larger than 4KB

A WebRTC offer/answer SDP blob sometimes exceeds cinder's 4096-byte value cap. When it does, it's split and written as multiple cinder values instead of one:
ConstantValue
Payload per chunk4000 bytes
Header2 bytes: [index: uint8, total: uint8], prepended to each chunk's payload before it's written as the cinder value
Max chunks255 (header's total byte is a uint8)
Chunk keyderiveKey(secret, label + "|chunk" + n)
Write order is what makes one subscription enough regardless of chunk count
1. PUT chunks 1..total-1 first, plain writes, nobody's watching for them yet
2. PUT chunk 0 last, with subscribable=1

By the time a subscriber's push for chunk 0 arrives, every other
chunk is already guaranteed present — no race, and the reader never
needs to be told the other chunks' keys, only how many there are
(chunk 0's own header says so).
Reading: subscribe to chunk 0's key (the pattern from WS /v1/subscribe above — subscribe, then also GET once, to cover the case chunk 0 was already written), read its header to learn total, then GET chunks 1..total-1 directly. When total == 1 (the common case — most real SDP blobs fit in one chunk), this is just a plain write/subscribe-read, identical to natrendezvous.

Who offers, who answers

WebRTC needs exactly one offerer and one answerer, but neither peer is told which to be — it's decided by racing a normal cinder write:
key = deriveKey(secret, "lead-claim")
PUT key ?write_once=1&ttl=30, body: any marker bytes

→ "ok"       you're the offerer
→ "conflict" the other side already claimed it — you're the answerer
This is exactly the write_once + conflict behavior from PUT above, used as a compare-and-set primitive — no new server behavior. The short 30s TTL means an abandoned attempt doesn't block a retry with the same secret for long.

The handshake

StepOffererAnswerer
1Create RTCPeerConnection + DataChannel (before the offer, so it's in the SDP)Create RTCPeerConnection, wait for ondatachannel
2createOffersetLocalDescription → wait for ICE gathering to fully complete (non-trickle: one complete SDP, not incremental candidates)Read the offer (chunked-blob read above, label "offer") → setRemoteDescription
3Write local SDP under label "offer"createAnswersetLocalDescription → wait for ICE gathering
4Read the answer (label "answer") → setRemoteDescriptionWrite local SDP under label "answer"
5ICE connects both sides; the data channel opens
STUN only (stun.l.google.com:19302), no TURN — works for most home/office networks but can fail, or fall back to a slower router-relayed path, behind symmetric NATs or Wi-Fi client isolation. Non-trickle ICE gathering has an 8s safety-valve timeout (uses whatever candidates exist by then rather than hanging). The offer/answer read each waits up to 120s for the other side to show up.

Messages over the open data channel

Cinder is done at this point — everything below is channel.send()/onmessage only. The channel is full-duplex: either side can send a manifest and its own files at any time, independent of what it's simultaneously receiving.
MessageDirectionShapeMeaning
manifestsender → receiver{"type":"manifest","batchSeq":N,"files":[{"path":"…","size":N}, …]}one JSON text message, sent first; declares what's coming and in what order
(binary)sender → receiverraw ArrayBuffereach file's bytes, in manifest order, as many messages as needed — chunk size is whatever this connection's own RTCSctpTransport.maxMessageSize allows, up to a 512KB target
file-ackreceiver → sender{"type":"file-ack","batchSeq":N,"index":i}sent once a file's declared byte count has fully arrived; the sender waits for this before moving to the next file
file-restartsender → receiver{"type":"file-restart","batchSeq":N,"index":i}sent before retrying a failed file — never followed by a resend until the receiver answers (see below)
file-restart-ackreceiver → sender{"type":"file-restart-ack","batchSeq":N,"index":i}"I've discarded any partial bytes for this file, safe to resend" — or the receiver replies with a plain file-ack instead if it already had the file complete (the earlier ack was just lost, not the data)
Files are received by tracking bytes-arrived against the manifest's declared size per file (ordered, reliable delivery makes this sufficient — no extra per-chunk framing needed). A retry never sends a single recovery byte without the receiver's explicit answer first: resending blind on a merely-lost file-ack (data was actually fine) would land bytes against whatever file the receiver has since moved on to and corrupt it.