Platform docs
API reference

Platform API.

The administrative surface that powers the console — workspaces, members, roles, service activation, PATs, audit, introspection. Generated from the live OpenAPI spec.

Browser sessions authenticate via the auth_token cookie set by /v1/auth/signin. CI/CD and other programmatic callers authenticate via a workspace API key (pcft_live_*) minted on the console's API keys page — pass it as Authorization: Bearer <token>. The policies bound to the key determine which actions are allowed, and a key can never carry more than the minter's own permissions. Signed-in users are checked against their workspace role's policy instead. Minting and revoking keys requires a signed-in session — a key cannot mint other keys.

Base URL

https://api.platform-auth.productcraft.co

Auth

Authorization: Bearer …

Spec version

OpenAPI 3.0.0 · v1.0.0

Authentication

post/v1/auth/signup

Create a new account and sign in

Request body

email*string

User email address

Example: "user@example.com"

username*string

Unique username (letters, numbers, dot, underscore, hyphen)

Example: "john_doe"

password*string

User password (min 8 characters)

Example: "MySecurePassword123"

display_namestring

Display name

Example: "John Doe"

session_durationobject

Session duration: "short" (24h), "long" (90d), or integer seconds (3600–7776000). Defaults to 30 days.

Example: "long"

Response · 200 Token response

access_token*string

Signed JWT, audience-bound to the resource indicator and carrying the workspace, the preset name and the client id. It carries no policy — authority is resolved per request from the grant row and the human’s live policy.

token_type*string

Example: "Bearer"

expires_in*number

Seconds until the access token expires (≤ 900).

Example: 900

refresh_token*string

Rotating refresh token. The one presented is consumed; replaying it revokes the whole grant family.

scope*string

Preset name(s) the grant started from. Present for RFC 6749 §5.1 compliance; it is not the authority — `policy` is.

Example: "mail"

policy*array

The policy this token carries. Effective authority is this intersected with the authorizing human's current workspace policy, re-evaluated on every call.

workspace_id*string · uuid

Workspace the grant is scoped to.

Example

Request

POST /v1/auth/signup
Content-Type: application/json

{
  "email": "user@example.com",
  "username": "john_doe",
  "password": "MySecurePassword123",
  "display_name": "John Doe",
  "session_duration": "long"
}

Response

{
  "access_token": "string",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "string",
  "scope": "mail",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "workspace_id": "00000000-0000-0000-0000-000000000000"
}
post/v1/auth/signin

Sign in with email/username and password

Every failed attempt answers `401 INVALID_CREDENTIALS`, whatever went wrong: unknown identifier, wrong password, an account with no password credential, an account locked out through a different identifier, an account an operator has blocked, and a wrong password against a suspended or deactivated account. The responses are deliberately identical in body, timing, DB round trips and Redis ops, so signin is not an account-existence oracle. See core/block/CLAUDE.md.

Request body

identifier*string

Email or username

Example: "user@example.com"

password*string

User password

Example: "MySecurePassword123"

session_durationobject

Session duration: "short" (24h), "long" (90d), or integer seconds (3600–7776000). Defaults to 30 days.

Example: "short"

Response · 200 Token response

access_token*string

Signed JWT, audience-bound to the resource indicator and carrying the workspace, the preset name and the client id. It carries no policy — authority is resolved per request from the grant row and the human’s live policy.

token_type*string

Example: "Bearer"

expires_in*number

Seconds until the access token expires (≤ 900).

Example: 900

refresh_token*string

Rotating refresh token. The one presented is consumed; replaying it revokes the whole grant family.

scope*string

Preset name(s) the grant started from. Present for RFC 6749 §5.1 compliance; it is not the authority — `policy` is.

Example: "mail"

policy*array

The policy this token carries. Effective authority is this intersected with the authorizing human's current workspace policy, re-evaluated on every call.

workspace_id*string · uuid

Workspace the grant is scoped to.

Example

Request

POST /v1/auth/signin
Content-Type: application/json

{
  "identifier": "user@example.com",
  "password": "MySecurePassword123",
  "session_duration": "short"
}

Response

{
  "access_token": "string",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "string",
  "scope": "mail",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "workspace_id": "00000000-0000-0000-0000-000000000000"
}
post/v1/auth/refresh

Refresh access token using refresh token

Request body

refresh_token*string

Refresh token (JWT)

Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Response · 200 Token response

access_token*string

Signed JWT, audience-bound to the resource indicator and carrying the workspace, the preset name and the client id. It carries no policy — authority is resolved per request from the grant row and the human’s live policy.

token_type*string

Example: "Bearer"

expires_in*number

Seconds until the access token expires (≤ 900).

Example: 900

refresh_token*string

Rotating refresh token. The one presented is consumed; replaying it revokes the whole grant family.

scope*string

Preset name(s) the grant started from. Present for RFC 6749 §5.1 compliance; it is not the authority — `policy` is.

Example: "mail"

policy*array

The policy this token carries. Effective authority is this intersected with the authorizing human's current workspace policy, re-evaluated on every call.

workspace_id*string · uuid

Workspace the grant is scoped to.

Example

Request

POST /v1/auth/refresh
Content-Type: application/json

{
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Response

{
  "access_token": "string",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "string",
  "scope": "mail",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "workspace_id": "00000000-0000-0000-0000-000000000000"
}
post/v1/auth/logout

Logout by revoking refresh token

Request body

refresh_token*string

Refresh token (JWT)

Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Response · 204

Logout successful (no content)

Example

Request

POST /v1/auth/logout
Content-Type: application/json

{
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
post/v1/auth/logout-currentAuth

Revoke the server-side session tied to the caller’s current access token

Response · 204

Logout successful (no content)

post/v1/auth/password/request-reset

Request a password reset email. Neither the response nor its timing reveals whether the email matched an account.

Request body

email*string

Email address to send reset token

Example: "user@example.com"

Response · 204

Reset request accepted. Identical whether or not the email matched an account — in status, body, cost, and in what the server is left doing afterwards (see PasswordService.requestPasswordReset). Accepted is not delivered: if the address matched an account the mail was handed to the broker, but a broker outage is logged server-side and still answers 204, so never treat this status as proof an email was sent.

Example

Request

POST /v1/auth/password/request-reset
Content-Type: application/json

{
  "email": "user@example.com"
}
post/v1/auth/password/reset

Reset password using the email-delivered reset token.

Request body

token*string

6-digit reset token sent via email

Example: "123456"

new_password*string

New password (min 8 characters)

Example: "MyNewPassword123"

Response · 204

Password reset; all active sessions revoked.

Example

Request

POST /v1/auth/password/reset
Content-Type: application/json

{
  "token": "123456",
  "new_password": "MyNewPassword123"
}

OAuth Sign-In

get/v1/auth/oauth/{provider}/start

Begin a federated sign-in flow; 302s to the provider authorize URL.

Path parameters

provider*enum (3)

OAuth provider id. Providers are gated on env-var configuration; unconfigured providers 400 with UNKNOWN_OAUTH_PROVIDER.

Query parameters

return_to*string

Absolute URL the user lands on after a successful signin. Must match the `OAUTH_RETURN_TO_ORIGINS` allowlist (CSV, wildcards supported as `https://*.productcraft.co`).

Response · 302

Redirect to provider authorize URL.

post/v1/auth/oauth/{provider}/callback

Complete a federated sign-in. auth-ui proxies the provider redirect (query params for Google/GitHub, form_post body for Apple) into this endpoint and uses the returned `tokens` to set the auth_token cookie.

Path parameters

provider*enum (3)

Request body

state*string

Opaque state token returned by the IdP, minted at `/start`. Single-use; binds the callback to a specific browser session.

id_tokenstring

JWT id_token from the IdP (OIDC providers; Apple form_post). Optional — either `idToken` or `code` must be supplied.

codestring

OAuth authorization code returned by the IdP. Optional — either `idToken` or `code` must be supplied.

userstring

Apple-only first-signin payload: JSON-encoded `{ name, email }`. Apple returns this only the first time a user signs in. Not signed — treated as untrusted input by the controller's payload parser.

Response · 200

tokens*object

Issued PlatformUser session tokens. auth-ui sets the auth_token cookie from `accessToken` and 302s the browser to `return_to`.

return_to*string

Original `return_to` the user was sent off with. auth-ui redirects here after writing the cookie.

Example

Request

POST /v1/auth/oauth/{provider}/callback
Content-Type: application/json

{
  "state": "string",
  "id_token": "string",
  "code": "string",
  "user": "string"
}

Response

{
  "tokens": {},
  "return_to": "string"
}

Introspect

get/v1/introspect

Introspect current user with workspace list

Response · 200

is_authenticated*boolean
principalobject
accountobject
workspacesarray

Example

Request

GET /v1/introspect

Response

{
  "is_authenticated": false,
  "principal": {},
  "account": {},
  "workspaces": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "slug": "string",
      "display_name": "string",
      "role": "owner",
      "services": [
        "mail",
        "waitlist"
      ],
      "avatar_url": "string"
    }
  ]
}
get/v1/introspect/workspaces/{workspace_id}

Introspect caller's membership + effective policy + enabled services in a workspace

Path parameters

workspace_id*string

Response · 200

idstring · uuid

Canonical workspace UUID. Only present for members — non-members get a uniform low-information response.

is_member*boolean
role*object
policy*array

Effective IAM-style policy for this caller in this workspace.

services*array

Enabled services on this workspace.

Example: ["mail","waitlist"]

Example

Request

GET /v1/introspect/workspaces/{workspace_id}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "is_member": false,
  "role": {},
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "services": [
    "mail",
    "waitlist"
  ]
}
post/v1/introspect/api-key

Resolve a Platform API Key (PAK) to its workspace + IAM-style policy. Downstream services use this with a 60s LRU cache and evaluate the policy via @repo/authz-nestjs evaluatePolicy().

Headers

authorization*string

Response · 201

is_valid*boolean
workspace_id*string · uuid
policy*array

Merged effective policy across every managed policy bound to this PAK.

created_by*string · uuid
api_key_id*string · uuid

Example

Request

POST /v1/introspect/api-key

Response

