# REST API

The API is a Cloudflare Worker. All application endpoints are under `/v1` and require
authentication. Values are always **ciphertext** — the API neither encrypts nor decrypts.

## Authentication

Send one of:

- `Authorization: Bearer <jwt>` — a Stytch B2B session JWT (web sessions), or a
  machine (M2M) access token.
- `Authorization: Basic base64(client_id:client_secret)` — machine (M2M) client
  credentials. The API runs the OAuth 2.0 client-credentials exchange
  server-side and authorizes the caller as a machine client — the same credential
  used on the hosted metadata plane (`mcp.seekrit.dev`). It holds no key material
  and cannot decrypt; use it to bootstrap an admin service token, then switch to
  that token for anything that touches ciphertext.
- `Authorization: Bearer skt_…` — a service token. Its stored `role` is `member`
  (runtime credential) or `admin` (passes admin-gated routes, for headless
  provisioning). `owner` is never issued to a token. A service token is **never**
  a valid Basic credential — it carries a private key.
- `Authorization: Bearer skc_…` — a CLI session, created by `seekrit login` and
  authorized in the dashboard. It authenticates **as the user** who authorized it,
  with that member's role in every org they belong to. It carries no key material,
  so decryption still needs that user's passphrase-unlocked private key.

Errors use `{ "error": { "code": "...", "message": "..." } }` with a matching HTTP status
(`400`, `401`, `403`, `404`, `409`, `429`, `500`). Organizations you don't belong to return `404`.
One code is recoverable rather than final: `mfa_required` (403) means the caller
must re-verify their second factor and retry (see CLI sign-in below).

## Signup (agent self-service)

| Method | Path | Description |
| --- | --- | --- |
| `POST` | `/v1/signup` | Create an organization and a machine (M2M) client for it — the zero-human on-ramp for agents. No auth; rate-limited. Body: `{ "orgName", "orgSlug", "clientName"? }` — `orgName` and `orgSlug` are **required** and should name the real project/company (not placeholders); `orgSlug` is lowercase alphanumeric + hyphens, disambiguated on collision. Returns `{ "org", "m2m": { "clientId", "clientSecret" } }` — the secret is shown **once**. The org starts memberless; a human joins later via invite. |

Also reachable as `POST https://mcp.seekrit.dev/signup` (the hosted MCP server
proxies to this endpoint).

## CLI sign-in (browser-approved)

What `seekrit login` drives. The CLI generates its own session token, registers
only the SHA-256 hash, and a human authorizes that hash in the dashboard — so the
credential never travels through the API, and an approval response carries nothing
secret.

| Method | Path | Description |
| --- | --- | --- |
| `POST` | `/v1/cli-login` | Open a sign-in request. No auth (the caller has no credential yet); rate-limited per IP. Body: `{ "sessionId": "skc_…", "tokenHash", "deviceLabel", "client"? }` — `deviceLabel`/`client` are self-reported and shown to the human, never used for authorization. Returns `{ "code", "verifyUrl", "requestExpiresAt", "pollIntervalSeconds" }`. Requests expire in 10 minutes. |
| `POST` | `/v1/cli-login/poll` | Ask whether the request was authorized. No auth. Body: `{ "code" }`. Returns `{ "status": "pending" \| "approved" \| "denied" \| "expired" }`, plus `{ "sessionId", "email", "expiresAt" }` once approved. |
| `GET` | `/v1/cli-login/:code` | The request's details, for the approval screen: `{ "request": { code, status, deviceLabel, client, ipAddress, createdAt, requestExpiresAt } }`. Requires a **browser session** — a CLI session may not authorize another device. |
| `POST` | `/v1/cli-login/:code/approve` | Authorize the device; the registered hash becomes a usable credential (90 days). Requires a browser session, and — for members with a second factor — that it was re-entered within the last 10 minutes, else `403 mfa_required`. Returns `{ "session": { id, expiresAt } }`. Audits `cli_session.approved`. |
| `POST` | `/v1/cli-login/:code/deny` | Decline the request. Requires a browser session. Audits `cli_session.denied`. |

