Skip to content

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.

  • JSON over HTTPS under the /v1 prefix, 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/batch and POST /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.
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

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)
  1. POST /v1/auth/prelogin is optional before sign-up. The client generates its own 16-byte account salt.
  2. POST /v1/auth/register/start with { email, registrationRequest } returns { registrationResponse, verificationRequired: true } and emails a 6-digit code, valid for 15 minutes. In dev mode the response also contains devVerificationCode. If the email already has an account, its owner gets a notice instead of a code, and the response looks exactly the same.
  3. POST /v1/auth/register/finish with { 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.

  1. POST /v1/auth/prelogin with { 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.
  2. POST /v1/auth/login/start with { email, credentialRequest } returns { loginId, credentialResponse }. Unknown emails get a well-formed fake response.
  3. POST /v1/auth/login/finish with { loginId, credentialFinalization, device: { name, platform }, trustedDeviceToken? } returns { accessToken, refreshToken, expiresIn, account, trustedDeviceToken }. A loginId is valid for 30 seconds and works once. Every failure, whatever the cause, is the same 401 with the login-failed problem 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.

  • Access tokens are random and last 15 minutes (expiresIn: 900).
  • Refresh tokens are random and last 30 days. POST /v1/auth/refresh with { 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/logout revokes the calling device’s tokens and returns 204.

Dangerous actions need proof that the user just typed their master password. A signed-in client runs a second OPAQUE login:

  1. POST /v1/auth/reauth/start with { credentialRequest } returns { reauthId, credentialResponse }.
  2. POST /v1/auth/reauth/finish with { reauthId, credentialFinalization, purpose } returns { reauthToken, expiresIn: 300 }. purpose is delete-account, change-password, rotate-secret-key, emergency-takeover, two-step (any change to two-step sign-in) or organisation (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.

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.

  1. Reauthenticate with purpose change-password.
  2. Derive the new keys, then POST /v1/account/password/registration with { registrationRequest }. It returns { registrationResponse } and stores nothing.
  3. PUT /v1/account/password with the X-Reauth-Token header and { registrationUpload, account: { kdf, accountSalt, wrappedAsk } }. account may also carry recoveryWrappedAsk to replace the recovery code. 204: every other device is signed out (their sessions end, they get session-revoked, and they no longer skip two-step sign-in); this one stays. The account gets an email and a password-changed security log entry.

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.

  1. Reauthenticate with purpose rotate-secret-key.
  2. Generate the Secret Key and a fresh salt, then POST /v1/account/password/registration as above.
  3. PUT /v1/account/secret-key with the X-Reauth-Token header and the same body as a password change. account may carry recoveryWrappedAsk to replace the recovery code too. 204: every other device is signed out, and the account gets an email and a secret-key-rotated security 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.

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.

  1. POST /v1/auth/recover/start with { 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.

  2. POST /v1/auth/recover/verify with { recoveryId, verificationCode } returns { account, challenge }: the account bundle, whose recoveryWrappedAsk the recovery code opens, and 32 random bytes to sign. A wrong code is 400 invalid-verification-code; five use up the reset.

  3. 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/registration with { recoveryId, registrationRequest } for { registrationResponse }.

  4. POST /v1/auth/recover/finish with { recoveryId, registrationUpload, account: { kdf, accountSalt, wrappedAsk, recoveryWrappedAsk }, signature }. A reset always replaces the recovery code, so recoveryWrappedAsk is required. signature is Ed25519 by the account’s signing key over:

    "octapass/v1/account-recovery" ‖ 0x00 ‖ for each field: len (u32 BE) ‖ UTF-8 bytes
    fields = accountId, challenge, registrationUpload, accountSalt, wrappedAsk, recoveryWrappedAsk

    204: every device is signed out and none skips two-step sign-in any more. A wrong signature is 401 login-failed and ends the reset. The account gets an email and a password-reset security log entry.

  5. 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.

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-step returns { enabled, methods, securityKeys: [{ id, name, createdAt, lastUsedAt }], recoveryCodesLeft, rpId }.
  • POST /v1/account/two-step/totp returns { setupId, secret, otpauthUri, expiresIn: 600 }: a new authenticator secret (160 bits, base32) and the otpauth:// URI for a QR code. Nothing changes yet.
  • POST /v1/account/two-step/totp/confirm with { setupId, code } turns two-step sign-in on with that authenticator, or replaces the current one. It returns { recoveryCodes } (10 codes like ABCDE-12345) only when it turned it on. A wrong code gets 401 invalid-second-factor and the setup stays usable; an expired one 400.
  • POST /v1/account/two-step/recovery-codes returns { recoveryCodes } and invalidates the old ones.
  • DELETE /v1/account/two-step turns it off and removes the authenticator, security keys and recovery codes. 204.
  • POST /v1/account/two-step/security-keys/options returns { setupId, publicKey: { challenge, rp, user, algorithms, excludeCredentials, timeout } } for navigator.credentials.create(). Two-step sign-in must be on already (with an authenticator app), and an account has at most 10 keys. Attestation is none; ES256 and Ed25519 keys are accepted.
  • POST /v1/account/two-step/security-keys with { setupId, name, credentialId, clientDataJson, attestationObject } returns the key record. 409 already-exists for a key that’s registered already.
  • DELETE /v1/account/two-step/security-keys/{id} removes a key. 204.
  • DELETE /v1/account/two-step/trusted-devices forgets every trusted device except the caller’s (no reauth: it only takes trust away). Sessions stay; sign devices out with DELETE /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-…:7
Authorization: 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 }]
}
  • vaults always 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.
  • type is personal, shared or family, and role is read, write, manage or owner. Shared and family vaults also have keyId and grant, described under sharing.
  • items holds the items whose revision is greater than the one given in since, including items in the trash (they have deletedAt). Vaults left out of since are returned in full.
  • deleted holds 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 of since) and drop local items the server no longer returns.
  • Remember each vault’s revision and send it as since next time.
  • Responses are paged: about 1,000 items or 8 MiB each. When hasMore is true, the response stopped early. Each vault’s revision is 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 until hasMore is false.

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-Match or If-None-Match gets 428.
  • DELETE …/items/{itemId} needs If-Match and moves the item to the trash.
  • POST …/items/{itemId}/restore takes it out of the trash.
  • Each PUT that 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 …/history returns { revisions: [{ revision, overview, details, updatedAt }] }, readable by every member of the vault. Open a version with the item’s current encryptedItemKey: 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 in itemBytes and 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 the read role get 403 forbidden.
  • In a vault with a keyId (shared and family vaults, and a personal vault from its first rotation), encryptedItemKey must be wrapped under the vault’s current key. After a key rotation, a write still using the old key answers 409 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.

Imports and bulk edits send many writes in one request:

POST /v1/batch
Authorization: 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 status and problem is 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 500 means some vaults may have been written. Sync before retrying.

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-stream
Content-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 exactly Content-Length long. Without Content-Length the answer is 411 length-required; over the file size limit, 413 payload-too-large, before any of the body is read.
  • Uploads need write, manage or owner on the vault, and an item that isn’t in the trash. They answer 201 with { id, vaultId, itemId, size, createdAt }, where size is 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 …/attachments lists an item’s files.
  • DELETE …/attachments/{attachmentId} (writers) answers 204; 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.

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.

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.

  1. POST /v1/vaults with { id, keyId, grant }, where grant gives the signed-in account the owner role. 201 with the vault’s record.
  2. GET /v1/accounts/lookup?email= returns { accountId, email, publicEncKey, publicSignKey, fingerprint }, or 404. fingerprint is the digits form; clients should compute their own from the keys.
  3. POST /v1/vaults/{vaultId}/members with { email, grant, keyId } (needs manage; owner to grant owner). The answer is always 202 with 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. keyId is optional and names the vault key the grant seals: if the key was rotated since, the answer is 409 vault-key-changed and nothing is stored. Clients should send it, so a rotation racing the invite can’t leave a grant of the old key behind.
  4. GET /v1/vaults/{vaultId}/members lists { 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.

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-…/rotate
Content-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", … }]
}
  • revision is the vault revision the client synced to. Any change since then answers 409 vault-changed: sync and rotate again.
  • grants must cover exactly the current members, each with their current role. items must cover exactly the vault’s items, trash included, each wrapped under the new key. Anything else is 400, and nothing changes.
  • invites is 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, is 400. 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-rotated goes in the rotating account’s security log.
  • A personal vault rotates the same way, with "grants": [], no invites, and wrappedVaultKey, the new key wrapped by the account key (context vaultkey:<vaultId>). Only personal vaults take wrappedVaultKey, and they need it. The rotation gives the vault a keyId, 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.

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.

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.

  1. POST /v1/families with { id, vault }, where vault is the same body as POST /v1/vaults. 201 with the family record: { id, organiserId, vaultId, maxMembers, members, invites, createdAt }.
  2. POST /v1/families/{familyId}/invites with { email, grant, keyId } (organiser only; a write grant for the Family vault; keyId as for vault invites). Always 202, 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.
  3. GET /v1/families returns { family, invitations }: the account’s family (with pending invites for the organiser only) and the invitations it received.
  4. POST /v1/families/{familyId}/invites/accept joins, turning the invite’s grant into the Family vault membership. 409 already-exists while in another family.
  5. DELETE /v1/families/{familyId}/invites/{accountId} cancels (organiser) or declines (invitee) an invite.
  6. 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 in invites (any left out are cancelled). The organiser can’t leave; DELETE /v1/families/{familyId} deletes the family and its vault.

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.

  1. POST /v1/orgs with { id, name } makes the caller the owner. POST …/invites with { email, role } always answers 202. Invites hold no key and last 14 days. GET /v1/orgs lists invitations to the account’s email, with the policies that come with joining.
  2. POST …/vaults takes the same body as POST /v1/vaults. Someone is entitled to a vault as an organisation owner (role owner), as a direct member, or through a linked group. The highest role wins.
  3. GET …/access lists vaults with something waiting: grants to make, and whether to rotate. canApply says 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.
  4. POST …/vaults/{vaultId}/grants with { keyId, grants } stores them. Each must be by the caller, for someone entitled, with exactly that role (400); a key rotated since answers 409 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.

SSO adds a step after OPAQUE and takes no part in deriving keys. See single sign-on.

  1. POST /v1/auth/login/finish answers 401 sso-required once the password was right, with sso: { challengeId, url, orgName, expiresIn }. Owners are exempt.
  2. The client opens url in a browser and keeps challengeId. The server, as OpenID Connect relying party, sends the browser to the provider (code flow, PKCE, state, nonce), and GET /v1/sso/callback ends on a page with a 6-digit code.
  3. POST /v1/auth/login/sso with { challengeId, code } answers like login/finish, or 401 second-factor-required if 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.

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-links with { ciphertext, expiresAt, maxViews, passphraseRequired }: expiresAt 1 hour to 30 days from now, maxViews 1 to 100, ciphertext up to 600 KiB. 201 with { 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 } with Cache-Control: no-store. The last view deletes the link; expired, used-up and unknown links all answer 404. Each IP gets 10 views at once, then one every 6 seconds.
  • GET /v1/share-links lists the account’s open links without their ciphertext; DELETE /v1/share-links/{id} deletes one (404 for anyone else’s).

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.

  1. POST /v1/emergency-contacts with { contactAccountId, sealedKey, scope, accessLevel, waitDays }. scope is { "type": "account" } or { "type": "vault", "vaultId": "…" } (a vault the grantor owns), accessLevel is view or takeover, waitDays 1 to 30. 201 with the record; 409 already-exists for 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.
  2. GET /v1/emergency-contacts returns { granted, received }: records with { id, grantorId, grantorEmail, contactId, contactEmail, scope, accessLevel, waitDays, status, requestedAt, releasesAt, releasedAt, createdAt }. status is idle, requested or released.
  3. The contact sends POST …/{id}/request. The grantor gets an email straight away and a reminder every day while the request waits.
  4. The grantor can POST …/{id}/deny at any time, which also takes back a key already released, or POST …/{id}/approve to release it now.
  5. At releasesAt the request is released, and both sides get an email.
  6. Once released, the contact gets GET …/{id}/release: { contact, sealedKey, grantor }. For an account scope, grantor is the grantor’s account bundle (the released ASK opens its private keys). A vault scope gets no grantor: 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 is 404. The contact decrypts it with the item key from the synced item and the file key in its details. Before release, all three answer 403.
  7. 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:

  1. GET …/{id}/release, open the ASK and make new credentials with a fresh Secret Key, salt and recovery code.
  2. POST …/{id}/takeover/registration with { registrationRequest }. It runs OPAQUE under the grantor’s email and stores nothing.
  3. POST …/{id}/takeover with X-Reauth-Token (the contact’s own, purpose emergency-takeover) and { registrationUpload, account, signature }. account.recoveryWrappedAsk is required. signature is by the grantor’s Ed25519 key, over "octapass/v1/emergency-takeover" ‖ 0x00 ‖ length-prefixed (u32 big-endian) UTF-8 recordId, grantorId, contactId, currentAccountSalt, registrationUpload, accountSalt, wrappedAsk, recoveryWrappedAsk. See the emergencyTakeover test vector in crates/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.

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.

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.

GET /v1/ws is a WebSocket that tells a signed-in client when to sync. It never carries item data.

  1. 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.
  2. The server answers { "type": "authenticated", "expiresIn": 897 }, the seconds left on that access token.
  3. After every token refresh, send a new auth message 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 4401 means the connection isn’t, or is no longer, authenticated (bad or expired token, revoked session, deleted account). 4400 means a protocol error, such as a first message that isn’t auth. 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.

Each login creates a device, named by the client.

  • GET /v1/devices returns [{ id, name, platform, createdAt, lastSeenAt, current }] for the account’s signed-in devices, most recently seen first. lastSeenAt updates on token refresh and WebSocket sign-in, so it’s accurate to about 15 minutes.
  • PATCH /v1/devices/current with { 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 get session-revoked, and it no longer skips two-step sign-in. Deleting the current device is the same as logging out. 204, or 404 if 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.

DELETE /v1/account
Authorization: Bearer …
X-Reauth-Token: …
Content-Type: application/json
{ "confirmEmail": "[email protected]" }

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 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

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.

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.png and /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-Type says), 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>/icons for 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.

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.

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.

{ "email": "[email protected]" }
{
"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.

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.