Keyring

Workspaces

The vendor's account: created once, then read and updated.

Generated from 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), or KEYRING_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.

Request
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"
  }'
Response
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 a krses_ dashboard session, as Authorization: Bearer.

Example.

Request
curl https://api.keyringdev.com/v1/workspace \
  -H "Authorization: Bearer $KEYRING_SECRET_KEY"
Response
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.

Request
curl https://api.keyringdev.com/v1/workspace \
  -H "Authorization: Bearer $KEYRING_SECRET_KEY"
Response
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 a krses_ dashboard session, as Authorization: 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.

Request
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"
    ]
  }'
Response
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. A krsk_ secret key is refused with 403 person_required.

Example.

Request
curl https://api.keyringdev.com/v1/workspace \
  -X DELETE \
  -H "Authorization: Bearer $SESSION_TOKEN"
Response
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',
);

On this page