{
  "is_valid": false,
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "created_by": "00000000-0000-0000-0000-000000000000",
  "api_key_id": "00000000-0000-0000-0000-000000000000"
}

Workspaces

get/v1/workspaces

List workspaces the current user belongs to

Query parameters

limit*string
cursor*string

Response · 200

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

Example

Request

GET /v1/workspaces

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "slug": "acme-corp",
      "display_name": "Acme Corp",
      "created_by": "00000000-0000-0000-0000-000000000000",
      "status": "active",
      "settings": {},
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z",
      "member_role_id": "00000000-0000-0000-0000-000000000000",
      "member_role": "owner"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
post/v1/workspaces

Create a new workspace. Cookie/JWT only — PAKs are workspace-scoped credentials and cannot create new workspaces.

Request body

slug*string

URL-friendly slug. Lowercase alphanumerics, separated by hyphens. Used in URLs and as a stable handle.

Example: "acme-corp"

display_name*string

Display name shown in console and on receipts.

Example: "Acme Corp"

Response · 201

id*string · uuid
slug*string

Example: "acme-corp"

display_name*string

Example: "Acme Corp"

created_by*string · uuid
status*string

Workspace status (e.g. `active`).

Example: "active"

settings*object

Free-form workspace settings blob.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces
Content-Type: application/json

{
  "slug": "acme-corp",
  "display_name": "Acme Corp"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "slug": "acme-corp",
  "display_name": "Acme Corp",
  "created_by": "00000000-0000-0000-0000-000000000000",
  "status": "active",
  "settings": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_slug}

Get workspace details

Path parameters

workspace_slug*string

Response · 200

id*string · uuid
slug*string

Example: "acme-corp"

display_name*string

Example: "Acme Corp"

created_by*string · uuid
status*string

Workspace status (e.g. `active`).

Example: "active"

settings*object

Free-form workspace settings blob.

created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_slug}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "slug": "acme-corp",
  "display_name": "Acme Corp",
  "created_by": "00000000-0000-0000-0000-000000000000",
  "status": "active",
  "settings": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_slug}

Update workspace display name, slug, or settings. Slug renames broadcast a `workspace.renamed` event so denormalised consumers (waitlist-api) sync.

Path parameters

workspace_slug*string

Request body

display_namestring
slugstring

New URL-friendly slug. Same constraints as the create form. Renames are broadcast on the workspace events queue so denormalised consumers (waitlist-api, future product surfaces) can sync. Old URLs continue to resolve for one rename cycle.

Example: "acme"

settingsobject

Free-form workspace settings blob.

Response · 200 Workspace updated.

id*string · uuid
slug*string

Example: "acme-corp"

display_name*string

Example: "Acme Corp"

created_by*string · uuid
status*string

Workspace status (e.g. `active`).

Example: "active"

settings*object

Free-form workspace settings blob.

created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_slug}
Content-Type: application/json

{
  "display_name": "string",
  "slug": "acme",
  "settings": {}
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "slug": "acme-corp",
  "display_name": "Acme Corp",
  "created_by": "00000000-0000-0000-0000-000000000000",
  "status": "active",
  "settings": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_slug}

Delete a workspace (requires workspace.delete). Cookie/JWT only — irreversible workspace destruction needs a human-attributed audit row.

Path parameters

workspace_slug*string

Response · 204

Workspace deleted.

post/v1/workspaces/{workspace_slug}/avatar

Upload (or replace) the workspace identity avatar. Gated by `workspace.settings.update` (owner + admin). PNG/JPEG/WEBP only, ≤2 MB, magic-byte sniffed. SVG is rejected (stored-XSS).

Path parameters

workspace_slug*string

Response · 200

avatar_url*string

Example

Request

POST /v1/workspaces/{workspace_slug}/avatar

Response

{
  "avatar_url": "string"
}
delete/v1/workspaces/{workspace_slug}/avatar

Clear the workspace avatar and delete the stored object. Gated by `workspace.settings.update`.

Path parameters

workspace_slug*string

Response · 204

Avatar cleared.

get/v1/workspaces/{workspace_slug}/members

List workspace members, oldest join first. Narrow with `?q=` (name / username / email substring) and/or `?role=`. Cursor-paginated.

Path parameters

workspace_slug*string

Query parameters

limitstring

Page size. Defaults to 20, clamped to 100.

cursorstring

Opaque `next_cursor` from the previous page.

qstring

Case-insensitive substring matched against the member’s display name, username OR primary email. `%` and `_` are matched literally.

rolestring

Exact role name the membership is bound to — `owner`, `admin`, `member`, or a custom role’s name. Composes with `q` and with the cursor.

Response · 200

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

Example

Request

GET /v1/workspaces/{workspace_slug}/members

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "account_id": "00000000-0000-0000-0000-000000000000",
      "username": "string",
      "display_name": "string",
      "primary_email": "string",
      "avatar_url": "string",
      "role_id": "00000000-0000-0000-0000-000000000000",
      "role": "string",
      "role_is_system": false,
      "joined_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
patch/v1/workspaces/{workspace_slug}/members/{account_id}/role

Update a member's role. Body: { roleId } or { role: "owner"|"admin"|"member" }.

Path parameters

workspace_slug*string
account_id*string

Request body

role_idstring

UUID of the role to assign. Use this to target custom roles.

Example: "11111111-1111-4111-8111-111111111111"

roleenum (3)

System role shortcut: 'owner' | 'admin' | 'member'. Either `roleId` or `role` must be supplied.

Response · 200

id*string · uuid
workspace_id*string · uuid
account_id*string · uuid
role_id*string · uuid
invited_by*string · uuid
joined_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_slug}/members/{account_id}/role
Content-Type: application/json

{
  "role_id": "11111111-1111-4111-8111-111111111111",
  "role": "owner"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "account_id": "00000000-0000-0000-0000-000000000000",
  "role_id": "00000000-0000-0000-0000-000000000000",
  "invited_by": "00000000-0000-0000-0000-000000000000",
  "joined_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_slug}/members/{account_id}

Remove a member from a workspace (or self-leave)

Path parameters

workspace_slug*string
account_id*string

Response · 204

Member removed.

post/v1/workspaces/invites/accept

Accept a workspace invite by code. Cookie/JWT only — invite acceptance binds a human identity to a workspace.

Request body

code*string

Invite code from the invite email / link.

Example: "inv_…"

Response · 201 The joined workspace.

id*string · uuid
slug*string

Example: "acme-corp"

display_name*string

Example: "Acme Corp"

created_by*string · uuid
status*string

Workspace status (e.g. `active`).

Example: "active"

settings*object

Free-form workspace settings blob.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/invites/accept
Content-Type: application/json

{
  "code": "inv_…"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "slug": "acme-corp",
  "display_name": "Acme Corp",
  "created_by": "00000000-0000-0000-0000-000000000000",
  "status": "active",
  "settings": {},
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_slug}/invites

List workspace invites, newest first. Cursor-paginated.

Path parameters

workspace_slug*string

Query parameters

limitstring

Page size. Defaults to 20, clamped to 100.

cursorstring

Opaque `next_cursor` from the previous page.

Response · 200

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

Example

Request

GET /v1/workspaces/{workspace_slug}/invites

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "code": "string",
      "email": "user@example.com",
      "role_id": "00000000-0000-0000-0000-000000000000",
      "max_uses": 0,
      "use_count": 0,
      "expires_at": "2026-01-01T00:00:00.000Z",
      "revoked_at": "2026-01-01T00:00:00.000Z",
      "created_at": "2026-01-01T00:00:00.000Z",
      "created_by": "00000000-0000-0000-0000-000000000000",
      "role_name": "string"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
post/v1/workspaces/{workspace_slug}/invites

Create a workspace invite link. Cookie/JWT only — invites bring new humans into the workspace.

Path parameters

workspace_slug*string

Request body

emailstring

Optional invitee email. When set, the invite is locked to that address.

Example: "alice@example.com"

role_idstring

UUID of the role to grant on accept. Use to target custom roles.

roleenum (3)

System role shortcut: 'owner' | 'admin' | 'member'.

max_usesnumber

Maximum number of accepts. Defaults to 1. Use higher values for "team link" invites.

Example: 1

expires_in_hoursnumber

Hours until the invite expires. Defaults to 168 (7 days).

Example: 168

Response · 201

id*string · uuid
workspace_id*string · uuid
code*string

Single-use invite code embedded in the invite URL.

email*string · email
role_id*string · uuid
max_uses*number
use_count*number
expires_at*string · date-time
revoked_at*string · date-time
created_at*string · date-time
created_by*string · uuid

Example

Request

POST /v1/workspaces/{workspace_slug}/invites
Content-Type: application/json

{
  "email": "alice@example.com",
  "role_id": "string",
  "role": "owner",
  "max_uses": 1,
  "expires_in_hours": 168
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "code": "string",
  "email": "user@example.com",
  "role_id": "00000000-0000-0000-0000-000000000000",
  "max_uses": 0,
  "use_count": 0,
  "expires_at": "2026-01-01T00:00:00.000Z",
  "revoked_at": "2026-01-01T00:00:00.000Z",
  "created_at": "2026-01-01T00:00:00.000Z",
  "created_by": "00000000-0000-0000-0000-000000000000"
}
delete/v1/workspaces/{workspace_slug}/invites/{invite_id}

Revoke a workspace invite. Cookie/JWT only.

Path parameters

workspace_slug*string
invite_id*string

Response · 204

Invite revoked.


Workspace-Roles

get/v1/workspaces/{workspace_slug}/roles

List roles in a workspace. System roles first, then alphabetical. 20/page (max 100), cursor-paginated.

Path parameters

workspace_slug*string

Query parameters

limitstring

Page size. Defaults to 20, clamped to 100.

cursorstring

Opaque `next_cursor` from the previous page.

Response · 200 Page of roles with `next_cursor` and `has_more`.

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

Example

Request

GET /v1/workspaces/{workspace_slug}/roles

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "string",
      "description": "string",
      "is_system": false,
      "policy": [
        {
          "effect": "allow",
          "actions": [
            "social.read",
            "social.list"
          ],
          "resources": [
            "*"
          ]
        }
      ],
      "policy_id": "00000000-0000-0000-0000-000000000000"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
post/v1/workspaces/{workspace_slug}/roles

Create a new custom role. Cookie/JWT only — role authoring is a human-admin surface.

Path parameters

workspace_slug*string

Request body

name*string

Role name. Free-form; not URL-significant.

Example: "BillingAdmin"

descriptionstring

Optional human-readable role description.

policyarray

IAM-style policy granting the role its permissions. Same wire shape as PAK policies. Omit or pass `[]` for a no-permissions role.

Example: [{"effect":"allow","actions":["mail.*.read"],"resources":["*"]}]

policy_idstring

Optional managed-policy id to bind the role to. When set, the role's effective policy comes from the bound managed policy and `policy` (inline) must be omitted.

Example: "11111111-1111-1111-1111-111111111111"

Response · 201

id*string · uuid
name*string

Role name. System role names are `owner`/`admin`/`member`.

description*string
is_system*boolean

True for built-in system roles (owner/admin/member); false for custom roles.

policy*array

Effective IAM-style policy. When `policy_id` is set this is the bound managed policy; otherwise it is the inline policy column.

policy_id*string · uuid

Managed policy ID if the role is bound to one, else null.

Example

Request

POST /v1/workspaces/{workspace_slug}/roles
Content-Type: application/json

{
  "name": "BillingAdmin",
  "description": "string",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "mail.*.read"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "policy_id": "11111111-1111-1111-1111-111111111111"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "description": "string",
  "is_system": false,
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "policy_id": "00000000-0000-0000-0000-000000000000"
}
get/v1/workspaces/{workspace_slug}/roles/{role_id}

Get a role + its permissions

Path parameters

workspace_slug*string
role_id*string

Response · 200

id*string · uuid
name*string

Role name. System role names are `owner`/`admin`/`member`.

description*string
is_system*boolean

True for built-in system roles (owner/admin/member); false for custom roles.

policy*array

Effective IAM-style policy. When `policy_id` is set this is the bound managed policy; otherwise it is the inline policy column.

policy_id*string · uuid

Managed policy ID if the role is bound to one, else null.

Example

Request

GET /v1/workspaces/{workspace_slug}/roles/{role_id}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "description": "string",
  "is_system": false,
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "policy_id": "00000000-0000-0000-0000-000000000000"
}
patch/v1/workspaces/{workspace_slug}/roles/{role_id}

