The store API, for applications
What an application can rely on when it reads and writes through a node's HTTP API. The
endpoints and their answers, one by one, are in openapi.yaml (served by every node at
/openapi.yaml, with a Swagger UI at /swagger-ui); this page is about what they mean
together.
The model
The store is one document, the same on every node once they have synced, read and
written as JSON. A path names a place in it: settings/theme is the key theme in the
map settings, and within a list a segment is an index (servers/0/name; -1 the last
item). Every node holds all of it and answers from its own copy: there is no leader to
ask, and no request goes to another node.
curl -u alice:secret -X PUT localhost:3000/store/settings \
-H 'Content-Type: application/json' -d '{"theme": "dark", "fontSize": 14}'
curl -u alice:secret localhost:3000/store/settings/theme # "dark"
curl -u alice:secret -H 'Accept: text/plain' localhost:3000/store/settings/theme # dark
curl -u alice:secret -X DELETE localhost:3000/store/settings/fontSize
curl -u alice:secret -X POST localhost:3000/store/queue/-1 -d '"job 7"' # append
curl -u alice:secret -X POST 'localhost:3000/store/stats/visits?increment=1' # count
GET /store/<path>reads,PUTwrites (what is there then reads as the value written; the maps on the way are created),DELETEdeletes (?prune=truealso removes the maps it leaves empty), andPOSTinserts into a list at an index, the items from there moving up, or with?increment=naddsnto a counter (Concurrent writes), or with?cast=<type>casts what is there to another type (Types).?signed=trueon any of them has the node sign the change (Signed changes).- Bodies are JSON (
application/json, the default), text (text/plain, stored as a string), or a value of a type of its own (Types); answers are JSON, or asAcceptasks. Numbers in JSON are stored as integers where they fit, as floats otherwise. - Every request needs a user from the node's
--htpasswd(basic authentication: send the password with the request, ascurl -udoes; the node asks for one with a401'sWWW-Authenticateonly of what isn't a browser, by itsSec-Fetch-*headers or aMozilla/…user agent, so a browser never prompts), named by a reverse proxy in front that authenticated it (--user-header;operations.md, Behind a reverse proxy), or a token from logging in on any node of the swarm (Authorization: Bearer,access.md, Tokens). What each may do, the swarm's user registry says, by path (access.md):403where it doesn't let the user, and of a part it may only partly read, a read shows the keys on the way to what it may. In a swarm without a registry, users are all alike: each can read and write everything but protocol data (below). - A key with
/in it, or an empty key, can be written inside a value at its parent, but not named in a path: read it through the parent.
Types
Every value is stored as one of Automerge's types: a map, a list, a text, or a scalar:
a string, bytes, a signed or unsigned integer, a float, a boolean, null, a counter or a
timestamp (milliseconds since 1970). Every read says which in x-continuum-type
(map, list, text, string, bytes, int, uint, float, boolean, null,
counter, timestamp), and so does ?shallow=true for each entry.
Each has a JSON form, so any value reads as JSON: bytes as base64, counters and the
integers as numbers, timestamps as RFC 3339 strings (2026-11-01T21:50:20.345Z), or as
Accept: application/json; timestamp-format=… says: rfc3339, unix (seconds, with
the milliseconds as a fraction), unix-ms or http-date (RFC 9110). JSON alone
doesn't tell them apart: written as JSON, values are maps, lists, strings, signed
integers, floats, booleans and nulls.
A scalar is also read and written as its own media type, its value in the body:
| Type | Media type | Body |
|---|---|---|
| string, text | text/plain |
the characters |
| bytes | application/octet-stream |
the bytes |
| int | application/vnd.continuum.int |
decimal |
| uint | application/vnd.continuum.uint |
decimal |
| float | application/vnd.continuum.float |
decimal |
| boolean | application/vnd.continuum.boolean |
true or false |
| counter | application/vnd.continuum.counter |
decimal (a PUT sets it) |
| timestamp | application/vnd.continuum.timestamp; format=… |
as the format says (rfc3339 by default) |
curl -u alice:secret -X PUT localhost:3000/store/logo -H 'Content-Type: application/octet-stream' --data-binary @logo.png
curl -u alice:secret -H 'Accept: application/octet-stream' localhost:3000/store/logo > logo.png
curl -u alice:secret -X PUT localhost:3000/store/deploy/at \
-H 'Content-Type: application/vnd.continuum.timestamp; format=unix' -d 1793569820
curl -u alice:secret -H 'Accept: application/vnd.continuum.timestamp; format=http-date' \
localhost:3000/store/deploy/at # Sun, 01 Nov 2026 21:50:20 GMT
Typed JSON (application/vnd.continuum.typed+json, for reads and writes) keeps every
type: it is JSON, but for what JSON would show as another type, which is a single-key
object whose key is a tag: {"$uint": 5}, {"$counter": 5}, {"$bytes": "<base64>"},
{"$timestamp": "2026-11-01T21:50:20.345Z"} (in its timestamp-format) and
{"$text": "…"} (a text); a float is a number, as in plain JSON. A map whose only key
starts with $ is {"$map": {…}}, so nothing else is taken for a tag; any other
single $ key is 400. A whole map with typed values in it is written at once this
way, and reads back as written. A text written over a text is changed where it differs
(a diff, as splices), so concurrent edits elsewhere in it stay.
Types are sticky: plain JSON (or text) written over a value takes its type where it is
that type's JSON form. A string over a timestamp that reads as one (RFC 3339, or as the
body's Content-Type: application/json; timestamp-format=… says, where numbers are
read as timestamps too), over bytes that is base64, over a text (then changed where it
differs); an integer over a float, an unsigned integer (if it isn't negative) or a
counter (which it sets). So what a client read as JSON writes back as it was stored,
whole maps included. Typed JSON and a type's own media type say the type themselves,
but for an integer over a float, which stays a float in typed JSON too.
A write that changes the type of what it writes over (a string over a timestamp that
doesn't read as one, a map over a list) says which in x-continuum-retyped
(deploy/at: timestamp -> string, comma-separated); with ?retype=false it is refused
instead (409, naming the first). A PUT answers with what is there after it, read
back, so its x-continuum-type is what the value was stored as.
POST <path>?cast=<type> casts what is there to a type (as x-continuum-type names
it), and answers as a PUT does: numbers (integers, floats, counters, timestamps as
milliseconds since 1970) to numbers where they are exactly that, any scalar but bytes
to a string or a text as its media type shows it (a timestamp in ?format=,
rfc3339 by default) and back, and bytes to a string or a text and back as UTF-8.
Anything else, or a value that isn't one of the type ("soon" to a timestamp, 2.5
to an integer), is 409, saying why; maps, lists and nulls aren't cast.
curl -u alice:secret -X POST 'localhost:3000/store/deploy/at?cast=timestamp&format=unix'
Accept is taken in its order (quality values aren't weighed): the first media type the
value can be shown as, JSON for */* or without an Accept, and 406 (naming those it
can be shown as) if none. text/plain shows a string as it is and anything else as
JSON.
Consistency
Reads are local. A node answers from its copy, which may lack writes other nodes
made a moment ago: they arrive by sync, as fast as messages get from the writer to
this node (a hop between neighbours, a few in a large swarm; measurements.md,
Propagation), and after a partition once it heals. There is
no read that waits for the rest of the swarm.
Writes are local too, and never refused for want of others. A write is applied to
the node's copy at once (the answer holds the value written) and spreads from there.
Two nodes cut off from each other both go on taking writes; when they meet again, the
writes merge. So a write that was answered 200 is durable on that node (with a
--document-file, within --document-append, 5 s by default) and reaches the others
eventually, but it isn't confirmed by anyone else when answered.
Read your own writes on the node you wrote to: its later reads include the write.
On another node, they include it once synced. ?heads=true on a read gives the
document's heads (x-continuum-heads, the hashes of its latest changes): two reads with
the same heads read the same document. Every write answers with the heads after it, in
the same header. ?at=<heads> reads the document as it was at those heads, on any node
that has them (404 on one that doesn't, yet), however it changed since: a client that
wrote through one node and reads its write through another reads with the write's
heads, and retries on 404.
One request is one change. What a PUT writes is applied whole or not at all, also
on the other nodes: a map written in one PUT never shows half its keys. There are no
transactions across requests: to change several values together, write their common
parent.
Concurrent writes
Writes made on different nodes before they synced are concurrent. They merge, the same way on every node:
- Different keys both stay: two nodes writing
settings/themeandsettings/fontSizeget both. - The same key: one write wins, the same one everywhere. "Newest" means by Automerge's operation order (the write made after seeing more changes; between equals, by the writer's actor ID), not by any clock: of two truly concurrent writes, which wins is arbitrary, but it is the same on every node.
- Maps written at the same key merge into one: their keys are united, each key as above.
- A write and a delete of the same key: the write stays (a delete removes only what the deleting node had seen).
- List inserts both stay, in an order every node agrees on. Writing a list item
(
PUTat its index) overwrites it: two nodes writing the same item concurrently are two writes of the same key. - Counters (
POST <path>?increment=n, made by the first increment) add up: two nodes incrementing one counter by 1 concurrently leave it 2 higher. Two nodes making the same counter concurrently make two, and one wins with its increments. - Strings are values, not texts: concurrent writes to one string don't merge character by character; one wins.
A write changes only what differs from what is there: a map written over a map keeps
the keys that stay (and what is inside them), gains those it lacks and loses those the
value doesn't have; a list written over a list does the same item by item, by
position; a value equal to the one there isn't written again. So a PUT of settings
as a whole, concurrent with a PUT of settings/theme elsewhere, keeps the theme,
unless the whole write changed the theme too (then one wins) or left it out (then the
theme's write stays, as against a delete). Where the path holds concurrent values, or
one of another kind (a map written over a list), it is written anew.
?conflicts=true on a read answers {"value": …, "conflicts": [...]}: every place on
the way where concurrent values were resolved, with all candidates and which was
selected (mergedMaps or newestWins). A later write at that path ends the conflict.
Following changes
GET /stream/<path> (or /stream for everything) is newline-delimited JSON: one event
with the current value, then one whenever the value there changes, by a write on this
node or by sync:
{"key":"settings/theme","value":"dark"}
{"key":"settings/theme","value":"light"}
{"key":"settings/theme","deleted":true}
While nothing changes, a blank line every 30 s (--stream-heartbeat) keeps proxies in
front from taking the stream for dead; skip blank lines.
An event carries the value as a read would return it then. Changes in quick succession may come as one event with the latest value: a stream tells the value, not every write. A change that leaves the value as it was sends nothing. The connection stays open; after it breaks, reconnect, and the first event is the current value again.
While a node isn't ready
A node serves the store once it holds the swarm's document (lifecycle.md):
- Joining (a new node, or one restarted without its document file):
503for reads, writes and streams, but forGET /store, the whole store, which answers with what has arrived so far.GET /ready(no authentication) says200once it is ready. - Rejoining a newer epoch after a remake it missed: reads answer from the document
before, with
x-continuum-stalenaming that epoch; writes are answered as usual and go into that document (so reads show them), carried over into the new epoch when the rejoin ends, unless newer writes touched their paths (Concurrent writes); streams are503. - Remakes (
remakes.md) don't interrupt anything else: a node switches to the new document between two requests, and writes made meanwhile are carried over (at their path, or where the remake's rules put them;remakes.md, Writes around a remake).
Load balancers can send requests to any ready node: /ready is the check.
Who wrote what
Every change records the node that committed it (as its Automerge author) and, for a
write through the API, the user who wrote it (in its message, as a trailer like Git's
Signed-off-by:: User: alice), with when (by the writer's clock). The user is a name
in the swarm's registry (access.md), as the writing node says: nothing
signs it, so a member could name another. In a swarm without a registry, user names
are each node's own (--htpasswd, or its proxy's): alice on two nodes is one person
only where their operators make it so, which nothing checks.
GET /admin/changes lists them,
newest first, with the paths each touched and whether a signature by its node vouches
for it (verified, Signed changes), and GET /admin/refused those this node refused under the swarm's policy (access.md, Changes judged on every node); GET /admin/changes/settings those that
touched what is at settings.
Every node holds every change, so user names are known to the whole swarm, and so is
who wrote what, until a remake drops the history (remakes.md).
Signed changes
Every node signs changes it commits with its node key: an Ed25519 signature over the change's hash, kept beside the document. One signature vouches for more than its change: a node's change builds on its previous one, and a change's hash covers what it builds on, so a signed change vouches for all the node's changes before it, those it builds on. No others: anyone can make a change under a node's actor, and one in the place of a change of the node's the document holds is forged, refused as its sender's, not the node's. So not every change is signed, only those that
- touch protocol data (
_continuum), - touch a path listed in
_continuum/access/signed(within it, or above it), a list of paths, each a list of segments as capabilities name them (access.md):[["config"], ["servers", "eu"]], - or were written with
?signed=true.
A change left unsigned, such as a metric written many times a second, is vouched for
by the node's next signed one. Which that is, --sign-every says:
sent(the default): the node signs its latest change, if nothing signed it yet, as a sync message takes it. The signature leaves in the same message as the changes it vouches for, so every change arrives vouched for. A node writing now and then signs each change; one writing many a second signs once per message, however many changes it carries: as many signatures as messages, not as changes.all: every change, as it is made. Each change has its own signature, also one never sent (a change made and kept while the node is cut off); the cost is a signature per change, made, kept and checked on every node.none: only those above. A change left unsigned waits for the node's next signed one: until then nobody can tell it is the node's.
Which to choose: sent for nearly every swarm, slow or fast, since it signs what
leaves and nothing more. all where each change should carry its own proof, as when
changes are read with their signatures one by one later, at a signature per change
(about 25 µs to make, 30 µs to check on every node, 96 bytes kept). none where who
wrote a change matters only for what protocol data and the listed paths hold, to save
the signatures entirely. A swarm whose policy requires signatures
(access.md, Changes judged on every node) signs what is sent with
none too: every change must arrive vouched for.
Signatures travel with the changes, in the sync messages that carry them, along the same paths: a message carries the signatures of the changes it carries, whatever the peer was sent before, so changes sent again (a message lost on the way) arrive vouched for as the first time; and a node checks one it receives once it holds the change (a signature costs about 30 µs to check, and one vouches for its node's changes before it). A message carrying no changes carries no signatures; one carrying more than 256 signed changes (a whole document pulled) carries each writer's latest among them instead. Its signatures count towards a message's size, past which its changes are offered as a delta (fetched in chunks, from any node holding them); a delta's signatures, each writer's latest with changes in it, are a blob of their own, fetched from the node that offered it before the changes. A writer is a node's run: each start, rejoin or drop makes a fresh one. A node signs the latest change of each of its runs that nothing vouches for as it starts, before anything leaves, and whenever its document is swapped (a drop, a switch, a rejoin): changes it kept unsigned, never sent.
Two cases still arrive unvouched, and wait under a policy requiring signatures: a change
vouched for only by a later change of its node that the rules refused (nodes take it,
then can't pass the proof on); and, in one transfer, the changes of runs past the
65536 a message carries signatures of at most, in an epoch with more runs writing than
that since it began. A remake starts the count again.
The signatures a node has are kept in its document file (--document-file) with the
document.
Signed values
Where the swarm has its users sign values (access.md, Values signed by
their users), a user writes one by signing it with its SSH key: the payload, one line
of JSON, then the signature, as application/continuum-signed.
# The swarm it is for; the envelope there, if any, and what replaces it names: its
# SHA-256 (none: "").
swarm=$(curl -sf -u alice localhost:3000/auth/whoami | jq -r .swarm)
read=$(curl -sf -u alice -H 'Accept: text/plain' 'localhost:3000/store/config?raw=true')
prev=${read:+$(printf %s "$read" | sha256sum | cut -d' ' -f1)}
printf '{"swarm":"%s","path":["config"],"value":{"mode":"fast"},"user":"alice","at":%s,"prev":"%s"}' \
"$swarm" "$(date +%s000)" "$prev" > payload
ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n continuum-value payload
{ cat payload; echo; cat payload.sig; } | curl -X PUT localhost:3000/store/config \
-u alice -H 'Content-Type: application/continuum-signed' --data-binary @-
The node writes it as its envelope if the authenticated user signed it (403 if not),
for this swarm and this path, with one of its keys, may write there (403), and it replaces what is
there (prev, the SHA-256 of the envelope it replaces; 409 if it names another:
read again and sign anew), and only where values are signed (409 elsewhere). It
answers with the value, as a PUT does; a signed null deletes, with 204. It stays
as an envelope (reads show nothing there), so the value written next names it in
prev: the chain goes on through deletions, and an envelope from before one can't be
put back. Under
the policy, every other write reaching a signed value, at it, within it or above it,
is 409, and so is the users API's.
A read opens envelopes: each shows as the value it holds, a signed null as nothing,
and a read of one names its signer (x-continuum-signed-by). ?raw=true shows them as
they are stored, envelopes, which anyone can check; so do typed JSON and native reads,
and streams (/stream).
Sealed values
A value may be sealed for its readers, encrypted with age to their SSH keys
(access.md, Values sealed for their readers): a string,
cs1.<age file>, that the API stores and shows as it is. Where the swarm declares
values sealed, a write leaving one there plain, or sealed for others than the declared
readers' keys, is 409.
Protocol data
_continuum holds what the nodes coordinate through: the swarm ID, the member registry,
remakes and votes (architecture.md, The document). It is part of the store and shows
in GET /store, but isn't application data: only users given it may write it
(access.md; in a swarm without a user registry, those in --protocol-writers), for
repairs. Keep application data out of it, and leave it out when reading
the whole store if it's in the way.
Large stores
?shallow=true on a read answers what is there one level deep (a map's keys with their
types and sizes, short scalars' values) instead of the whole value, to browse a large
store a level at a time. Requests are limited to 2 MiB. Every write keeps its history
until a remake compacts it; a document of many small writes grows faster than its
content (remakes.md).
