7KEY Privacy

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

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

FieldTypeNotes
emailstringRequired
localestringen (default) or vi
StatusBody / code
200{ "ok": true, "required": true }; or "required": false when the server sends no email
400auth.invalid_email
429auth.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.

FieldTypeNotes
emailstringNormalized to lower case
kdfobject{ "type": "pbkdf2-sha256", "iterations": 600000–5000000, "salt": base64 ≤ 64 bytes }
authKeystringbase64, 32 bytes
wrappedVaultKeysealedThe vault key sealed under the wrapping key
verificationCodestring6 digits; required when emailVerification is on
inviteIdstringOptional. A pending invitation for email; the new account joins that organization
ssoGrantstringOptional. A registration grant from POST /api/sso/session; see Single sign-on
StatusBody / code
201{ "accountId": string, "token": string, "expiresAt": number, "orgId"?: string }, orgId when an invitation was accepted
400auth.invalid_body, auth.invalid_email, auth.invalid_kdf, auth.invalid_auth_key, auth.invalid_wrapped_key, auth.invalid_code, auth.code_expired
403auth.bad_registration_token, sso.expired (the ssoGrant is unknown, used, expired or for another address)
409auth.email_taken, only when emailVerification is on and the code was valid
429auth.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

FieldTypeNotes
emailstring
authKeystringbase64, 32 bytes
StatusBody / code
200{ "token": string, "expiresAt": number, "wrappedVaultKey": sealed }
400auth.invalid_credentials (malformed body)
401auth.invalid_credentials (wrong email or key; the two are not distinguished)
429auth.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.

StatusBody / code
200The normal login response
401auth.invalid_2fa (wrong code) or auth.two_factor_expired (challenge gone)
429auth.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": ... }.

StatusBody / code
200{ "pinKey": string }; the attempt count resets
403auth.pin_wrong with params.count, the tries left
410auth.pin_off: no PIN, or the fifth wrong try just wiped it
401auth.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.

FieldTypeNotes
currentAuthKeystringProves knowledge of the current password
kdfobjectAs in register
authKeystringThe new auth key
wrappedVaultKeysealedThe same vault key, wrapped under the new password
keepSessionboolOptional. 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.

FieldTypeNotes
currentAuthKeystringProves knowledge of the master password
authKeystringThe recovery auth key
wrappedVaultKeysealedThe 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.

FieldTypeNotes
emailstring
recoveryAuthKeystringFrom the code being used
kdfobjectAs in register
authKeystringThe new auth key
wrappedVaultKeysealedThe vault key, wrapped under the new password
recoveryobject{ authKey, wrappedVaultKey } for the new code
localestringOptional, 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 pathWhoDoes
GET /api/orgsanyoneOrganizations the caller belongs to, with their role
POST /api/orgs { name }anyone, up to 10 ownedCreates one; the caller is its owner
GET /api/orgs/:idmembersThe 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 }ownerSets 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/:inviteIdadminsWithdraws an invitation
PATCH /api/orgs/:id/members/:accountId { role }ownerMakes someone admin or member
POST /api/orgs/:id/owner { accountId, authKey }ownerMakes 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/:accountIdowner, admins for members, anyone for themselvesRemoves them from the organization and its teams; affected teams are flagged for re-keying
POST /api/orgs/:id/teams { id, name, wrappedKey }adminsCreates 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 teamAdds 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 teamsadminsOptional 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 }adminsaccess 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 teamReplaces 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/:accountIdadminsRemoves them and flags the team for re-keying
DELETE /api/orgs/:id/teams/:teamIdadminsDeletes the team
GET /api/orgs/:id/teams/:teamId/projectsteam 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 teamCreates 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 teamReplaces the sealed project (name, environments, project key)
DELETE /api/orgs/:id/teams/:teamId/projects/:projectIdadmins in the teamDeletes the project and its secrets
PUT /api/orgs/:id/teams/:teamId/projects/:projectId/environments/:environmentId/protection { protected }admins in the teamProtects 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/secretsteam 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 membersCreates 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 membersWrites 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 membersDeletes up to 1,000 secrets of the project in one transaction
DELETE /api/orgs/:id/teams/:teamId/projects/:projectId/secrets/:secretIdteam membersDeletes a secret
GET /api/orgs/:id/teams/:teamId/projects/:projectId/secrets/:secretId/versionsteam 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 membersLogs 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/linksteam membersRepository 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 teamLinks a repository; at least one of branch and githubEnvironment. At most 50 per project
DELETE /api/orgs/:id/teams/:teamId/projects/:projectId/links/:linkIdadmins in the teamUnlinks; the repository stops reading at once
GET /api/orgs/:id/teams/:teamId/vaultadmins in the teamEvery 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 membersCreates 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/:itemIdteam membersDeletes one team vault item for everyone; logged as team.item_deleted
GET /api/orgs/:id/audit?before=<seq>admins100 most recent events before seq, and whether the hash chain verifies
GET /api/invitesanyoneInvitations for the caller's email
GET /api/invites/:id/previewno 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/acceptthe invited addressJoins 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/:idthe invited addressDeclines

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.

