Workspaces
The vendor's account: created once, then read and updated.
packages/api/src/resources/workspaces.controller.ts by packages/docs/tools/generate-reference.mjs. Every example is a real exchange recorded against the control plane by packages/api/src/testing/reference-examples.spec.ts, with ids from that run. Credentials in the formats this product issues (kr_, krsk_, krses_, JWTs) and values under credential-named fields (password, *_secret, *_token, *_key, lookup_hash) are elided at record time; the recorded file and every page of this site are swept for credential shapes (kr_, krsk_, krses_, JWTs, URLs carrying a password, password fields) before either is accepted. Do not edit by hand: src/reference.spec.ts regenerates it and fails on a difference.POST /v1/workspaces
The one endpoint a secret key cannot authenticate, because it is what mints
the first one. The workspace identifier is generated first so the insert
can run under the workspace's own scope: workspace's policy is on id,
so a row can only be created by a session already scoped to it -- which
means even this handler never needs an owner connection.
Two credentials reach it (WorkspaceCreationGuard), and they produce
deliberately different workspaces. A person becomes its owner, which is
the self-serve path a dashboard needs. The bootstrap token creates one
with no members at all, which is what a scripted deployment wants and what
week 2 shipped -- a seeded workspace whose first human arrives by invitation.
A person whose session was in no workspace lands in the one they just made,
exactly as POST /v1/auth/invites/accept does. One already working
somewhere is not moved out from under themselves.
Answers 201 on success.
Authentication.
- A signed-in person's
krses_session (who becomes the owner), orKEYRING_BOOTSTRAP_TOKEN(a workspace with no members).
Body. Validated by this schema, from the control plane's own source:
const CreateWorkspace = z.object({
name: Name,
slug: Slug,
billing_email: z.email().optional(),
env: Env.default('live'),
});Example.
curl https://api.keyringdev.com/v1/workspaces \
-X POST \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Payments",
"slug": "acme-payments",
"billing_email": "billing@acme.example"
}'HTTP/1.1 201 Created
Content-Type: application/json
{
"workspace": {
"object": "workspace",
"id": "01a0fac2-a367-76e5-9af4-ead368f3c163",
"name": "Acme Payments",
"slug": "acme-payments",
"billing_email": "billing@acme.example",
"embed_origins": [],
"created_at": "2026-10-02T03:57:34.951Z",
"updated_at": "2026-10-02T03:57:34.951Z"
},
"secret_key": {
"object": "secret_key",
"id": "01a0fac2-a370-7f7c-9ce3-e89fd96c0d68",
"env": "live",
"display_prefix": "krsk_live_yOsAPB",
"name": "Bootstrap secret key",
"meta": {},
"expires_at": null,
"revoked_at": null,
"revoked_reason": null,
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": null,
"created_at": "2026-10-02T03:57:34.951Z",
"last_used_at": null,
"key": "krsk_live_yOsAPB…",
"key_shown_once": true
},
"membership": {
"user_id": "01a0fac2-a2cf-7c9a-af8f-6200bd2eea4e",
"role": "owner"
}
}GET /v1/workspace
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Example.
curl https://api.keyringdev.com/v1/workspace \
-H "Authorization: Bearer $KEYRING_SECRET_KEY"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "workspace",
"id": "01a0fac2-a367-76e5-9af4-ead368f3c163",
"name": "Acme Payments",
"slug": "acme-payments",
"billing_email": "billing@acme.example",
"embed_origins": [],
"created_at": "2026-10-02T03:57:34.951Z",
"updated_at": "2026-10-02T03:57:34.951Z"
}Example: With a revoked secret key.
curl https://api.keyringdev.com/v1/workspace \
-H "Authorization: Bearer $KEYRING_SECRET_KEY"HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": {
"code": "invalid_key",
"message": "Invalid API key."
}
}PATCH /v1/workspace
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Body. Validated by this schema, from the control plane's own source:
const UpdateWorkspace = z.object({
name: Name.optional(),
billing_email: z.email().nullable().optional(),
embed_origins: EmbedOrigins.optional(),
});Report section 7.3: the origins an embed token may be presented from. The
database checks the same shape (keyring.embed_origins_valid, migration
0010) and keeps checking it whatever writes the column; this is here so a bad
entry is a 400 naming the field rather than a 500 wrapping a constraint
violation.
A serialised origin and nothing else: no path, no trailing slash, no *.
Anything else compares unequal to what a browser puts in Origin, so
registering one would buy a control that silently never matches.
const EmbedOrigins = z
.array(
z
.string()
.max(255)
.regex(
/^https?:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/,
'must be a scheme, host and optional port with no path or trailing slash',
),
)
.max(20)
.refine((origins) => new Set(origins).size === origins.length, {
message: 'embed origins must be unique',
});Example.
curl https://api.keyringdev.com/v1/workspace \
-X PATCH \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"embed_origins": [
"https://app.acme.example"
]
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "workspace",
"id": "01a0fac2-a367-76e5-9af4-ead368f3c163",
"name": "Acme Payments",
"slug": "acme-payments",
"billing_email": "billing@acme.example",
"embed_origins": [
"https://app.acme.example"
],
"created_at": "2026-10-02T03:57:34.951Z",
"updated_at": "2026-10-02T03:57:35.004Z"
}DELETE /v1/workspace
Soft delete of the workspace: every kr_ key is revoked and every tenant
soft deleted, while the krsk_ keys are kept (see below).
Needs a signed-in person who owns the workspace: a krsk_ secret key is
refused with 403, however live it is.
Every kr_ key is revoked through cascadeRevoke, so the policy feed gets
its tombstones, and every tenant is soft deleted and its embed_epoch
bumped and published after commit (EmbedEpochService), which is what makes
an embed token minted before the delete stop verifying, cache or no cache.
Pending invitations are revoked. The workspace's krsk_ keys are
deliberately not revoked: the SDK fleet authenticates its policy poll
with one, and revoking it would strand the tombstones no node could then
fetch. PrincipalGuard and the ingest
handler refuse a deleted workspace's krsk_ instead, so it reaches the
policy plane, and the rate-limit and idempotency planes for
KEYRING_DELETED_WORKSPACE_HOT_GRACE_S (7 days by default, so at the
defaults an idempotency record written before the delete never outlives
the window), and nothing else. People stop
resolving the workspace on their own: the membership and switch reads join
on w.deleted_at is null.
Billing: a live Stripe subscription is cancelled immediately, before anything is written, with no proration and no refund. Leaving it running would keep charging a card for a workspace nobody can reach, and its webhooks resolve through the customer id, which this delete does not touch. Stripe going first means a failure there leaves the workspace whole and the caller retrying; the reverse would strand a paid subscription with no owner. A cancel that succeeded followed by a failed write is safe to retry -- cancelling is idempotent. If the subscription is live and this deployment has no Stripe configured, the delete is refused rather than orphaning it. The slug stays taken.
Answers 200 on success.
Authentication.
- A
krses_dashboard session of a workspace owner only. Akrsk_secret key is refused with403 person_required.
Example.
curl https://api.keyringdev.com/v1/workspace \
-X DELETE \
-H "Authorization: Bearer $SESSION_TOKEN"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "workspace",
"id": "01a0fac2-a8cf-7c7c-b419-65393568a3c0",
"deleted": true,
"revoked_keys": 0,
"subscription_cancelled": false
}Shared validators
Defined once in packages/api/src/validation.ts and used by the schemas above.
const Env = z.enum(['live', 'test']);const Name = z.string().min(1).max(200);const Slug = z.string().regex(
/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/,
// The length floor is in the pattern and invisible from outside it, so the
// message says it: "p1" is rejected and "must be a lowercase slug" does not
// explain why.
'must be 3 to 63 characters of lowercase letters, digits and hyphens, starting and ending with a letter or digit',
);