Update a role or replace its permission set. Cookie/JWT only — role authoring is a human-admin surface.

Path parameters

workspace_slug*string
role_id*string

Request body

namestring
descriptionstring
policyarray

Replaces the role policy if provided. Omit to leave policy unchanged.

policy_idstring

Bind / unbind the role to a managed policy. UUID → bind, null → unbind, omit → leave unchanged.

Response · 200

id*string · uuid
name*string

Role name. System role names are `owner`/`admin`/`member`.

description*string
is_system*boolean

True for built-in system roles (owner/admin/member); false for custom roles.

policy*array

Effective IAM-style policy. When `policy_id` is set this is the bound managed policy; otherwise it is the inline policy column.

policy_id*string · uuid

Managed policy ID if the role is bound to one, else null.

Example

Request

PATCH /v1/workspaces/{workspace_slug}/roles/{role_id}
Content-Type: application/json

{
  "name": "string",
  "description": "string",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "policy_id": "string"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "description": "string",
  "is_system": false,
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "policy_id": "00000000-0000-0000-0000-000000000000"
}
delete/v1/workspaces/{workspace_slug}/roles/{role_id}

Delete a custom role (fails if it has members). Cookie/JWT only — role authoring is a human-admin surface.

Path parameters

workspace_slug*string
role_id*string

Response · 204

Custom role deleted.


Workspace-Policies

get/v1/workspaces/{workspace_slug}/policies

List managed policies in a workspace, alphabetically. 20/page (max 100), cursor-paginated.

Path parameters

workspace_slug*string

Query parameters

limitstring

Page size. Defaults to 20, clamped to 100.

cursorstring

Opaque `next_cursor` from the previous page.

Response · 200 Page of managed policies with `next_cursor` and `has_more`.

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

Example

Request

GET /v1/workspaces/{workspace_slug}/policies

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "name": "mail-readonly",
      "description": "string",
      "policy": [
        {
          "effect": "allow",
          "actions": [
            "social.read",
            "social.list"
          ],
          "resources": [
            "*"
          ]
        }
      ],
      "created_by": "00000000-0000-0000-0000-000000000000",
      "created_at": "2026-01-01T00:00:00.000Z",
      "updated_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
post/v1/workspaces/{workspace_slug}/policies

Create a managed policy. Cookie/JWT only — policy authoring is a human-admin surface.

Path parameters

workspace_slug*string

Request body

name*string

Policy name (1-64 chars, alphanumeric + space/underscore/hyphen). Unique per workspace.

Example: "mail-readonly"

descriptionstring

Optional human description. Pass `null` to clear an existing description on PATCH.

Example: "Read-only access to all Mail resources"

policy*array

IAM-style policy. Empty array means "no permissions" — useful as a placeholder before granting any actions.

Example: [{"effect":"allow","actions":["mail.read","mail.list"],"resources":["*"]}]

Response · 201 Policy created.

id*string · uuid
workspace_id*string · uuid
name*string

Example: "mail-readonly"

description*string
policy*array

IAM-style policy statements bound to this managed policy.

created_by*string · uuid

Account that authored the row. Only revealed to callers with `workspace.audit.read`; plain members see null.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_slug}/policies
Content-Type: application/json

{
  "name": "mail-readonly",
  "description": "Read-only access to all Mail resources",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "mail.read",
        "mail.list"
      ],
      "resources": [
        "*"
      ]
    }
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "name": "mail-readonly",
  "description": "string",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "created_by": "00000000-0000-0000-0000-000000000000",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_slug}/policies/actions/catalog

List every known workspace action (catalog of statement actions).

Path parameters

workspace_slug*string

Response · 200

actions*array

Every known workspace action across all enabled service catalogs.

Example

Request

GET /v1/workspaces/{workspace_slug}/policies/actions/catalog

Response

{
  "actions": [
    "string"
  ]
}
get/v1/workspaces/{workspace_slug}/policies/{policy_id}

Get a managed policy by id

Path parameters

workspace_slug*string
policy_id*string

Response · 200 Policy detail. `binding_count.{pak,role}` reports how many things are currently bound — surface this in delete-confirm UX.

id*string · uuid
workspace_id*string · uuid
name*string

Example: "mail-readonly"

description*string
policy*array

IAM-style policy statements bound to this managed policy.

created_by*string · uuid

Account that authored the row. Only revealed to callers with `workspace.audit.read`; plain members see null.

created_at*string · date-time
updated_at*string · date-time
binding_count*object

Counts of currently-bound consumers — surface this in delete-confirm UX so the operator sees cascade impact.

Example

Request

GET /v1/workspaces/{workspace_slug}/policies/{policy_id}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "name": "mail-readonly",
  "description": "string",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "created_by": "00000000-0000-0000-0000-000000000000",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "binding_count": {}
}
patch/v1/workspaces/{workspace_slug}/policies/{policy_id}

Update a managed policy. Cookie/JWT only — policy authoring is a human-admin surface.

Path parameters

workspace_slug*string
policy_id*string

Request body

namestring

Policy name (1-64 chars, alphanumeric + space/underscore/hyphen). Unique per workspace.

Example: "mail-readonly"

descriptionstring

Optional human description. Pass `null` to clear an existing description on PATCH.

Example: "Read-only access to all Mail resources"

policyarray

IAM-style policy. Empty array means "no permissions" — useful as a placeholder before granting any actions.

Example: [{"effect":"allow","actions":["mail.read","mail.list"],"resources":["*"]}]

Response · 200 Updated policy.

id*string · uuid
workspace_id*string · uuid
name*string

Example: "mail-readonly"

description*string
policy*array

IAM-style policy statements bound to this managed policy.

created_by*string · uuid

Account that authored the row. Only revealed to callers with `workspace.audit.read`; plain members see null.

created_at*string · date-time
updated_at*string · date-time
binding_count*object

Counts of currently-bound consumers — surface this in delete-confirm UX so the operator sees cascade impact.

Example

Request

PATCH /v1/workspaces/{workspace_slug}/policies/{policy_id}
Content-Type: application/json

{
  "name": "mail-readonly",
  "description": "Read-only access to all Mail resources",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "mail.read",
        "mail.list"
      ],
      "resources": [
        "*"
      ]
    }
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "name": "mail-readonly",
  "description": "string",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "created_by": "00000000-0000-0000-0000-000000000000",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z",
  "binding_count": {}
}
delete/v1/workspaces/{workspace_slug}/policies/{policy_id}

Delete a managed policy. Cascades: API keys lose the binding (deny-all if no other bindings remain); roles fall back to their inline policy column. Cookie/JWT only.

Path parameters

workspace_slug*string
policy_id*string

Response · 204

Policy deleted.


Workspace-Services

get/v1/workspaces/{workspace_slug}/services

List services activated on this workspace. Complete — one row per product, not paginated.

Path parameters

workspace_slug*string

Response · 200

data*array

Example

Request

GET /v1/workspaces/{workspace_slug}/services

Response

{
  "data": [
    {
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "service": "mail",
      "enabled": false,
      "enabled_at": "2026-01-01T00:00:00.000Z",
      "enabled_by": "00000000-0000-0000-0000-000000000000",
      "disabled_at": "2026-01-01T00:00:00.000Z",
      "disabled_by": "00000000-0000-0000-0000-000000000000",
      "settings": {}
    }
  ]
}
post/v1/workspaces/{workspace_slug}/services/{service}

Enable (or re-enable) a service for this workspace

Path parameters

workspace_slug*string
service*string

Request body

settingsobject

Optional per-service settings blob captured at enable time.

Response · 201

workspace_id*string · uuid
service*string

Service identifier (e.g. `mail`, `waitlist`, `social`, `auth`).

Example: "mail"

