obsync wire protocol v1¶
Internals, for contributors and reviewers.
Dated 2026-09-07. HTTP/1.1, JSON request and response bodies unless a chunk
body is named, UTF-8, no cookies on the device API. Every path is prefixed
/v1. Errors are {"error":"<snake_case_code>","detail":"<human text>"}
with the status codes listed. Times are unix milliseconds unless the field
says seconds. Hex is lowercase.
Authentication¶
Device endpoints require four headers:
| Header | Value |
|---|---|
X-Obsync-Device |
device id, 32 hex chars |
X-Obsync-Ts |
unix seconds, decimal |
X-Obsync-Nonce |
32 hex chars, fresh per request |
X-Obsync-Sig |
hex HMAC-SHA-256 (see below) |
sig = HMAC-SHA-256(device_secret,
"obsync/v1\n" + METHOD + "\n" + path_and_query + "\n" + ts + "\n" + nonce + "\n" + hex(SHA-256(body)))
path_and_query is the request target exactly as sent (/v1/changes?since=7).
An empty body hashes as SHA-256 of zero bytes. Rejections: 401
bad_signature, 401 stale_timestamp (outside ±300 s), 401 replayed_nonce
(seen within 600 s, and the 600 s survives a restart: accepted nonces rest on
the journal volume and are fsynced before the request is answered), 503
nonce_cache_full (the replay cache is at its ceiling; refusing beats
forgetting a nonce still inside its window), 503 nonce_share_full (this
device holds its whole share of that cache, 50,000 nonces, a quarter of it;
only this device is refused), 507 storage_full (the journal volume has no
room for that record, so every signed request is refused, a read included;
server 1.1.5, and 503 nonce_log_unavailable before), 503 nonce_log_faulted
(a refused record could not be cut back off the volume either, so every
signed request is refused, and /readyz answers 503 not_ready, until the
server restarts; server 1.1.5. Like 503 journal_faulted, a client says it
as the restart it needs and does not retry it as absence; plugin 1.1.5), 503
nonce_log_unavailable (the volume would not take that record for any other
reason), 403 device_revoked (answered from the device
record before the signature is checked, because revocation destroys the
wrapped secret and leaves nothing to check it against; archiving that device
does not change this answer, because it does not remove the record),
403 device_pending
(a claimed device that has not yet collected the envelope its creator
approved, answered only AFTER its signature verifies; only that pairing's
envelope endpoint admits it, with 409 not_approved until the approval).
Pairing claim and envelope fetch are the only device endpoints with their own
rules (below). Admin endpoints use the dashboard session cookie plus
X-Obsync-Csrf.
In OBSYNC_EDGE=cloudflare mode every request must also carry the edge's
connecting-address and request-id headers, once each, from a peer inside
OBSYNC_TRUSTED_PROXY_CIDRS, or it is refused with 421 edge_required.
Idempotence¶
A nonce is spent by being sent, so a retry that reuses the headers of a lost
request is answered 401 replayed_nonce — a refusal the client manufactured
for itself. Every retry is signed afresh, with a new timestamp and a new
nonce.
That makes the question "may this request happen twice?" the client's to answer before it retries at all, so every route states it here. Repeatable means a second send leaves the server where one send would have left it and answers the same.
| Route | Repeatable | Why |
|---|---|---|
GET /livez, GET /readyz |
yes | reads |
GET /v1/account |
yes | read |
POST /v1/setup |
no | creates or recovers the account and mints a credential |
POST /v1/account/recovery |
no | explicit registration acknowledgement; a lost response is surfaced |
POST /v1/pairing |
no | mints a pairing and an enroll token |
POST /v1/pairing/{id}/claim |
no | mints a device credential |
GET /v1/pairing/{id} |
yes | read |
POST /v1/pairing/{id}/reveal |
yes | holds one key; the same key again answers 204 again |
POST /v1/pairing/{id}/approve |
no | consumes the pairing |
POST /v1/pairing/{id}/reject |
no | destroys the pending device; refuses an approved pairing |
GET /v1/pairing/{id}/envelope |
no | single use; then 410 envelope_consumed |
GET /v1/devices |
yes | read |
PATCH /v1/devices/{id} |
no | write |
POST /v1/devices/{id}/revoke |
no | write, and one-way |
POST /v1/devices/{id}/archive |
no | write; a second send leaves the same state and answers 204 |
POST /v1/devices/heartbeat |
no | write |
POST /v1/chunks/exists |
yes | a read; POST only because the sid list is long |
POST /v1/chunks/get |
yes | a read; same reason |
PUT /v1/chunks/{sid} |
yes | the sid IS the body's hash |
GET /v1/chunks/{sid} |
yes | read |
POST /v1/files/{id}/versions |
no | appends a version and moves the heads; may answer with an identical version's id |
GET /v1/files…, GET /v1/changes |
yes | reads |
POST /v1/dashboard/login-link |
no | mints a single-use token |
A client that loses the answer to a no row must not re-send it. The
outcome is unknown, not failed: the request may already have been applied.
Settle it by reading what the server holds — the file record for a version
post, the device list for a revoke or an archive — or tell the user, with the reason, that
it is unknown. The plugin's table is ROUTES in plugin/src/transport.ts
and a test asserts every route it emits appears there.
Health¶
GET /livez→200 okwhile the process runs.GET /readyz→200 {"ready":true}when volumes are writable, the journal is replayed and its usage is verified, neither the journal nor the nonce log is faulted (the nonce log since server 1.1.5), and no shutdown is in progress; else503 not_ready. A journal whose usage survey was refused is re-surveyed by this probe, so a fixed volume answers200again without any write.
Setup and account¶
POST /v1/setup(no device auth; the token is the credential){"setup_token":"…","account_name":"…","device":{"name":"…","platform": "…","app_version":"…"}}→201 {"account_id":"…","device_id":"<32hex>", "device_secret":"<64hex>"}: creates the account and enrols the first device in one step, since pairing requires a paired device. Without recovery proof,409 already_set_upafterwards;401 bad_setup_tokenotherwise. The token is compared FIRST, so both refusals are reachable only in that order: a caller holding the token learns the account exists, and a caller without it learns nothing about whether the server is claimed.- First setup may include
recovery_verifier:<64hex>, committed in the same durable account frame. Existing accounts may be re-entered through the samePOST /v1/setupwith the setup token andrecovery_proof:<64hex>; the response also hasrecovered:true. The proof is 32 bytes fromHKDF-SHA-256(VRK, salt=utf8("obsync/v1/account-recovery"), info="", L=32). The verifier is lowercase hexSHA-256(proof). The server compares hashes in constant time, never receives VRK or a content decryption key, and returns403 bad_recovery_prooffor a wrong proof. An account with no verifier answers409 recovery_unavailable, whatever the proof, unless the operator's offlineobsyncd recovery reset applyhas armed it since. That step rotates the setup token and arms exactly one re-enrolment: arecovery_proofthen registers the verifier it derives (timed, so the last-device hold applies from then) and enrols. The server has nothing to check that proof against, so the authority is the offline reset and the new token only it hands out; the proof only chooses the verifier. The first verifier registered after the reset spends the arm, by this route or by a device'sPOST /v1/account/recovery, and a later recovery is proved against it. No device-authenticated request can clear a verifier or arm this. A valid recovery enrolls a new active device on the same account, without renaming it, replacing content or reviving revoked credentials. It is never auto-retried. A server before 1.1.5 answers409 recovery_unavailableinstead of re-enrolling. POST /v1/account/recovery(device auth){"recovery_verifier":"<64hex>"}→204. Register once after the client has successfully opened its vault. Repeating the same verifier is harmless;409 recovery_mismatchrefuses replacement. Invalid shape is400before storage changes. The verifier survives journal replay and snapshots but is omitted from account responses. Old accounts without the field remain readable and retain their last-device safeguard. Since 1.1.5 the server also records when the verifier was registered, and nothing but the operator's offlineobsyncd recovery reset applyremoves a verifier; no route does.GET /v1/account(device auth) →{"account_id","name","created", "quota_bytes","used_bytes","device_count"}.device_countis how many devices can sync: the active ones and those still pairing. A revoked device, archived or not, stays a record and is not counted. A server before 1.1.5 counted every record, revoked ones included; the field kept its name because the one client that shows it prints "N device(s)", and the devices that can sync is what that sentence means. The dashboard's overview carries the sameaccountobject.
Pairing¶
Since 1.1.3, a claim may include vault: {"envelope":"<base64>","nonce":"<24hex>"}.
It seals UTF-8 JSON {"name":"<vault name>","notes":<Markdown note count>} with
AES-256-GCM, a random 12-byte nonce, and the pairing ID as additional data.
The key is HKDF(PS, "obsync/v1/pair-vault", pairing_id) (32 bytes), distinct
from the vault-key envelope key. Names are 1–256 JavaScript string units with
no control or bidi formatting characters; counts are nonnegative safe integers.
The server accepts only a valid base64 envelope of at most 2048 characters
and at least 16 decoded bytes, plus a 24-character hexadecimal nonce. It
validates before enrolling, retains only those two fields in the in-memory
pairing, and returns them inside claimant.vault to the creator alone.
No clear vault name or note count reaches storage or logs. The approving device
must authenticate and validate present details before offering approval.
Absent details preserve pairing with older clients or servers; an old server
ignores the optional field, so its approval prompt cannot name the new vault.
POST /v1/pairing(device auth) →201 {"pairing_id":"<32hex>", "enroll_token":"<64hex>","expires":<unix_s>}.POST /v1/pairing/{id}/claim(no device auth; body carries the token){"enroll_token":"…","name":"…","platform":"ios|ipados|android|macos| windows|linux","app_version":"…"}→201 {"device_id":"<32hex>", "device_secret":"<64hex>"}.404 unknown_pairing,410 pairing_expired,409 already_claimed. The device is created in statepending: it holds a credential, but every device-authenticated route refuses it until it collects the envelope the creator approved. Since 1.1.4 an expired pairing still answers410 pairing_expiredfor an hour to a claim carrying its token (the server remembers at most 256), while any other token reads404 unknown_pairing, as it would for a live pairing. Since 1.1.5 the claim carriesclaimant_pub(pairing v2, below); the server validates its shape and returns it to the creator verbatim.GET /v1/pairing/{id}(device auth, creator only) →{"state":"open| claimed|approved|consumed|expired","claimant":{"device_id","name", "platform","app_version","claimant_pub"?}|null}. For the same hour after expiry the creator readsexpiredor, if the key was collected,consumed, with anullclaimant.POST /v1/pairing/{id}/reveal(device auth, creator only, 1.1.5){"creator_pub":"<87 base64url>"}→204: the creator's pairing v2 key, held verbatim and returned to the waiting claimant (below).409 not_claimedbefore a claim,409 already_approvedafter the approval,410 pairing_expiredafter the ten minutes, and409 already_revealedfor a key other than the one already revealed; the same key again answers204again.POST /v1/pairing/{id}/approve(device auth, creator only){"envelope":"<base64 AES-GCM ciphertext>","nonce":"<24hex>"}→204. Approval activates nothing by itself (1.1.4; earlier servers activated the claimant here).POST /v1/pairing/{id}/reject(device auth, creator only) →204; the pending device and its wrapped secret are destroyed. Only a CLAIMED pairing is rejectable: an unclaimed one is409 not_claimedand one the creator already approved is409 already_approvedand changes nothing, because the claimant is a paired device by then and deletion carries no last-active guard. A paired device is taken away withPOST /v1/devices/{id}/revoke. Expiry of a pairing whose claimant never collected the envelope, approved or not, destroys them the same way, and so does a restart: pairings live in memory, so a claim that does not survive one leaves a device nobody can approve, and the start destroys it. The claimant pairs again.GET /v1/pairing/{id}/envelope(device auth, claimant only) →409 not_approveduntil the creator approves (the claimant polls this), carrying"creator_pub"beside"error"once the creator revealed it; then{"envelope","nonce","creator_pub"?}exactly once;410 envelope_consumedafterwards, and410 pairing_expiredonce the ten minutes have passed. Collecting it moves the device to stateactive: the activation is journaled before the envelope is answered, and a refused journal write consumes nothing.
Pairing v2: one way only (1.1.5)¶
Before 1.1.5 the code's pairing secret PS alone sealed the vault-key
envelope, and the two screens compared HKDF(PS, "obsync/v1/pair-match",
pairing_id + ":" + device_id): anyone who read the code could open the key,
and whoever chose the device id could make the two codes agree. 1.1.5 pairs
only as below, and refuses a device that would pair the old way, in either
role.
- Keys. Each side generates an ephemeral P-256 key pair whose private
key is non-extractable and never leaves the device, fresh for each code.
The public key crosses raw-uncompressed (65 bytes,
0x04prefix) as base64url: the claimant's asclaimant_pubin the claim, the creator's ascreator_pubin the reveal. The server validates the shape (87 base64url characters decoding to 65 bytes beginning0x04), holds the two fields verbatim with the in-memory pairing, never logs them and does no elliptic-curve mathematics on them. - The code commits to the creator's key. The creator makes its key pair
before the code, and the code is
base32(pairing_id || enroll_token || PS || C), 80 bytes and 128 characters, whereC = SHA-256( "obsync/v2/pair-commit" || pairing_id || creator_pub)[0..16], the key raw. The code is the one channel the network cannot change, so the claimant learns which key to expect from it; a reader of the code learns nothing that computes a match code. A 64-byte code (a device before 1.1.5) is refused with the words to update that device; anything else short of 80 bytes is incomplete. - The order is the authentication. The creator reads the claim once and
FIXES it: the claimant key it holds is the one it compares and seals for,
and it never reads the claim again. Only then does it reveal its key. So
whoever puts a key into the claim chooses it without knowing the creator's.
The claimant shows no code until the reveal reaches it on its
not_approvedpoll, checks it againstC(a key that fails ends the claim: nothing shared, the words say the key does not match the code), and from then on accepts no other creator key, waiting or with the envelope. - Match code. Six digits of
HKDF(ikm = PS, salt = "obsync/v2/pair-match", info = pairing_id || claimant_pub || creator_pub), the keys raw, as a big-endian integer modulo 1,000,000, shownddd ddd. Every input on the claimant's screen came through the code or from itself; no device id is an input, because the server chooses those and could choose one after reading the creator's key. A substituted key then agrees by chance alone, one in a million per attempt, and each attempt costs the person a new code. - Seal.
K = HKDF-SHA-256(ikm = ECDH(claimant, creator), salt = PS, info = "obsync/v2/pair" || pairing_id); the envelope is AES-256-GCM over{"vrk":…}with a random 12-byte nonce and additional datapairing_id || claimant_pub || creator_pub(the raw keys), so it cannot be replayed into another pairing or opened against a substituted key. A copy of the code and a record of every request open nothing. - What is refused. A claim without
claimant_pub(a device before 1.1.5, or a key stripped on the way: the two look the same) is rejected on the creator, which says to update the new device; a claim whose name orapp_versionis not what a server accepts (1 to 64, and 1 to 32, printable ASCII) is rejected as one no obsync server sends. The claimant refuses an envelope withoutcreator_pub. A claim a device before 1.1.5 held across a restart is dropped at the next start, never finished; one the update interrupted after it collected its key leaves its device listed with no keeper, to be revoked under Devices. - The server first. A server before 1.1.5 drops the key fields and has
no reveal, so before
POST /v1/pairinga 1.1.5 creator readsGET /v1/plugin/manifestand makes no code unless theversionit reports is 1.1.5 or later (major.minor.patch, a pre-release counts); an older version, a malformed one, or none (404 plugin_unavailable) is refused with one sentence: update the obsync server to 1.1.5 or later, then pair. The version is unauthenticated, and that is safe here: a forged answer can only cause the refusal, or reach a server that cannot complete the reveal. - Paired means kept. Collection (
consumed) proves only that the envelope left the server: a claimant that cannot open it, or whose person cancels, revokes itself. So the creator says "paired" only once the claimant's row inGET /v1/devicesisactiveand shows the sync a kept key starts: - A
last_heartbeatat all (server 1.1.5, issue #290) proves it. Only a started sync sends a heartbeat: never the claim, never the survey. A pending device's heartbeat is refused, and the row is minted for this claim. - Otherwise,
last_seenafterlast_sign_in. The sign-in is the claimant's first request after collection, reading the server's vault before it keeps the key.
Seen events are stamped in whole seconds, so a heartbeat in the second of
the sign-in leaves last_seen equal to last_sign_in. That is why the
heartbeat's own field decides, whatever second it shares.
A row that is revoked or gone reads "did not keep the vault key"; neither within ten minutes reads "not confirmed".
Skew.
- A 1.1.4 server is refused before any code is made.
- A server that does not list last_heartbeat (a 1.1.5 build before #290)
is read by the comparison alone. A same-second start there waits out the
ten minutes and reads "not confirmed", never "paired" early.
- A client that does not read the field sees what it saw before.
Devices¶
The plugin's syncFolders selection is local-only and is not a field of
device policy, heartbeat, pairing or the domain map. It grants no API
permission and cannot be expanded by another device; all paired devices
retain the account-wide authority described below.
GET /v1/devices→{"devices":[{"device_id","name","platform", "app_version","created","last_seen","last_sign_in","last_edit", "last_heartbeat","address","country", "policy":{"per_file_max_bytes","total_budget_bytes"}, "state":"pending|active|revoked","revoked":false,"archived":false}]}. Onlyactivedevices count for the last-device rule.archived(server 1.1.5) is a property of a REVOKED device and never a state of its own: an archived device is still listed, still"revoked":true, and still named, so a client that does not read the field shows exactly what it showed before. A client that does read it leaves those devices out of the lists a person manages.last_heartbeat(server 1.1.5, #290) is the device's latest heartbeat, in the same whole-second stamps aslast_seen, andnullbefore any.PATCH /v1/devices/{id}{"name"?, "policy"?}(self or any paired device) →200the device.POST /v1/devices/{id}/revoke→204. A device cannot revoke itself while it is the only active device unless account recovery is registered:409 last_devicewithout it, and since 1.1.5409 recovery_too_newwhile the verifier is younger than seven days, a constant. A verifier registered before 1.1.5 carries no time and counts as older.POST /v1/devices/{id}/archive(server 1.1.5) →204: a REVOKED device is taken off the device lists a person manages. Nothing is destroyed: the record still answers that device403 device_revoked, and still names the versions it wrote.409 device_not_revokedfor an active or pending device (revoke it first),409 own_devicefor the asking device,404 unknown_device; a second archive of the same device answers204. The flag is on the journal before the204, inside thedevice_updateframe, so a server that predates it replays that frame as the no-op update it reads rather than refusing the journal. A server before 1.1.5 answers404 not_found(no route).POST /v1/devices/heartbeat{"app_version","policy"}→204; updateslast_seenand the reported policy. Sent on start and hourly.
Chunks¶
POST /v1/chunks/exists{"sids":["<64hex>",…]}(≤ 4096) →{"missing": ["<64hex>",…]}.PUT /v1/chunks/{sid}body = raw ciphertext,Content-Lengthrequired, ≤ 8 MiB + 16 bytes (8 MiB plaintext plus the AES-GCM tag); larger declarations receive413 body_too_largebefore body storage. The server hashes while streaming to a temp file and refuses with422 sid_mismatchifSHA-256(body) ≠ sid,507 volume_fullbelow the watermark,507 storage_fullwhen the filesystem itself has no room (ENOSPC, or a filesystem quota'sEDQUOT: a capacity declared larger than the disk; any write route answers it, server 1.1.5, and500 io_errorbefore),507 quota_exceededover the account quota,503 slow_bodywhen the body arrives more slowly than the minimum rate below. Success201(new) or200(already present). Idempotent.GET /v1/chunks/{sid}→ raw ciphertext withContent-Length; honorsRange(single range) →206.404 unknown_chunk.POST /v1/chunks/get{"sids":[…]}(≤ 64) →multipart/mixed, one part per sid in request order, each withX-Obsync-SidandContent-Length; a missing sid yields a zero-length part withX-Obsync-Missing: 1. The sum of stored ciphertext lengths must be ≤ 32 MiB, excluding multipart framing, or the response is413 batch_too_large. Clients budget by the ciphertext maximum: the plugin fetches at most three chunks of one file per batch, while ordinary upload concurrency remains four on desktop. From plugin 1.1.4 it also asks for the single chunks of up to 64 notes of one feed page at once, within 32 MiB (8 MiB on a phone) counted by the lengths their records declare, and refuses a larger answer. Cuts request count over a proxied hop.
Files and versions¶
POST /v1/files/{file_id}/versions{"version_id":"<64hex>","parents":["<64hex>",…],"sids":["<64hex>",…], "bytes":<n>,"domain_id":"<32hex>","manifest_ct":"<base64>", "manifest_nonce":"<24hex>","deleted":false,"accept_existing":false}→201 {"seq":<n>,"version_id":"<64hex>", "heads":["<64hex>",…],"conflicted":false}.version_idin the answer is the version the store holds for this post: the posted id, except on the one case below. Rules: every sid must exist (409 missing_chunkswith the list);version_idmust equal the server's recomputation (422 version_id_mismatch);domain_idis required and must equal the file's own (409 domain_mismatch), which its first version fixed for life;parentsequal to the current heads → sole head; otherwise the version is added as a head andconflicted:true. A file holds at most 64 heads, the same number of parents a version may declare, so a conflict is always resolvable by one merge naming every head; a version whose acceptance would leave a 65th is refused with409 too_many_headsand nothing already stored changes. Posting an existingversion_idis a200no-op.- One position, one version. Two devices that resolve the same conflict
to the same bytes post the same parents and the same chunks under two
version ids, because the id covers the encrypted manifest and its nonce;
the file forks and closing it costs another version. A post carrying
"accept_existing":truewhose(file_id, parents as a set, sids in order, deleted)equals a version the store already holds is answered200with THAT version'sseqandversion_id, and no frame is written. The same sids under other parents, or the same parents with other sids, is a new version as before, and so is a tombstone over an empty file. The field is the client's promise to store theversion_idit is answered with: a client that keeps the id it computed omits it (as every 1.0.x client does) and is never answered with another id, because it would otherwise remember a version this server never stored. The decision is logged (decision=deduplicated). Plugin 1.1.3 relies on it for a fork whose heads do not merge: every device that settles the fork posts the same closing version and the same first version of one conflict copy, under a file id derived from the fork (docs/architecture.md3.4.1), and the server keeps one of each. No server change. GET /v1/files/{file_id}→{"file_id","domain_id","heads":[…], "conflicted","versions":[{"version_id","parents","sids","bytes", "manifest_ct","manifest_nonce","device_id","ts","deleted"}]}newest first, capped atOBSYNC_RETENTION_VERSIONSplus every head, and at 450 MiB of JSON ("Limits and headers"). The domain is stated once on the file, because every version of a file is in it.GET /v1/files/{file_id}/versions/{version_id}→ one version record.GET /v1/files?after=<file_id>&limit=<n>→{"files":[{"file_id", "domain_id","heads","conflicted","latest_ts"}],"next":"<file_id>|null"}. Used for initial reconciliation, and by a device checking which of its versions a server rebuilt from a backup still holds (plugin 1.1.3,docs/architecture.md6.2.4); the feed is the normal path.
A tombstone is a version with "deleted":true and no sids.
Since plugin 1.1.3, when a concurrent edit wins over a deletion, its live
settlement names both the locally held version and the tombstone as parents.
The deletion remains in version history but ceases to be a current head.
Only those observed versions are incorporated; an unseen concurrent live edit
remains a head for the ordinary merge rule. Identical settlements opt into
accept_existing, so two devices resolving the same position store one version.
A failed publication retains the local file and warns truthfully; a successful
settlement adds no deletion notice. Replaying the historical tombstone against
its live descendant changes nothing. This uses the existing version graph and
v1 manifest, so older servers accept it and older clients read the kept note
normally; they may still create a new unresolved deletion fork themselves.
A retirement (plugin 1.1.3) is a tombstone for a file id that duplicates
another id holding the same note at the same name. Its manifest adds
"keeper":"<32hex>", the id that keeps the name. A receiver that still records
the retired id there, over a file whose bytes the keeper's live head holds,
records the name under the keeper and deletes nothing. A plugin before 1.1.3
ignores the field and applies an ordinary deletion. No server change.
Folder records (plugin 1.1.0)¶
A folder is one more version on this same endpoint, with no chunks. The
server has no folder concept and needs none: sids is empty and bytes is
0, which it already accepts for a tombstone, and the manifest — which it
cannot read — says the rest. No server change; a 1.0.x server serves this.
Inside manifest_ct:
| Field | Value |
|---|---|
v |
2 — a file manifest is 1 and is unchanged |
kind |
"directory" |
path |
the folder's canonical relative vault path |
domain |
the domain id, as a file manifest carries it |
size |
0 |
chunks |
[] |
sha256 |
"" |
deleted |
false to create the folder, true to remove it |
There is no mtime: a folder has no content to be newer than, and leaving it
out is what makes the manifest two devices produce for one folder identical
(docs/architecture.md 3.4.1). With the file id derived from the path and the
nonce derived from the message, two devices publishing the same folder produce
the same version_id, so the second post is the 200 no-op this document
already specifies for a version the server holds.
That also makes a folder CREATED AGAIN where one was deleted -- a rename back
to an earlier spelling is one -- the folder's first version, which the server
holds and appends nothing for. So from plugin 1.1.4 a device that creates or
renames a folder here, and is answered with heads that do not include the
record it posted, posts it once more with those heads as its parents; every
device doing the same computes the same version. A device's start-up
publication of a folder it merely has no record for does not: a folder a
tombstone found occupied and kept is not brought back to the devices that
deleted it. A 1.1.3 device applies such a record as any other create.
Whose folders a device publishes and receives records for. A device that
syncs only some folders (syncFolders, local-only, above) publishes a folder
record for each SELECTED folder and for every folder inside it, and receives
the same. The selected folder itself is included because a folder record IS
its path: nothing else can carry that folder's own creation, removal or
rename. A folder ABOVE a selected one is never published, and a FILE record
keeps the stricter rule -- a selected folder is a directory, never a file
wearing that exact name.
A receiver additionally admits a folder record whose path differs from a
SELECTED folder by the capitalisation of its LAST component alone -- an
ancestor spelled differently is a folder it syncs in neither direction, and no
host could apply that difference anyway, because rename(2) resolves a
destination's directory components. That tolerance exists for one thing: a
rename of the folder this device selects, made elsewhere. It is admitted only
in the state that rename creates on the wire, and refused in every other:
The admission rule. A folder record whose path differs from a selected folder by the capitalisation of its last component alone is admitted only when the tombstone for THAT folder's own record -- the record this device holds for it, by its file id -- has been applied, no record has been written for that folder since, and no folder record has already used that admission. The first record WRITTEN in that state takes it -- one the vault refuses, or one that fails and is retried, does not (plugin 1.1.4); anything else is refused as
decision=not_synced reason=outside_sync_scope, with one notice naming both spellings.
The retirement is a STATE, not a clock. It lasts until a record is written for that folder -- by the feed, by the re-case itself, or by this device's own republication of that folder, which is what happens when the tombstone was a DELETION that found the folder occupied and no rename follows it. That republication waits until the feed has caught up past the tombstone (plugin 1.1.4): a start-up pass holds it, so a device stopped between the tombstone and the rename's record still takes the rename when it starts. When the tombstone REMOVED the directory, nothing republishes it and the retirement stays armed but inert: a record it admits names a folder this vault no longer holds, and the vault's answer refuses it. Inside the window one record one capitalisation off that folder is admitted, and the vault's own answer still decides what becomes of it. The rule grants a sender no authority it did not have: a device that can publish a folder record can rename that folder in any case. What it takes away is a SECOND device's folder being read as this device's rename.
The rule is what a string comparison cannot be: a device whose filesystem
KEEPS the two spellings apart can hold Team docs and team docs at once, and
its record for the second one is indistinguishable, as a string, from a rename
of the first. Asking the vault does not settle it either -- a receiver that
folds case holds one directory entry for both BY CONSTRUCTION, so it answers
"one entry" for a folder it has never heard of. What settles it is that a
rename retires the old name: both senders of a capitalisation-only rename
publish the old spelling's tombstone before the new record (below), and a
second folder carries no tombstone at all. Admitted, the record is applied by
asking the vault as before: where the two spellings are one directory entry the
record names that device's selected folder, the entry is re-cased and the
selection follows the new spelling; where they are two, the record names a
folder that device does not sync and is skipped in the same words.
Publication order, for a rename that changes case alone. A folder rename
publishes a tombstone for the old path, a record for the new one, and a move
per file beneath it. For an ordinary rename the moves go FIRST, so the old
folder is empty on the receiving device by the time its tombstone arrives. For
a rename that changes only capitalisation the FOLDER RECORD goes first, and
that order is load-bearing rather than cosmetic: on a host that folds case the
two spellings are one directory entry, rename(2) resolves the directory
components of a destination and renames only its last component, so no
per-file move can re-case a directory. The folder record is the only record
entitled to, and a receiver applies it by renaming the directory entry itself
and carrying every record beneath it along.
First on the WIRE, not first in a queue. A sender drains its queue in
batches and posts each batch concurrently, so a record enqueued first can
still be journaled after one enqueued behind it. For this record that is not
good enough, and the guarantee is therefore stated as an order on the wire:
the sender waits for the server to acknowledge the folder record before it
sends any move under it, and a post that FAILS keeps that hold rather than
losing it -- the publication is put back in front of the moves it orders and
attempted up to three times in all. The hold also survives the SENDER: a
publication that has not been acknowledged is written into the device's own
state with the fact that it orders what follows it, so a device stopped
mid-drain -- quit, reloaded, closed -- restores it at the head of its queue
before it reconciles anything and before any file work, and the moves queue
behind it again. Every other folder record the next start owes is re-derived
by that pass from the vault's own listing, which publishes them before it
queues any file work for the same reason. At that bound the hold expires with one
logged decision (push path_class=folder decision=expired reason=folder_post
attempt=3 budget=3) and one notice, the moves go out and are refused by a
folding receiver in the usual words, and the sender's next start-up pass
republishes the record. The queue never waits forever and never gives the
hold up silently. A receiver that meets a per-file move whose only
difference lies in a directory component -- the shape a device older than
1.1.0 publishes, which sends no folder record at all -- REFUSES it
(decision=case_move_refused reason=folder_case, one notice per folder) and
changes nothing, because recording a spelling its own listing contradicts is
what makes two devices trade the same rename forever.
A rename nobody reported has the same order. A folder re-capitalised
while Obsidian was closed is found by the start-up pass, which publishes the
same two records in the same order -- the old record's tombstone first, the
new record behind it as the same wire barrier, and any moves the pass
publishes for the notes underneath behind that. The reverse order is what a
receiver cannot survive for an EMPTY folder: it re-cases the directory from
the record and then meets the tombstone for the spelling it has just left,
whose removal resolves to the one directory entry that rename produced.
Receivers therefore also refuse to remove a directory whose vault spelling
differs from the record asking for it (folder path_class=folder
decision=kept reason=vault_spelling), which holds whatever order a sender on
an earlier build used.
A refusal is not a loss, and it is not permanent. A version refused while
the two devices spelled the folder differently is never re-delivered by the
feed, which advances past it. So when the folder record does arrive and the
directory is re-cased, the receiver asks the server for the head of every
record that re-case carried -- one GET /v1/files/{id} per record, bounded by
the folder -- and applies each one through the ordinary path. A note edited on
the other device while the two disagreed arrives then, rather than waiting for
whatever touches it next.
What a receiver writes for a NEW file under such a folder. A move is refused; a file id the receiver has never seen is not a move, and it is written. It lands in the directory the vault shows, because creating a directory that is already there changes nothing, and it is RECORDED at the spelling the vault shows rather than the one the manifest carries. A record that disagreed with its own vault was published back as a rename the sender never made, which a folding device answers with a conflict copy.
v is the compatibility contract. A device that does not know a v
refuses the manifest before reading any other field, writes nothing, and lets
the feed advance. Plugins 1.0.0 through 1.0.6 -- every shipped 1.0.x -- do
exactly that with v: 2, so a folder record can never be written as a file at
the folder's path there. parseManifest is byte-identical at all seven of
those tags (sha256 8aa8a2df240bcd8ffba197fc9b2e238bb9b727ef0416007eb526a3082e8aa4de
of the function at each), and the copy the tests run against is
plugin/test/fixtures/decoder-1.0.x.mjs, which says how to re-derive both.
Any later record type must move v again for the same reason.
Rewrite pause controls (plugin 1.1.3)¶
A hold is a separate encrypted manifest with v: 3, kind: "pause", a
canonical file path, its 16-byte lowercase-hex target file id, boolean
paused, the engine's domain, and the constant fields size: 0,
chunks: [], sha256: "", deleted: false. Its opaque file id is the first
16 bytes of HMAC-SHA256(manifest_key, UTF8("obsync/v1/pause/" + target)).
The receiver checks that binding and the ordinary domain/size/chunk/deletion
bindings before acting. Nothing about a hold travels in clear text.
A note manifest can additionally carry answer: true: its author observed a
background write within five seconds of a received version without recent
trusted Markdown editor input (including an active IME composition). Merely
showing the note in a passive editor does not exempt the write. This advisory signal permits detection of sequential
rewrites as well as overlapping ones. It grants no additional authority.
An overlapping answer can be detected first by the device whose editor has
recent trusted input. It uses the same control, holding before the ordinary
conflict-copy rule can replace its saved text. A recipient with current
background-answer proof for that exact target retains its background Resume
role. These are local decisions; they add no manifest field or server API.
A pause control holds the target only if the control version is still a head;
an old pause replayed after Resume is ignored. Local pause state survives a
restart. Clearing the control does not resume another device automatically:
each held device explicitly resumes after its user stops the rewriting
plugin. Each publication parents every observed control head. Identical
controls are reused without another post. Opposite controls at one position
must never be deduplicated as the same operation: their v, kind, target
and paused fields participate in the client's adoption check. Concurrent
opposite posts can leave two heads; the current pause holds, and the next
explicit Resume parents both, leaving one cleared head. No polling loop
publishes new control versions. The existing server API needs no change.
Plugins before 1.1.3 reject v: 3 as an unknown manifest version and advance
the feed. Because the control has its own file id, rejection cannot overwrite,
delete or replace the actual note or its history. Mixed versions keep syncing
ordinary notes, but all devices must update for a shared hold to stop the
rewrite storm. The unchanged 1.1.1 client is exercised by
plugin/test/legacy-pause-check.mjs against a control followed by a normal
note; the receipt records refusal, application and an advanced feed cursor.
Change feed¶
GET /v1/changes?since=<seq>&wait=<seconds ≤ 55>&limit=<n ≤ 1000>→{"seq":<last_included>,"head_seq":<journal_head>,"changes":[{"seq", "file_id","domain_id","version_id","parents","sids","bytes","manifest_ct", "manifest_nonce","device_id","ts","deleted","heads","conflicted"}]}. A feed entry arrives without its file, so it carries its owndomain_id. Withwait, the server holds the request until a new version lands or the wait elapses, then returns whatever exists (possibly an empty list). An empty page whoseseqis abovesinceis normal, and the client continues from thatseq: frames the feed does not carry (devices, sign-ins, background work) move the head without waking a held request, so a poll that starts behind the head answers at once with it.sincebeyondhead_seq→416 seq_ahead.
Domains¶
A domain is the key-scoping unit (docs/architecture.md 3.1 and 5.1). It has
no endpoints: a domain exists because a file record names it, and which
PATHS it covers is owner-only metadata the server never sees. That metadata
is one encrypted object under a reserved file id, written and read through
the version endpoints above like any other file; the server cannot tell it
from a note, and there is no request on this API that hands the server a
content key.
Dashboard (admin) API¶
Cookie session; every mutating call carries X-Obsync-Csrf equal to the
__Host-obsync_csrf cookie.
The two cookies are __Host-obsync_session (HttpOnly) and
__Host-obsync_csrf (readable by the page's own script), both Secure,
Path=/, SameSite=Strict, with no Domain. The __Host- prefix makes
the browser enforce that set, so the dashboard must be reached at an origin
the browser treats as secure: an https address in any browser, or plain
http to localhost/127.0.0.1 in Chrome and Firefox but not Safari, which
sends no Secure cookie to a plaintext origin. Plain HTTP to any other IP
address or LAN name is not a supported way to reach the dashboard
(docs/security/dashboard.md). A session ends after 12 hours, after 1 hour
with no request on it, on sign-out, on sign-out-everywhere, or when the
device whose link opened it is revoked.
POST /v1/dashboard/login-link(device auth) →{"url":"…/login? token=…","expires"}; single use, 5 minutes. The link remembers the device that minted it.GET /login?token=…→ sets the session cookies, redirects to/. Spends a link, or accepts the standing setup token as the recovery sign-in.401 bad_login_token. There is deliberately no attempt limit in front of the constant-time compare: one keyed by request source would refuse every visitor at once behind a proxy this deployment does not trust (docs/security/dashboard.md).POST /v1/admin/logout→204.POST /v1/admin/logout-all→204: closes EVERY dashboard session, including the one that asked, and drops every login link that has been minted and not yet spent. An unspent link is the same key to the same dashboard, so leaving one alive would hand back what the button took away. Its log line carries both counts.GET /v1/admin/overview→{"account":{…as GET /v1/account}, "edge":"none|cloudflare","public_url":"…"|null,"volumes":[<volume>…], "versions":{"total":<n>,"files":<n>},"activity":{"versions_per_hour": [{"hour":<unix_s>,"count":<n>}]} (24 entries, oldest first), "last_gc":<gc>|null,"last_scrub":<scrub>|null, "session":{"recovery":<bool>}}(recoveryis true when this session was opened with the setup token rather than a device's link) where<volume>={"role":"blobs|journal|mirror","path_class":"<StorageClass label or 'host'>","bytes_total","bytes_used","bytes_free","watermark_bytes", "usage_unverified":<bool>}(usage_unverifiedis true whenbytes_usedis the last figure read successfully rather than a current one; writes are being refused withjournal_unverifiedwhile it is),<gc>={"ts","duration_ms","chunks_collected","bytes_collected", "chunks_retained"},<scrub>={"ts","duration_ms","chunks_verified", "bytes_verified","mismatches","quarantined":<count>,"complete_pass"}.GET /v1/admin/devices→ as/v1/devicesplushistory:[{"ts","event": "sign_in|edit|heartbeat","address","country"}]bounded by retention.POST /v1/admin/devices/{id}/revoke→204;409 last_devicewhen the target is the only ACTIVE device and account recovery is unregistered, and409 recovery_too_newwhen it is registered and younger than seven days. Revocation also closes the dashboard sessions that device's links opened and drops the links it minted.POST /v1/admin/devices/{id}/archive→204; asPOST /v1/devices/{id}/archive: a revoked device only (409 device_not_revoked),404 unknown_device.GET /v1/admin/storage→{"volumes":[<volume>…],"retention":{"days", "versions"},"watermark":{"spec":"5%,2GiB"},"gc":{"state":"idle|running", "last":<gc>|null},"scrub":{"state":"idle|running","rate_bytes_per_sec", "last":<scrub>|null},"quarantine":[{"sid","ts","bytes","reason"}]}.POST /v1/admin/gc/run,POST /v1/admin/scrub/run→202.GET /v1/admin/logs?device=<id prefix>&limit=<n ≤ 500>→{"lines": [{"ts","method","path_class","device":"<id>"|null,"status","bytes", "duration_ms","decision"}]}newest first: the pinned request log line as JSON.
Plugin distribution¶
GET /v1/plugin/manifest→ the plugin'smanifest.jsonplus{"bundle_sha256":"<64hex>","styles_sha256":"<64hex>"}. This version metadata is unauthenticated.- The retired
GET /v1/plugin/bundleandGET /v1/plugin/stylesroutes return404 not_found. Native installation and updates use Obsidian's Community Plugins browser and the matching GitHub Release. Packaged native files and their ZIP remain build/release inputs; v2 release evidence binds their bytes. The plugin never fetches executable code from its server. The native installer does not document verification of this project's evidence.
Limits and headers¶
- Request headers ≤ 16 KiB; JSON bodies ≤ 4 MiB, and the setup and pairing-claim bodies, which carry their token, ≤ 16 KiB; chunk ciphertext bodies ≤ 8 MiB + 16 bytes.
- Every JSON body is read before its credential verifies (a signature covers
the body's hash; the setup and enrolment tokens ride inside it), so every
such body holds a share of one 64 MiB reservation across every connection
from before its first byte is read until its credential verifies, any wait
for a lock included. A setup or claim body is parsed before its token
verifies, so each reserves 4 MiB: the body and everything parsing 16 KiB
can allocate (at most about 72 bytes a byte). A body that does not fit is
answered with a bare
503(no body,Retry-After: 1,Connection: close) before a byte of it is read, and a repeatable request retries. - Heads per file record ≤ 64; versions per file record ≤
OBSYNC_RETENTION_VERSIONSplus one per head; sids per version ≤ 65,536; parents per version ≤ 64;manifest_ct≤ 1 MiB of base64. - Response bound, enforced by
render's own test against the ceilings above: a full head list is under 8 KiB, so a 1000-entry/v1/changespage carries at most 64,000 head ids. The widest single version and the widest change entry are each under 6 MiB. One file record never passes 450 MiB: every head is in it (64 of the widest versions fit), and the other versions follow newest first until the next would pass it, so a long retention leaves older versions out of the record rather than growing it past what a client accepts. At the shipped retention of 10 nothing is left out; when something is, the server logsevent=file_record decision=trimmed. A/v1/changespage also stops before its entries pass 8 MiB of JSON and always carries at least one, so no page passes 8 MiB; itsseqis then belowhead_seq, and the next request from that cursor carries on. A client that wants a smaller page setslimit. - Idle connection timeout 60 s (long-poll requests excepted up to their
wait); header read timeout 10 s; body read minimum rate 16 KiB/s, measured from the server's first read of the body, so time the server spends before it (authentication waiting on a slow volume) is never charged to the sender. A body slower than that, on any route, is503 slow_body: the sender's link, not the server's storage, and a client retries it. A body that ends or breaks before it is whole is503 body_incomplete, retried the same way, and a chunked body whose framing is not HTTP is400 bad_request;413 body_too_largeis only ever a body past its ceiling. - Every response carries
Cache-Control: no-storeand the security headers listed inAGENTS.md.X-Obsync-Seq(journal head) rides only a response to a caller that proved a credential: it is write activity, and an unauthenticated caller polling it could reconstruct when the owner writes (docs/security/dashboard.md). "Proved" is a fact the server records where a credential VERIFIES -- a device signature, a dashboard session, a login or setup token -- never an inference from the route or the status. Every route that requires a credential authenticates before it validates anything, so a caller holding no credential is told nothing a refusal has to tell it: a missing, malformed, stale, replayed or unverifiable credential -- an unknown device id included -- is401, and a request that reaches an edge-fronted deployment without the edge's headers is421 edge_required. One refusal is answered before verification, the403 device_revokedabove, and it is not an exception to this: revocation destroys the wrapped secret, so there is nothing left to verify the signature against, the refusal repeats only the device the caller itself named, and the server classes it as what it is -- answered without proof. - Every request logs one line:
ts method path_class device status bytes duration_ms decision.