Sync API
In progress
The sync API is public and documented so that self-hosters and third-party clients can use it. The server stores ciphertext only: it can’t read item titles, URLs, usernames, passwords or notes.
Conventions
Section titled “Conventions”- JSON over HTTPS under the
/v1prefix, with camelCase field names. - Every binary value (salts, OPAQUE messages, keys, ciphertext) is base64url without padding.
- Ids are UUIDv7 strings generated by the client, in lowercase hyphenated form.
- Timestamps are RFC 3339 strings in UTC.
- Authenticated requests send
Authorization: Bearer <accessToken>. - Request bodies are limited to 1 MiB, except
POST /v1/batchandPOST /v1/vaults/{vaultId}/rotate(16 MiB) and attachment uploads, which are raw bytes up to the server’s file size limit. - CORS is limited to the origins in
OCTAPASS_WEB_ORIGINS.
Endpoints
Section titled “Endpoints”| Endpoint | Purpose |
|---|---|
GET /healthz, GET /readyz |
Liveness, and readiness (database and attachment storage reachable) |
GET /v1/server-info |
Name, version, apiVersion: 1, signup mode, push relay |
GET /v1/openapi.json |
This API as OpenAPI 3.1 |
POST /v1/auth/prelogin |
KDF parameters and account salt for an email |
POST /v1/auth/register/start, …/finish |
OPAQUE registration with email verification |
POST /v1/auth/login/start, …/finish |
OPAQUE login, returns tokens and the account bundle |
POST /v1/auth/login/second-factor |
Finish a login that asked for two-step sign-in |
POST /v1/auth/refresh |
Rotate the access and refresh tokens |
POST /v1/auth/logout |
Revoke this device’s tokens |
POST /v1/auth/reauth/start, …/finish |
Fresh password check before a dangerous action |
POST /v1/auth/recover/start, …/verify, …/registration, …/finish |
Reset the master password with the recovery code |
GET /v1/account |
The account bundle |
DELETE /v1/account |
Delete the account and everything in it |
GET /v1/account/usage |
What the vaults the account owns hold, and the server’s limits |
POST /v1/account/password/registration, PUT /v1/account/password |
Change the master password |
GET, DELETE /v1/account/two-step |
Two-step sign-in status; turn it off |
POST /v1/account/two-step/totp, …/totp/confirm |
Add or replace the authenticator app |
POST /v1/account/two-step/recovery-codes |
New recovery codes |
POST /v1/account/two-step/security-keys/options, POST …/security-keys |
Register a security key |
DELETE /v1/account/two-step/security-keys/{id} |
Remove a security key |
DELETE /v1/account/two-step/trusted-devices |
Make every other device do the second step at its next login |
GET /v1/sync?since=… |
Vaults, and items changed since given revisions |
PUT /v1/vaults/{vaultId}/items/{itemId} |
Create (If-None-Match: *) or update (If-Match) an item |
DELETE /v1/vaults/{vaultId}/items/{itemId} |
Move an item to the trash (If-Match) |
POST /v1/vaults/{vaultId}/items/{itemId}/restore |
Restore an item from the trash |
GET /v1/vaults/{vaultId}/items/{itemId}/history |
The item’s last 10 past versions, newest first, as ciphertext |
POST /v1/batch |
Up to 1,000 item writes at once, atomic per vault |
GET /v1/vaults/{vaultId}/items/{itemId}/attachments |
An item’s attachments: ids, stored sizes, times |
PUT /v1/vaults/{vaultId}/items/{itemId}/attachments/{attachmentId} |
Upload an encrypted file (raw bytes) |
GET /v1/vaults/{vaultId}/items/{itemId}/attachments/{attachmentId} |
Download an encrypted file |
DELETE /v1/vaults/{vaultId}/items/{itemId}/attachments/{attachmentId} |
Delete a file |
GET /v1/ws |
WebSocket: “vault changed” notifications, sign-out and deletion notices |
GET /v1/devices |
Signed-in devices |
PATCH /v1/devices/current |
Rename this device |
DELETE /v1/devices/{deviceId} |
Sign a device out remotely |
GET /v1/accounts/lookup?email= |
An account’s id, public keys and fingerprint, to share with it |
GET, POST /v1/transparency/… |
The key log |
POST /v1/vaults |
Create a shared vault |
GET, POST /v1/vaults/{vaultId}/members |
List members; add one (or change their role) with a signed grant |
DELETE /v1/vaults/{vaultId}/members/{accountId} |
Remove a member, or leave |
POST /v1/vaults/{vaultId}/rotate |
New vault key, on demand or after a member leaves: new grants (or a personal vault’s wrapped key) and re-wrapped item keys in one transaction |
GET, POST /v1/families |
The account’s family and invitations; create a family |
DELETE /v1/families/{familyId} |
Delete the family and its Family vault |
POST /v1/families/{familyId}/invites |
Invite someone, with their Family vault grant |
POST /v1/families/{familyId}/invites/accept |
Join |
DELETE /v1/families/{familyId}/invites/{accountId} |
Cancel or decline an invite |
DELETE /v1/families/{familyId}/members/{accountId} |
Leave, or remove a member |
GET, POST /v1/share-links |
The account’s share links; create one |
GET /v1/share-links/{id} |
View a share link (no account needed; counts a view) |
DELETE /v1/share-links/{id} |
Delete one of your links |
GET, POST /v1/emergency-contacts |
Emergency contacts both ways; add one |
DELETE /v1/emergency-contacts/{id} |
Remove a contact, or step down as one |
POST /v1/emergency-contacts/{id}/request, …/deny, …/approve |
The emergency access timeline |
GET /v1/emergency-contacts/{id}/release, …/sync |
For the contact, once released: the sealed key and the grantor’s vaults |
GET /v1/emergency-contacts/{id}/vaults/{vaultId}/items/{itemId}/attachments/{attachmentId} |
For the contact, once released: one of the grantor’s encrypted files |
PUT /v1/emergency-contacts/{id}/key |
For the grantor: seal a rotated vault’s key to the contact again |
POST /v1/emergency-contacts/{id}/takeover/registration, …/takeover |
For a released takeover contact: a new master password, Secret Key and recovery code for the grantor |
POST /v1/families/{familyId}/organiser-recovery |
A member lets the organiser help recover their account |
PUT /v1/account/secret-key |
New Secret Key, same master password |
GET, POST /v1/orgs |
The account’s organisation and invitations; create one |
PATCH, DELETE /v1/orgs/{orgId} |
Rename (owner); delete with a reauth token (owner) |
GET /v1/orgs/{orgId}/members, PATCH, DELETE …/members/{accountId} |
Members; change a role (owner); remove or leave |
POST /v1/orgs/{orgId}/invites, DELETE …/invites/{inviteId}, POST …/invites/{inviteId}/accept |
Invitations |
GET, POST /v1/orgs/{orgId}/groups, PATCH, DELETE …/groups/{groupId}, PUT, DELETE …/groups/{groupId}/members/{accountId} |
Groups (admin) |
GET, POST /v1/orgs/{orgId}/vaults, DELETE …/vaults/{vaultId}, PUT, DELETE …/vaults/{vaultId}/groups/{groupId} |
Organisation vaults and the groups linked to them |
GET /v1/orgs/{orgId}/access, POST …/vaults/{vaultId}/grants |
What vaults wait for from a key holder; give it |
PUT /v1/orgs/{orgId}/policies, GET …/events |
Policies (owner); the activity log (admin) |
GET, PUT, DELETE /v1/orgs/{orgId}/sso, POST …/sso/test |
Identity provider settings (owner) |
POST /v1/auth/login/sso, GET /v1/sso/start/{token}, GET /v1/sso/callback |
The SSO sign-in step |
GET /v1/security-log |
The account’s own security log |
GET /v1/icons/{domain} |
A website’s icon as a PNG (no account needed); see website icons |
Authentication
Section titled “Authentication”Octa Pass uses OPAQUE (RFC 9807) through opaque-ke 4.0.1, with:
| Setting | Value |
|---|---|
| OPRF | ristretto255 |
| Key exchange | TripleDH over ristretto255 with SHA-512 |
| Key stretching | Identity (Argon2id already ran on the client) |
| Password input | auth_secret, see encryption |
| Credential identifier | The email, trimmed and lowercased |
| Identifiers and context | The opaque-ke defaults (none) |
Sign-up
Section titled “Sign-up”POST /v1/auth/preloginis optional before sign-up. The client generates its own 16-byte account salt.POST /v1/auth/register/startwith{ email, registrationRequest }returns{ registrationResponse, verificationRequired: true }and emails a 6-digit code, valid for 15 minutes. In dev mode the response also containsdevVerificationCode. If the email already has an account, its owner gets a notice instead of a code, and the response looks exactly the same.POST /v1/auth/register/finishwith{ email, verificationCode, registrationUpload, account, personalVault: { id, wrappedVaultKey } }returns{ accountId }. Five wrong guesses use up the code. Asking for a new code before the old one expires keeps the wrong guesses already made, so wait the 15 minutes out after five. An email can be sent at most 10 codes and try at most 20 wrong codes per UTC day.
The server checks the shape of the account bundle: a 16-byte accountSalt, 32-byte public keys, Argon2id parameters between m = 64 MiB, t = 3, p = 1 and m = 1 GiB, t = 16, p = 4 (the range clients accept), and version 1 ciphertext envelopes for every wrapped key.
POST /v1/auth/preloginwith{ email }returns{ kdf, accountSalt }. Unknown emails get a deterministic fake salt and the default parameters, so the answer doesn’t reveal whether an account exists.POST /v1/auth/login/startwith{ email, credentialRequest }returns{ loginId, credentialResponse }. Unknown emails get a well-formed fake response.POST /v1/auth/login/finishwith{ loginId, credentialFinalization, device: { name, platform }, trustedDeviceToken? }returns{ accessToken, refreshToken, expiresIn, account, trustedDeviceToken }. AloginIdis valid for 30 seconds and works once. Every failure, whatever the cause, is the same401with thelogin-failedproblem type.
A login that creates a session without a valid trustedDeviceToken emails the account owner a “New sign-in” notice with the device name, platform and time, unless it’s the account’s only device within 10 minutes of sign-up.
If the account has two-step sign-in on and no valid trustedDeviceToken was sent, the answer is instead 401 second-factor-required once the password is proven, and the login continues there.
Tokens
Section titled “Tokens”- Access tokens are random and last 15 minutes (
expiresIn: 900). - Refresh tokens are random and last 30 days.
POST /v1/auth/refreshwith{ refreshToken }returns a new pair, and the old refresh token stops working. Presenting an already-rotated refresh token revokes the whole session, because it means the token was copied. - The server stores only SHA-256 hashes of tokens.
POST /v1/auth/logoutrevokes the calling device’s tokens and returns204.
Reauthentication
Section titled “Reauthentication”Dangerous actions need proof that the user just typed their master password. A signed-in client runs a second OPAQUE login:
POST /v1/auth/reauth/startwith{ credentialRequest }returns{ reauthId, credentialResponse }.POST /v1/auth/reauth/finishwith{ reauthId, credentialFinalization, purpose }returns{ reauthToken, expiresIn: 300 }.purposeisdelete-account,change-password,rotate-secret-key,emergency-takeover,two-step(any change to two-step sign-in) ororganisation(deleting an organisation or one of its vaults).
The reauth token works once, for the account that requested it and the stated purpose only, within 5 minutes. A wrong password gets 401 login-failed.
Changing the master password
Section titled “Changing the master password”The account key (ASK) stays the same, so nothing is re-encrypted: the client sends a new OPAQUE record and the ASK wrapped for the new password, with a fresh account salt.
- Reauthenticate with purpose
change-password. - Derive the new keys, then
POST /v1/account/password/registrationwith{ registrationRequest }. It returns{ registrationResponse }and stores nothing. PUT /v1/account/passwordwith theX-Reauth-Tokenheader and{ registrationUpload, account: { kdf, accountSalt, wrappedAsk } }.accountmay also carryrecoveryWrappedAskto replace the recovery code.204: every other device is signed out (their sessions end, they getsession-revoked, and they no longer skip two-step sign-in); this one stays. The account gets an email and apassword-changedsecurity log entry.
Rotating the Secret Key
Section titled “Rotating the Secret Key”The same exchange with the same master password: the AUK and the OPAQUE secret derive from the password and the Secret Key together, so a new Secret Key means a new OPAQUE record. The ASK stays, so nothing is re-encrypted and emergency contacts keep working.
- Reauthenticate with purpose
rotate-secret-key. - Generate the Secret Key and a fresh salt, then
POST /v1/account/password/registrationas above. PUT /v1/account/secret-keywith theX-Reauth-Tokenheader and the same body as a password change.accountmay carryrecoveryWrappedAskto replace the recovery code too.204: every other device is signed out, and the account gets an email and asecret-key-rotatedsecurity log entry.
Show the new Secret Key in a new Recovery Kit before anything else. If the answer is lost, compare accountSalt from GET /v1/account with the salt you sent, to tell whether it landed: the new key is on this device only. Other devices sign in with the new key; delete this device’s device unlock record.
Resetting the master password
Section titled “Resetting the master password”Someone who forgot the master password, or lost the Secret Key too, resets it with the recovery code from the Recovery Kit. The server can’t check the recovery code; the client proves it opened the ASK by signing with the account’s Ed25519 key, which only the ASK unwraps.
-
POST /v1/auth/recover/startwith{ email }returns{ recoveryId }and emails a 6-digit code. An email without an account gets the same answer and no email. The day’s code budget is the same as sign-up’s, counted separately. -
POST /v1/auth/recover/verifywith{ recoveryId, verificationCode }returns{ account, challenge }: the account bundle, whoserecoveryWrappedAskthe recovery code opens, and 32 random bytes to sign. A wrong code is400 invalid-verification-code; five use up the reset. -
The client opens the ASK with the recovery code (and its private keys with the ASK), derives the new keys, and sends
POST /v1/auth/recover/registrationwith{ recoveryId, registrationRequest }for{ registrationResponse }. -
POST /v1/auth/recover/finishwith{ recoveryId, registrationUpload, account: { kdf, accountSalt, wrappedAsk, recoveryWrappedAsk }, signature }. A reset always replaces the recovery code, sorecoveryWrappedAskis required.signatureis Ed25519 by the account’s signing key over:"octapass/v1/account-recovery" ‖ 0x00 ‖ for each field: len (u32 BE) ‖ UTF-8 bytesfields = accountId, challenge, registrationUpload, accountSalt, wrappedAsk, recoveryWrappedAsk204: every device is signed out and none skips two-step sign-in any more. A wrong signature is401 login-failedand ends the reset. The account gets an email and apassword-resetsecurity log entry. -
Log in with the new password. If two-step sign-in is on, the login asks for the second step as usual: a reset never skips it.
A reset lasts 15 minutes from recover/start. An unknown, expired or finished recoveryId, or registration before verify, is 400 recovery-expired: start again.
Two-step sign-in
Section titled “Two-step sign-in”An account can ask new devices for a second step after the password: a code from an authenticator app, a security key, or a single-use recovery code. It guards access to the ciphertext and plays no part in the encryption.
Trusted devices. Every successful login returns a trustedDeviceToken. Keep it with the session and send it with the next login from the same device: a valid one skips the second step, and is used up and replaced by the new one in the answer (two logins can’t both use it). A token belongs to the device that signed in with it and is valid only while that device is in GET /v1/devices (not signed out, session not lapsed), for at most 30 days. Logging out, signing the device out remotely, a lapsed session, or a reused refresh token for the account forgets it. Turning two-step sign-in on forgets every device’s token except the caller’s, and DELETE /v1/account/two-step/trusted-devices does the same at any time. Refreshing never hands out a token.
The second step. login/finish answers 401 with problem type second-factor-required and a secondFactor member:
{ "challengeId": "…", "methods": ["totp", "webauthn", "recovery-code"], "webauthn": { "challenge": "…", "rpId": "vault.example.com", "allowCredentials": ["…"], "timeout": 120000, "userVerification": "discouraged" }, "expiresIn": 300}webauthn is present only when the account has security keys. Finish with POST /v1/auth/login/second-factor and exactly one of:
| Field | Value |
|---|---|
totpCode |
The 6-digit code (RFC 6238: SHA-1, 30 seconds, the previous and next step accepted). Each step works once. |
recoveryCode |
One of the recovery codes. Case, spaces and dashes don’t matter. Each works once. |
webauthn |
{ credentialId, clientDataJson, authenticatorData, signature } from navigator.credentials.get() with the given options |
{ "challengeId": "…", "totpCode": "123456" }| Response | Meaning |
|---|---|
200 |
Same body as a successful login/finish |
401 invalid-second-factor |
Wrong or already used code, or an assertion that doesn’t verify. The challenge stays usable for 5 tries in all. |
401 login-failed |
Unknown, expired (5 minutes) or used-up challenge: start the login again |
429 rate-limited |
10 wrong second steps for the account (each try is counted before it’s checked, so parallel guesses can’t get past the limit; a right one is given back): paused, one more try every 5 minutes. The challenge stays usable until it expires. Logged and emailed to the owner (at most once an hour). |
Settings. Every change needs an X-Reauth-Token with purpose two-step, except confirming a setup, which the setup id authorizes.
GET /v1/account/two-stepreturns{ enabled, methods, securityKeys: [{ id, name, createdAt, lastUsedAt }], recoveryCodesLeft, rpId }.POST /v1/account/two-step/totpreturns{ setupId, secret, otpauthUri, expiresIn: 600 }: a new authenticator secret (160 bits, base32) and theotpauth://URI for a QR code. Nothing changes yet.POST /v1/account/two-step/totp/confirmwith{ setupId, code }turns two-step sign-in on with that authenticator, or replaces the current one. It returns{ recoveryCodes }(10 codes likeABCDE-12345) only when it turned it on. A wrong code gets401 invalid-second-factorand the setup stays usable; an expired one400.POST /v1/account/two-step/recovery-codesreturns{ recoveryCodes }and invalidates the old ones.DELETE /v1/account/two-stepturns it off and removes the authenticator, security keys and recovery codes.204.POST /v1/account/two-step/security-keys/optionsreturns{ setupId, publicKey: { challenge, rp, user, algorithms, excludeCredentials, timeout } }fornavigator.credentials.create(). Two-step sign-in must be on already (with an authenticator app), and an account has at most 10 keys. Attestation isnone; ES256 and Ed25519 keys are accepted.POST /v1/account/two-step/security-keyswith{ setupId, name, credentialId, clientDataJson, attestationObject }returns the key record.409 already-existsfor a key that’s registered already.DELETE /v1/account/two-step/security-keys/{id}removes a key.204.DELETE /v1/account/two-step/trusted-devicesforgets every trusted device except the caller’s (no reauth: it only takes trust away). Sessions stay; sign devices out withDELETE /v1/devices/{deviceId}.204.
Security keys are bound to the relying party id rpId (the web vault’s host by default, OCTAPASS_WEBAUTHN_RP_ID to override), and ceremonies must come from a web vault origin or the server’s public URL.
The server stores the authenticator secret sealed under a key derived from its own secret (XChaCha20-Poly1305, bound to the account), recovery codes as HMACs, and security keys’ public keys.
Every vault has a revision that increases by one on every change to its items. Each item stores the vault revision of its last change.
GET /v1/sync?since=0192b7a0-…:41,0192b7a1-…:7Authorization: Bearer …{ "vaults": [{ "id": "0192b7a0-…", "type": "personal", "revision": 43, "wrappedVaultKey": "…", "role": "owner" }], "items": [{ "id": "…", "vaultId": "0192b7a0-…", "revision": 43, "encryptedItemKey": "…", "overview": "…", "details": "…", "updatedAt": "2026-10-23T09:12:44.120Z" }], "deleted": [{ "vaultId": "0192b7a0-…", "itemId": "…", "revision": 42 }]}vaultsalways lists every vault the account can access, with its current revision. A vault that disappears from the list is one the account can no longer access (it left, was removed, or the vault was deleted): drop it locally.typeispersonal,sharedorfamily, androleisread,write,manageorowner. Shared and family vaults also havekeyIdandgrant, described under sharing.itemsholds the items whose revision is greater than the one given insince, including items in the trash (they havedeletedAt). Vaults left out ofsinceare returned in full.deletedholds items removed for good. Items stay in the trash for 30 days, then they’re purged and leave a tombstone for 90 days. A client that hasn’t synced for longer than that should sync from scratch (leave the vault out ofsince) and drop local items the server no longer returns.- Remember each vault’s
revisionand send it assincenext time. - Responses are paged: about 1,000 items or 8 MiB each. When
hasMoreistrue, the response stopped early. Each vault’srevisionis then the one the response is complete up to (a page never ends in the middle of a revision), so store the revisions and sync again with them untilhasMoreisfalse.
Writes and conflicts
Section titled “Writes and conflicts”Create an item with If-None-Match: *, and update it with the revision you last saw:
PUT /v1/vaults/0192b7a0-…/items/0192b7a2-…If-Match: "41"Content-Type: application/json
{ "encryptedItemKey": "…", "overview": "…", "details": "…" }The response is 200 with { "revision": 44 } and an ETag: "44" header. All three fields must be version 1 ciphertext envelopes; overview may be up to 64 KiB and details up to 512 KiB.
If the item changed on the server, or already exists when creating, the response is 409 with the conflict problem type and the stored item in current:
{ "type": "https://octap.app/problems/conflict", "title": "Conflict", "status": 409, "detail": "the item changed on the server", "current": { "id": "…", "vaultId": "…", "revision": 43, "encryptedItemKey": "…", "overview": "…", "details": "…", "updatedAt": "…" }}The client merges field by field when it can. Otherwise the server version wins and the client keeps its own as a conflict copy. See offline access and sync.
- A write without
If-MatchorIf-None-Matchgets428. DELETE …/items/{itemId}needsIf-Matchand moves the item to the trash.POST …/items/{itemId}/restoretakes it out of the trash.- Each
PUTthat replaces a version adds that version to the item’s history, unless the body sets"skipHistory": true, for writes that are bookkeeping rather than edits (a one-time code’s counter moving). Trashing and restoring add nothing.GET …/historyreturns{ revisions: [{ revision, overview, details, updatedAt }] }, readable by every member of the vault. Open a version with the item’s currentencryptedItemKey: an edit keeps the item key, and a rotation only re-wraps it. A client that gives an item a new key loses its history. History counts initemBytesand goes with its item. - Deleting an item that’s already in the trash, or restoring one that isn’t, changes nothing and returns the current revision.
- Vaults and items the account can’t access answer
404. Members with thereadrole get403 forbidden. - In a vault with a
keyId(shared and family vaults, and a personal vault from its first rotation),encryptedItemKeymust be wrapped under the vault’s current key. After a key rotation, a write still using the old key answers409 vault-key-changed: sync, open the new key and encrypt again. A client holding changes queued offline has to re-wrap their item keys with the old key before dropping it; every revision moved on, so an edit then goes through the usual conflict handling. - Each account can store 50,000 items and 1 GiB of item ciphertext in the vaults it owns by default, trash included (self-hosters can change this). A write that would go over answers
403 quota-exceeded. Updates that don’t grow, deletes and restores always work.
Batch writes
Section titled “Batch writes”Imports and bulk edits send many writes in one request:
POST /v1/batchAuthorization: Bearer …Content-Type: application/json
{ "operations": [ { "op": "put", "vaultId": "0192b7a0-…", "itemId": "0192b7a3-…", "item": { "encryptedItemKey": "…", "overview": "…", "details": "…" } }, { "op": "put", "vaultId": "0192b7a0-…", "itemId": "0192b7a2-…", "ifMatch": 41, "item": { … } }, { "op": "delete", "vaultId": "0192b7a0-…", "itemId": "0192b7a4-…", "ifMatch": "*" }, { "op": "restore", "vaultId": "0192b7a0-…", "itemId": "0192b7a5-…" } ]}op |
ifMatch |
Same as |
|---|---|---|
put |
omitted: create | PUT with If-None-Match: * |
put |
a revision: update that revision | PUT with If-Match |
put |
"*": update whatever is stored, if it exists |
Batch only |
delete |
required: a revision, or "*" |
DELETE with If-Match |
restore |
ignored | POST …/restore |
The response is 200 with one result per operation, in request order:
{ "results": [ { "itemId": "0192b7a3-…", "status": 200, "revision": 44 }, { "itemId": "0192b7a2-…", "status": 409, "problem": { "type": "https://octap.app/problems/conflict", "status": 409, "current": { … }, … } }, { "itemId": "0192b7a4-…", "status": 424, "problem": { "type": "https://octap.app/problems/batch-aborted", … } } ]}- Each
statusandproblemis what the single-item endpoint would have answered. - Atomic per vault. If any operation on a vault fails, none of that vault’s operations are applied: the failing ones report their own error and the rest answer
424 batch-aborted. Operations on other vaults in the same batch are unaffected. - All changes to one vault share one new revision.
- At most 1,000 operations and 16 MiB per request, and each item at most once. Ids must be well-formed. Breaking any of these rejects the whole request with
400. - A
500means some vaults may have been written. Sync before retrying.
Attachments
Section titled “Attachments”Files kept with an item. Each file is encrypted on the device with its own random key, a chunk at a time (the format is in the core README). The file key, wrapped by the item key, goes into the item’s encrypted details with the file’s name, media type and size. The server stores the ciphertext and knows only its size, when it was uploaded and which item it belongs to.
PUT /v1/vaults/{vaultId}/items/{itemId}/attachments/{attachmentId}Authorization: Bearer …Content-Type: application/octet-streamContent-Length: 70052
<the encrypted file>- The attachment id is a client-made UUID. Upload first, then add the file to the item’s details with a normal item write, so no item ever lists a file that isn’t there. If that write fails, delete the upload.
- The body must be an encrypted attachment (at least 52 bytes, starting with version byte
0x01) and exactlyContent-Lengthlong. WithoutContent-Lengththe answer is411 length-required; over the file size limit,413 payload-too-large, before any of the body is read. - Uploads need
write,manageorowneron the vault, and an item that isn’t in the trash. They answer201with{ id, vaultId, itemId, size, createdAt }, wheresizeis the stored size. - The bytes count against the vault owner’s attachment quota (1 GiB by default), not the uploader’s, as items do. An upload that would go over it gets
403 quota-exceeded, also before its body is read. GET …/attachments/{attachmentId}streams the encrypted file back (application/octet-stream) to any member of the vault, trash included.GET …/attachmentslists an item’s files.DELETE …/attachments/{attachmentId}(writers) answers204; take the file out of the item’s details first.- Files go with their item: when the trash is purged after 30 days, when their vault is deleted, and when the account is deleted. The server queues each file for deletion in the same transaction and deletes it from storage right after, retrying with backoff (from a minute up to a day) while object storage is unreachable.
- Adding or removing files doesn’t change the vault revision; the item write that lists them does.
GET /v1/account/usage returns what the vaults the account owns hold, trash included, next to the server’s limits: { items, maxItems, itemBytes, maxItemBytes, attachmentBytes, maxAttachmentBytes, maxFileBytes }. All sizes are stored bytes.
Sharing
Section titled “Sharing”Shared vaults work like personal ones for sync and writes, but every member holds their own copy of the vault key, sealed to their X25519 public key and signed by whoever granted it. The server stores these grants and checks who may grant what; it can’t open them. The formats are in the octapass-core README, and the client side is exposed by @octapass/core.
| Role | Can |
|---|---|
read |
Sync and decrypt |
write |
Also create, edit, delete and restore items |
manage |
Also add and remove members (not owners), change roles below owner, rotate the key |
owner |
Also grant and remove owner. A vault is deleted with its last owner. |
Grants
Section titled “Grants”A grant is { vaultId, recipientAccountId, role, wrappedVaultKey, grantedBy, grantedAt, signature }. wrappedVaultKey is the sealed key (105 bytes for version 1, 1,193 for the hybrid version 2), grantedAt is Unix milliseconds and signature is the granter’s Ed25519 signature over all of it. The server only accepts grants with grantedBy set to the signed-in account and stores the granter’s public signing key with each one.
In sync, a shared vault’s record carries the rest of the member’s grant:
{ "id": "0192b7a9-…", "type": "shared", "revision": 12, "role": "write", "wrappedVaultKey": "AT…", "keyId": "Xq…", "grant": { "grantedBy": "0192b7a0-…", "granterPublicSignKey": "…", "grantedAt": 1761177600000, "signature": "…" }}Clients rebuild the signed grant from the record and their own account id, and verify the signature before using the key. The granter’s public key comes from the server, so clients compare fingerprints out of band (in person, on a call) and warn about members they haven’t verified.
Creating and inviting
Section titled “Creating and inviting”POST /v1/vaultswith{ id, keyId, grant }, wheregrantgives the signed-in account theownerrole.201with the vault’s record.GET /v1/accounts/lookup?email=returns{ accountId, email, publicEncKey, publicSignKey, fingerprint }, or404.fingerprintis the digits form; clients should compute their own from the keys.POST /v1/vaults/{vaultId}/memberswith{ email, grant, keyId }(needsmanage;ownerto grantowner). The answer is always202with no body: for an email without an account, or a grant for another account, nothing is stored. Sending a grant for an existing member replaces it, which is how roles change.keyIdis optional and names the vault key the grant seals: if the key was rotated since, the answer is409 vault-key-changedand nothing is stored. Clients should send it, so a rotation racing the invite can’t leave a grant of the old key behind.GET /v1/vaults/{vaultId}/memberslists{ accountId, email, role, publicEncKey, publicSignKey, fingerprint, grantedBy, addedAt }for any member.
Lookups and invites share one budget per account (see rate limits), because they’re where an email’s existence shows.
Removing members and rotating the key
Section titled “Removing members and rotating the key”A vault’s key can also be rotated on demand, with nobody leaving: the same request, by anyone with manage.
DELETE /v1/vaults/{vaultId}/members/{accountId} removes a member (needs manage, owner to remove an owner), or leaves when the id is your own. The last owner can’t leave. Family vaults change members through families instead.
A removed member still knows the old vault key, so the client then rotates it:
POST /v1/vaults/0192b7a9-…/rotateContent-Type: application/json
{ "revision": 12, "keyId": "new key id", "grants": [{ "vaultId": "…", "recipientAccountId": "…", "role": "owner", … }, …], "items": [{ "itemId": "…", "encryptedItemKey": "wrapped under the new key" }, …], "invites": [{ "vaultId": "…", "recipientAccountId": "…", "role": "write", … }]}revisionis the vault revision the client synced to. Any change since then answers409 vault-changed: sync and rotate again.grantsmust cover exactly the current members, each with their current role.itemsmust cover exactly the vault’s items, trash included, each wrapped under the new key. Anything else is400, and nothing changes.invitesis for family vaults: a grant of the new key for each pending invite to keep, with the invite’s role. A grant for anyone without a pending invite, or two for one invite, is400. Pending invites it leaves out are cancelled, so no invite keeps the old key; the organiser can invite again.- In one transaction the server replaces every grant, every item key, the re-sealed invites and the vault’s
keyId, cancels the other pending invites, and gives every item the new revision, so all members sync them again. Item bodies don’t change: they stay encrypted under their item keys. - Needs
manage. The answer is{ revision }.vault-key-rotatedgoes in the rotating account’s security log. - A personal vault rotates the same way, with
"grants": [], noinvites, andwrappedVaultKey, the new key wrapped by the account key (contextvaultkey:<vaultId>). Only personal vaults takewrappedVaultKey, and they need it. The rotation gives the vault akeyId, which from then on refuses writes on the old key. - Vault-scoped emergency contacts stay on the old key and are marked
keyStale; see emergency access.
Key log
Section titled “Key log”An append-only log of account public keys, which clients verify (design: plan 03; formats: the octapass-core README). Everything here needs a signed-in account. Not yet reviewed by an outside cryptographer.
| Endpoint | Does |
|---|---|
GET /v1/transparency/head?since= |
The log’s public keys, a head signed now, and an RFC 9162 consistency proof from size since |
POST /v1/transparency/label |
{ blinded } → { evaluated, proof }: the VOPRF step that gives an email’s label. Charged to a per-account budget (40, then one per 30 s) |
GET /v1/transparency/proof?label=&since= |
The same as head, plus entry: { leafIndex, record, inclusion } for the label’s newest record, if it has one |
GET /v1/transparency/entries?start=&end= |
{ start, frontier, leaves }: up to 10,000 64-byte leaves, and the perfect subtrees before start, so a client can rebuild the root |
POST /v1/transparency/publish |
{ record, wrappedPqPrivateKey } → { leafIndex }. Stores the account’s ML-KEM key and appends its record. The same record again answers the same. A different one for this log is 409 already-exists: take the record from GET /v1/account |
keyRecord also appears in GET /v1/account (with wrappedPqPrivateKey), GET /v1/accounts/lookup and each member of GET /v1/vaults/{vaultId}/members. Clients seal to the record’s publicPqKey only after verifying it against the keys offered with it. Deleting an account deletes its record; the leaf stays, because a log can’t give one back, and holds only two hashes.
Families
Section titled “Families”A family is an organiser and up to 5 more people (6 in all; self-hosters set OCTAPASS_FAMILY_MAX_MEMBERS, 2 to 100) sharing a Family vault. Each account is in at most one family.
POST /v1/familieswith{ id, vault }, wherevaultis the same body asPOST /v1/vaults.201with the family record:{ id, organiserId, vaultId, maxMembers, members, invites, createdAt }.POST /v1/families/{familyId}/inviteswith{ email, grant, keyId }(organiser only; awritegrant for the Family vault;keyIdas for vault invites). Always202, as for vault invites. Members plus pending invites can’t go over the limit:409 family-full, checked before the email is. The invitee gets an email.GET /v1/familiesreturns{ family, invitations }: the account’s family (with pending invites for the organiser only) and the invitations it received.POST /v1/families/{familyId}/invites/acceptjoins, turning the invite’s grant into the Family vault membership.409 already-existswhile in another family.DELETE /v1/families/{familyId}/invites/{accountId}cancels (organiser) or declines (invitee) an invite.DELETE /v1/families/{familyId}/members/{accountId}leaves the family, or (organiser) removes a member, who also leaves the Family vault. The organiser rotates its key next, with grants of the new key for the pending invites ininvites(any left out are cancelled). The organiser can’t leave;DELETE /v1/families/{familyId}deletes the family and its vault.
Organisations
Section titled “Organisations”The rules are in organisations. Roles are owner, admin and member, and open nothing. Each account is in one organisation. Organisations an account isn’t in answer 404; a role that’s too low answers 403.
An organisation vault is a shared vault with orgId on its VaultRecord. It opens as any shared vault does, and POST /v1/vaults/{vaultId}/rotate works as usual. Its members must be members of the organisation: POST /v1/vaults/{vaultId}/members for anyone else answers 202 and stores nothing, as for an unknown email.
POST /v1/orgswith{ id, name }makes the caller the owner.POST …/inviteswith{ email, role }always answers202. Invites hold no key and last 14 days.GET /v1/orgslists invitations to the account’s email, with the policies that come with joining.POST …/vaultstakes the same body asPOST /v1/vaults. Someone is entitled to a vault as an organisation owner (roleowner), as a direct member, or through a linked group. The highest role wins.GET …/accesslists vaults with something waiting:grantsto make, and whether torotate.canApplysays the caller holds the key and may manage the vault. This list comes from the server: show each person, with a fingerprint computed on the device, to a human, and seal nothing without their confirmation. Otherwise a hostile server can add itself.POST …/vaults/{vaultId}/grantswith{ keyId, grants }stores them. Each must be by the caller, for someone entitled, with exactly that role (400); a key rotated since answers409 vault-key-changed.
Taking access away happens on the server at once. Anyone no longer entitled loses the vault and it’s marked for rotation. Anyone whose entitled role went down loses it until a lower grant is made, since a role is part of the signed grant. A change that leaves a vault with nobody who can manage it answers 409 org-vault-orphaned with vaultIds; DELETE …/members/{accountId}?orphanVaults=true goes ahead. DELETE …/vaults/{vaultId} needs a reauth token of purpose organisation, and no key.
PUT …/policies takes { requireTwoStep, requireSso, ssoMaxAgeHours, disableShareLinks, disableEmergencyAccess, disableExport, maxAutoLockMinutes?, minPasswordLength? }. The first five are enforced by the server: 403 org-policy, or vaults withheld from /v1/sync and vault routes for a member without two-step sign-in (accessWithheld in GET /v1/orgs). disableExport, maxAutoLockMinutes and minPasswordLength are for clients to apply. requireSso needs a finished test sign-in, or 400 sso-config.
Single sign-on
Section titled “Single sign-on”SSO adds a step after OPAQUE and takes no part in deriving keys. See single sign-on.
POST /v1/auth/login/finishanswers401 sso-requiredonce the password was right, withsso: { challengeId, url, orgName, expiresIn }. Owners are exempt.- The client opens
urlin a browser and keepschallengeId. The server, as OpenID Connect relying party, sends the browser to the provider (code flow, PKCE,state,nonce), andGET /v1/sso/callbackends on a page with a 6-digit code. POST /v1/auth/login/ssowith{ challengeId, code }answers likelogin/finish, or401 second-factor-requiredif a second step comes next.409 sso-pending: the browser hasn’t finished.401 invalid-second-factor: a wrong code, up to 5.401 login-failed: start over.
A refresh fails with 401 unauthorized once the session’s SSO sign-in is older than ssoMaxAgeHours (0: only at sign-in). The client then signs in again.
Share links
Section titled “Share links”A share link carries one item to someone without an account. The client encrypts a snapshot under a random key that goes in the link’s fragment (https://<host>/s/<id>#<key>), which browsers never send to servers.
POST /v1/share-linkswith{ ciphertext, expiresAt, maxViews, passphraseRequired }:expiresAt1 hour to 30 days from now,maxViews1 to 100, ciphertext up to 600 KiB.201with{ id, expiresAt }. An account can have 100 links open (403 quota-exceeded).GET /v1/share-links/{id}needs no account. It counts a view and returns{ ciphertext, passphraseRequired, expiresAt, viewsLeft }withCache-Control: no-store. The last view deletes the link; expired, used-up and unknown links all answer404. Each IP gets 10 views at once, then one every 6 seconds.GET /v1/share-linkslists the account’s open links without their ciphertext;DELETE /v1/share-links/{id}deletes one (404for anyone else’s).
Emergency access
Section titled “Emergency access”The grantor seals their account key (ASK), or one vault’s key, to a contact’s public key. The contact can request it; unless the grantor denies the request within the waiting period, the server releases it.
POST /v1/emergency-contactswith{ contactAccountId, sealedKey, scope, accessLevel, waitDays }.scopeis{ "type": "account" }or{ "type": "vault", "vaultId": "…" }(a vault the grantor owns),accessLevelisviewortakeover,waitDays1 to 30.201with the record;409 already-existsfor the same contact twice. The contact gets an email, so adding contacts is rate limited, per account and per contact; removing a contact and adding it again counts as a new one.GET /v1/emergency-contactsreturns{ granted, received }: records with{ id, grantorId, grantorEmail, contactId, contactEmail, scope, accessLevel, waitDays, status, requestedAt, releasesAt, releasedAt, createdAt }.statusisidle,requestedorreleased.- The contact sends
POST …/{id}/request. The grantor gets an email straight away and a reminder every day while the request waits. - The grantor can
POST …/{id}/denyat any time, which also takes back a key already released, orPOST …/{id}/approveto release it now. - At
releasesAtthe request is released, and both sides get an email. - Once released, the contact gets
GET …/{id}/release:{ contact, sealedKey, grantor }. For an account scope,grantoris the grantor’s account bundle (the released ASK opens its private keys). A vault scope gets nograntor: the bundle’s private keys would open the vaults shared with the grantor, so the contact gets only the sealed key of the one vault.GET …/{id}/sync?since=is a read-only sync of the grantor’s vaults in scope.GET …/{id}/vaults/{vaultId}/items/{itemId}/attachments/{attachmentId}streams one of the grantor’s encrypted attachments from a vault in scope, exactly like the grantor’s own download; a file in a vault outside the scope is404. The contact decrypts it with the item key from the synced item and the file key in its details. Before release, all three answer403. DELETE …/{id}ends it, from either side.
Every step is recorded in the grantor’s security log.
After a rotation. Records also carry keyStale when true (a vault scope whose key was rotated) and organiserRecovery when true. The grantor renews a stale record with PUT …/{id}/key and { sealedKey, keyId }, where keyId names the vault key it seals: 409 vault-key-changed if it was rotated since. Clients must only do this after the user compared the contact’s fingerprint, never by themselves: the server controls both keyStale and the public key a lookup returns.
Takeover. takeover needs the account scope (400 otherwise). Once released, the contact can replace the grantor’s credentials:
GET …/{id}/release, open the ASK and make new credentials with a fresh Secret Key, salt and recovery code.POST …/{id}/takeover/registrationwith{ registrationRequest }. It runs OPAQUE under the grantor’s email and stores nothing.POST …/{id}/takeoverwithX-Reauth-Token(the contact’s own, purposeemergency-takeover) and{ registrationUpload, account, signature }.account.recoveryWrappedAskis required.signatureis by the grantor’s Ed25519 key, over"octapass/v1/emergency-takeover" ‖ 0x00 ‖length-prefixed (u32 big-endian) UTF-8recordId, grantorId, contactId, currentAccountSalt, registrationUpload, accountSalt, wrappedAsk, recoveryWrappedAsk. See theemergencyTakeovertest vector incrates/octapass-core/tests/vectors/key-rotation.json.
204, in one transaction: the credentials are replaced, every device of the grantor is signed out, two-step sign-in is turned off, the record is deleted, and emergency-takeover is logged (with two-step-disabled if it was on). The grantor is emailed. A record that was denied or removed first, a view record, a wrong signature, or a salt that changed since (any credentials change) answers 403: read the release again. If the answer is lost, the record being gone from GET /v1/emergency-contacts means it landed: the new kit is the only copy.
Organiser recovery. A family member opts in with POST /v1/families/{familyId}/organiser-recovery and { sealedKey, waitDays }, sealing their ASK to the organiser. 201 with an emergency record: account scope, takeover, organiserRecovery: true. 409 already-exists if on already, or if the organiser is already their emergency contact. It follows the same request, wait and takeover steps, except that …/sync and attachment downloads answer 403: the organiser can reset the account, never read it. DELETE /v1/emergency-contacts/{id} turns it off; leaving the family, being removed or the family’s deletion removes it too.
Service accounts
Section titled “Service accounts”A service account reads one shared vault with no person present. Everything is made on the creator’s device; the token never reaches the server.
| Method | Path | Who | What |
|---|---|---|---|
POST |
/v1/vaults/{vaultId}/service-accounts |
Manager of a shared vault | Stores the public keys, the sealed name and the signed grant. grant.recipientAccountId is the service account’s id; the role is read or write. |
GET |
/v1/vaults/{vaultId}/service-accounts |
Manager | All of them, revoked ones included. |
DELETE |
/v1/vaults/{vaultId}/service-accounts/{id} |
Manager | Revokes it at once. Rotate the vault key next. |
POST |
/v1/service-accounts/challenge |
Anyone | A single-use challenge, good for 30 seconds. The answer doesn’t depend on whether the id exists. |
POST |
/v1/service-accounts/session |
Anyone | Checks an Ed25519 signature over the challenge, and answers with a 15-minute access token. |
GET |
/v1/service-accounts/sync |
The service account | Its one vault, shaped like GET /v1/sync. Each first page is a service-account-read entry in the creator’s security log. |
PUT |
/v1/service-accounts/items/{itemId} |
A write service account |
As PUT /v1/vaults/{vaultId}/items/{itemId}. |
A person’s access token isn’t accepted on the last two, and a service account’s isn’t accepted anywhere else.
POST /v1/vaults/{vaultId}/rotate takes serviceGrants: a grant of the new key for each active service account, with its role. One left out keeps a key that no longer opens the vault, so it fails closed.
Security log
Section titled “Security log”GET /v1/security-log returns the account’s own events, newest first, kept for a year: { events: [{ type, createdAt, otherAccountEmail, emergencyContactId }] }. Types: emergency-contact-added, emergency-contact-removed, emergency-access-requested, emergency-access-denied, emergency-access-approved, emergency-access-released, two-step-enabled, two-step-disabled, authenticator-changed, security-key-added, security-key-removed, recovery-codes-renewed, recovery-code-used, two-step-locked, trusted-devices-forgotten, password-changed, password-reset, vault-exported, secret-key-rotated, vault-key-rotated, emergency-takeover, organiser-recovery-enabled, organiser-recovery-disabled, service-account-created, service-account-revoked, service-account-read, service-account-wrote, service-account-denied, connect-secret-read. otherAccountEmail is omitted once that account is deleted. Service account entries carry serviceAccountId; connect-secret-read entries carry sealedDetail, an envelope only the account can open. Clients send those with POST /v1/security-log/connect and { sealedDetails: [...] }, at most 100 at a time. The newest 500 of the frequent entries (service-account-read, service-account-wrote, connect-secret-read) and the newest 500 of all others are listed, so machines can’t push people’s entries out.
Live notifications
Section titled “Live notifications”GET /v1/ws is a WebSocket that tells a signed-in client when to sync. It never carries item data.
- Connect, then send
{ "type": "auth", "accessToken": "…" }as the first message, within 10 seconds. Browsers can’t set headers on a WebSocket, so the token travels in this message. - The server answers
{ "type": "authenticated", "expiresIn": 897 }, the seconds left on that access token. - After every token refresh, send a new
authmessage with the new access token, for the same device. When the token expires without one, the server closes the connection.
The server then sends:
| Message | Meaning |
|---|---|
{ "type": "vault-changed", "vaultId": "…", "revision": 44 } |
A vault the account can access changed. Sync it if the revision is newer than yours. |
{ "type": "membership-changed", "vaultId": "…" } |
A shared or family vault gained or lost a member, or its key was rotated. Sent to every member, and to whoever just lost access. Sync. |
{ "type": "session-revoked" } |
This device was signed out (remotely, by its own logout, or because its refresh token was reused). The connection closes. |
{ "type": "account-deleted" } |
The account was deleted. Wipe local data. The connection closes. |
- The server pings every 30 seconds and drops connections that don’t answer by the next ping. Browsers answer pings on their own.
- Close code
4401means the connection isn’t, or is no longer, authenticated (bad or expired token, revoked session, deleted account).4400means a protocol error, such as a first message that isn’tauth. Reconnect with jitter after other closes. - Notifications are a hint, not a log. A client that was offline, or misses one, syncs on reconnect as usual.
- A server instance only sees changes written through itself. Multi-instance deployments should keep clients syncing on focus and reconnect until notifications are shared between instances.
Devices
Section titled “Devices”Each login creates a device, named by the client.
GET /v1/devicesreturns[{ id, name, platform, createdAt, lastSeenAt, current }]for the account’s signed-in devices, most recently seen first.lastSeenAtupdates on token refresh and WebSocket sign-in, so it’s accurate to about 15 minutes.PATCH /v1/devices/currentwith{ name }(1 to 64 characters) renames the calling device.204.DELETE /v1/devices/{deviceId}signs that device out: its tokens stop working, its live connections getsession-revoked, and it no longer skips two-step sign-in. Deleting the current device is the same as logging out.204, or404if the account has no such signed-in device.
Devices with no session left (signed out, or idle past the 30-day refresh token) are forgotten by the server’s maintenance.
Account deletion
Section titled “Account deletion”DELETE /v1/accountAuthorization: Bearer …X-Reauth-Token: …Content-Type: application/json
The request needs a delete-account reauth token and the account email. In one transaction the server deletes the account, its OPAQUE record and keys, all devices and sessions, pending verification codes, every vault it solely owns (personal, shared or family) with all items, attachments and tombstones (an organisation vault other people are in lives on), its other vault memberships, the family it organises (or its family membership and invites), its share links, its emergency-access records in both directions and its security log. Other accounts’ security logs keep their events but no longer name it. The grants it gave members of vaults that live on keep its random account id and public signing key, because they’re part of what it signed; nothing else about it remains. Members of its shared vaults get membership-changed. The only record left is an anonymous count of deletions per day. The server then sends a confirmation email, and the email address can be used for a new account straight away.
Other signed-in devices get 401 with the account-deleted problem type on their next request or refresh, and connected ones get account-deleted on the WebSocket right away. Clients must then wipe all local data for that account.
| Response | Meaning |
|---|---|
204 |
Deleted |
400 invalid-request |
confirmEmail doesn’t match. The reauth token stays valid. |
403 reauth-required |
Missing, expired, used or someone else’s reauth token |
Errors
Section titled “Errors”Errors are application/problem+json (RFC 9457). Match on type, never on title or detail.
type (after https://octap.app/problems/) |
Status | When |
|---|---|---|
invalid-request |
400 | Malformed JSON, field or header |
invalid-verification-code |
400 | Wrong, expired or exhausted sign-up or reset code |
recovery-expired |
400 | A master password reset that’s unknown, expired, used up or not verified yet |
unauthorized |
401 | Missing, invalid or expired token |
login-failed |
401 | Any failed login or reauthentication, or an unknown or expired second-step challenge |
second-factor-required |
401 | The password was right; finish with the second step (see secondFactor) |
invalid-second-factor |
401 | A wrong second-step code or security key, or a wrong code confirming an authenticator |
account-deleted |
401 | The account behind this token was deleted |
forbidden |
403 | A role that doesn’t allow the action, such as writing to a vault as read |
quota-exceeded |
403 | The write or upload would go over the vault owner’s item, storage or attachment quota |
reauth-required |
403 | A dangerous action without a valid reauth token |
signups-closed, invite-required |
403 | The server doesn’t accept this sign-up |
not-found |
404 | Unknown route, vault or item; a site without a usable icon |
icons-off |
404 | GET /v1/icons/… on a server with OCTAPASS_ICONS=off: stop asking it |
method-not-allowed |
405 | Wrong HTTP method |
conflict |
409 | The item changed; see current |
vault-key-changed |
409 | An item key wrapped under, or an invite’s or emergency renewal’s keyId naming, a vault key that was rotated away |
vault-changed |
409 | A key rotation built from an old vault revision |
already-exists |
409 | A second family, a family for an account already in one, the same emergency contact twice, or an attachment id already used on the item |
family-full |
409 | No room in the family for another invite |
sso-required |
401 | The password was right; finish through the identity provider (see sso) |
sso-pending |
409 | login/sso before the browser finished at the identity provider |
sso-config |
400 | The identity provider can’t be used, a test sign-in is missing, or SSO is required right now |
org-policy |
403 | An organisation policy forbids this for its members |
org-vault-orphaned |
409 | This would leave organisation vaults with nobody able to manage them (see vaultIds) |
batch-aborted |
424 | In a batch: another operation on the same vault failed |
account-exists, vault-exists |
409 | Sign-up with a taken email or vault id |
payload-too-large |
413 | Body over 1 MiB (16 MiB for a batch), or an attachment over the file size limit |
length-required |
411 | Attachment upload without Content-Length |
precondition-required |
428 | Item write without If-Match / If-None-Match |
rate-limited |
429 | Too many attempts; see Retry-After |
email-unavailable |
503 | The server can’t send email, so it can’t verify sign-ups or resets |
unavailable |
503 | Not ready, or overloaded |
internal |
500 | A bug; details are only in the server log |
Rate limits
Section titled “Rate limits”The auth endpoints, lookups, share-link views and new emergency contacts use token buckets. Unknown emails are limited exactly like real ones.
| Bucket | Charged for | Default |
|---|---|---|
| Per client IP (IPv6 by /64) | Every auth request | 30, then one every 2 seconds |
| Per email and IP | Failed logins and reauthentications | 10, then one per minute |
| Per email | Failed logins from anywhere | 100, then one every 10 seconds |
| Per email | register/start and register/finish |
20, then one per minute |
| Per email | recover/start |
20, then one per minute (a bucket apart from sign-up’s) |
| Per account | GET /v1/accounts/lookup and invites (vault and family) |
20, then one every 30 seconds |
| Per account | POST /v1/transparency/label |
40, then one every 30 seconds |
| Per client IP | GET /v1/share-links/{id} |
10, then one every 6 seconds |
| Per account | POST /v1/emergency-contacts |
10, then one every 15 minutes |
| Per contact | POST /v1/emergency-contacts naming that contact, from any account |
5, then one per hour |
| Per client IP | GET /v1/icons/{domain} |
200, then four a second |
Starting a login costs the email nothing, so nobody can lock an account by flooding login/start; the email’s buckets are only charged when a login fails, and only checked before a new one starts. An attacker who exhausts their own (email, IP) bucket doesn’t block the owner on another address. Sign-ups have the daily caps described under sign-up on top. Each IP has at most 20 logins in flight; starting more ends its oldest ones.
Website icons
Section titled “Website icons”GET /v1/icons/{domain} returns a site’s icon so clients never ask an icon service, and so a vault’s list of sites isn’t sent anywhere else. It takes no token and reads no cookies: the server can’t tie the request to an account. {domain} is a public DNS name such as example.com (:443 or :80 may follow; no scheme, path or other port). Clients send the registrable domain of the item’s first URL, so the server learns bank.com rather than login.bank.com. IP addresses, single labels and special-use names (localhost, .local, .internal, .test and the like) get 400.
| Answer | Meaning | Cache-Control |
|---|---|---|
200 image/png |
The icon, at most 128 × 128 px | public, max-age=604800 (7 days) |
404 not-found |
The site has no usable icon: show the initial | public, max-age=86400 (1 day) |
404 icons-off |
The server has icons turned off: stop asking | none |
429 rate-limited |
See Retry-After |
none |
Every answer has Access-Control-Allow-Origin: *. How the server fetches:
- From the site only. It reads the home page (HTTPS first, then HTTP, then the same for
www.), takes its<link rel="icon">and<link rel="apple-touch-icon">tags (preferring a declared size from 64 to 128 px), then falls back to/apple-touch-icon.pngand/favicon.ico. It never asks a third-party icon service; it follows a link to another host only when the site’s own page names it (a CDN, say). - Public addresses only. It resolves names itself and connects only to the addresses it checked: never private, loopback, link-local, multicast, CGNAT (
100.64.0.0/10), documentation, benchmarking or reserved ranges, IPv6 unique local or link-local addresses, or IPv4 addresses inside IPv6 ones (mapped, 6to4). Only ports 80 and 443, no credentials in URLs, no proxy. Redirects are followed by hand, at most 3, each one checked the same way. - Small and quick. 3 seconds to connect, 5 per request, 15 per site; the page is read up to 512 KB and icons over 256 KB are refused, counted after decompression so a gzip bomb stops at the cap.
- Re-encoded. The bytes are sniffed (PNG, ICO, GIF, JPEG, WebP, BMP or SVG, whatever the
Content-Typesays), decoded with limits (2048 px a side, 32 MB of memory), scaled to 128 px and encoded again as PNG. SVG is drawn to pixels without loading external images or fonts, and compressed SVG is refused. Clients never get the site’s own file, so never an SVG with scripts. - Cached in
<data dir>/iconsfor 7 days (misses for 1 day), under file names keyed with the server’s secret so the folder isn’t a readable list of sites. One fetch per site at a time, eight sites at once across all clients.
Masked email
Section titled “Masked email”The server’s own masked email service, when it has one: maskedEmailRelay in GET /v1/server-info says so. Without one, every route here answers 404 with the type masked-email-off. All need a session. Aliases forward to the account’s verified email and nowhere else, so no request names a destination. See the relay.
| Method and path | Body | Answer |
|---|---|---|
GET /v1/masked-email/aliases |
200 { aliases, maxAliases, domain }, newest first |
|
POST /v1/masked-email/aliases |
201 an alias. 409 masked-email-limit at the account’s limit, 429 rate-limited at the daily limit |
|
PATCH /v1/masked-email/aliases/{id} |
{ enabled } |
200 the alias. Turning it on also ends a pause |
DELETE /v1/masked-email/aliases/{id} |
204. Its contacts go too, and mail to it is refused |
|
POST /v1/masked-email/aliases/{id}/block |
{ sender } |
204. This address can’t reach this alias |
POST /v1/masked-email/aliases/{id}/unblock |
{ sender } |
204 |
An alias is { id, address, enabled, paused, forwarded, blocked, replied, createdAt }. paused means the service stopped it, or the whole account, after bounces or spam reports. A route answers 503 when the relay can’t be reached.
Email breach check
Section titled “Email breach check”POST /v1/health/breach-email needs an access token. It asks Have I Been Pwned about one address, for clients whose user opted in (see breach checks). The address goes in the body so it stays out of URLs and access logs, and the server keeps nothing about it.
{ "breaches": [ { "name": "Adobe", "title": "Adobe", "domain": "adobe.com", "breachDate": "2013-10-04", "dataClasses": ["Email addresses", "Passwords"] } ]}An unknown address gets 200 with an empty list. 404 means the server has no HIBP key; GET /v1/server-info says so ahead of time with emailBreachChecks. 429 has a Retry-After: an account’s budget ran out, or the server reached its HIBP limit. 503 means HIBP didn’t answer or refused the key. HIBP’s HTML descriptions are never passed on.
Planned
Section titled “Planned”| Endpoint | Purpose |
|---|
Planned for 1.0: refresh tokens bound to a per-device Ed25519 key, and signed vault manifests. After each push, the client signs (vault_id, revision, hash of item revisions) with its account Ed25519 key, so the server can’t hide items or roll them back.