The HTTP API of openvireo-sync-server. It stores encrypted .kdbx files and never sees their contents.
See docs/api.md for how the pieces fit together.
Which routes exist depends on SYNC_AUTH_MODE: standalone has the database and pairing routes, jwt the
vault and internal routes, both all of them. /health and /events always exist. Routes the mode doesn’t
have answer 404. The internal routes also need SYNC_INTERNAL_TOKEN to be set.
CORS: for origins in SYNC_ALLOWED_ORIGINS every route answers preflight with 204, allowing
GET, HEAD, PUT, POST, DELETE and the headers Authorization, Content-Type, If-Match, If-None-Match, and
responses expose ETag, X-Database-Revision, Last-Modified.
Every response has Cache-Control: no-store.
The machine-readable description is openapi.yaml (OpenAPI 3.1.0); import it into an HTTP client or a code generator. HTTP API explains how the routes fit together.
Authentication
| Scheme | |
|---|---|
device | A device token (self-hosted). From users add, devices add or a pairing claim. |
access | An account access token from the account server: EdDSA, aud containing SYNC_AUTH_AUDIENCE (default ov-sync), and a vlt claim mapping vault ids to read, write or manage. |
internal | The SYNC_INTERNAL_TOKEN shared secret. |
Health
Liveness
GET /api/v1/health
Liveness check (getHealth)
Token: none
| Status | |
|---|---|
| 200 | The server is up (object) |
Databases
A self-hosted user’s databases, with device tokens
GET /api/v1/databases
List the user’s databases (listDatabases)
Token: device
| Status | |
|---|---|
| 200 | OK (object) |
| 401 | Missing, invalid or expired token (Error) |
| 403 | The token doesn’t grant this, or is the wrong kind of token for the route (Error) |
GET /api/v1/databases/{id}
Download a database (getDatabase)
HEAD works the same and returns no body.
Token: device
| Parameter | In | Type | |
|---|---|---|---|
id | path | string | |
If-None-Match (optional) | header | string | * on uploads (create only), an ETag on downloads (304 when unchanged) |
| Status | |
|---|---|
| 200 | The encrypted file (string (binary)) |
| 304 | The ETag in If-None-Match is still current |
| 401 | Missing, invalid or expired token (Error) |
| 404 | Not found (Error) |
PUT /api/v1/databases/{id}
Upload a database (putDatabase)
If-None-Match: * creates it (412 if it exists). If-Match: "<etag>" replaces exactly that version (412
if another device uploaded in the meantime). With neither header the upload creates the database, or gets
428 if it already exists. Sends a database event to the user’s other streams.
Token: device
| Parameter | In | Type | |
|---|---|---|---|
id | path | string | |
If-Match (optional) | header | string | |
If-None-Match (optional) | header | string | * on uploads (create only), an ETag on downloads (304 when unchanged) |
Request body (application/octet-stream): The encrypted database, starting with the KDBX file signature
| Status | |
|---|---|
| 200 | 200 when it replaced a version, 201 when it created the database or vault (Meta) |
| 201 | 200 when it replaced a version, 201 when it created the database or vault (Meta) |
| 400 | Invalid id, JSON or body (Error) |
| 401 | Missing, invalid or expired token (Error) |
| 412 | If-Match or If-None-Match didn’t hold; download, merge and try again (Error) |
| 413 | Over SYNC_MAX_DB_BYTES; no body |
| 415 | The body isn’t a KDBX file (Error) |
| 428 | The upload needs If-Match (update) or If-None-Match (create) (Error) |
DELETE /api/v1/databases/{id}
Delete a database and its history (deleteDatabase)
Token: device
| Parameter | In | Type | |
|---|---|---|---|
id | path | string | |
If-Match (optional) | header | string |
| Status | |
|---|---|
| 204 | Deleted |
| 401 | Missing, invalid or expired token (Error) |
| 404 | Not found (Error) |
| 412 | If-Match or If-None-Match didn’t hold; download, merge and try again (Error) |
GET /api/v1/databases/{id}/history
The current and kept versions, newest first (listDatabaseVersions)
Token: device
| Parameter | In | Type | |
|---|---|---|---|
id | path | string |
| Status | |
|---|---|
| 200 | OK (object) |
| 401 | Missing, invalid or expired token (Error) |
| 404 | Not found (Error) |
GET /api/v1/databases/{id}/history/{revision}
Download one kept version (getDatabaseVersion)
Token: device
| Parameter | In | Type | |
|---|---|---|---|
id | path | string | |
revision | path | integer |
| Status | |
|---|---|
| 200 | The encrypted file (string (binary)) |
| 401 | Missing, invalid or expired token (Error) |
| 404 | Not found (Error) |
Devices
Pairing new devices
POST /api/v1/devices/pairing
Create a pairing code for another device of the same user (createPairingCode)
Works once and for 10 minutes. A user has at most 5 codes waiting; a new one replaces the oldest.
Token: device
| Status | |
|---|---|
| 201 | Created (object) |
| 401 | Missing, invalid or expired token (Error) |
POST /api/v1/devices/pairing/claim
Trade a pairing code for a device token (claimPairingCode)
Case, spaces and the dash in the code don’t matter. Wrong codes are limited to 10 per client address and 100 in all per 10 minutes; after that, claims get 429 with Retry-After.
Token: none
Request body (application/json)
| Field | Type | |
|---|---|---|
code | string | |
deviceName | string |
| Status | |
|---|---|
| 201 | The new device and its token, shown only once (object) |
| 400 | Invalid id, JSON or body (Error) |
| 429 | Too many wrong codes (Error) |
Vaults
Account vaults, with access tokens from the account server
GET /api/v1/vaults
The vaults in the token’s vlt claim that exist (any role) (listVaults)
Token: access
| Status | |
|---|---|
| 200 | OK (object) |
| 401 | Missing, invalid or expired token (Error) |
| 403 | The token doesn’t grant this, or is the wrong kind of token for the route (Error) |
GET /api/v1/vaults/{vault}
Download a vault (read) (getVault)
HEAD works the same and returns no body. A vault the token doesn’t list gets 403, whether it exists or not.
Token: access
| Parameter | In | Type | |
|---|---|---|---|
vault | path | string (uuid) | |
If-None-Match (optional) | header | string | * on uploads (create only), an ETag on downloads (304 when unchanged) |
| Status | |
|---|---|
| 200 | The encrypted file (string (binary)) |
| 304 | The ETag in If-None-Match is still current |
| 400 | Invalid id, JSON or body (Error) |
| 401 | Missing, invalid or expired token (Error) |
| 403 | The token doesn’t grant this, or is the wrong kind of token for the route (Error) |
| 404 | Not found (Error) |
PUT /api/v1/vaults/{vault}
Create (manage) or update (write) a vault (putVault)
If-None-Match: * creates it and needs the manage role. If-Match: "<etag>" updates it and needs write.
Without either header the upload always gets 428. Sends a vault event to the streams whose token lists
the vault.
Token: access
| Parameter | In | Type | |
|---|---|---|---|
vault | path | string (uuid) | |
If-Match (optional) | header | string | |
If-None-Match (optional) | header | string | * on uploads (create only), an ETag on downloads (304 when unchanged) |
Request body (application/octet-stream): The encrypted database, starting with the KDBX file signature
| Status | |
|---|---|
| 200 | 200 when it replaced a version, 201 when it created the database or vault (Meta) |
| 201 | 200 when it replaced a version, 201 when it created the database or vault (Meta) |
| 400 | Invalid id, JSON or body (Error) |
| 401 | Missing, invalid or expired token (Error) |
| 403 | The token doesn’t grant this, or is the wrong kind of token for the route (Error) |
| 412 | If-Match or If-None-Match didn’t hold; download, merge and try again (Error) |
| 413 | Over SYNC_MAX_DB_BYTES; no body |
| 415 | The body isn’t a KDBX file (Error) |
| 428 | The upload needs If-Match (update) or If-None-Match (create) (Error) |
DELETE /api/v1/vaults/{vault}
Delete a vault and its history (manage) (deleteVault)
Token: access
| Parameter | In | Type | |
|---|---|---|---|
vault | path | string (uuid) | |
If-Match (optional) | header | string |
| Status | |
|---|---|
| 204 | Deleted; the vault’s streams get a vault event with deleted true |
| 400 | Invalid id, JSON or body (Error) |
| 401 | Missing, invalid or expired token (Error) |
| 403 | The token doesn’t grant this, or is the wrong kind of token for the route (Error) |
| 404 | Not found (Error) |
| 412 | If-Match or If-None-Match didn’t hold; download, merge and try again (Error) |
GET /api/v1/vaults/{vault}/history
The current and kept versions, newest first (read) (listVaultVersions)
Token: access
| Parameter | In | Type | |
|---|---|---|---|
vault | path | string (uuid) |
| Status | |
|---|---|
| 200 | OK (object) |
| 401 | Missing, invalid or expired token (Error) |
| 403 | The token doesn’t grant this, or is the wrong kind of token for the route (Error) |
| 404 | Not found (Error) |
GET /api/v1/vaults/{vault}/history/{revision}
Download one kept version (read) (getVaultVersion)
Token: access
| Parameter | In | Type | |
|---|---|---|---|
vault | path | string (uuid) | |
revision | path | integer |
| Status | |
|---|---|
| 200 | The encrypted file (string (binary)) |
| 401 | Missing, invalid or expired token (Error) |
| 403 | The token doesn’t grant this, or is the wrong kind of token for the route (Error) |
| 404 | Not found (Error) |
Events
Change notifications (server-sent events)
GET /api/v1/events
Change notifications as server-sent events (streamEvents)
Starts with retry: 5000; a : ping comment every 25 seconds keeps it open.
With a device token: event: database, data {"id", "etag", "revision"} or {"id", "deleted": true},
for every change to one of the user’s databases.
With an access token: event: vault (same data, for the vaults in the token) and event: access, data
{"vault": uuid|null, "change": "granted"|"revoked"|"rotating"|"rotated"|"org"}, when the account server
reports an access change for the token’s subject. The stream ends once the token’s exp has passed; the
client reconnects with a fresh token, and also after any access event.
Token: device or access
| Status | |
|---|---|
| 200 | The stream (string) |
| 401 | Missing, invalid or expired token (Error) |
Internal
Called by the account server only
POST /api/v1/internal/notify
Push an access event to the open streams of some subjects (notifyAccess)
Delivery is best effort. With Postgres, the event reaches streams on every instance.
Token: internal
Request body (application/json)
| Field | Type | |
|---|---|---|
subjects | array of Principal | |
event | "access" | |
data | object |
| Status | |
|---|---|
| 204 | Sent |
| 400 | Invalid id, JSON or body (Error) |
| 401 | Missing, invalid or expired token (Error) |
DELETE /api/v1/internal/vaults/{vault}
Delete a vault and its history when no client does, e.g. an expired recovery archive (deleteVaultInternal)
Token: internal
| Parameter | In | Type | |
|---|---|---|---|
vault | path | string (uuid) |
| Status | |
|---|---|
| 204 | Deleted; the vault’s streams get a vault event with deleted true |
| 400 | Invalid id, JSON or body (Error) |
| 401 | Missing, invalid or expired token (Error) |
| 404 | Not found (Error) |
Schemas
Meta
| Field | Type | |
|---|---|---|
id | string | The database id or vault UUID |
revision | integer | |
etag | string | The quoted SHA-256 of the file |
size | integer | Bytes |
updatedAt | string (date-time) |
Principal
string, matching ^(user|sa):[0-9a-f-]{36}$
Error
| Field | Type | |
|---|---|---|
error | string | A message for people |
code (optional) | invalid_request, unauthorized, forbidden, not_found, conflict, precondition_failed, precondition_required, rate_limited, internal_error | Only on the vault and internal routes, and on /events when access tokens are accepted |
The Sync Server docs cover OpenVireo Sync Server, develop (0d4f752), last updated 26 October 2026. Found a mistake? Edit this page on GitHub.