enabled*boolean
enabled_at*string · date-time
enabled_by*string · uuid
disabled_at*string · date-time
disabled_by*string · uuid
settings*object

Per-service settings blob (service-specific shape).

Example

Request

POST /v1/workspaces/{workspace_slug}/services/{service}
Content-Type: application/json

{
  "settings": {}
}

Response

{
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "service": "mail",
  "enabled": false,
  "enabled_at": "2026-01-01T00:00:00.000Z",
  "enabled_by": "00000000-0000-0000-0000-000000000000",
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_by": "00000000-0000-0000-0000-000000000000",
  "settings": {}
}
delete/v1/workspaces/{workspace_slug}/services/{service}

Disable a service (settings are retained)

Path parameters

workspace_slug*string
service*string

Response · 204

Service disabled. Settings preserved for re-enable.

patch/v1/workspaces/{workspace_slug}/services/{service}/settings

Replace the settings jsonb for an active service

Path parameters

workspace_slug*string
service*string

Request body

object

Response · 200

workspace_id*string · uuid
service*string

Service identifier (e.g. `mail`, `waitlist`, `social`, `auth`).

Example: "mail"

enabled*boolean
enabled_at*string · date-time
enabled_by*string · uuid
disabled_at*string · date-time
disabled_by*string · uuid
settings*object

Per-service settings blob (service-specific shape).

Example

Request

PATCH /v1/workspaces/{workspace_slug}/services/{service}/settings
Content-Type: application/json

{
  "default_sender": "noreply@acme.com"
}

Response

{
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "service": "mail",
  "enabled": false,
  "enabled_at": "2026-01-01T00:00:00.000Z",
  "enabled_by": "00000000-0000-0000-0000-000000000000",
  "disabled_at": "2026-01-01T00:00:00.000Z",
  "disabled_by": "00000000-0000-0000-0000-000000000000",
  "settings": {}
}

Platform-Api-Keys

get/v1/workspaces/{workspace_slug}/api-keysAuth

List PAKs in this workspace, newest first. Plaintext tokens are NOT returned — only prefixes + metadata. Pass `?limit=`/`?cursor=` to get the standard cursor-paginated `{data, pagination}` envelope (20/page, max 100); callers that pass neither still get the legacy bare JSON array.

Path parameters

workspace_slug*string

Query parameters

limitstring

Page size. Defaults to 20, clamped to 100. Sending it (at any value) also opts the response into the `{data, pagination}` envelope.

cursorstring

Opaque `next_cursor` from the previous page. Sending it also opts the response into the `{data, pagination}` envelope.

Response · 200 Two shapes, selected by the request — see `oneOf`. - **`?limit=` or `?cursor=` present** → the standard `{data, pagination}` envelope with `next_cursor` + `has_more`. Use this; it is the only shape that pages. - **neither present** → the legacy bare JSON array of every PAK in the workspace (capped at 1000). DEPRECATED as of 2026-09 and retained only so `@productcraft/platform-auth` <= 0.0.12 clients, which cannot send either query param, keep working. It will be removed once those clients are off it; new integrations must send `?limit=`.

object

Example

Request

GET /v1/workspaces/{workspace_slug}/api-keys
Authorization: Bearer YOUR_TOKEN

Response

{}
post/v1/workspaces/{workspace_slug}/api-keysAuth

Mint a new PAK. The plaintext `token` is returned ONCE — store it now. The minter can grant at most their own effective permissions. Cookie/JWT only — PAKs cannot mint other PAKs.

Path parameters

workspace_slug*string

Request body

name*string

Human-readable PAK name (shown in the keys list).

Example: "Production social client"

descriptionstring

Optional description (e.g. which service uses the key).

policy_ids*array

IDs of managed policies (workspace_policy rows) to bind to this PAK. Bind one or more — the PAK's effective policy is the union of all bound statements.

Example: ["11111111-1111-1111-1111-111111111111"]

expires_in_daysenum (3)

Lifetime of the key in days. Omit or send `null` for a key that never expires — the historical behaviour, what every PAK minted before this field existed holds, and still the default. Enforced at introspection: past `expires_at` the key resolves to 401 exactly like a revoked one.

Example: 90

Response · 201

token*string

The full plaintext PAK token. Returned ONCE — store it now; it cannot be retrieved later.

Example: "pcft_live_abc123XYZ..."

record*object
id*string · uuid
workspace_id*string · uuid
created_by*string · uuid
name*string
description*string
token_prefix*string

The first ~14 chars of the token (prefix). Plaintext token is never returned after mint.

Example: "pcft_live_abc123"

policies*array

Managed policies currently bound to this PAK.

last_used_at*string · date-time
revoked_at*string · date-time
expires_at*string · date-time

When this key stops introspecting. `null` means it never expires.

rotated_from*string · uuid

Id of the key this one replaced, when it was minted by a rotation.

status*enum (3)

Lifecycle state at the time of the request. Only `active` keys introspect. `revoked` beats `expired` — revocation is a deliberate act by a named actor and the row keeps saying so.

created_at*string · date-time
updated_at*string · date-time

Example

Request

POST /v1/workspaces/{workspace_slug}/api-keys
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "Production social client",
  "description": "string",
  "policy_ids": [
    "11111111-1111-1111-1111-111111111111"
  ],
  "expires_in_days": 90
}

Response

{
  "token": "pcft_live_abc123XYZ...",
  "record": {
    "id": "00000000-0000-0000-0000-000000000000",
    "workspace_id": "00000000-0000-0000-0000-000000000000",
    "created_by": "00000000-0000-0000-0000-000000000000",
    "name": "string",
    "description": "string",
    "token_prefix": "pcft_live_abc123",
    "policies": [
      {
        "id": "00000000-0000-0000-0000-000000000000",
        "name": "mail-readonly",
        "description": "string"
      }
    ],
    "last_used_at": "2026-01-01T00:00:00.000Z",
    "revoked_at": "2026-01-01T00:00:00.000Z",
    "expires_at": "2026-01-01T00:00:00.000Z",
    "rotated_from": "00000000-0000-0000-0000-000000000000",
    "status": "active",
    "created_at": "2026-01-01T00:00:00.000Z",
    "updated_at": "2026-01-01T00:00:00.000Z"
  }
}
post/v1/workspaces/{workspace_slug}/api-keys/{id}/rotateAuth

Mint a replacement for an existing PAK, carrying its name, description and policy bindings over. The outgoing key keeps working for `grace_hours` (24 by default) so a fleet can roll over without downtime. The plaintext `token` is returned ONCE. Cookie/JWT only — a PAK cannot rotate a PAK, for the same reason it cannot mint one.

Path parameters

workspace_slug*string
id*string

Request body

grace_hoursnumber

How long the OUTGOING key keeps working, in hours. `0` kills it immediately. Capped at 168 (7 days) — rotation answers "this key may be compromised", so an unbounded overlap would defeat the point. Rotation can only ever shorten a key: if the old one was already due to expire sooner, that earlier instant stands.

Example: 24

Response · 201

token*string

The full plaintext PAK token. Returned ONCE — store it now; it cannot be retrieved later.

Example: "pcft_live_abc123XYZ..."

record*object
id*string · uuid
workspace_id*string · uuid
created_by*string · uuid
name*string
description*string
token_prefix*string

The first ~14 chars of the token (prefix). Plaintext token is never returned after mint.

Example: "pcft_live_abc123"

policies*array

Managed policies currently bound to this PAK.

last_used_at*string · date-time
revoked_at*string · date-time
expires_at*string · date-time

When this key stops introspecting. `null` means it never expires.

rotated_from*string · uuid

Id of the key this one replaced, when it was minted by a rotation.

status*enum (3)

Lifecycle state at the time of the request. Only `active` keys introspect. `revoked` beats `expired` — revocation is a deliberate act by a named actor and the row keeps saying so.

created_at*string · date-time
updated_at*string · date-time
rotated_from*string · uuid

Id of the key this one replaces.

previous_key_expires_at*string · date-time

When the replaced key stops introspecting. Until then BOTH keys work, so a fleet can roll over without downtime. Downstream services cache introspection for 60s, so treat this instant as "+ up to a minute" when planning a cutover.

Example

Request

POST /v1/workspaces/{workspace_slug}/api-keys/{id}/rotate
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "grace_hours": 24
}

Response

{
  "token": "pcft_live_abc123XYZ...",
  "record": {
    "id": "00000000-0000-0000-0000-000000000000",
    "workspace_id": "00000000-0000-0000-0000-000000000000",
    "created_by": "00000000-0000-0000-0000-000000000000",
    "name": "string",
    "description": "string",
    "token_prefix": "pcft_live_abc123",
    "policies": [
      {
        "id": "00000000-0000-0000-0000-000000000000",
        "name": "mail-readonly",
        "description": "string"
      }
    ],
    "last_used_at": "2026-01-01T00:00:00.000Z",
    "revoked_at": "2026-01-01T00:00:00.000Z",
    "expires_at": "2026-01-01T00:00:00.000Z",
    "rotated_from": "00000000-0000-0000-0000-000000000000",
    "status": "active",
    "created_at": "2026-01-01T00:00:00.000Z",
    "updated_at": "2026-01-01T00:00:00.000Z"
  },
  "rotated_from": "00000000-0000-0000-0000-000000000000",
  "previous_key_expires_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_slug}/api-keys/{id}/bindingsAuth

Replace the managed-policy bindings on a PAK. Re-runs caller-narrowing on the merged union of statements.

Path parameters

workspace_slug*string
id*string

Request body

policy_ids*array

Replacement set of managed-policy IDs. Empty array is rejected — revoke the PAK to disable it.

Response · 200 Updated PAK with new bindings.

id*string · uuid
workspace_id*string · uuid
created_by*string · uuid
name*string
description*string
token_prefix*string

The first ~14 chars of the token (prefix). Plaintext token is never returned after mint.

Example: "pcft_live_abc123"

policies*array

Managed policies currently bound to this PAK.

last_used_at*string · date-time
revoked_at*string · date-time
expires_at*string · date-time

When this key stops introspecting. `null` means it never expires.

rotated_from*string · uuid

Id of the key this one replaced, when it was minted by a rotation.

status*enum (3)

Lifecycle state at the time of the request. Only `active` keys introspect. `revoked` beats `expired` — revocation is a deliberate act by a named actor and the row keeps saying so.

created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_slug}/api-keys/{id}/bindings
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "policy_ids": [
    "string"
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "created_by": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "description": "string",
  "token_prefix": "pcft_live_abc123",
  "policies": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "mail-readonly",
      "description": "string"
    }
  ],
  "last_used_at": "2026-01-01T00:00:00.000Z",
  "revoked_at": "2026-01-01T00:00:00.000Z",
  "expires_at": "2026-01-01T00:00:00.000Z",
  "rotated_from": "00000000-0000-0000-0000-000000000000",
  "status": "active",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
