HTTP API
HTTP API
All endpoints live under /v1/. A server address is a protocol, a domain and a port, so the base
URL is:
<protocol>://<domain>:<port>/v1/
for example https://octi.example.invalid:443/v1/.
Error bodies are human-readable plain text, not JSON. Branch on the status code and the
X-Octi-Reason header, never on the body text.
Authentication
Every request except registration carries two headers:
| Header | Value |
|---|---|
Authorization |
Basic + base64 of <accountId>:<devicePassword> |
X-Device-ID |
The caller’s device UUID |
The server splits the decoded credential on the first :, parses the left half as the account
UUID, and looks up the device by (account id, device id). The password is compared in constant
time. Both headers are required: the device id identifies which device inside the account is
calling, and the account id comes only from the credential.
Authentication failures:
| Status | Cause |
|---|---|
400 |
X-Device-ID missing or not a UUID |
400 |
Authorization missing, not Basic, not decodable, or without a : |
404 |
No device with that (account id, device id) pair |
401 |
Device exists but the password does not match |
The Android client treats 401, 404 and 410 on any call as “no longer registered” and pauses
the connector. The pinned server never returns 410; the client accepts it defensively.
Device metadata headers
Optional on any request. This is how a device advertises itself to peers.
| Header | Meaning |
|---|---|
Octi-Device-Version |
Client version string |
Octi-Device-Platform |
Platform identifier, android for the Android client |
Octi-Device-Label |
Human-readable device name |
Octi-Device-Capabilities |
JSON array of capability tags, see capabilities.md |
How the server treats them:
- Values are stored on the device record and echoed back to peers through
GET /v1/devices. - An absent header leaves the stored value untouched; a header with a new value overwrites it. Omitting a header never clears a value.
Octi-Device-Labelis trimmed and truncated to 128 characters. Blank after trimming normalizes to null, which means different things on the two paths. At registration the device is created with no label. On an existing device it looks like an absent header and is treated as no update, so the stored label survives. A label cannot be cleared over HTTP at this revision.Octi-Device-VersionandOcti-Device-Platformare stored verbatim, with no length or charset constraint at this revision.Octi-Device-Capabilitiesis validated as a whole set. One bad tag discards the entire header value; the request itself still succeeds. See capabilities.md.- During registration only,
Octi-Device-Versionfalls back to theUser-Agentheader when absent. - Metadata is recorded only for requests that pass authentication and the per-account rate limit,
so a rejected request updates neither
lastSeennor any metadata field.
Producer policy is separate. The Android client strips non-printable ASCII from the device model and truncates to 128 characters. The server does not require that, and other clients need not copy it.
Identifiers and validation
| Identifier | Rule |
|---|---|
| Account id | UUID. Issued by the server at registration. |
| Device id | UUID. Chosen by the client. Registrable once globally: a second POST /v1/account with a device id that already exists anywhere on the server returns 400. |
| Module id | Semantically opaque to the server, syntactically constrained: at most 1024 characters and matching ^[a-z]+(\.[a-z0-9_]+)*$. Anything else returns 400. |
device-id query parameter |
UUID of a device on the caller’s account. Not a UUID gives 400; not a device on this account gives 404. |
Endpoint index
Routes served at the pinned server revision:
| Method and path | Purpose | Called by shipping clients |
|---|---|---|
POST /v1/account |
Register a device, creating or joining an account | yes |
DELETE /v1/account |
Delete the whole account | yes |
POST /v1/account/share |
Create a share code | yes |
GET /v1/account/storage |
Quota and server limits | yes |
GET /v1/devices |
List the account’s devices | yes |
DELETE /v1/devices/{deviceId} |
Remove one device | yes |
POST /v1/devices/reset |
Wipe module data for devices | yes |
GET /v1/module/{moduleId} |
Read a document | yes |
POST /v1/module/{moduleId} |
Unconditional legacy write | yes |
PUT /v1/module/{moduleId} |
Conditional write with blob references | yes |
DELETE /v1/module/{moduleId} |
Delete a document and everything it references | no |
GET /v1/module/{moduleId}/blobs and the other blob routes |
Blob transfer, see the scope note below | partially |
GET /v1/status |
Liveness probe, returns {"status":"ok"}, unauthenticated |
no |
GET /v1/metrics |
Aggregate server counters, unauthenticated | no |
GET /v1/myip |
Returns {"ip":"<caller ip>"}, unauthenticated |
no |
GET /v1/ws |
WebSocket upgrade, see websocket.md | yes |
DELETE /v1/module/{moduleId} is listed so the endpoint set is not silently partial. It takes the
same device-id parameter as the other module routes, deletes the document and every blob it
references, and emits a deleted notification. No shipping client calls it.
Account endpoints
POST /v1/account
Registers the calling device. With ?share=<code> it joins that code’s account; without it, a new
account is created. See linking.md for the full flow.
Request: X-Device-ID required, Authorization must not be present, device metadata headers
optional.
Response 200:
{ "account": "<account uuid>", "password": "<device password>" }
| Status | Cause |
|---|---|
400 |
X-Device-ID missing or not a UUID |
400 |
Device id already registered anywhere on this server |
400 |
Authorization parsed as Basic credentials |
403 |
?share= did not match a stored share, or its account no longer exists |
409 |
Account is at its device limit (default 64, deployment-configurable) |
On 403 because the account vanished, and on 409, the share code is restored and can be retried.
The check is narrower than “no Authorization header”. The server rejects only a header it can
parse as Basic credentials: it starts with Basic , the rest base64-decodes, the decoded text
contains a :, and the part before that : parses as a UUID. Anything else counts as no header
and the
registration proceeds. A Bearer token, a Basic value that is not valid base64, and Basic
credentials with a non-UUID username all fall into that gap.
The gap matters with ?share=: the check runs before the share is consumed, so a rejected header
leaves the code usable while an ignored one burns it. Send no Authorization header at all.
DELETE /v1/account
Authenticated. Aborts upload sessions, releases quota, and deletes every device, every share and
all stored data. Responds 200 with no body. Not reversible, not confirmable, and it destroys the
caller’s own registration too.
POST /v1/account/share
Authenticated. Returns {"code": "<share code>"}. See linking.md.
GET /v1/account/storage
Authenticated. Reports the account’s usage plus the server’s configured limits. Use it to discover a deployment’s values instead of hard-coding them. It does not report every server setting: the per-IP rate limit, the CORS allowlist and the retention thresholds are all absent.
{
"storageApiVersion": 2,
"accountQuotaBytes": 52428800,
"usedBytes": 0,
"reservedBytes": 0,
"availableBytes": 52428800,
"maxBlobBytes": 10485760,
"maxModuleDocumentBytes": 262144,
"maxActiveUploadSessionsPerDevice": 8,
"idleSessionTtlSeconds": 3600,
"absoluteSessionTtlSeconds": 86400,
"maxDevicesPerAccount": 64,
"maxModulesPerDevice": 256,
"maxBlobRefsPerModule": 64,
"maxActiveUploadSessionsPerAccount": 32,
"completeIdleTtlSeconds": 600,
"accountRateLimit": 256,
"accountRateLimitWindowSeconds": 60
}
All seventeen fields are always present. availableBytes is
max(0, accountQuotaBytes - usedBytes - reservedBytes). reservedBytes covers blob upload sessions
that reserved space but have not committed.
storageApiVersion is the feature probe for the blob layer. The Android client treats 1 or
higher as “blob-backed modules supported”, and a 404 or 405 here as “legacy server, no blob
support”. The value at this revision is 2. A client that never uses blobs need not call this
endpoint.
The Android client’s DTO decodes only five of these fields. That is a client limitation; the server sends the response above.
Device endpoints
GET /v1/devices
Authenticated. Lists every device on the caller’s account, including the caller.
{
"devices": [
{
"id": "11111111-1111-1111-1111-111111111111",
"version": "1.2.3",
"platform": "android",
"label": "Example Phone",
"capabilities": ["encryption:AES256_GCM_SIV", "encryption:AES256_SIV", "encryption:_reported"],
"addedAt": "2026-01-01T12:00:00Z",
"lastSeen": "2026-01-02T08:30:00Z"
}
]
}
Field notes:
version,platformandlabelare always present, andnullwhen the device has never reported them.capabilitiesis omitted entirely when the device has never reported a valid capability set. When present it is a real JSON array of strings, not a stringified one. See capabilities.md.addedAtandlastSeenare ISO-8601 instants in UTC.
This is also how a client discovers its peers. There is no separate peer list.
DELETE /v1/devices/{deviceId}
Authenticated. Removes another device (or the caller), aborting its upload sessions and deleting
all of its stored modules. 400 if the path segment is not a UUID, 404 if no such device is on
this account, 200 on success.
The removed device’s credentials stop working at once. Its next request gets 404, which is how it
learns it was revoked.
POST /v1/devices/reset
Authenticated. Deletes stored module data without removing the device registrations.
{ "targets": ["11111111-1111-1111-1111-111111111111"] }
An empty targets array means every device on the account. 404 if any listed device is not on
this account, and nothing is reset in that case. 200 on success.
Client divergence: the server expects targets to be an array of UUID strings. The Android
client’s DTO would encode each entry as {"id": "<uuid>"}, which the server rejects with 400. It
never fires today because the Android client only ever sends an empty list.
Module endpoints
All three take the same target selector:
/v1/module/{moduleId}?device-id=<target device uuid>
moduleId is the same for every owner; device-id selects whose copy is addressed. Reading a
peer’s document and writing your own use the same route with a different device-id.
GET /v1/module/{moduleId}?device-id=
Authenticated read.
| Status | Meaning |
|---|---|
204 |
No document metadata: the slot has never been written, or it was deleted. No headers, no body. |
200 |
Document present. |
400 |
Missing or malformed moduleId, missing or malformed device-id |
404 |
Target device is not on this account |
A 200 response carries:
| Header | Value |
|---|---|
X-Modified-At |
Server-side modification time as an HTTP-date (RFC 1123) |
ETag |
Current strong entity tag, quoted, 32 lowercase hex characters |
Content-Type |
application/octet-stream |
The body is the raw stored bytes, the ciphertext from encryption.md. It may be
zero length. ETag is present whenever metadata exists.
Two Android quirks. It reads the standard Date response header to estimate its clock offset
against the server, ordinary HTTP rather than an Octi extension. It treats a body of the four bytes
null as empty; the pinned server never emits that.
Write semantics and concurrency
The two write verbs are not interchangeable. Confusing them is the easiest way to destroy a peer’s data.
POST is the unconditional legacy write. The body is raw ciphertext, there is no precondition,
and it always overwrites. Once a module has external blob references it returns 409, since a raw
overwrite cannot say what happens to the blobs.
PUT is the conditional commit. It requires exactly one applicable precondition:
| Situation | Header to send |
|---|---|
| Updating a document you have read | If-Match: "<the ETag from your read>" |
| Creating a document that must not exist yet | If-None-Match: * |
Rules the server enforces:
- Sending both headers is
400. - Sending neither is
412with the messagePUT requires If-Match or If-None-Match: *. If-None-Match: *when the module already exists is412.If-Matchwhen the module does not exist is412.If-Matchwhose value is not the current tag is412.- A malformed entity tag is
400. Weak tags (W/"...") are rejected outright, sinceIf-Matchrequires strong comparison. Both"quoted"and bare unquoted forms are accepted.
On 412, re-read before retrying. The slot changed since the tag you hold was issued.
Repeating the same body with a refreshed tag silently discards what the other writer committed.
Read, reconcile, then write. The Android client refreshes its cached tag and retries once, safe only
because the single-writer invariant means the concurrent writer was itself.
Entity tags are random 16-byte values, hex-encoded, regenerated on every successful write. They are not content-derived, so writing an identical document twice gives two different tags. Compare them for equality only.
POST /v1/module/{moduleId}?device-id=
Body: raw ciphertext bytes. No content type is required.
| Status | Meaning |
|---|---|
200 |
Written. Response carries the new ETag header. |
409 |
The module has external blob references; use PUT |
409 |
Module count limit for this device reached (default 256) |
413 |
Body exceeds the payload limit (default 128 KiB) |
507 |
Account quota exceeded, with X-Octi-Reason: account_quota_exceeded |
PUT /v1/module/{moduleId}?device-id=
Body: JSON.
{
"documentBase64": "<base64 of the ciphertext>",
"blobRefs": [ { "blobId": "<blob id>" } ]
}
blobRefs may be omitted or empty; a client without blobs always sends it empty. Each listed blob
must already be uploaded through the blob session routes, which this revision does not specify.
| Status | Meaning |
|---|---|
200 |
Committed. Response carries the ETag header and the body {"etag":"<hex>"} |
400 |
Both preconditions sent, malformed entity tag, invalid base64, duplicate blobId values, too many blob refs, or a blobId that cannot be resolved |
412 |
Precondition missing, stale, or not applicable |
409 |
Module count limit for this device reached |
413 |
Decoded document exceeds maxModuleDocumentBytes (default 256 KiB) |
507 |
Account quota exceeded, with X-Octi-Reason: account_quota_exceeded |
The route’s raw body limit is twice maxModuleDocumentBytes, leaving room for base64 expansion of
a document at the maximum size.
The Android client falls back from PUT to POST on 404 or 405, treating those as “this server
predates blob support”.
Errors
| Status | Emitted by | Meaning |
|---|---|---|
400 |
any | Malformed header, identifier, precondition, or body |
401 |
any authenticated route | Wrong device password |
403 |
POST /v1/account |
Invalid share code, or its account is gone |
404 |
any authenticated route | Caller device unknown for this account |
404 |
module and device routes | Target device not on this account |
409 |
POST /v1/account |
Device limit reached |
409 |
module writes | Blob-backed module written with POST, or module count limit |
412 |
PUT /v1/module/... |
Precondition failed |
413 |
any | Request body or decoded document too large |
429 |
any | Rate limited, with Retry-After in delta-seconds |
500 |
any | Unhandled server error |
507 |
module writes, blob routes | Storage refusal, qualified by X-Octi-Reason |
X-Octi-Reason
Only ever sent alongside 507, with exactly two defined values:
| Value | Emitted by | Meaning |
|---|---|---|
account_quota_exceeded |
module POST, module PUT, and blob session routes |
The account is at its storage quota |
server_disk_low |
blob routes only | The server host is below its free-disk floor |
A module write therefore never produces server_disk_low. A client that only writes documents
handles account_quota_exceeded, and should treat a 507 with a missing or unknown reason as a
generic storage refusal.
Retry-After is emitted only in delta-seconds form, never as an HTTP-date.
Limits and rate limiting
Every value below is a deployment-configurable default from the server’s configuration, not a
protocol constant. GET /v1/account/storage reports the live values for most of them.
| Limit | Default | Notes |
|---|---|---|
| Request body | 128 KiB | Global; the PUT module route raises it to 512 KiB |
| Module document | 256 KiB | Checked after base64 decoding |
| Devices per account | 64 | 409 at registration |
| Modules per device | 256 | 409 on write |
| Blob refs per module | 64 | 400 on commit |
| Account storage quota | 50 MB | 507 with account_quota_exceeded |
| Per-IP rate limit | 512 requests per 60 s | 429 with Retry-After |
| Per-account rate limit | 256 requests per 60 s | 429 with Retry-After |
Rate limiting is layered. The per-IP limiter runs before authentication and counts every request
except CORS preflight OPTIONS. The per-account limiter runs after credentials validate, so one
account on a shared NAT address cannot exhaust another’s budget. An operator can disable both with
one switch.
Browser clients face one more gate: the server’s CORS allowlist. It ships with the official octi-web origins; an operator can replace or empty it. Non-browser clients are unaffected.
Retention and garbage collection
The server deletes idle data on its own. Both sweeps are destructive and neither is announced, so a client that assumes the server keeps what it wrote indefinitely will lose data.
| Sweep | Deletes | Clock it reads | Threshold | Interval |
|---|---|---|---|---|
| Device GC | The device registration and every module that device owns | lastSeen on the device record |
90 days | 10 minutes |
| Module GC | One module slot, with its document and its blobs | The module’s effective last-access time | 90 days | 10 minutes |
Both loops run every 10 minutes, with the first pass one minute after server start, so deletion happens up to one interval after the threshold is crossed.
lastSeen is refreshed by any request from that device that passes authentication and the
per-account rate limit, including the WebSocket upgrade. It tracks the caller only: a peer reading
or writing your slots does not refresh your lastSeen. A device that goes quiet for 90 days is
deleted with all
of its data, even while its peers still read that data. Its next request gets 404, as if it had
been revoked, and it must register again. The value lives in memory and is written to disk at most
once every 30 seconds.
A module’s last-access time is refreshed by reading the slot, from any device on the account, by
writing it with POST or PUT, and by the blob routes: listing blobs, downloading a blob, and
creating, appending to or finalizing an upload session. It lives in memory and is persisted at most
once every 30 seconds. With no persisted value the server falls back to the modification time of
the slot’s metadata file, then of its payload file.
Module GC skips a slot whose upload session is active, or finalized but not yet committed, and has not itself expired, so an in-flight upload cannot be reaped underneath itself. Device GC has no such exemption: it aborts the device’s upload sessions and deletes it.
Both thresholds are server configuration with a 90-day default. GET /v1/account/storage does not
report them and the pinned revision parses no command-line flag for either one, so a client cannot
discover a deployment’s values. Do not hard-code 90 days. Treat “stored data disappears after long
inactivity” as the contract: keep talking to the server, and be able to re-upload.
Blob layer, out of scope
The following routes exist at the pinned revision and are not specified here:
GET /v1/module/{moduleId}/blobs
GET /v1/module/{moduleId}/blobs/{blobId}
DELETE /v1/module/{moduleId}/blobs/{blobId}
POST /v1/module/{moduleId}/blob-sessions
GET /v1/module/{moduleId}/blob-sessions/{sessionId}
PATCH /v1/module/{moduleId}/blob-sessions/{sessionId}
POST /v1/module/{moduleId}/blob-sessions/{sessionId}/finalize
DELETE /v1/module/{moduleId}/blob-sessions/{sessionId}
They implement resumable chunked upload, download and lifecycle for file attachments. A client that
never attaches files never touches them, and can always send PUT with an empty blobRefs list.
For the shapes, read the client’s Retrofit declarations in
OctiServerApi.kt,
the server’s
BlobRoute.kt,
and for the encryption of blob content the committed vectors in
streaming-vectors.json.
Known module ids
| Module id | Content |
|---|---|
eu.darken.octi.module.core.meta |
Device metadata |
eu.darken.octi.module.core.power |
Battery and charging state |
eu.darken.octi.module.core.wifi |
Wi-Fi connection information |
eu.darken.octi.module.core.connectivity |
Network connectivity information |
eu.darken.octi.module.core.apps |
Installed applications |
eu.darken.octi.module.core.clipboard |
Shared clipboard |
eu.darken.octi.module.core.files |
Shared files |
Module ids are opaque to the protocol. This table is what the Android client currently produces and consumes, not a closed set. Ignore an unknown module id and carry on; never reject the peer or the response. The document schemas behind these ids are out of scope for this revision.