Manage the resulting sessions under [Identity](#identity).

## Health

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/health` | Liveness check (no auth). |

## Identity

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/me` | The current user and their organizations. |
| `GET` | `/v1/me/keys` | Your public key and passphrase-encrypted private key. |
| `PUT` | `/v1/me/keys` | Upload your keys (one-time key setup). |
| `GET` | `/v1/me/cli-sessions` | Devices authorized with `seekrit login`: `{ "sessions": [...], "currentSessionId" }`. Revoked and expired sessions stay listed (the ledger of what has signed in as you); `currentSessionId` is set when the caller *is* a CLI session. |
| `DELETE` | `/v1/me/cli-sessions/:sessionId` | Revoke a CLI session — it stops authenticating on its next request. Allowed from a CLI session for its own id (that's `seekrit logout`). Audits `cli_session.revoked`. |
| `GET` | `/v1/me/notifications` | Your email notification preferences (all types, defaults applied). |
| `PUT` | `/v1/me/notifications` | Update notification preferences. Body: `{ "prefs": { "<type>": true \| false } }` (partial). Returns the full merged map. |

## Organizations

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs` | List organizations you can access. |
| `POST` | `/v1/orgs` | Create an organization (creator becomes owner). |
| `GET` | `/v1/orgs/:orgId` | Get one organization and your role. |
| `PATCH` | `/v1/orgs/:orgId` | Rename an organization: `{ name }` (display name only — the slug is fixed). *(admin)* |
| `GET` | `/v1/orgs/:orgId/members` | List members (includes public keys, for granting). |
| `GET` | `/v1/orgs/:orgId/mfa-policy` | Get the org's second-factor policy: `{ required, configured }`. `configured` is false when the identity provider isn't set up. *(admin)* |
| `PATCH` | `/v1/orgs/:orgId/mfa-policy` | Require (or stop requiring) MFA for all members: `{ required }`. *(admin)* |
| `GET` | `/v1/orgs/:orgId/invites` | List pending invitations. *(admin)* |
| `POST` | `/v1/orgs/:orgId/invites` | Invite someone by email `{ email, role? }` (`role` defaults to `member`, `admin` allowed — never `owner`). Sends a magic-link invitation; they join at that role on first sign-in. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/invites/:inviteId` | Revoke a pending invitation. *(admin)* |

## Applications & environments

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/apps` | List applications. |
| `POST` | `/v1/orgs/:orgId/apps` | Create an application. *(admin)* |
| `GET` | `/v1/orgs/:orgId/apps/:appId` | Get an application and its environments. |
| `PATCH` | `/v1/orgs/:orgId/apps/:appId` | Rename an application: `{ name }` (display name only — the slug is fixed). *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/apps/:appId` | Delete an application. *(admin)* |
| `GET` | `/v1/orgs/:orgId/apps/:appId/envs` | List environments. |
| `POST` | `/v1/orgs/:orgId/apps/:appId/envs` | Create an environment (body includes the wrapped DEK). *(admin)* |
| `GET` | `/v1/orgs/:orgId/envs/:envId` | Get an environment. |
| `DELETE` | `/v1/orgs/:orgId/envs/:envId` | Delete an environment. *(admin — or any grant-holder when it's a branch)* |
| `GET` | `/v1/orgs/:orgId/envs/:envId/grantees` | The public keys of this environment's grant-holders, for wrapping a new key to them. No key material is returned. |

An environment is owned by **either** an application or a group; `applicationId`
and `groupId` are both present on the row, with the unused one `null`.

Environment lists exclude [branches](/docs/guides/branches) — they're returned by
the branch endpoints below.

Each environment returned by `GET /v1/orgs/:orgId/apps/:appId` and
`GET /v1/orgs/:orgId/groups/:groupId` also carries a `canDecrypt` boolean: whether
the calling principal holds a key grant for that environment. Secret names and
versions are visible to any member, but values only decrypt with a grant — so the
dashboard uses `canDecrypt` to lock the environments you can't read. It's computed
per request, not a stored field.

## Branches

A [branch](/docs/guides/branches) is an ephemeral environment overlaying another:
an ordinary environment row with a `parentEnvironmentId` and an `expiresAt`. It
stores only the values that differ; the rest resolve from its parent at read
time, so creating one copies and re-encrypts nothing.

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/envs/:envId/branches` | List branches of an environment. |
| `POST` | `/v1/orgs/:orgId/envs/:envId/branches` | Fork this environment: `{ slug, name?, ttlSeconds?, wrappedDek, recoveryWrappedDek?, grants? }`. `envId` is the **parent**. |
| `GET` | `/v1/orgs/:orgId/apps/:appId/branches` | Every branch in an application. |
| `DELETE` | `/v1/orgs/:orgId/envs/:branchId` | Tear one down (the environment-delete route above). |

`ttlSeconds` omitted takes the default (7 days); pass an explicit `null` for no
expiry. The cap is 30 days. Expired branches are deleted automatically, along
with their overrides, key grants, and any service token bound to them.

`grants` wraps the branch's new DEK to principals that already hold a grant on
the parent (get their public keys from `…/envs/:envId/grantees`) — a grant naming
anyone else is rejected. Creating a branch requires a grant on the parent, not an
admin role: it conveys no access the caller doesn't already have. Parents must be
application environments and must not themselves be branches.

## Groups

A group is a reusable, org-scoped secret bag. Its environments (matched by slug)
reuse the same secret and key-grant endpoints above.

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/groups` | List groups. |
| `POST` | `/v1/orgs/:orgId/groups` | Create a group. *(admin)* |
| `GET` | `/v1/orgs/:orgId/groups/:groupId` | Get a group and its environments. |
| `PATCH` | `/v1/orgs/:orgId/groups/:groupId` | Rename a group: `{ name }` (display name only — the slug is fixed). *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/groups/:groupId` | Delete a group. *(admin)* |
| `GET` | `/v1/orgs/:orgId/groups/:groupId/envs` | List a group's environments. |
| `POST` | `/v1/orgs/:orgId/groups/:groupId/envs` | Create a group environment (body includes the wrapped DEK). *(admin)* |

### Composition

Which groups an application environment pulls in, and their precedence.

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/envs/:envId/groups` | List composed groups (slug, name, position). |
| `POST` | `/v1/orgs/:orgId/envs/:envId/groups` | Compose a group `{ groupId, position? }`. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/envs/:envId/groups/:groupId` | Remove a composed group. *(admin)* |

## Resolve

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/resolve` | The full layered environment for the calling principal. |

For a service token, the org + app + environment come from the token itself. For
a user session pass `?env=<envId>`. Repeat `?with=<groupSlug>:<envSlug>` to
resolve specific groups at a different slug. Add `?branch=<slug|id>` to read an
ephemeral [branch](/docs/guides/branches) of that environment — a service token
stays bound to its own environment, so it can only name branches of that one.
Returns `{ scope, layers }`, where `layers` are ordered lowest-precedence first
(composed groups → the app environment → the branch's overrides) and each carries
its `environmentId`, secret ciphertexts, and the DEK wrapped to the caller. A
branch resolve additionally reports `scope.branchOf`, and needs a grant on every
layer including the parent — so branch access never exceeds parent access. The client decrypts and merges; the server sees only
ciphertext. The [language SDKs](/docs/guides/sdks), CLI, and `seekrit-run` all
implement this same decrypt-and-merge against this endpoint.

> **Note:** Resolve is edge-cached per principal, so a fleet of identical containers or CI jobs sharing one service token is served from cache without re-querying the database. Responses reflect secret writes, key changes, and composition changes within seconds (a write invalidates the cache; a short TTL is the backstop). Because the cache key includes the calling principal, one caller never receives another's wrapped DEK. Successful resolves are metered for usage/billing but are not written to the audit log; a *denied* resolve is audited (`env.resolve_denied`).

## Secrets

Values are opaque ciphertext blobs produced by the client.

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/envs/:envId/secrets` | List secrets (with ciphertext). |
| `PUT` | `/v1/orgs/:orgId/envs/:envId/secrets/:name` | Create or update a secret (appends a version). |
| `GET` | `/v1/orgs/:orgId/envs/:envId/secrets/:name/versions?limit=` | The secret's history, newest first: `{ versions: [{ version, ciphertext, createdByType, createdById, restoredFromVersion, createdAt }], currentVersion }`. `limit` defaults to 50, max 200. |
| `POST` | `/v1/orgs/:orgId/envs/:envId/secrets/:name/restore` | Roll back to an earlier version: `{ version }`. Returns `{ secret, restoredFrom }`. |
| `DELETE` | `/v1/orgs/:orgId/envs/:envId/secrets/:name` | Delete a secret and its whole version history. |

> **Note:** **Restore is keyless.** It replays a ciphertext the server already stores — no decryption, no re-encryption — so a machine (M2M) credential can roll a secret back even though it can never read the value. The replayed blob lands as a *new* version (`restoredFromVersion` points at its source), so history stays append-only and a rollback is itself undoable. `404` if the version doesn't exist; `400` if the secret already holds that exact value.

## Key grants

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/envs/:envId/key` | The calling principal's own wrapped DEK. |
| `GET` | `/v1/orgs/:orgId/envs/:envId/keys` | List all grants for the environment. *(admin)* |
| `POST` | `/v1/orgs/:orgId/envs/:envId/keys` | Grant a wrapped DEK to a principal. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/envs/:envId/keys/:grantId` | Revoke a grant. *(admin)* |

## KMS keys

Client-side managed keys for application-layer encryption and signing. Material
is generated by the client and stored only as wrapped grants; `sign` keys also
publish a public key per version. Management is *(admin)*; using a key needs a
grant. See [Managed keys](/docs/concepts/kms).

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/kms/keys` | List keys the caller can see (admins: all org keys; members: keys they hold a grant for). |
| `POST` | `/v1/orgs/:orgId/kms/keys` | Create a key: `{ name, purpose ('encrypt'\|'sign'), spec, applicationId?, groupId?, publicKeyJwk? (sign only), grants: [{ principalType, principalId, wrappedKey }] }`. At most one of `applicationId`/`groupId`. *(admin)* |
| `GET` | `/v1/orgs/:orgId/kms/keys/:keyId` | Key metadata + every version (public keys for `sign`). *(admin)* |
| `GET` | `/v1/orgs/:orgId/kms/keys/:keyId/key` | The caller's wrapped material across granted versions (`{ purpose, spec, currentVersion, grants: [{ version, wrappedKey }] }`). `403` if the key is disabled. |
| `GET` | `/v1/orgs/:orgId/kms/keys/:keyId/public` | Published public keys of a `sign` key, per version. Grant-free within the org. |
| `GET` | `/v1/orgs/:orgId/kms/keys/:keyId/grants` | Grantees, each with the versions they hold. *(admin)* |
| `POST` | `/v1/orgs/:orgId/kms/keys/:keyId/grants` | Grant the current version: `{ principalType, principalId, wrappedKey }`. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/kms/keys/:keyId/grants?principalType=&principalId=` | Revoke a principal entirely (all versions). *(admin)* |
| `POST` | `/v1/orgs/:orgId/kms/keys/:keyId/rotate` | Add a version and re-wrap for grantees: `{ publicKeyJwk? (sign only), grants: [{ principalType, principalId, wrappedKey }] }`. *(admin)* |
| `POST` | `/v1/orgs/:orgId/kms/keys/:keyId/disable` | Disable a key — blocks all use (encrypt/decrypt/sign, new grants/rotations); grant rows retained. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/kms/keys/:keyId` | Soft-delete a key — hides it from every listing and read path; row and versions retained for audit; name frees up for reuse. *(admin)* |

> **Note:** Key material never reaches the server. For an `encrypt` key each `wrappedKey` is the AES key wrapped to a principal (`wd1.`); for a `sign` key it is the wrapped PKCS8 private key, and the public key is stored per version for grant-free verification. Ciphertext (`ce1.`), wrapped data keys (`dk1.`), and signatures (`sg1.`) are produced and consumed entirely client-side.

## Recovery

Customer-controlled M-of-N recovery. The client generates and Shamir-splits the recovery key;
these routes carry only the recovery **public** key, opaque wrapped shares, and ciphertext grants.
Management is *(admin)*; a custodian reads their own share and contributes to a ceremony. See
[Customer-controlled recovery](/docs/concepts/recovery).

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/recovery` | Status: threshold, custodians, and environment coverage. *(admin)* |
| `POST` | `/v1/orgs/:orgId/recovery` | Enable recovery: `{ recoveryPublicKeyJwk, threshold, shares[], grants[] }`. *(admin)* |
| `POST` | `/v1/orgs/:orgId/recovery/rotate` | Rotate to a fresh recovery key, custodian set, and env re-wraps. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/recovery` | Disable recovery and delete every recovery grant. *(admin)* |
| `POST` | `/v1/orgs/:orgId/recovery/grants` | Backfill recovery grants: `{ grants: [{ environmentId, wrappedDek }] }`. *(admin)* |
| `GET` | `/v1/orgs/:orgId/recovery/share` | The caller's own wrapped recovery share (custodian only). |
| `GET` | `/v1/orgs/:orgId/recovery/env-keys` | Every DEK wrapped to the recovery key, for the target to reconstruct. *(admin)* |
| `GET` | `/v1/orgs/:orgId/recovery/requests` | List recovery ceremonies. *(admin)* |
| `POST` | `/v1/orgs/:orgId/recovery/requests` | Start a ceremony: `{ targetPublicKeyJwk, targetType?, targetId?, reason? }`. *(admin)* |
| `GET` | `/v1/orgs/:orgId/recovery/requests/:id` | A request plus its contributed (re-wrapped) shares. |
| `POST` | `/v1/orgs/:orgId/recovery/requests/:id/shares` | A custodian contributes: `{ shareIndex, contributedShare }`. |
| `POST` | `/v1/orgs/:orgId/recovery/requests/:id/complete` | Target restores access: `{ principalType, principalId, grants[] }`. *(admin)* |
| `POST` | `/v1/orgs/:orgId/recovery/requests/:id/cancel` | Cancel an open request. *(admin)* |

> **Note:** The server never sees a recovery share or the recovery key in the clear. Shares are wrapped to each custodian (`wd1.`); during a ceremony each is unwrapped and re-wrapped to the target on the custodian's own device; the recovery key is reconstructed only on the target's machine, from ciphertext this API merely relays.

## Service tokens

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/tokens` | List service tokens. *(admin)* |
| `POST` | `/v1/orgs/:orgId/tokens` | Register a token (client sends hash + public key; `role` defaults to `member`, pass `admin` for a provisioning token; optional `environmentId` binds it to an app environment). *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/tokens/:tokenId` | Revoke a token (idempotent). *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/tokens/:tokenId/permanent` | Delete a token: drops the env/KMS grants wrapped to it and marks it deleted (soft delete — the row is retained for the audit trail but hidden from listings). Requires the token to already be revoked (`409` otherwise). *(admin)* |

A service token may read or write secrets only for environments it holds a key
grant for — its bound environment and the group slices composed into it. It
cannot address other environments in the org.

## Honey tokens

Decoy credentials that grant nothing and alert on use. All routes are *(admin)*,
and gated on the **Honey tokens** plan feature. See the
[guide](/docs/guides/honey-tokens).

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/honey-tokens` | List decoys with their trip counts and the time, source IP, and user agent of the last trip. Never returns the token hash. |
| `POST` | `/v1/orgs/:orgId/honey-tokens` | Register a decoy (client sends `tokenId` + `tokenHash`, plus an optional `placement` note). Deliberately takes **no** `publicKeyJwk`: a decoy must not be wrappable, so nothing can be granted to it. |
| `DELETE` | `/v1/orgs/:orgId/honey-tokens/:honeyTokenId` | Delete a decoy, which stops it alerting. A hard delete — there are no grants to clear, and recorded trips live on in the append-only audit log. |

There is no endpoint that *trips* a honey token, because tripping isn't an
operation: presenting one as `Authorization: Bearer skt_…` on **any** route
records the trip, alerts the org's admins, and returns the same
`401 unknown service token` an unregistered token receives. The responses are
identical by construction so a decoy can't be identified by probing.

## Temporary access (leases)

Vault-style short-lived credentials. All routes are *(admin)*.

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/leases/broker-key` | The org broker's public key. Wrap the admin credential to it before registering a target. |
| `GET` | `/v1/orgs/:orgId/leases/targets` | List provisioning targets (never the wrapped admin secret's plaintext). |
| `POST` | `/v1/orgs/:orgId/leases/targets` | Register a target. **Postgres/MySQL/Redis:** `config` = provider, executor, connection, `accessLevel` `readonly`/`readwrite`/`custom` (Postgres adds `schema`; MySQL adds `userHost`; Redis's connection takes an optional `db` index), or custom create/revoke templates. **SSH:** `config` = `provider: "ssh"`, `executor: "in_do"`, `caPublicKey`, optional `allowedPrincipals`/`extensions`/`maxTtlSeconds`/`connection`. **AWS:** `config` = `provider: "aws"`, `executor: "in_do"`, `roleArn`, `region`, optional `externalId`/`sessionPolicy`/`maxTtlSeconds`. **GCP:** `config` = `provider: "gcp"`, `executor: "in_do"`, `serviceAccount`, optional `scopes`/`delegates`/`maxTtlSeconds`. **MongoDB:** `config` = `provider: "mongodb"`, `executor: "in_do"`, `connection`, `accessLevel` `readonly`/`readwrite`/`custom` (custom adds a `roles` array of `{role,db}`), optional `authSource`/`tls`/`maxTtlSeconds`. `wrappedAdminSecret` is the admin credential (a pg/mysql/redis/mongodb connection string, the SSH CA private key, the AWS base IAM credential as JSON `{accessKeyId,secretAccessKey}`, or the GCP service-account key JSON) wrapped to the broker key. |
| `DELETE` | `/v1/orgs/:orgId/leases/targets/:targetId` | Remove a target. |
| `GET` | `/v1/orgs/:orgId/leases` | List the lease ledger (status, resource ref, provider, expiry — no secret material). |
| `POST` | `/v1/orgs/:orgId/leases` | Mint a lease. The body is tagged by `provider` and must match the target. **Postgres:** `{ provider: "postgres", targetId, roleName, verifier, ttlSeconds }` → returns `connection` (host/port/db/username), never the password. **MySQL:** `{ provider: "mysql", targetId, roleName, verifier, ttlSeconds }` (verifier = `mysql_native_password` hash) → returns `connection`, never the password. **Redis:** `{ provider: "redis", targetId, roleName, verifier, ttlSeconds }` (verifier = lowercase-hex SHA-256 digest; `database` in the response is the logical db index) → returns `connection`, never the password. **SSH:** `{ provider: "ssh", targetId, publicKey, principals[], ttlSeconds }` → returns `ssh` (the signed `certificate`, `principals`, `caPublicKey`, expiry), never a private key. **AWS:** `{ provider: "aws", targetId, recipientPublicKey, ttlSeconds }` (`recipientPublicKey` = an ephemeral P-256 JWK; `ttlSeconds` 900–43200) → returns `aws` (`wrappedCredential` — the STS credential wrapped to `recipientPublicKey` — plus `region`, `roleArn`, expiry), never a plaintext credential. **GCP:** `{ provider: "gcp", targetId, recipientPublicKey, ttlSeconds }` (`recipientPublicKey` = an ephemeral P-256 JWK; `ttlSeconds` 60–43200) → returns `gcp` (`wrappedCredential` — the OAuth access token wrapped to `recipientPublicKey` — plus `serviceAccount`, `scopes`, expiry), never a plaintext token. **MongoDB:** `{ provider: "mongodb", targetId, recipientPublicKey, ttlSeconds }` (`recipientPublicKey` = an ephemeral P-256 JWK) → returns `mongodb` (`wrappedCredential` — the created user's password wrapped to `recipientPublicKey` — plus `host`, `port`, `database`, `authSource`, expiry), never a plaintext credential. |
| `DELETE` | `/v1/orgs/:orgId/leases/:leaseId` | Revoke a lease. Postgres/MySQL/Redis/MongoDB drop the role/user immediately; SSH, AWS, and GCP mark the ledger (the certificate / token stays valid until it expires). |

> **Note:** Postgres, MySQL, Redis, and SSH are tier 1 (verifier injection): the client generates the secret half locally and sends only a verifier — a **SCRAM verifier** (Postgres), a **`mysql_native_password` hash** (MySQL), a **SHA-256 digest** (Redis), or an **SSH public key** (SSH). None can authenticate on its own, so the plaintext password / private key never reaches the API. **AWS, GCP, and MongoDB are tier 2**: the target mints the credential (STS `AssumeRole`; GCP `generateAccessToken`; a MongoDB `createUser` password generated by the broker), so the broker wraps it to the consumer's ephemeral public key (`recipientPublicKey`) and returns only that ciphertext — decryptable solely on the requesting machine. The admin credential used to provision (a pg/mysql/redis/mongodb admin connection string, the SSH CA private key, the AWS base IAM credential, or the GCP service-account key) is wrapped to the broker Durable Object's public key and is never seen in plaintext by the control plane.

## Secret rotation

Managed, scheduled replacement of a stored secret's value. All routes are
*(admin)*. No route returns a secret value; a rotated value is read back through
the normal secret/resolve paths. See [Secret rotation](/docs/concepts/rotation).

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/rotation/rotator-key` | The rotator public key (the org broker's) plus `grantedEnvironmentIds` — the environments that already hold a rotator grant. Wrap an environment's DEK to this key client-side before configuring rotation. |
| `GET` | `/v1/orgs/:orgId/rotation` | List rotation policies: kind, cadence, status, last/next run, `lastError`. Never values. |
| `POST` | `/v1/orgs/:orgId/rotation` | Configure (or replace) a secret's policy. `{ environmentId, secretName, config, intervalSeconds, targetId?, wrappedDek?, rotateNow? }`. `config` is tagged by `kind`: `{ kind: "generated", length?, alphabet? }` contacts nothing; `{ kind: "postgres"\|"mysql"\|"redis", username, passwordLength?, statements? }` re-keys an **existing** account (`mysql` adds `userHost`) and requires a matching `targetId` from temporary access. `intervalSeconds` is 300–31536000. `wrappedDek` is the environment DEK wrapped to the rotator key — **required unless that environment already holds a rotator grant**, and impossible for the server to produce. `rotateNow` rotates once immediately and returns the new `version`. |
| `GET` | `/v1/orgs/:orgId/rotation/:rotationId` | One policy. |
| `PATCH` | `/v1/orgs/:orgId/rotation/:rotationId` | Change `intervalSeconds`, `config` (same kind only), or `status` (`active`/`paused`). Resuming clears the failure streak. |
| `POST` | `/v1/orgs/:orgId/rotation/:rotationId/rotate` | Rotate now — the same path the scheduler uses. Returns `{ rotation, version, rotatedAt }`. |
| `DELETE` | `/v1/orgs/:orgId/rotation/:rotationId` | Remove the policy; the secret and its versions are untouched. `rotatorRevoked` reports whether the environment's rotator grant was dropped (it is, once nothing there rotates). |

> **Note:** Rotation is the one path where a server-side component can encrypt into an environment — writing a new value requires the environment's data key. The boundary is one ordinary key grant: an `environment_keys` row with `principal_type = rotator`, holding the DEK wrapped to the per-org broker Durable Object's public key, whose private half never leaves DO storage (so a database dump is still only ciphertext). You create that grant from your own client — the API cannot — it covers only the environments where you enabled rotation, it is re-read on every rotation so revoking it halts rotation at once, and it is dropped when the environment's last policy goes away. For the database kinds the target still receives only a **verifier**, exactly as with temporary access.

## Audit

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/audit` | Paginated audit entries. *(admin)* Query: `cursor`, `limit`, `action`, `resourceType`. |

> **Note:** Token creation is client-driven: the client generates the keypair and token string locally and submits only the token id, a SHA-256 hash, and the public key. The server never sees the token secret.

## Plans & billing

Read the org's plan, current metered usage, and start self-serve billing. See the
[plans & billing guide](/docs/guides/billing).

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/billing` | The org's plan and entitlements. Returns `{ subscription, plan, promo, entitlements[], usage[], overrides[], enforced, manage }`. Readable by any member. |
| `POST` | `/v1/orgs/:orgId/billing/checkout` | Start a Checkout Session to upgrade. Body `{ family }` (a plan family). Returns `{ url }` to redirect the browser to. *(admin)* |
| `POST` | `/v1/orgs/:orgId/billing/portal` | Open the Billing Portal to manage/update payment. Returns `{ url }`. *(admin)* |
| `POST` | `/v1/orgs/:orgId/billing/cancel` | Downgrade to the Free (default) plan: cancels any active paid subscription in the biller and reverts the org to Free immediately. Returns `{ ok: true }`. *(admin)* |
| `POST` | `/v1/orgs/:orgId/billing/promo` | Redeem a promo code, moving the org onto the plan the code grants. Body `{ code }` (normalized server-side). Returns the same body as `GET /billing`. *(admin)* |

`usage[]` is the **metered** dimensions, each `{ key, metric, label, quantity, included, available }`
— `quantity` is the current usage for this period, `included` is what the plan covers before overage
(`null` = unlimited), and `available` is `false` when a metric can't be measured (shown as unknown,
not zero). `manage` reports which self-serve actions this deployment supports:
`{ checkout, checkoutFamilies[], portal }`.

> **Note:** An organization can **pay** for a higher plan (checkout/portal), but it can't **grant** itself one — comped plan assignment and per-org entitlement overrides are an operator-only surface, not reachable by org admins. While `enforced` is `false`, plan limits are informational: every org has full access and the usage numbers preview what a plan will include once enforcement is on.

## Audit log export (OTLP)

Stream an organization's audit trail to your own SIEM/observability tooling. See
the [audit log export guide](/docs/guides/audit-export).

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/log-sink` | Get the org's OTLP log-sink config: `endpoint`, `enabled`, `headerNames`, and delivery status (`lastSuccessAt`, `lastAttemptAt`, `lastError`). Header **values** are never returned. *(admin)* |
| `PUT` | `/v1/orgs/:orgId/log-sink` | Configure the sink: `{ endpoint, headers?, enabled? }`. `endpoint` is the OTLP/HTTP logs URL; `headers` (e.g. `{ "Authorization": "Bearer …" }`) is encrypted at rest — omit it to leave the stored value unchanged, pass `{}` to clear it. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/log-sink` | Remove the sink. *(admin)* |
| `POST` | `/v1/orgs/:orgId/log-sink/test` | Synchronously POST a synthetic record to the endpoint and return `{ ok, status, error }`. *(admin)* |

> **Note:** Delivery is **at-least-once** and near-real-time: an every-minute sweep ships new audit rows to your endpoint as OTLP log records, advancing a per-org watermark only after the collector accepts a batch (a transient outage re-ships rather than drops). Records carry attribution and the audit log's redacted metadata only — **never** secret values, ciphertext, or credentials.

## Agent access policy

Which upstream each agent may reach, with which credential, for which operations.
The policy bundle is signed in the publishing admin's browser and is **opaque to
the API** — see the [agent access policy guide](/docs/guides/agent-proxy/policy).

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/agents` | List agent identities: name, slug, bound environment, live policy version, and when a proxy last fetched it. *(admin)* |
| `POST` | `/v1/orgs/:orgId/agents` | Create an identity: `{ name, slug, environmentId? }`. Holds no key material and can never be granted any — it is a policy subject only. *(admin)* |
| `GET` | `/v1/orgs/:orgId/agents/:agentId` | One identity, plus its bound environment's slug. *(admin)* |
| `PATCH` | `/v1/orgs/:orgId/agents/:agentId` | Update `{ name?, enabled?, environmentId? }`. Disabling is the revocation path: policy fetches start failing, and each proxy's current bundle expiry bounds the window. Audits `agent.updated`. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/agents/:agentId` | Delete the identity and its published versions. Audits `agent.deleted`. *(admin)* |
| `GET` | `/v1/orgs/:orgId/agents/:agentId/policies` | Published versions (newest first, 50 max), each with its signed `bundle`, signer thumbprint, expiry, and decoded `rules`. *(admin)* |
| `POST` | `/v1/orgs/:orgId/agents/:agentId/policies` | Publish `{ bundle }` — an `ap1.<body>.<signature>` envelope signed client-side. Requires a **user session**: an `admin` service token cannot publish, so an agent cannot widen its own policy. The bundle must be signed by the caller's own key, name this org/agent/slug, carry `policy_version` = current + 1 (else `409`), and expire between 1 hour and 90 days out. Audits `agent.policy_published` with the signer and version. *(admin)* |
| `POST` | `/v1/orgs/:orgId/agents/:agentId/policies/:version/rollback` | Republish an earlier version's bundle as a new version — a roll-*forward*, so nothing is re-signed and the restored bundle keeps its original expiry. Audits `agent.policy_rolled_back`. *(admin)* |
| `GET` | `/v1/orgs/:orgId/agents/signers/me` | Your own signing thumbprint (RFC 7638), for the trust-anchor snippet: `{ "signer": { userId, thumbprint } \| null }`. *(admin)* |
| `GET` | `/v1/agents/:agentRef/policy` | **The proxy's read path.** `agentRef` is the identity's id or slug, scoped to the caller's org. Returns `{ bundle, version, expiresAt, agent, signerThumbprint }` with an `ETag`; send `If-None-Match` and a steady state costs a `304`. Requires a service token (or a user session, for debugging) — a machine (M2M) credential is refused. `403` when the identity is disabled or the token is bound to a different environment; `404` when nothing is published yet. Not audited per fetch (it is a poll, like `/v1/resolve`); it records `lastPolicyFetchAt`, throttled to a minute. |

> **Warning:** The bundle is stored and served verbatim: the API cannot forge one, because the signature is made in a browser with a key it never holds. A proxy verifies every bundle against signer thumbprints pinned in its **own local file** before acting on a rule, so withholding policy makes a proxy fail closed — and widening it is not something this API can do.

## Third-party sync

Push an environment's resolved secrets to a platform that holds its own copy.
See the [third-party sync guide](/docs/guides/third-party-sync).

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/orgs/:orgId/sync/connections` | List sync connections. Returns `publicKeyJwk` and status; **never** the wrapped credential. *(admin)* |
| `GET` | `/v1/orgs/:orgId/sync/connections/:connectionId/public-key` | Mint (or return) the keypair for a connection id, before the connection exists. Wrap the destination credential and the environment DEKs to this key. *(admin)* |
| `POST` | `/v1/orgs/:orgId/sync/connections` | Create a connection: `{ id?, name, config, wrappedCredential }`. `config` is tagged by `provider`: `{ provider: "vercel", teamId? }`, `{ provider: "cloudflare-workers" \| "cloudflare-pages" \| "cloudflare-secrets-store", accountId }` (32 hex), `{ provider: "railway", tokenKind }` (`account` \| `project`, default `account` — it selects the auth header), `{ provider: "aws-secrets-manager" \| "aws-parameter-store", region, accessKeyId }`, `{ provider: "render" }` (a Render API key is user-scoped — no account field), `{ provider: "fly" }` (nothing to scope either: a Fly app name is globally unique, and the auth scheme is read from the token), `{ provider: "northflank" }` / `{ provider: "digitalocean" }` / `{ provider: "heroku" }` (also empty — a Northflank token carries its own team scope, a DigitalOcean token its own account, and a Heroku token its user's access to every app they can reach), `{ provider: "netlify", accountId }` (the team slug or account ID — Netlify keeps environment variables on the account, and a personal access token does not say which team to write), `{ provider: "bunnyshell" }` (empty again — a Bunnyshell access token carries its user's access to every organization they belong to, and both variable collections name their parent by a globally unique ID), or `{ provider: "github-actions", baseUrl? }` (empty for github.com too — a GitHub token addresses everything by `owner/repo` or `org`, which the destination names; `baseUrl` points the connection at a self-hosted **GitHub Enterprise Server** API root and must be `https`), `{ provider: "gcp-secret-manager", projectId }` (the project ID or number whose Secret Manager to write — a service-account key names its own identity, but can be granted secrets in projects other than its own), or `{ provider: "langgraph-platform", region?, baseUrl?, tenantId? }` (all three optional and all about *where the control plane is*: `region` is one of `us` (the default), `eu`, `apac`, `aws-us`, and matters because a LangSmith key minted in one region is refused by another with a bare `401`; `baseUrl` points the connection at a self-hosted LangSmith control plane, must be `https`, and is rejected together with `region`; `tenantId` is the workspace UUID, needed only for an organization-scoped key, which reaches several workspaces). `wrappedCredential` is the destination's credential wrapped to the connection's public key — an API token, for AWS the **secret access key** alone (the access key id is an identifier and lives in `config`), and for GCP the whole **service-account key JSON**. Pass the same `id` you fetched the key for. *(admin)* |
| `POST` | `/v1/orgs/:orgId/sync/connections/:connectionId/verify` | Test the stored credential against `{ destination }`. Returns `{ ok: true }` or `400 { ok: false, error }`. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/sync/connections/:connectionId` | Delete the connection, its bindings, and every `sync` key grant made to it, then destroy its keypair. *(admin)* |
| `GET` | `/v1/orgs/:orgId/sync/bindings` | List bindings with their destination, filters, and last-run status. *(admin)* |
| `POST` | `/v1/orgs/:orgId/sync/bindings` | Enable sync for one environment: `{ connectionId, environmentId, destination, nameTransform?, include?, exclude?, onDelete?, mode?, wrappedDeks, acknowledgedDecryption }`. `destination` is tagged by `provider` and must match the connection: **vercel** `{ projectId, targets[], gitBranch? }`; **cloudflare-workers** `{ scriptName }`; **cloudflare-pages** `{ projectName, environments[] }` (`production`/`preview`); **cloudflare-secrets-store** `{ storeId, scopes[] }`; **railway** `{ projectId, environmentId, serviceId?, skipDeploys? }` (all UUIDs; omit `serviceId` to write the environment's shared variables); **aws-secrets-manager** `{ layout: "secret-per-name" \| "json-bundle", pathPrefix?, secretName?, kmsKeyId? }` (`secretName` required for `json-bundle`); **aws-parameter-store** `{ path, type: "SecureString" \| "String", tier, kmsKeyId? }` (`path` starts and ends with `/`); **render** `{ kind: "service", serviceId }` or `{ kind: "env-group", envGroupId }`; **fly** `{ appName }`; **northflank** `{ projectId, secretGroupId }` (both are the slugs from their URLs); **digitalocean** `{ kind: "app", appId, scope? }` or `{ kind: "component", appId, componentName, scope? }` (`appId` is the App Platform app UUID; `scope` is `RUN_TIME` (default), `BUILD_TIME`, or `RUN_AND_BUILD_TIME`); **heroku** `{ app }` (the app name or its UUID); **netlify** `{ siteId, contexts[], branch?, secret? }` (`siteId` is the site's API ID — a UUID, since Netlify resolves no site names on this endpoint and an unresolved one writes to the whole team; `contexts` is one or more of `production`, `deploy-preview`, `branch-deploy`, `branch`, `dev`, with `branch` requiring `branch`; `secret` defaults to `true` and marks what seekrit **creates** as a write-only Netlify secret); **bunnyshell** `{ kind: "environment", environmentId, secret? }` or `{ kind: "project", projectId, secret? }` (IDs are Bunnyshell's own opaque strings, from `bns environments list` / `bns projects list`; a `project` binding's values are inherited by every environment created in it *after* the push, which is how ephemeral environments get seeded, and existing ones are untouched; `secret` defaults to `true` and sets `isSecret` on variables seekrit **creates**). **github-actions** `{ kind: "repo", owner, repo }`, `{ kind: "environment", owner, repo, environment }`, or `{ kind: "org", org, visibility, selectedRepositoryIds? }` — the three scopes are three endpoints with three blast radii and three separate encryption keys; `visibility` is `all` \| `private` \| `selected` and has **no default** (`all` reaches every repository in the org, including ones added later), and `selectedRepositoryIds` is required, non-empty, and numeric for `selected` and rejected otherwise. **gcp-secret-manager** `{ layout: "secret-per-name" \| "json-bundle", idPrefix?, secretId?, replication: "automatic" \| "user-managed", locations?, kmsKeyName?, pruneVersions? }` (`secretId` required for `json-bundle`; `idPrefix` takes only `[A-Za-z0-9_-]` — a Secret Manager ID has no slashes; `locations` required for `user-managed` replication, which is immutable once a secret exists; `kmsKeyName` is a full Cloud KMS resource name and, keys being regional, is accepted only with `automatic` replication or a single location; `pruneVersions` destroys the version each push supersedes, and only ever one seekrit itself wrote). **langgraph-platform** `{ deploymentId }` (the deployment UUID — a deployment's secrets belong to the deployment, so there is nothing narrower to address, and every write creates a new **revision**, which rebuilds and rolls out the Agent Server). `wrappedDeks` is one `{ environmentId, wrappedDek }` per environment the run reads — the target **plus every composed group environment**. `acknowledgedDecryption` must be `true`. *(admin)* |
| `PATCH` | `/v1/orgs/:orgId/sync/bindings/:bindingId` | Update destination, filters, `onDelete`, `mode`, or `enabled`. Cannot move a binding to another environment or connection — delete and recreate, so a fresh grant and acknowledgment are required. *(admin)* |
| `DELETE` | `/v1/orgs/:orgId/sync/bindings/:bindingId` | Delete the binding and revoke its environment's grant to that connection (unless another binding still needs it). *(admin)* |
| `POST` | `/v1/orgs/:orgId/sync/bindings/:bindingId/run` | Push now, synchronously. Returns `{ run: { id, status, pushed[], deleted[], failures[], error } }`. *(admin)* |
| `GET` | `/v1/orgs/:orgId/sync/runs` | Run history (optionally `?bindingId=`), newest first. Carries destination key **names**, counts, and error strings — never values. *(admin)* |

> **Warning:** Sync is the one feature where seekrit's servers decrypt. It applies only to environments with a `sync` key grant, and only for the destination that grant names. The grant is computed client-side, so the API cannot create one on its own — `POST /sync/bindings` fails without `wrappedDeks`. Every run writes a durable `sync.run_succeeded` / `sync.run_failed` audit row.
