# Sync API

> The Octa Pass sync API v1: OPAQUE login, tokens, master password changes and resets, revision-based sync, item writes, live notifications, sharing, families, organisations, single sign-on, share links and emergency access.

Source: https://octap.app/docs/developers/sync-api/
Status: in-progress
Project: Octa Pass

:::note[In progress]
The server in `apps/server` implements everything on this page. Details may still change before the first release. The full OpenAPI 3.1 document is served by every server at `GET /v1/openapi.json` and checked in at `apps/server/openapi.json`. The request and response types live in the `octapass-api` Rust crate.
:::

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

- 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](#attachments), which are raw bytes up to the server's file size limit.
- CORS is limited to the origins in `OCTAPASS_WEB_ORIGINS`.

## 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](#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](#website-icons) |

## 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](https://octap.app/docs/security/encryption/) |
| Credential identifier | The email, trimmed and lowercased |
| Identifiers and context | The opaque-ke defaults (none) |

### Sign-up

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](https://octap.app/docs/security/encryption/#ciphertext-format) for every wrapped key.

### Login

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](#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

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

### Reauthentication

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

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

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.

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

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.

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

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:

   ```text
   "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.

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

```json
{
  "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 |

```json
{ "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.

## Sync

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.

```http
GET /v1/sync?since=0192b7a0-…:41,0192b7a1-…:7
Authorization: Bearer …
```

```json
{
  "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](#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`.

## Writes and conflicts

Create an item with `If-None-Match: *`, and update it with the revision you last saw:

```http
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`:

```json
{
  "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](https://octap.app/docs/features/offline-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.

## Batch writes

Imports and bulk edits send many writes in one request:

```http
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:

```json
{
  "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.

## 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](https://github.com/octap-app/octapass/tree/main/crates/octapass-core#attachments)). 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.

```http
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.

## 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](https://github.com/octap-app/octapass/tree/main/crates/octapass-core#sharing), and the client side is exposed by `@octapass/core`.

### Roles

| 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

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:

```json
{
  "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

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](#rate-limits)), because they're where an email's existence shows.

### 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](#families) instead.

A removed member still knows the old vault key, so the client then rotates it:

```http
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](#emergency-access).

## Key log

An append-only log of account public keys, which clients verify (design: plan 03; formats: the [`octapass-core` README](https://github.com/octapass/octapass/tree/main/crates/octapass-core#key-log)). 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

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.

## Organisations

The rules are in [organisations](https://octap.app/docs/features/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`.

### Single sign-on

SSO adds a step after OPAQUE and takes no part in deriving keys. See [single sign-on](https://octap.app/docs/features/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.

## 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-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).

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

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](#rate-limits), 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](#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.

## Service accounts

A [service account](https://octap.app/docs/features/service-accounts/) 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

`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

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

## Devices

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.

## Account deletion

```http
DELETE /v1/account
Authorization: Bearer …
X-Reauth-Token: …
Content-Type: application/json

{ "confirmEmail": "alice@example.com" }
```

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

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

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](#sign-up) on top. Each IP has at most 20 logins in flight; starting more ends its oldest ones.

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

## 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](https://octap.app/docs/self-hosting/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

`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](https://octap.app/docs/security/breach-checks/#email-breach-alerts-opt-in)). The address goes in the body so it stays out of URLs and access logs, and the server keeps nothing about it.

```json
{ "email": "name@example.com" }
```

```json
{
  "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

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