get/v1/workspaces/{workspace_slug}/api-keys/{id}Auth

Get a single PAK by id.

Path parameters

workspace_slug*string
id*string

Response · 200

id*string · uuid
workspace_id*string · uuid
created_by*string · uuid
name*string
description*string
token_prefix*string

The first ~14 chars of the token (prefix). Plaintext token is never returned after mint.

Example: "pcft_live_abc123"

policies*array

Managed policies currently bound to this PAK.

last_used_at*string · date-time
revoked_at*string · date-time
expires_at*string · date-time

When this key stops introspecting. `null` means it never expires.

rotated_from*string · uuid

Id of the key this one replaced, when it was minted by a rotation.

status*enum (3)

Lifecycle state at the time of the request. Only `active` keys introspect. `revoked` beats `expired` — revocation is a deliberate act by a named actor and the row keeps saying so.

created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_slug}/api-keys/{id}
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "created_by": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "description": "string",
  "token_prefix": "pcft_live_abc123",
  "policies": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "mail-readonly",
      "description": "string"
    }
  ],
  "last_used_at": "2026-01-01T00:00:00.000Z",
  "revoked_at": "2026-01-01T00:00:00.000Z",
  "expires_at": "2026-01-01T00:00:00.000Z",
  "rotated_from": "00000000-0000-0000-0000-000000000000",
  "status": "active",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
patch/v1/workspaces/{workspace_slug}/api-keys/{id}Auth

Update a PAK’s name or description (does not touch bindings).

Path parameters

workspace_slug*string
id*string

Request body

namestring
descriptionstring

Response · 200

id*string · uuid
workspace_id*string · uuid
created_by*string · uuid
name*string
description*string
token_prefix*string

The first ~14 chars of the token (prefix). Plaintext token is never returned after mint.

Example: "pcft_live_abc123"

policies*array

Managed policies currently bound to this PAK.

last_used_at*string · date-time
revoked_at*string · date-time
expires_at*string · date-time

When this key stops introspecting. `null` means it never expires.

rotated_from*string · uuid

Id of the key this one replaced, when it was minted by a rotation.

status*enum (3)

Lifecycle state at the time of the request. Only `active` keys introspect. `revoked` beats `expired` — revocation is a deliberate act by a named actor and the row keeps saying so.

created_at*string · date-time
updated_at*string · date-time

Example

Request

PATCH /v1/workspaces/{workspace_slug}/api-keys/{id}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "name": "string",
  "description": "string"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "created_by": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "description": "string",
  "token_prefix": "pcft_live_abc123",
  "policies": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "name": "mail-readonly",
      "description": "string"
    }
  ],
  "last_used_at": "2026-01-01T00:00:00.000Z",
  "revoked_at": "2026-01-01T00:00:00.000Z",
  "expires_at": "2026-01-01T00:00:00.000Z",
  "rotated_from": "00000000-0000-0000-0000-000000000000",
  "status": "active",
  "created_at": "2026-01-01T00:00:00.000Z",
  "updated_at": "2026-01-01T00:00:00.000Z"
}
delete/v1/workspaces/{workspace_slug}/api-keys/{id}Auth

Revoke a PAK. Default behaviour: introspect returns null on the next upstream call; downstream services fail-closed within the 60s cache TTL window. Pass `?force=true` to additionally publish a Redis fan-out that evicts the matching cache entry across every PAK-consuming service immediately — use it when rotating a leaked PAK and the 60s window matters.

Path parameters

workspace_slug*string
id*string

Query parameters

forceboolean

When `true`, publish a `pak:revoked` Redis fan-out so subscribed services evict their caches immediately instead of waiting out the 60s TTL.

Response · 204

PAK revoked.


Workspace-Audit-Logs

get/v1/workspaces/{workspace_slug}/audit-logs

List workspace audit log entries, newest first. Filter by `?action=`, `?actor_id=`, and/or the `?since=` / `?until=` date range. 20/page (max 100), cursor-paginated.

Path parameters

workspace_slug*string

Query parameters

limitstring

Page size. Defaults to 20, clamped to 100.

cursorstring

Opaque `next_cursor` from the previous page.

actionstring

Exact-match filter on the action identifier (e.g. `workspace.member.removed`).

actor_idstring · uuid

Exact-match filter on the acting account UUID. System rows have a null actor and are excluded by this filter.

sincestring · date-time

Inclusive lower bound on `created_at`. ISO-8601 date (`2026-05-01`, midnight UTC) or date-time (`2026-05-01T09:00:00Z`). Composes with `action` / `actor_id` / `until` and with the cursor.

untilstring · date-time

Inclusive upper bound on `created_at`. Same formats as `since`. Note a bare date resolves to midnight UTC, so `until=2026-05-01` excludes that day’s later entries.

Response · 200 Page of audit entries with `next_cursor` and `has_more`.

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

Example

Request

GET /v1/workspaces/{workspace_slug}/audit-logs

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "actor_id": "string",
      "actor_type": "platform_user",
      "action": "workspace.member.removed",
      "resource": "workspace_membership",
      "resource_id": "00000000-0000-0000-0000-000000000000",
      "ip": "string",
      "metadata": {},
      "created_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
get/v1/workspaces/{workspace_slug}/audit-logs/export

Stream the workspace audit log as NDJSON or CSV, applying the same `?action=` / `?actor_id=` / `?since=` / `?until=` filters as the list. Newest first. Reading the export is itself audited.

The response is streamed: rows are walked in keyset batches and written as they are read, so neither the service nor the client holds the whole log. This is the SOC 2 / GDPR export the platform docs promise. Hard cap of 1,000,000 rows per call; a wider request is refused with 413 before any body is written, so a truncated file can never masquerade as a complete one.

Path parameters

workspace_slug*string

Query parameters

formatenum (2)

Defaults to `ndjson`.

actionstring
actor_idstring
sincestring · date-time
untilstring · date-time

Response · 200

Streamed export. `application/x-ndjson` (one JSON object per line) or `text/csv` (RFC 4180, header row first).

get/v1/workspaces/{workspace_slug}/audit-feed

Unified workspace audit feed (cursor-paginated)

Audit timeline for workspace-scoped events — workspace, member, role, invite, service-activation, PAK and OAuth-consent mutations, plus successful sign-ins and completed password resets. Per-app Auth rows are NOT merged in: they live in another database with no cursor that spans both, and they are served by `GET /v1/apps/:appId/audit-logs` (paginated), `.../audit-logs/export` (streamed) and the per-app audit sink. The `sources` field reports that explicitly rather than implying a pending follow-up.

Path parameters

workspace_slug*string

Query parameters

sincestring · date-time

ISO-8601 inclusive lower bound on `timestamp`. Items older than `since` are not returned.

untilstring · date-time

ISO-8601 inclusive upper bound on `timestamp`. Composes with `since`, `action`, `actor_id` and with the cursor. A bare date resolves to midnight UTC, so `until=2026-05-01` excludes that day’s later entries.

actionstring

Exact-match filter on the action identifier (e.g. `workspace.member.removed`). A partial string matches nothing.

actor_idstring · uuid

Exact-match filter on the acting account UUID. Must be a well-formed UUID — anything else is a 400, not an empty page. System rows have a null actor and are excluded by this filter.

cursorstring

Opaque cursor from a previous response's `pagination.next_cursor`. Resumes the feed at the same merge boundary.

limitinteger

Per-page cap. Clamped to 1..500; default 100.

sourcesstring

Comma-separated subset of sources to include (e.g. `workspace_audit,heimdall_audit`). Omitted = all enabled sources.

Response · 200

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

sources*array

Per-source diagnostics so the UI / customer can see which sources contributed to this page and which are stubbed pending the cross-service fanout.

Example

Request

GET /v1/workspaces/{workspace_slug}/audit-feed

Response

{
  "data": [
    {
      "source": "workspace_audit",
      "timestamp": "2026-01-01T00:00:00.000Z",
      "actor_id": "00000000-0000-0000-0000-000000000000",
      "actor_type": "platform_user",
      "action": "workspace.member.removed",
      "resource": "workspace_membership",
      "resource_id": "00000000-0000-0000-0000-000000000000",
      "metadata": {},
      "source_ref": "4e5f5c9e-b4ee-4401-9275-283feb66c178"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  },
  "sources": [
    {
      "source": "workspace_audit",
      "included": false,
      "count": 0,
      "error": "string"
    }
  ]
}

Account

delete/v1/account

Delete the calling account. Auto-promotes the oldest admin (or oldest non-self member) to owner in any workspace where the departing account is the sole owner. Linked provider grants that require server-side revocation (Sign in with Apple) are revoked with the provider.

Response · 204

Account deleted. All sessions revoked, and any linked Sign in with Apple grant is queued for revocation with Apple. The revocation is durable and retried in the background, so a 204 does not depend on Apple being reachable.


Me

get/v1/meAuth

Read the caller's account profile.

Response · 200

id*string · uuid
email*string
username*string
display_name*string
status*string

Account status (e.g. `active`, `suspended`).

email_verified_at*string · date-time

Timestamp when this account verified its primary email, or null if unverified.

avatar_url*string

Short-lived (15 min) presigned GET URL for the avatar object in private object storage. NULL when no avatar has been set; the frontend then renders initials. The API mints a fresh URL on every response, so a stable bookmark of this value is not meaningful.

Example

Request

GET /v1/me
Authorization: Bearer YOUR_TOKEN

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "email": "string",
  "username": "string",
  "display_name": "string",
  "status": "string",
  "email_verified_at": "2026-01-01T00:00:00.000Z",
  "avatar_url": "string"
}
patch/v1/me/profileAuth

