HTTP API
The Worker's API, as used by the extension and the share page. It is not a public, versioned API: it changes with the extension, and only the extension from the same release is guaranteed to work with it.
Conventions
- Base URL:
https://<worker>/api. - Bodies: JSON in, JSON out. Timestamps are Unix seconds. Binary values (ciphertext, nonces, keys, salts) are standard base64 with padding, except where noted as base64url.
- Authentication:
Authorization: Bearer <token>, where the token comes fromregisterorlogin. There are no cookies, so there is no CSRF surface. - CORS: only
chrome-extension://andmoz-extension://origins are allowed. The share page calls the API from the same origin and needs no CORS. - Sealed value: an object
{ "ciphertext": string, "nonce": string }produced by AES-256-GCM with a 12-byte nonce. - Errors: every failure has the shape below.
codeis stable and meant for programs;erroris an English sentence for humans;paramsholds values for the localized message.
``json { "code": "auth.rate_limited", "error": "Too many attempts, try again later", "params": { "seconds": 840 } } ``
Rate limits per endpoint are listed in Limits. Any endpoint can also return 429 server.rate_limited and 500 server.error. Any endpoint that takes a body returns 400 auth.invalid_body when the body is not a JSON object (malformed JSON, an array, a scalar or nothing at all), before it looks at any field.
Server
GET /api/config
What the server requires at sign-up. No authentication.
{ "registration": "token", "emailVerification": false }
registration is token or open. emailVerification is true when a sender and a mail provider are configured. When Turnstile is configured, the response also carries turnstileSiteKey, and register, verify-email, recover/email, recover/start and POST /api/shares/:id/code need a token in the X-Turnstile-Token header, or answer 403 auth.bot_check_failed. When PRIVACY_URL is set, the response carries it as privacyUrl; clients link the privacy policy only when it is present.
GET /api/time
The server clock, used by the extension to correct 2FA codes on a machine whose clock is off.
{ "serverTime": 1791820800 }
GET /api/logo?domain=<hostname>
Session required. Returns the website icon for a hostname as an image, fetched from DuckDuckGo or, failing that, Google, and cached at the edge for 7 days. The hostname must be a plain domain name; anything else, or a site without an icon, answers 404 server.not_found. Images over 64 KiB are refused. The response is Cache-Control: private, no-store, so browsers keep no copy.
Accounts
POST /api/auth/verify-email
Send a 6-digit sign-up code to an address. Only meaningful when emailVerification is on.
| Field | Type | Notes |
|---|---|---|
email | string | Required |
locale | string | en (default) or vi |
| Status | Body / code |
|---|---|
| 200 | { "ok": true, "required": true }; or "required": false when the server sends no email |
| 400 | auth.invalid_email |
| 429 | auth.code_cooldown with params.seconds |
An address that already has an account receives a "you already have an account" email instead of a code, and the response is the same, so the endpoint does not reveal which addresses are registered. The account lookup and the email happen after the response is sent, so a failed send is logged, not returned, and the timing is the same either way.
POST /api/auth/register
Create an account. The client derives every key; the server receives only the auth key and the wrapped vault key.
Header X-Registration-Token is required when registration is token, unless inviteId names a pending invitation for the same address: an invitation is the admin's consent, so invited people can sign up on a token-only server.
| Field | Type | Notes |
|---|---|---|
email | string | Normalized to lower case |
kdf | object | { "type": "pbkdf2-sha256", "iterations": 600000–5000000, "salt": base64 ≤ 64 bytes } |
authKey | string | base64, 32 bytes |
wrappedVaultKey | sealed | The vault key sealed under the wrapping key |
verificationCode | string | 6 digits; required when emailVerification is on |
inviteId | string | Optional. A pending invitation for email; the new account joins that organization |
ssoGrant | string | Optional. A registration grant from POST /api/sso/session; see Single sign-on |
| Status | Body / code |
|---|---|
| 201 | { "accountId": string, "token": string, "expiresAt": number, "orgId"?: string }, orgId when an invitation was accepted |
| 400 | auth.invalid_body, auth.invalid_email, auth.invalid_kdf, auth.invalid_auth_key, auth.invalid_wrapped_key, auth.invalid_code, auth.code_expired |
| 403 | auth.bad_registration_token, sso.expired (the ssoGrant is unknown, used, expired or for another address) |
| 409 | auth.email_taken, only when emailVerification is on and the code was valid |
| 429 | auth.registration_limit |
Without email verification, an address that already has an account gets a 201 with a random accountId and a token that is not stored, and nothing changes, so the response does not reveal that the address is taken.
POST /api/auth/prelogin
The KDF parameters to derive the auth key with, before signing in.
Request { "email": string }. Response:
{ "kdf": { "type": "pbkdf2-sha256", "iterations": 600000, "salt": "…" } }
An unknown address gets plausible parameters derived from a server secret, the same on every call, so the response does not reveal whether an account exists.
POST /api/auth/login
| Field | Type | Notes |
|---|---|---|
email | string | |
authKey | string | base64, 32 bytes |
| Status | Body / code |
|---|---|
| 200 | { "token": string, "expiresAt": number, "wrappedVaultKey": sealed } |
| 400 | auth.invalid_credentials (malformed body) |
| 401 | auth.invalid_credentials (wrong email or key; the two are not distinguished) |
| 429 | auth.rate_limited with params.seconds until the next try is allowed |
Failures are throttled per address and network: five are free, then each one doubles the wait, from 5 seconds up to an hour. After 20 failures in an hour on one address from any network, every attempt on it is also slowed, by up to 5 seconds. There is no hard lock. A 200 adds "upgradeKdf": true when the account uses fewer than 600,000 iterations; the client then re-wraps the vault key through change-password with keepSession.
When the account has two-factor authentication on, a correct authKey returns 200 { "twoFactor": "required", "challenge": string, "expiresAt": number } instead, with no token and no wrapped key. A client that does not know this field finds no token and fails closed. Wrong credentials answer exactly as above, so the response never reveals whether 2FA is on before the password is right.
A correct password for a member (not an owner or admin) of an organization that requires single sign-on answers 403 auth.sso_required with params.identifier, the organization identifier to start SSO with. The same check runs again in login/2fa.
POST /api/auth/login/2fa
Unauthenticated, rate-limited like login, and counted against the same throttle. Request { "challenge": string, "code": string }, where code is a 6-digit TOTP code (RFC 6238, SHA-1, 30 s, ±1 step) or a backup code (xxxxx-xxxxx; case, spaces and dashes are ignored). A TOTP code is accepted once; a backup code once ever.
| Status | Body / code |
|---|---|
| 200 | The normal login response |
| 401 | auth.invalid_2fa (wrong code) or auth.two_factor_expired (challenge gone) |
| 429 | auth.rate_limited with params.seconds |
A challenge lasts five minutes, is single use, and is dropped after five wrong codes.
GET /api/auth/resume-key
Authenticated. Returns { "resumeKey": "<base64>" }, a random 32-byte key that belongs to this session and is created on first request. The extension seals the vault key under it so the vault reopens after a browser restart; deleting the session deletes the key.
PUT /api/auth/pin
Authenticated. Body { "verifier": "<base64, 32 bytes>" }. Turns on PIN unlock for this session: stores a keyed hash of the verifier, creates a random 32-byte pinKey, resets the attempt count and returns { "pinKey": string }. The extension derives the verifier and a second, local half from the PIN with PBKDF2; the server never sees the PIN or the local half.
POST /api/auth/pin/unlock
Authenticated with the session the PIN belongs to. Body { "verifier": ... }.
| Status | Body / code |
|---|---|
| 200 | { "pinKey": string }; the attempt count resets |
| 403 | auth.pin_wrong with params.count, the tries left |
| 410 | auth.pin_off: no PIN, or the fifth wrong try just wiped it |
| 401 | auth.unauthorized: the session ended, so the PIN is gone with it |
Each call spends a try before comparing, in one statement, so parallel guesses cannot exceed five.
DELETE /api/auth/pin
Authenticated. Turns PIN unlock off for this session. Returns { "ok": true }.
PUT /api/auth/biometric
Authenticated. Body { credentialId, publicKey, algorithm, rpId, origin }: the credential a platform authenticator (Touch ID, Windows Hello) just created on an extension page, its public key as base64url SPKI, -7 (ES256) or -257 (RS256), and the extension's origin chrome-extension://<id> with that id as RP ID. Anything else is 400 auth.invalid_body. Returns { "bioKey": string }, a random 32-byte key for this session.
POST /api/auth/biometric/challenge
Authenticated. Returns { challenge, credentialId }: a one-time base64url challenge that expires in two minutes, or 410 auth.biometric_off. The session keeps its five most recent open challenges, so a second request does not void a prompt that is still on screen.
POST /api/auth/biometric/unlock
Authenticated. Body { clientDataJSON, authenticatorData, signature } from navigator.credentials.get. The challenge named in clientDataJSON must be one of the session's open challenges, and it is spent before anything else is checked, then the server verifies the type, challenge, origin, RP ID hash, the user present and user verified flags, and the signature against the registered key. Returns { "bioKey": string }, or 403 auth.biometric_failed.
DELETE /api/auth/biometric
Authenticated. Turns biometric unlock off for this session.
POST /api/auth/logout
Authenticated. Revokes the current session. Returns { "ok": true }.
POST /api/auth/change-password
Authenticated. Replaces the KDF parameters, auth key and wrapped vault key in one step, then revokes every session of the account.
| Field | Type | Notes |
|---|---|---|
currentAuthKey | string | Proves knowledge of the current password |
kdf | object | As in register |
authKey | string | The new auth key |
wrappedVaultKey | sealed | The same vault key, wrapped under the new password |
keepSession | bool | Optional. true revokes every session but this one |
Returns { "ok": true }, or 400 for a malformed field and 403 auth.invalid_credentials when currentAuthKey is wrong.
POST /api/auth/delete-account
Authenticated. Request { "currentAuthKey": string }. Deletes the account, its items and its sessions. Returns { "ok": true }, or 403 auth.invalid_credentials.
Shares are not linked to accounts, so links already sent keep working until they expire or run out of views.
Recovery code
A recovery code is 20 random bytes, shown as 32 base32 characters in groups of four. The client derives two keys from it with HKDF-SHA256 (salt personal-vault/recovery/v1): wrap seals the vault key, and auth is sent to the server, which keeps only an HMAC of it, as for the auth key. The server cannot open the vault key with what it stores.
GET /api/account/recovery
Authenticated. Returns { "createdAt": number | null }.
PUT /api/account/recovery
Authenticated. Creates or replaces the recovery code; the previous one stops working.
| Field | Type | Notes |
|---|---|---|
currentAuthKey | string | Proves knowledge of the master password |
authKey | string | The recovery auth key |
wrappedVaultKey | sealed | The vault key, sealed under the code |
Returns { "createdAt": number }, or 403 auth.invalid_credentials.
DELETE /api/account/recovery
Authenticated. Request { "currentAuthKey": string }. Removes the recovery code. Returns { "ok": true }, or 403 auth.invalid_credentials.
POST /api/auth/recover/email
Unauthenticated, rate-limited per IP and throttled per address and network like login, with counters separate from sign-in. Request { "email", "recoveryAuthKey", "locale"? }. Checks the recovery code and, when the server can send email, emails a 6-digit code to the account's address. Returns { "required": boolean }; required is false on a server without email, where the next two calls take no emailCode. Errors: 401 auth.invalid_recovery_code for a wrong code or an unknown address alike, 429 auth.code_cooldown within a minute of the last code.
POST /api/auth/recover/start
Unauthenticated, rate-limited like recover/email. Request { "email", "recoveryAuthKey", "emailCode"? }. Returns { "wrappedVaultKey": sealed }, the vault key sealed under the code. Errors: 401 auth.invalid_recovery_code, 400 auth.invalid_code for a wrong emailed code (five tries), 400 auth.code_expired after 15 minutes.
POST /api/auth/recover/finish
Unauthenticated. Sets a new master password and a new recovery code in one step, then revokes every session and emails the owner when the server can send email. The code used stops working.
| Field | Type | Notes |
|---|---|---|
email | string | |
recoveryAuthKey | string | From the code being used |
kdf | object | As in register |
authKey | string | The new auth key |
wrappedVaultKey | sealed | The vault key, wrapped under the new password |
recovery | object | { authKey, wrappedVaultKey } for the new code |
locale | string | Optional, en or vi, for the notification |
Returns { "ok": true }, or the errors of recover/start. When the account had two-factor authentication on, the recovery code bypasses it, turns it off and deletes the backup codes; the response adds "twoFactorDisabled": true and the owner gets a separate email.
Two-factor authentication
The TOTP secret is stored encrypted under a key derived from AT_REST_SECRET; backup codes are stored as keyed hashes. Every request below is authenticated and needs currentAuthKey; locale (en or vi) is optional.
GET /api/account/2fa
Returns { "enabled": boolean, "enabledAt": number | null, "backupCodesLeft": number }.
POST /api/account/2fa/setup
Request { "currentAuthKey" }. Returns { "secret": string, "uri": string }, the base32 secret and its otpauth:// URI. The secret waits 15 minutes for enable. 409 two_factor.already_enabled when it is already on.
POST /api/account/2fa/enable
Request { "currentAuthKey", "code" } with a current code from the new secret. Returns { "enabledAt": number, "backupCodes": string[] } (ten codes, shown once), signs out every other session and emails the owner. Errors: 400 auth.invalid_2fa, 400 two_factor.setup_expired.
POST /api/account/2fa/disable
Request { "currentAuthKey", "code" }, where code is a TOTP or backup code. Returns { "ok": true } and emails the owner. Wrong codes answer 401 auth.invalid_2fa and are throttled per address and network.
POST /api/account/2fa/backup-codes
Request { "currentAuthKey", "code" }. Returns { "backupCodes": string[] }; every earlier backup code stops working.
Account key pair
Every account has an ECDH P-256 key pair so that team keys can be wrapped for it (see Teams and CI secrets). The extension creates it the first time the vault opens.
GET /api/account/identity
Authenticated. Returns { "identity": { publicKey, wrappedPrivateKey } }, or { "identity": null } before one exists. wrappedPrivateKey is sealed under the vault key; the server cannot open it.
PUT /api/account/identity
Authenticated. Body { publicKey, wrappedPrivateKey }. Stores the pair once; 409 account.identity_exists afterwards, so a stolen session cannot swap in a key its holder controls.
Organizations
All authenticated. Membership and roles live in D1; teams, wrapped team keys and the audit log live in the organization's Durable Object (org:<id>), which checks the caller's role again against its own copy. An organization the caller is not in answers 404 orgs.not_found, never 403, so its existence does not leak.
Project and secret writes carry the team's key generation; a write for an older one gets 409 orgs.stale_team_key, and every write is refused with 409 orgs.rekey_required while the team waits for a new key after someone left.
| Method and path | Who | Does |
|---|---|---|
GET /api/orgs | anyone | Organizations the caller belongs to, with their role |
POST /api/orgs { name } | anyone, up to 10 owned | Creates one; the caller is its owner |
GET /api/orgs/:id | members | The organization (with allowedDomains), its members (email, role, public key), pending invitations (admins only) and teams. Members see their own teams; admins see all team names but get a wrapped key only for teams they are in. projects maps each team the caller holds a key for to { generation, projects }, the same records as the team's project list, so the console loads in one request |
PUT /api/orgs/:id/domains { domains } | owner | Sets the allowed email domains (up to 10, exact match, [] allows any address). Invitations and joining are refused outside them; current members keep access. Returns { allowedDomains }; 400 orgs.invalid_domains |
POST /api/orgs/:id/invites { email, role, locale? } | admins (owners for admin) | Invites an address for 14 days and emails it; inviting a pending address again resends with a fresh expiry. 201 { id, expiresAt, emailed, link }: link opens the invitation in the dashboard, to share by hand when emailed is false (no email provider). 409 orgs.seat_limit with params.limit when members plus pending invitations fill the organization's seats. 400 orgs.domain_not_allowed with params.domains when the address is outside the organization's allowed domains |
DELETE /api/orgs/:id/invites/:inviteId | admins | Withdraws an invitation |
PATCH /api/orgs/:id/members/:accountId { role } | owner | Makes someone admin or member |
POST /api/orgs/:id/owner { accountId, authKey } | owner | Makes an admin the owner; the caller becomes an admin. authKey is the caller's, derived from their master password as at sign-in (403 auth.invalid_credentials otherwise). 400 orgs.transfer_needs_admin for a non-admin, 400 orgs.transfer_needs_key before they set up their account, 400 orgs.domain_not_allowed outside the allowed domains |
DELETE /api/orgs/:id/members/:accountId | owner, admins for members, anyone for themselves | Removes them from the organization and its teams; affected teams are flagged for re-keying |
POST /api/orgs/:id/teams { id, name, wrappedKey } | admins | Creates a team at key generation 0; wrappedKey is the new team key wrapped for the caller. 409 orgs.team_limit with params.limit past the organization's team limit |
PUT /api/orgs/:id/teams/:teamId/members/:accountId { wrappedKey, generation } | admins who are in the team | Adds someone with the team key wrapped for their public key. generation must be the team's current one, else 409 orgs.stale_team_key |
POST /api/orgs/:id/invites teams | admins | Optional teams: [{ teamId, access }] on an invitation. Teams list the invited email under invited until it is accepted, then the account under pending until an admin in the team adds it, which clears it. Withdrawing the invitation forgets the teams |
PUT /api/orgs/:id/teams/:teamId/members/:accountId/access { access } | admins | access is edit or view. People who can view read everything in the team but their writes fail with 403 orgs.read_only; teams list them in viewers, and GET /api/vaults marks their team vaults viewOnly. Adding a team member also accepts access; leaving it out keeps the current one, or edit for someone new |
PUT /api/orgs/:id/teams/:teamId/key { generation, wrappedKeys, projects, secrets, versions, items } | admins who are in the team | Replaces the team key: generation is the current one plus 1, wrappedKeys maps every current team member to the new key wrapped for them, and projects, secrets and items map every project, secret and team vault item of the team, no more and no fewer, to its re-encryption under the new keys; versions does the same for every earlier secret value, and leaving it out drops the history. One transaction; clears the re-key flag. 409 orgs.stale_team_key or 409 orgs.team_changed when the team moved on |
DELETE /api/orgs/:id/teams/:teamId/members/:accountId | admins | Removes them and flags the team for re-keying |
DELETE /api/orgs/:id/teams/:teamId | admins | Deletes the team |
GET /api/orgs/:id/teams/:teamId/projects | team members | { generation, rekeyNeeded, projects: [{ id, sealed, createdAt }] }; each project is sealed under the team key |
POST /api/orgs/:id/teams/:teamId/projects { id, sealed, generation } | admins in the team | Creates a project. 409 orgs.project_limit with params.limit past the organization's project limit |
PUT /api/orgs/:id/teams/:teamId/projects/:projectId { sealed, generation } | admins in the team | Replaces the sealed project (name, environments, project key) |
DELETE /api/orgs/:id/teams/:teamId/projects/:projectId | admins in the team | Deletes the project and its secrets |
PUT /api/orgs/:id/teams/:teamId/projects/:projectId/environments/:environmentId/protection { protected } | admins in the team | Protects or unprotects an environment: only admins can then write or delete its secrets, others get 403 orgs.protected_environment. Projects are listed with protectedEnvironments. Logged as environment.protected / environment.unprotected |
GET /api/orgs/:id/teams/:teamId/projects/:projectId/environments/:environmentId/secrets | team members | { generation, secrets: [{ id, sealed, updatedAt, updatedBy }] }; logged as secrets.read |
PUT /api/orgs/:id/teams/:teamId/projects/:projectId/secrets/:secretId { environmentId, sealed, generation } | team members | Creates or replaces a secret. A secret stays in the project and environment it was created in (409 orgs.invalid_secret); at most 1,000 per project (409 orgs.secret_limit) |
POST /api/orgs/:id/teams/:teamId/projects/:projectId/secrets { generation, secrets: [{ id, environmentId, sealed }] } | team members | Writes up to 500 secrets in one transaction, with the same rules as a single write; nothing is written if any entry is refused |
POST /api/orgs/:id/teams/:teamId/projects/:projectId/secrets/delete { ids } | team members | Deletes up to 1,000 secrets of the project in one transaction |
DELETE /api/orgs/:id/teams/:teamId/projects/:projectId/secrets/:secretId | team members | Deletes a secret |
GET /api/orgs/:id/teams/:teamId/projects/:projectId/secrets/:secretId/versions | team members | { generation, environmentId, versions: [{ id, sealed, updatedAt, updatedBy }] }, the last 5 earlier values, newest first, sealed like the secret. Logged as secret.history_read |
POST /api/orgs/:id/teams/:teamId/projects/:projectId/access { environmentId, access } | team members | Logs that a secret was revealed or copied, an environment exported, or secrets injected for an AI agent by 7key agent, in the console |
GET /api/orgs/:id/teams/:teamId/projects/:projectId/links | team members | Repository links: repository, environment, rule, who linked it and when it was last read |
POST /api/orgs/:id/teams/:teamId/projects/:projectId/links { id, environmentId, repository, branch, githubEnvironment, publicKey, wrappedKey, generation } | admins in the team | Links a repository; at least one of branch and githubEnvironment. At most 50 per project |
DELETE /api/orgs/:id/teams/:teamId/projects/:projectId/links/:linkId | admins in the team | Unlinks; the repository stops reading at once |
GET /api/orgs/:id/teams/:teamId/vault | admins in the team | Every project, secret and team vault item of the team, for re-keying; logged as team.vault_read |
PUT /api/orgs/:id/teams/:teamId/items/:itemId { sealed, generation } | team members | Creates or replaces one team vault item, sealed under the team key with personal-vault/team-item/<team>/<generation>/<item>. 200 { updatedAt }. 409 orgs.rekey_required while the team waits for a new key, 409 orgs.stale_team_key for an older generation, 409 orgs.item_limit past 1,000 items. Logged as team.item_saved |
DELETE /api/orgs/:id/teams/:teamId/items/:itemId | team members | Deletes one team vault item for everyone; logged as team.item_deleted |
GET /api/orgs/:id/audit?before=<seq> | admins | 100 most recent events before seq, and whether the hash chain verifies |
GET /api/invites | anyone | Invitations for the caller's email |
GET /api/invites/:id/preview | no session (rate-limited) | Organization name, inviter, role, expiry, the invited address, hasAccount and domainAllowed, so the link can open on account creation or sign-in. 404 once accepted, declined, withdrawn or expired |
POST /api/invites/:id/accept | the invited address | Joins the organization with the invited role. 403 orgs.invite_domain_blocked when the organization no longer accepts the address's domain; 403 orgs.single_org when a Single organization policy forbids it |
DELETE /api/invites/:id | the invited address | Declines |
Single sign-on
Commercial; see Single sign-on. Every route below answers 403 license.required when the organization is neither on the enterprise plan nor covered by a valid LICENSE_KEY, except the read-only overview, which reports the licence instead.
POST /api/sso/prepare
No session; rate-limited per network.
| Field | Type | Notes |
|---|---|---|
email | string | A work email; its domain must be verified by one organization. Ignored when identifier is given |
identifier | string | The organization identifier |
returnTo | string | Exactly <server>/app/, https://<extension id>.chromiumapp.org/ for an allowed extension, or http://127.0.0.1:<port>/sso (port 1024 to 65535) for the CLI |
clientChallenge | string | base64url SHA-256 of a random verifier the client keeps; the grant is only redeemable with that verifier |
| Status | Body / code | |
|---|---|---|
| 200 | `{ "orgName": string, "identifier": string \ | null, "authorizeUrl": string }` |
| 400 | sso.invalid_return, sso.invalid_request | |
| 403 | license.required | |
| 404 | sso.not_found (no organization, or its provider is missing or off) | |
| 502 | sso.discovery_failed |
The server stores a one-time state (10 minutes) with the sealed PKCE code verifier it will send to the provider, the nonce and the client challenge. authorizeUrl carries response_type=code, scope=openid email profile, state, nonce, code_challenge (S256) and the fixed redirect_uri=<server>/api/sso/callback.
GET /api/sso/callback?code=…&state=…
The provider's redirect. An unknown, used or expired state answers 400 sso.expired as JSON and redirects nowhere. Otherwise the state is consumed and the browser is sent to the stored returnTo with a fragment: #sso_grant=<grant> on success, or #sso_error=<code> with sso.failed, sso.email_unverified, sso.email_not_allowed, sso.not_found or license.required. The grant is single-use and lives two minutes. A state from POST /api/orgs/:id/sso/link never yields a grant: it ends with #sso_link=linked, or #sso_link_error=<code> with the codes above, sso.link_email_mismatch or sso.link_taken.
POST /api/sso/session
Request { "grant": string, "clientVerifier": string, "locale"?: "en" | "vi" }. The grant is consumed even when the verifier is wrong.
| Status | Body / code |
|---|---|
| 200 | { "status": "signed_in", "email", "orgId", "kdf", "token", "expiresAt", "wrappedVaultKey" } for an existing account; the client asks for the master password to open wrappedVaultKey |
| 200 | { "status": "register", "email", "orgName", "registrationGrant" } for a new address; pass registrationGrant as ssoGrant to POST /api/auth/register within 15 minutes |
| 403 | sso.expired, orgs.single_org, orgs.seat_limit, sso.email_not_allowed, license.required |
Account two-factor authentication is not asked for here; the identity provider's sign-in stands in for it.
Organization settings
All need an owner or admin session.
| Method and path | Does |
|---|---|
GET /api/orgs/:id/sso | Licence status, identifier, domains with their TXT record, provider (without the secret), policies |
PUT /api/orgs/:id/sso/identifier | { "identifier" }, 3–32 of [a-z0-9-]; 409 sso.identifier_taken |
POST /api/orgs/:id/sso/domains | { "domain" }; issues the token for 7key-verify=<token>; 409 sso.domain_taken, sso.domain_limit |
POST /api/orgs/:id/sso/domains/:domain/verify | Looks the TXT record up over DNS over HTTPS; 400 sso.domain_not_verified, 409 sso.domain_taken |
DELETE /api/orgs/:id/sso/domains/:domain | Removes the domain |
PUT /api/orgs/:id/sso/provider | { "issuer", "clientId", "clientSecret"?, "enabled", "decryption": "master_password" }; the issuer must be https and its discovery document must name it; 400 sso.invalid_provider, sso.discovery_failed |
PUT /api/orgs/:id/sso/policies | { "requireSso", "singleOrg" }; Require SSO needs an enabled provider |
Each change is recorded in the organization's audit log. Every write returns the same overview as GET.
Linking a member's account
Any member's session. For addresses that are not on a verified domain.
| Method and path | Does | |
|---|---|---|
GET /api/orgs/:id/sso/link | `{ "available", "onDomain", "twoFactor", "link": { "linkedAt" } \ | null }` |
POST /api/orgs/:id/sso/link | { "currentAuthKey", "code"?, "returnTo" }; code is required when 2FA is on. Returns { "authorizeUrl" } to open in the browser. 403 auth.invalid_credentials, 401 auth.invalid_2fa, 400 sso.invalid_return, 409 sso.invalid_request (address on a verified domain), 404 sso.not_found, 403 license.required | |
DELETE /api/orgs/:id/sso/link | Removes the link and returns the same status as GET |
Team vaults
GET /api/vaults
Every team vault the caller can read, across all their organizations: one entry per team they are in, with orgId, orgName, teamId, name, generation, rekeyNeeded, the team key wrapped for the caller (wrappedKey) and every item as { id, sealed, updatedAt, updatedBy }. Admins who are not in a team do not get its vault. Writes go through the organization routes above.
CI
POST /api/ci/secrets
No session. Authorization: Bearer <GitHub Actions OIDC token> requested with this server's origin as the audience, and a body { "link": "<org>.<link>" }. Returns { projectId, environmentId, generation, wrappedKey, secrets: [{ id, sealed }] }: the environment key wrapped for the repository and the sealed values, nothing readable. 401 ci.unauthorized for a missing or invalid token, 403 ci.forbidden when the repository, branch or GitHub environment does not match the link. Rate limited per link.
Items
All item endpoints require authentication. An item is a single sealed blob; the server never sees its fields.
GET /api/items?since=<timestamp>
Changes since a cursor, oldest first. Omit since (or send 0) for everything.
{
"serverTime": 1791820799,
"items": [
{ "id": "…", "ciphertext": "…", "nonce": "…", "updatedAt": 1791820700, "deletedAt": null },
{
"id": "…",
"ciphertext": null,
"nonce": null,
"updatedAt": 1791820750,
"deletedAt": 1791820750
}
]
}
Use serverTime as the next since. A deleted item comes back as a tombstone with deletedAt set and no ciphertext.
PUT /api/items/:id
Create or replace an item. :id is a UUID chosen by the client. Body: a sealed value, ciphertext at most 64 KiB.
| Status | Body / code |
|---|---|
| 200 | { "id": string, "updatedAt": number } |
| 400 | items.invalid_id, items.invalid_payload |
| 403 | items.forbidden (the id belongs to another account), items.quota_exceeded |
Writing to a deleted item's id restores it.
With ?ifUpdatedAt=<number> the write is conditional: it replaces the item only if it still exists and its updatedAt equals that number, and never creates or restores one. Otherwise it returns 409 items.conflict. The extension uses it for background writes such as recording when a login was last used, so they cannot overwrite an edit or undo a deletion made on another device.
POST /api/items/batch
Create up to 100 new items in one request, for imports. Body: { "items": [{ "id": uuid, "ciphertext": string, "nonce": string }] }.
Returns { "updatedAt": number, "created": string[] }. An id that already exists, in this account or another, is left untouched and is missing from created. Errors: 400 items.invalid_id, 400 items.invalid_payload (empty, more than 100, or a malformed item), 403 items.quota_exceeded when the batch would take the account past its item limit. The whole batch counts as one write for rate limiting.
DELETE /api/items/:id
Replaces the item with a tombstone. Returns { "id": string, "deletedAt": number }, or 404 items.not_found.
Shares
POST /api/shares
Authenticated. Store an encrypted share. The key stays in the link fragment and never reaches the server.
| Field | Type | Notes |
|---|---|---|
ciphertext | string | ≤ 16 KiB decoded |
nonce | string | 12 bytes |
ttlSeconds | number | 60 – 2,592,000 |
maxViews | integer or null | 1 – 100, or null for no view limit (the link works until it expires) |
recipients | string[] | Optional, up to 10 addresses; needs email on the server |
| Status | Body / code | |
|---|---|---|
| 201 | `{ "id": string, "expiresAt": number, "maxViews": number \ | null, "revokeToken": string }` |
| 400 | shares.invalid_payload, shares.invalid_ttl, shares.invalid_max_views, shares.invalid_recipients, shares.email_unavailable |
id and revokeToken are base64url. The server stores only a hash of revokeToken; keep it to revoke or check the share later.
The link is https://<worker>/s/<id>#<key>, where <key> is the base64url share key, followed by .p when the link also needs a passphrase.
POST /api/shares/status
Authenticated. Look up shares you created. Request:
{ "shares": [{ "id": "…", "revokeToken": "…" }] }
Up to 50 entries. Each answer is either { "id", "active": true, "expiresAt", "viewsLeft" } or { "id", "active": false }. viewsLeft is null for a link without a view limit. A wrong token reads as inactive.
DELETE /api/shares/:id
Authenticated. Header X-Revoke-Token: <token>. Deletes the share at once. Returns { "ok": true }, or 404 shares.gone for an unknown id or a wrong token.
GET /api/shares/:id
No authentication. Spends one view and returns the ciphertext:
{ "ciphertext": "…", "nonce": "…", "expiresAt": 1791907200, "viewsLeft": 0 }
viewsLeft is null when the link has no view limit, and 0 when this was the last view.
| Status | Code |
|---|---|
| 403 | shares.email_required: restricted share; no view is spent |
| 404 | shares.gone: unknown, expired, revoked or out of views, indistinguishably |
POST /api/shares/:id/code
No authentication. Request { "email": string, "locale"?: "en" | "vi" }. Emails a 6-digit code if the address is a recipient of the share.
Always returns { "ok": true }, whether or not the address is allowed, the share exists, a cooldown applies, or sending failed. Anything else would let someone test addresses against the recipient list.
POST /api/shares/:id/open
No authentication. Request { "email": string, "code": string }. On success, spends one view and returns the same body as GET /api/shares/:id.
Errors: 400 shares.invalid_code, 400 shares.code_expired, 404 shares.gone.
Pages
| Path | Content |
|---|---|
/s/:id | The recipient page. Served with Cache-Control: no-store and a strict CSP. |
/ | A short home page linking to the web app, the extension, security and API. |
/security, /api-docs | The threat model and this reference. |
/download/7key-extension.zip | The extension built for this server, for loading unpacked. |
Error codes
| Code | Status | Meaning |
|---|---|---|
auth.bad_registration_token | 403 | Missing or wrong X-Registration-Token |
auth.invalid_body | 400 | Body is not a JSON object |
auth.invalid_email | 400 | Not an email address |
auth.invalid_kdf | 400 | KDF type, iterations or salt out of bounds |
auth.invalid_auth_key | 400 | Auth key is not 32 bytes of base64 |
auth.invalid_wrapped_key | 400 | Wrapped vault key is not a valid sealed value |
auth.email_taken | 409 | An account already uses the address |
auth.registration_limit | 429 | Daily account limit for this network reached |
auth.invalid_code | 400 | Sign-up code is wrong or missing |
auth.code_expired | 400 | Sign-up code expired or used up its attempts |
auth.code_cooldown | 429 | A code was sent less than a minute ago |
auth.email_failed | 502 | The mail provider rejected the message |
auth.invalid_credentials | 400, 401, 403 | Wrong email or key, or a malformed credential |
auth.rate_limited | 429 | Too many failed logins for this email |
auth.unauthorized | 401 | Missing, unknown or expired session token |
items.invalid_id | 400 | Id is not a UUID |
items.invalid_payload | 400 | Ciphertext or nonce malformed or too large |
items.forbidden | 403 | The id belongs to another account |
items.not_found | 404 | No live item with that id |
items.quota_exceeded | 403 | The account holds the maximum number of items |
shares.invalid_payload | 400 | Ciphertext or nonce malformed or too large, or a bad status list |
shares.invalid_ttl | 400 | ttlSeconds out of range |
shares.invalid_max_views | 400 | maxViews missing, or not null or 1 – 100 |
shares.invalid_recipients | 400 | More than 10 recipients or an invalid address |
shares.email_unavailable | 400 | Recipients given, but the server cannot send email |
shares.gone | 404 | Share unknown, expired, revoked or out of views |
shares.email_required | 403 | Restricted share; open it with an emailed code |
shares.invalid_code | 400 | Share code wrong or missing |
shares.code_expired | 400 | Share code expired or used up its attempts |
server.rate_limited | 429 | Rate limit reached |
server.not_found | 404 | No such endpoint |
server.error | 500 | Unexpected failure; details go to the Worker log only |
auth.sso_required | 403 | The organization requires SSO; params.identifier names it |
license.required | 403 | SSO needs the enterprise plan or a valid LICENSE_KEY |
orgs.single_org | 403 | A Single organization policy forbids joining |
sso.not_found | 404 | No enabled SSO for that email or identifier |
sso.invalid_request | 400 | Missing email or identifier, or a malformed field |
sso.invalid_return | 400 | returnTo is not on the allow-list |
sso.invalid_identifier | 400 | Identifier is not 3–32 of [a-z0-9-] |
sso.identifier_taken | 409 | Another organization uses the identifier |
sso.invalid_domain | 400 | Not a domain name |
sso.domain_limit | 409 | Twenty domains already |
sso.domain_taken | 409 | Another organization verified the domain |
sso.domain_not_verified | 400 | The TXT record was not found |
sso.invalid_provider | 400 | Issuer, client ID, secret or decryption option invalid |
sso.discovery_failed | 400, 502 | The provider's discovery document could not be read or matched |
sso.expired | 400, 403 | State or grant unknown, used, expired, or verifier wrong |
sso.failed | redirect | The code exchange or the ID token check failed |
sso.email_unverified | redirect | The ID token does not say the email is verified |
sso.email_not_allowed | redirect, 403 | Email not on a verified domain and identity not linked |
sso.link_email_mismatch | redirect | The IdP returned another email than the account being linked |
sso.link_taken | redirect | The IdP identity is linked to another member already |