FieldTypeNotes
emailstringA work email; its domain must be verified by one organization. Ignored when identifier is given
identifierstringThe organization identifier
returnTostringExactly <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
clientChallengestringbase64url SHA-256 of a random verifier the client keeps; the grant is only redeemable with that verifier
StatusBody / code
200`{ "orgName": string, "identifier": string \null, "authorizeUrl": string }`
400sso.invalid_return, sso.invalid_request
403license.required
404sso.not_found (no organization, or its provider is missing or off)
502sso.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.

StatusBody / 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
403sso.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 pathDoes
GET /api/orgs/:id/ssoLicence 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/verifyLooks the TXT record up over DNS over HTTPS; 400 sso.domain_not_verified, 409 sso.domain_taken
DELETE /api/orgs/:id/sso/domains/:domainRemoves 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 pathDoes
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/linkRemoves 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.

StatusBody / code
200{ "id": string, "updatedAt": number }
400items.invalid_id, items.invalid_payload
403items.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.

FieldTypeNotes
ciphertextstring≤ 16 KiB decoded
noncestring12 bytes
ttlSecondsnumber60 – 2,592,000
maxViewsinteger or null1 – 100, or null for no view limit (the link works until it expires)
recipientsstring[]Optional, up to 10 addresses; needs email on the server
StatusBody / code
201`{ "id": string, "expiresAt": number, "maxViews": number \null, "revokeToken": string }`
400shares.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.

StatusCode
403shares.email_required: restricted share; no view is spent
404shares.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

PathContent
/s/:idThe 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-docsThe threat model and this reference.
/download/7key-extension.zipThe extension built for this server, for loading unpacked.

Error codes

CodeStatusMeaning
auth.bad_registration_token403Missing or wrong X-Registration-Token
auth.invalid_body400Body is not a JSON object
auth.invalid_email400Not an email address
auth.invalid_kdf400KDF type, iterations or salt out of bounds
auth.invalid_auth_key400Auth key is not 32 bytes of base64
auth.invalid_wrapped_key400Wrapped vault key is not a valid sealed value
auth.email_taken409An account already uses the address
auth.registration_limit429Daily account limit for this network reached
auth.invalid_code400Sign-up code is wrong or missing
auth.code_expired400Sign-up code expired or used up its attempts
auth.code_cooldown429A code was sent less than a minute ago
auth.email_failed502The mail provider rejected the message
auth.invalid_credentials400, 401, 403Wrong email or key, or a malformed credential
auth.rate_limited429Too many failed logins for this email
auth.unauthorized401Missing, unknown or expired session token
items.invalid_id400Id is not a UUID
items.invalid_payload400Ciphertext or nonce malformed or too large
items.forbidden403The id belongs to another account
items.not_found404No live item with that id
items.quota_exceeded403The account holds the maximum number of items
shares.invalid_payload400Ciphertext or nonce malformed or too large, or a bad status list
shares.invalid_ttl400ttlSeconds out of range
shares.invalid_max_views400maxViews missing, or not null or 1 – 100
shares.invalid_recipients400More than 10 recipients or an invalid address
shares.email_unavailable400Recipients given, but the server cannot send email
shares.gone404Share unknown, expired, revoked or out of views
shares.email_required403Restricted share; open it with an emailed code
shares.invalid_code400Share code wrong or missing
shares.code_expired400Share code expired or used up its attempts
server.rate_limited429Rate limit reached
server.not_found404No such endpoint
server.error500Unexpected failure; details go to the Worker log only
auth.sso_required403The organization requires SSO; params.identifier names it
license.required403SSO needs the enterprise plan or a valid LICENSE_KEY
orgs.single_org403A Single organization policy forbids joining
sso.not_found404No enabled SSO for that email or identifier
sso.invalid_request400Missing email or identifier, or a malformed field
sso.invalid_return400returnTo is not on the allow-list
sso.invalid_identifier400Identifier is not 3–32 of [a-z0-9-]
sso.identifier_taken409Another organization uses the identifier
sso.invalid_domain400Not a domain name
sso.domain_limit409Twenty domains already
sso.domain_taken409Another organization verified the domain
sso.domain_not_verified400The TXT record was not found
sso.invalid_provider400Issuer, client ID, secret or decryption option invalid
sso.discovery_failed400, 502The provider's discovery document could not be read or matched
sso.expired400, 403State or grant unknown, used, expired, or verifier wrong
sso.failedredirectThe code exchange or the ID token check failed
sso.email_unverifiedredirectThe ID token does not say the email is verified
sso.email_not_allowedredirect, 403Email not on a verified domain and identity not linked
sso.link_email_mismatchredirectThe IdP returned another email than the account being linked
sso.link_takenredirectThe IdP identity is linked to another member already