Update the caller's profile. Currently only `displayName` is editable. An empty body is a no-op that returns the current profile. Cookie/JWT only — this is a human-self-service surface, not a PAK-mutation surface.

Request body

display_namestring

New display name. 1-128 chars. Optional — empty PATCH is a no-op.

Response · 200

id*string · uuid
email*string
username*string
display_name*string
status*string

Account status (e.g. `active`, `suspended`).

email_verified_at*string · date-time

Timestamp when this account verified its primary email, or null if unverified.

avatar_url*string

Short-lived (15 min) presigned GET URL for the avatar object in private object storage. NULL when no avatar has been set; the frontend then renders initials. The API mints a fresh URL on every response, so a stable bookmark of this value is not meaningful.

Example

Request

PATCH /v1/me/profile
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "display_name": "string"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "email": "string",
  "username": "string",
  "display_name": "string",
  "status": "string",
  "email_verified_at": "2026-01-01T00:00:00.000Z",
  "avatar_url": "string"
}
post/v1/me/avatarAuth

Upload (or replace) the caller's avatar. PNG/JPEG/WEBP only — SVG is rejected because user-uploaded SVG from a productcraft.co origin is a stored-XSS vector. ≤2 MB. Magic bytes are sniffed; a mislabeled file is rejected.

Response · 200

avatar_url*string

Example

Request

POST /v1/me/avatar
Authorization: Bearer YOUR_TOKEN

Response

{
  "avatar_url": "string"
}
delete/v1/me/avatarAuth

Clear the caller's avatar and delete the stored object.

Response · 204

Avatar removed (or already absent).


Session

get/v1/sessionAuth

List active sessions

Response · 200 Active sessions

Array of object

id*string

Session identifier

Example: "sess_01HZY2G3A7M8Q9W4E6R2K1"

account_id*string

Owning account identifier

Example: "acc_01HZY2F2B6N7M8Q9W4E6R2"

ip*string

Client IP address

Example: "203.0.113.42"

user_agent*string

HTTP User-Agent string

Example: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"

created_at*string · date-time

Creation timestamp (ISO 8601)

Example: "2025-09-20T12:34:56.000Z"

expires_at*string · date-time

Expiration timestamp (ISO 8601)

Example: "2025-09-21T12:34:56.000Z"

last_used_at*string · date-time

Last time this session was used (ISO 8601) or null

Example: "2025-09-20T18:00:00.000Z"

Example

Request

GET /v1/session
Authorization: Bearer YOUR_TOKEN

Response

[
  {
    "id": "sess_01HZY2G3A7M8Q9W4E6R2K1",
    "account_id": "acc_01HZY2F2B6N7M8Q9W4E6R2",
    "ip": "203.0.113.42",
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
    "created_at": "2025-09-20T12:34:56.000Z",
    "expires_at": "2025-09-21T12:34:56.000Z",
    "last_used_at": "2025-09-20T18:00:00.000Z"
  }
]
delete/v1/session/{id}Auth

Revoke one of the caller's own sessions. Takes effect immediately — the refresh token stops working straight away, not at the end of its window.

Path parameters

id*string

Response · 200

`revoked` is false when the session was already revoked, so repeating the call is safe.


Verification

post/v1/verification/verify

Verify an email code

Request body

code*string

Verification code from the email link.

Example: "a1b2c3d4…"

Response · 200 Email verified successfully

message*string

Human-readable message

Example: "Email verified successfully"

Example

Request

POST /v1/verification/verify
Content-Type: application/json

{
  "code": "a1b2c3d4…"
}

Response

{
  "message": "Email verified successfully"
}
post/v1/verification/resendAuth

Resend verification email

Response · 200 Verification email sent

message*string

Human-readable message

Example: "Email verified successfully"

Example

Request

POST /v1/verification/resend
Authorization: Bearer YOUR_TOKEN

Response

{
  "message": "Email verified successfully"
}

Jwks

get/.well-known/jwks.json

Public JWKS for verifying platform-issued JWTs (issuer `https://api.auth.productcraft.co`).

Response · 200

keys*array

Array of public JWKs. Token verifiers iterate these by `kid` to find the matching signer.

Example

Request

GET /.well-known/jwks.json

Response

{
  "keys": [
    {
      "kty": "RSA",
      "alg": "RS256",
      "kid": "k_2026_05_01",
      "use": "sig"
    }
  ]
}

Oauth-Authorization-Server

get/.well-known/oauth-authorization-server

OAuth 2.1 authorization server metadata (RFC 8414)

Discovery document for third-party clients (Claude Desktop, Cursor, the ProductCraft MCP server). PKCE with `S256` is mandatory; no other code-challenge method is advertised or accepted. `scopes_supported` lists **presets**, not scopes: authority is the policy document on the grant (`authority_model`).

Response · 200

issuer*string

Issuer identifier. Matches the `iss` claim on every access token this server mints.

Example: "https://api.platform-auth.productcraft.co"

authorization_endpoint*string

RFC 6749 authorization endpoint.

token_endpoint*string

RFC 6749 token endpoint.

registration_endpoint*string

RFC 7591 dynamic client registration endpoint.

revocation_endpoint*string

RFC 7009 token revocation endpoint.

introspection_endpoint*string

RFC 7662 token introspection endpoint.

jwks_uri*string

JWKS the access-token signature verifies against.

response_types_supported*array

Only `code` — the implicit and password grants do not exist here.

Example: ["code"]

grant_types_supported*array

Example: ["authorization_code","refresh_token"]

code_challenge_methods_supported*array

S256 only. `plain` is rejected, and an authorization request without a challenge is rejected.

Example: ["S256"]

token_endpoint_auth_methods_supported*array

Example: ["none","client_secret_basic","client_secret_post"]

scopes_supported*array

Preset names accepted in the `scope` parameter. A preset is a policy *template*, never authority in itself — see `authority_model`. Ordinary OAuth scopes do not exist on this server.

Example: ["readonly","mail","support","full"]

scope_descriptions*object

Plain-language description per preset. Non-standard, published so a client can render the same wording the consent screen uses.

authority_model*string

Non-standard. Declares that authority on this server is an IAM policy document stored on the grant, intersected per request with the authorizing human’s current workspace policy — so a demotion or an offboarding takes effect on the next call rather than at token expiry. The `scope` parameter only names a preset the human then narrows.

Example: "policy"

resource_indicators_supported*array

RFC 8707 resource indicators this server will mint an audience for.

service_documentation*string

Human-readable documentation for this authorization server.

Example

Request

GET /.well-known/oauth-authorization-server

Response

{
  "issuer": "https://api.platform-auth.productcraft.co",
  "authorization_endpoint": "string",
  "token_endpoint": "string",
  "registration_endpoint": "string",
  "revocation_endpoint": "string",
  "introspection_endpoint": "string",
  "jwks_uri": "string",
  "response_types_supported": [
    "code"
  ],
  "grant_types_supported": [
    "authorization_code",
    "refresh_token"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ],
  "token_endpoint_auth_methods_supported": [
    "none",
    "client_secret_basic",
    "client_secret_post"
  ],
  "scopes_supported": [
    "readonly",
    "mail",
    "support",
    "full"
  ],
  "scope_descriptions": {},
  "authority_model": "policy",
  "resource_indicators_supported": [
    "string"
  ],
  "service_documentation": "string"
}
post/v1/oauth/register

Register an OAuth client dynamically (RFC 7591)

Public endpoint, rate-limited per source IP. Every client registered here is marked **unverified** and stays that way; the consent screen shows the badge. `client_secret` and `registration_access_token` are returned once and stored only as hashes.

Request body

redirect_uris*array

Exact redirect URIs. Matched byte-for-byte at `/authorize`; the only relaxation is the RFC 8252 §7.3 loopback port. `https` anywhere, `http` on `127.0.0.1` / `[::1]` only, or a reverse-DNS private-use scheme. No wildcards, no fragments.

Example: ["http://127.0.0.1:33418/callback"]

client_name*string

Display name shown on the consent screen next to an "unverified" badge. Control characters are stripped; the client cannot supply any other consent-screen copy.

Example: "Claude Desktop"

grant_typesarray

Must include `authorization_code`. `refresh_token` is the only other supported value.

Example: ["authorization_code","refresh_token"]

response_typesarray

Only `code` is supported.

Example: ["code"]

token_endpoint_auth_methodstring

`none` for a public client (PKCE only), or `client_secret_basic` / `client_secret_post` for a confidential client. Defaults to `none`.

Example: "none"

scopestring

Space-delimited ceiling of scopes this client may ever request. Defaults to the full published scope list; the human still approves each one.

Example: "workspace:read mail:read mail:send"

client_uristring

Absolute https homepage for the client.

logo_uristring

Absolute https logo URL. Stored but deliberately never rendered on the consent screen — a client-supplied image is client-supplied display text by another name.

Response · 201

client_id*string

Example: "pcft_cli_2f5c..."

client_id_issued_at*number

Unix seconds at which the client_id was issued.

client_secret_expires_at*number

0 — the client secret does not expire on its own.

Example: 0

registration_client_uri*string

Endpoint the registration access token is used against.

redirect_uris*array
client_name*string
grant_types*array
response_types*array
token_endpoint_auth_method*string
scope*string
is_verified*boolean

Always false for a dynamically registered client. The consent screen shows this to the human.

Example: false

client_secret*string

Returned exactly once, and only for confidential clients. Stored hashed; it cannot be retrieved again.

registration_access_token*string

Bearer token that reads and updates THIS registration and nothing else. Returned once.

Example

Request

POST /v1/oauth/register
Content-Type: application/json

{
  "redirect_uris": [
    "http://127.0.0.1:33418/callback"
  ],
  "client_name": "Claude Desktop",
  "grant_types": [
    "authorization_code",
    "refresh_token"
  ],
  "response_types": [
    "code"
  ],
  "token_endpoint_auth_method": "none",
  "scope": "workspace:read mail:read mail:send",
  "client_uri": "string",
  "logo_uri": "string"
}

Response

{
  "client_id": "pcft_cli_2f5c...",
  "client_id_issued_at": 0,
  "client_secret_expires_at": 0,
  "registration_client_uri": "string",
  "redirect_uris": [
    "string"
  ],
  "client_name": "string",
  "grant_types": [
    "string"
  ],
  "response_types": [
    "string"
  ],
  "token_endpoint_auth_method": "string",
  "scope": "string",
  "is_verified": false,
  "client_secret": "string",
  "registration_access_token": "string"
}
get/v1/oauth/register/{client_id}

Read a client registration (RFC 7592)

Requires `Authorization: Bearer <registration_access_token>`. The token addresses exactly one registration; presenting it for a different `client_id` fails.

Path parameters

client_id*string

The `client_id` to read.

Response · 200

client_id*string

Example: "pcft_cli_2f5c..."

client_id_issued_at*number

Unix seconds at which the client_id was issued.

client_secret_expires_at*number

0 — the client secret does not expire on its own.

Example: 0

registration_client_uri*string

Endpoint the registration access token is used against.

redirect_uris*array
client_name*string
grant_types*array
response_types*array
token_endpoint_auth_method*string
scope*string
is_verified*boolean

Always false for a dynamically registered client. The consent screen shows this to the human.

Example: false

Example

Request

GET /v1/oauth/register/{client_id}

Response

{
  "client_id": "pcft_cli_2f5c...",
  "client_id_issued_at": 0,
  "client_secret_expires_at": 0,
  "registration_client_uri": "string",
  "redirect_uris": [
    "string"
  ],
  "client_name": "string",
  "grant_types": [
    "string"
  ],
  "response_types": [
    "string"
  ],
  "token_endpoint_auth_method": "string",
  "scope": "string",
  "is_verified": false
}
put/v1/oauth/register/{client_id}

Update a client registration (RFC 7592)

Requires `Authorization: Bearer <registration_access_token>`. Only display metadata, redirect URIs and the scope ceiling can change — a client cannot promote itself to verified, nor change its authentication method.

Path parameters

client_id*string

The `client_id` to update.

Request body

redirect_uris*array

Exact redirect URIs. Matched byte-for-byte at `/authorize`; the only relaxation is the RFC 8252 §7.3 loopback port. `https` anywhere, `http` on `127.0.0.1` / `[::1]` only, or a reverse-DNS private-use scheme. No wildcards, no fragments.

Example: ["http://127.0.0.1:33418/callback"]

client_name*string

Display name shown on the consent screen next to an "unverified" badge. Control characters are stripped; the client cannot supply any other consent-screen copy.

Example: "Claude Desktop"

grant_typesarray

Must include `authorization_code`. `refresh_token` is the only other supported value.

Example: ["authorization_code","refresh_token"]

response_typesarray

Only `code` is supported.

Example: ["code"]

token_endpoint_auth_methodstring

`none` for a public client (PKCE only), or `client_secret_basic` / `client_secret_post` for a confidential client. Defaults to `none`.

Example: "none"

scopestring

Space-delimited ceiling of scopes this client may ever request. Defaults to the full published scope list; the human still approves each one.

Example: "workspace:read mail:read mail:send"

client_uristring

Absolute https homepage for the client.

logo_uristring

Absolute https logo URL. Stored but deliberately never rendered on the consent screen — a client-supplied image is client-supplied display text by another name.

Response · 200

client_id*string

Example: "pcft_cli_2f5c..."

client_id_issued_at*number

Unix seconds at which the client_id was issued.

client_secret_expires_at*number

0 — the client secret does not expire on its own.

Example: 0

registration_client_uri*string

Endpoint the registration access token is used against.

redirect_uris*array
client_name*string
grant_types*array
response_types*array
token_endpoint_auth_method*string
scope*string
is_verified*boolean

Always false for a dynamically registered client. The consent screen shows this to the human.

Example: false

Example

Request

PUT /v1/oauth/register/{client_id}
Content-Type: application/json

{
  "redirect_uris": [
    "http://127.0.0.1:33418/callback"
  ],
  "client_name": "Claude Desktop",
  "grant_types": [
    "authorization_code",
    "refresh_token"
  ],
  "response_types": [
    "code"
  ],
  "token_endpoint_auth_method": "none",
  "scope": "workspace:read mail:read mail:send",
  "client_uri": "string",
  "logo_uri": "string"
}

Response

{
  "client_id": "pcft_cli_2f5c...",
  "client_id_issued_at": 0,
  "client_secret_expires_at": 0,
  "registration_client_uri": "string",
  "redirect_uris": [
    "string"
  ],
  "client_name": "string",
  "grant_types": [
    "string"
  ],
  "response_types": [
    "string"
  ],
  "token_endpoint_auth_method": "string",
  "scope": "string",
  "is_verified": false
}
get/v1/oauth/authorize

OAuth 2.1 authorization endpoint

Requires PKCE with `code_challenge_method=S256`; `plain` and a missing challenge are both rejected. `redirect_uri` must match a registered URI exactly, with only the RFC 8252 §7.3 loopback port relaxation. Responds 302 either to sign-in (no session) or to the consent screen.

Query parameters

resourceobject

RFC 8707 resource indicator. Becomes the access token `aud`.

stateobject
code_challenge_method*object
code_challenge*object
scope*object

Space-delimited preset names (`readonly`, `mail`, `support`, `full`). A preset resolves to a policy template the human narrows on the consent screen; it is never authority in itself.

redirect_uri*object
client_id*object
response_type*object

Response · 302

Redirect to sign-in or to the consent screen.

post/v1/oauth/consent/{request_id}

Record the human’s consent decision

Approving mints a single-use authorization code bound to the client, the redirect URI, the PKCE challenge, the chosen workspace and the approved policy. The policy is narrowed against the human's own authority through the same `assertCallerCanGrant` the workspace-role and PAK lanes use; a grant that exceeds the granter is refused, never trimmed. Denying returns a redirect carrying `error=access_denied`. CSRF: the session must be presented as an `Authorization: Bearer` header. The ambient `auth_token` cookie is not accepted on its own, and a request whose `Sec-Fetch-Site` / `Origin` says a browser on another origin is driving it is refused — cookie policy cannot be the only CSRF control on the endpoint that mints a code.

Path parameters

request_id*string · uuid

Request body

approved*boolean

True to approve, false to deny.

workspace_idstring · uuid

Workspace the grant is scoped to. Required when approving.

policyarray

The policy the human approved. Defaults to the policy the requested presets resolve to. May narrow it, and may add an escalation action a preset withheld — but never beyond the human's own authority: a grant that exceeds the granter is refused with 403 and the response names the refused statements.

Response · 200

redirect_to*string

Absolute URL the browser must be sent to. Always the redirect URI captured when the request was created.

Example

Request

POST /v1/oauth/consent/{request_id}
Content-Type: application/json

{
  "approved": false,
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ]
}

Response

{
  "redirect_to": "string"
}
get/v1/oauth/connections

List clients this human has connected

The user-facing half of revocation: which third-party clients hold a grant, in which workspace, carrying which policy.

Query parameters

limitstring

Page size. Defaults to 20, clamped to 100.

cursorstring

Opaque `next_cursor` from the previous page.

Response · 200 Page of connections, newest first, with `next_cursor` and `has_more`.

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

Example

Request

GET /v1/oauth/connections

Response

{
  "data": [
    {
      "grant_id": "00000000-0000-0000-0000-000000000000",
      "client_id": "string",
      "client_name": "string",
      "is_verified": false,
      "account_id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "preset": "mail",
      "policy": [
        {
          "effect": "allow",
          "actions": [
            "social.read",
            "social.list"
          ],
          "resources": [
            "*"
          ]
        }
      ],
      "resource": "string",
      "created_at": "2026-01-01T00:00:00.000Z",
      "revoked_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
delete/v1/oauth/connections/{grant_id}

Disconnect a client

Revokes the whole grant family — the grant, its authorization codes, its refresh tokens and every live access token — in one transaction.

Path parameters

grant_id*string · uuid

Response · 204

Disconnected.

get/v1/oauth/workspaces/{workspace_id}/connections

List every client connected to a workspace

The workspace-side half of revocation, for owners and admins: every grant made against this workspace, by any member, including grants made by people who have since left. Requires `workspace.connection.read`.

Path parameters

workspace_id*string · uuid

Query parameters

limitstring

Page size. Defaults to 20, clamped to 100.

cursorstring

Opaque `next_cursor` from the previous page.

Response · 200 Page of connections, newest first, with `next_cursor` and `has_more`.

data*array
pagination*object
next_cursor*string

Example: null

has_more*boolean

Example: false

Example

Request

GET /v1/oauth/workspaces/{workspace_id}/connections

Response

{
  "data": [
    {
      "grant_id": "00000000-0000-0000-0000-000000000000",
      "client_id": "string",
      "client_name": "string",
      "is_verified": false,
      "account_id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "preset": "mail",
      "policy": [
        {
          "effect": "allow",
          "actions": [
            "social.read",
            "social.list"
          ],
          "resources": [
            "*"
          ]
        }
      ],
      "resource": "string",
      "created_at": "2026-01-01T00:00:00.000Z",
      "revoked_at": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
delete/v1/oauth/workspaces/{workspace_id}/connections/{grant_id}

Disconnect a client on behalf of the workspace

Revokes the whole grant family for a grant made by any member of this workspace — the lever a workspace owner needs when an employee leaves and their MCP client is still running. Requires `workspace.connection.revoke`.

Path parameters

workspace_id*string · uuid
grant_id*string · uuid

Response · 204

Disconnected.

post/v1/oauth/workspaces/{workspace_id}/connections/{grant_id}/restore

Lift a workspace revocation so the member may reconnect

A workspace revocation is durable: the member re-running the connect flow does not undo it, so an administrator has to. This clears the block only — every token minted under the old grant stays revoked and the member still has to approve a fresh consent screen. Requires `workspace.connection.revoke`.

Path parameters

workspace_id*string · uuid
grant_id*string · uuid

Response · 204

Revocation lifted.

post/v1/oauth/token

OAuth 2.1 token endpoint

Accepts `authorization_code` (with a mandatory `code_verifier`) and `refresh_token`. Both credentials are single-use: replaying a code or a refresh token issues nothing and revokes the entire grant family, so anything already minted from that grant stops working immediately.

Request body

grant_type*string

`authorization_code` or `refresh_token`.

Example: "authorization_code"

codestring

The single-use authorization code. `authorization_code` grant only.

code_verifierstring

PKCE verifier. Required on the `authorization_code` grant — there is no non-PKCE path.

redirect_uristring

Must be byte-identical to the `redirect_uri` used at `/authorize`. `authorization_code` grant only.

refresh_tokenstring

The rotating refresh token. `refresh_token` grant only.

client_idstring

Client identifier. Required for public clients; confidential clients may instead use HTTP Basic.

client_secretstring

Client secret for `client_secret_post` authentication.

scopestring

Narrowed preset name(s) on the `refresh_token` grant. A preset the original grant did not carry is rejected; narrowing keeps every deny and only removes actions, so it can never widen.

resourcestring

RFC 8707 resource indicator. Must match the one the grant was created for.

Response · 200

access_token*string

Signed JWT, audience-bound to the resource indicator and carrying the workspace, the preset name and the client id. It carries no policy — authority is resolved per request from the grant row and the human’s live policy.

token_type*string

Example: "Bearer"

expires_in*number

Seconds until the access token expires (≤ 900).

Example: 900

refresh_token*string

Rotating refresh token. The one presented is consumed; replaying it revokes the whole grant family.

scope*string

Preset name(s) the grant started from. Present for RFC 6749 §5.1 compliance; it is not the authority — `policy` is.

Example: "mail"

policy*array

The policy this token carries. Effective authority is this intersected with the authorizing human's current workspace policy, re-evaluated on every call.

workspace_id*string · uuid

Workspace the grant is scoped to.

Example

Request

POST /v1/oauth/token
Content-Type: application/json

{
  "grant_type": "authorization_code",
  "code": "string",
  "code_verifier": "string",
  "redirect_uri": "string",
  "refresh_token": "string",
  "client_id": "string",
  "client_secret": "string",
  "scope": "string",
  "resource": "string"
}

Response

{
  "access_token": "string",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "string",
  "scope": "mail",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "workspace_id": "00000000-0000-0000-0000-000000000000"
}
post/v1/oauth/revoke

Revoke a token (RFC 7009)

Revoking a refresh token takes the whole grant family with it. Always answers 200, including for an unknown token — otherwise the endpoint would be a token-validity oracle.

Request body

token*string

The access or refresh token to revoke.

token_type_hintstring

`access_token` or `refresh_token`. Advisory only.

client_idstring
client_secretstring

Response · 200

Revoked, or the token was already unknown.

Example

Request

POST /v1/oauth/revoke
Content-Type: application/json

{
  "token": "string",
  "token_type_hint": "string",
  "client_id": "string",
  "client_secret": "string"
}
post/v1/oauth/introspect

Introspect an access token (RFC 7662)

Client-authenticated, and a client may only introspect its own tokens. `policy` is the ceiling the human approved; `effective_policy` is that intersected with the human's current workspace policy, resolved live on every call with no cache, so a demotion or an offboarding lands on the next call rather than at token expiry.

Request body

token*string

The access token to introspect.

client_idstring
client_secretstring

Response · 200

active*boolean

False for an unknown, expired, revoked, or foreign token — the four are deliberately indistinguishable.

scopestring

Preset name(s) the grant started from. Not authority.

client_idstring
substring · uuid

The human who consented.

workspace_idstring · uuid
audstring

Resource indicator the token is bound to.

expnumber

Expiry, unix seconds.

policyarray

The policy stored on the token — the ceiling the human approved at consent.

effective_policyarray

Effective authority: the token's policy intersected with the human's CURRENT workspace policy, recomputed on every call with no cache. An action the human's role loses disappears here without anyone revoking the token; a human who left the workspace makes the token report `active: false`. This is a report — the authorization decision itself re-evaluates both policies per (action, resource) cell.

Example

Request

POST /v1/oauth/introspect
Content-Type: application/json

{
  "token": "string",
  "client_id": "string",
  "client_secret": "string"
}

Response

{
  "active": false,
  "scope": "string",
  "client_id": "string",
  "sub": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "aud": "string",
  "exp": 0,
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "effective_policy": [
    {
      "effect": "allow",
      "actions": [
        "social.read",
        "social.list"
      ],
      "resources": [
        "*"
      ]
    }
  ]
}
post/v1/oauth/delegated/authorize

Decide one action for a delegated access token

Returns the live effective-policy decision for a single `(action, resource)` pair. Evaluated per call against the grant’s policy AND the authorizing human’s current workspace policy — never from a decision cached at consent time. The delegated access token is presented as the bearer.

Headers

authorization*string

Request body

action*string

The action to decide, in `<service>.<resource>.<verb>` form — the same catalogue the workspace-role and PAK lanes evaluate against.

Example: "mail.message.send"

resourcestring

Resource URN the action targets. Omitted means `*`, which the human’s URN-narrowed denies still constrain.

Example: "pcft:envoi:domain/8f14e45f-ceea-467a-9c1a-2b8a4f2a11c3"

Response · 200

allowed*boolean

Whether the effective policy — the grant’s policy AND the authorizing human’s current workspace policy — allows this action right now.

reason*string

Machine-readable outcome: `allowed`, `denied_by_effective_policy`, `token_inactive`, `token_expired`, `grant_revoked`, `granter_not_a_member`, or `granter_account_inactive` (the authorizing human’s account is suspended or deactivated).

Example: "allowed"

Example

Request

POST /v1/oauth/delegated/authorize
Content-Type: application/json

{
  "action": "mail.message.send",
  "resource": "pcft:envoi:domain/8f14e45f-ceea-467a-9c1a-2b8a4f2a11c3"
}

Response

{
  "allowed": false,
  "reason": "allowed"
}
post/v1/oauth/delegated/authorize-batch

Decide many actions for a delegated access token

Evaluates a list of actions against the same live effective policy as `POST /authorize`, in one pass. Exists so an MCP server can filter a several-hundred-tool catalog down to what the session actually permits without a round trip per tool. It is a **listing** aid: a tool invocation is still decided on its own, per call.

Headers

authorization*string

Request body

actions*array

Actions to decide, in `<service>.<resource>.<verb>` form. Duplicates are evaluated once and echoed per entry.

Example: ["mail.send","mail.list"]

resourcestring

Resource URN every action is decided against. Omitted means `*`, which the human’s URN-narrowed denies still constrain.

Example: "pcft:envoi:domain/8f14e45f-ceea-467a-9c1a-2b8a4f2a11c3"

Response · 200

decisions*array

One decision per requested action, in the order they were requested.

reason*string

Machine-readable outcome for the token as a whole: `evaluated`, `token_inactive`, `token_expired`, `grant_revoked`, `granter_not_a_member`, or `granter_account_inactive`. Anything but `evaluated` means every decision is `false`.

Example: "evaluated"

Example

Request

POST /v1/oauth/delegated/authorize-batch
Content-Type: application/json

{
  "actions": [
    "mail.send",
    "mail.list"
  ],
  "resource": "pcft:envoi:domain/8f14e45f-ceea-467a-9c1a-2b8a4f2a11c3"
}

Response

{
  "decisions": [
    {
      "action": "mail.send",
      "allowed": false
    }
  ],
  "reason": "evaluated"
}
post/v1/oauth/delegated/downstream-assertion

Mint a short-lived assertion for one downstream service

Exchanges a delegated access token for a ~60s assertion audienced at ONE named service, so that service can verify the caller offline without calling back (`docs/mcp.md` §5b). The assertion carries identity — account, workspace, client, grant — and deliberately no policy: the target resolves the delegated effective policy per request through `/v1/introspect`, so a revoked grant or a demoted human is refused on the next call rather than at this assertion’s expiry.

Headers

authorization*string

Request body

audience*string

Hostname of the service the assertion is for, e.g. `api.mail.productcraft.co`. Must be one we issue for; anything else is 400. This is an allowlist and not a free-text audience, because the audience string is what separates this family from the session lane.

Example: "api.mail.productcraft.co"

Response · 200

assertion*string

The signed assertion. Verifies offline against the platform JWKS. Carries identity only — no policy.

expires_in*number

Seconds until it expires. Deliberately short.

Example: 60

audience*string

The audience it was minted for, echoed back.

Example: "api.mail.productcraft.co"

Example

Request

POST /v1/oauth/delegated/downstream-assertion
Content-Type: application/json

{
  "audience": "api.mail.productcraft.co"
}

Response

{
  "assertion": "string",
  "expires_in": 60,
  "audience": "api.mail.productcraft.co"
}
post/v1/oauth/delegated/authorize-assertion

Decide one action for a downstream assertion

The REST-service counterpart of `POST /authorize`. A service holds only the short-lived assertion, never the delegated access token, so it asks here. Evaluated against the grant’s consented policy AND the authorizing human’s current workspace policy, live and uncached — a revoked grant, a demoted human or an offboarded one is refused on the next call.

Headers

authorization*string

Request body

action*string

Action to decide, `<service>.<resource>.<verb>`.

resourcestring

URN the action targets. Omitted means `*`, the strict reading.

Response · 200

allowed*boolean

Whether the effective policy — the grant’s policy AND the authorizing human’s current workspace policy — allows this action right now.

reason*string

Machine-readable outcome: `allowed`, `denied_by_effective_policy`, `token_inactive`, `token_expired`, `grant_revoked`, `granter_not_a_member`, or `granter_account_inactive` (the authorizing human’s account is suspended or deactivated).

Example: "allowed"

Example

Request

POST /v1/oauth/delegated/authorize-assertion
Content-Type: application/json

{
  "action": "string",
  "resource": "string"
}

Response

{
  "allowed": false,
  "reason": "allowed"
}
API Reference | Platform Docs | ProductCraft