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 Personal Access Token (pcft_live_*) issued from the console’s API Keys page — pass it as Authorization: Bearer <token>. The PAT's policy determines which actions are allowed; every endpoint also evaluates the caller's workspace role policy, so the effective surface is the intersection.

Base URL

https://api.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

Access token (JWT)

Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

refresh_tokenstring

Refresh token (JWT)

Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

token_type*string

Token type

Example: "Bearer"

expires_in*number

Access token TTL in seconds

Example: 3600

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": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}
post/v1/auth/signin

Sign in with email/username and password

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

Access token (JWT)

Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

refresh_tokenstring

Refresh token (JWT)

Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

token_type*string

Token type

Example: "Bearer"

expires_in*number

Access token TTL in seconds

Example: 3600

Example

Request

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

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

Response

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}
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

Access token (JWT)

Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

refresh_tokenstring

Refresh token (JWT)

Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

token_type*string

Token type

Example: "Bearer"

expires_in*number

Access token TTL in seconds

Example: 3600

Example

Request

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

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

Response

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}
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. The response shape never reveals whether the email matched an account.

Request body

email*string

Email address to send reset token

Example: "user@example.com"

Response · 201

Reset request accepted. If the email matches an account, an email has been queued.

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 · 201

Password reset; all active sessions revoked.

Example

Request

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

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

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": [
        "envoi",
        "rally"
      ]
    }
  ]
}
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: ["envoi","rally"]

Example

Request

GET /v1/introspect/workspaces/{workspace_id}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "is_member": false,
  "role": {},
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "agora.read",
        "agora.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "services": [
    "envoi",
    "rally"
  ]
}
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": [
        "agora.read",
        "agora.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

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 (rally-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 (rally-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)

Path parameters

workspace_slug*string

Response · 204

Workspace deleted.

get/v1/workspaces/{workspace_slug}/members

List workspace members

Path parameters

workspace_slug*string

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/{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",
      "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

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

Path parameters

workspace_slug*string

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/{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

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

Path parameters

workspace_slug*string
invite_id*string

Response · 204

Invite revoked.


Workspace-Roles

get/v1/workspaces/{workspace_slug}/roles

List all roles in a workspace

Path parameters

workspace_slug*string

Response · 200

data*array

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": [
            "agora.read",
            "agora.list"
          ],
          "resources": [
            "*"
          ]
        }
      ],
      "policy_id": "00000000-0000-0000-0000-000000000000"
    }
  ]
}
post/v1/workspaces/{workspace_slug}/roles

Create a new custom role

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":["envoi.*.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": [
        "envoi.*.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": [
        "agora.read",
        "agora.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": [
        "agora.read",
        "agora.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

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": [
        "agora.read",
        "agora.list"
      ],
      "resources": [
        "*"
      ]
    }
  ],
  "policy_id": "string"
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "description": "string",
  "is_system": false,
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "agora.read",
        "agora.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)

Path parameters

workspace_slug*string
role_id*string

Response · 204

Custom role deleted.


Workspace-Policies

get/v1/workspaces/{workspace_slug}/policies

List every managed policy in a workspace

Path parameters

workspace_slug*string

Response · 200

data*array

Example

Request

GET /v1/workspaces/{workspace_slug}/policies

Response

{
  "data": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "workspace_id": "00000000-0000-0000-0000-000000000000",
      "name": "envoi-readonly",
      "description": "string",
      "policy": [
        {
          "effect": "allow",
          "actions": [
            "agora.read",
            "agora.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"
    }
  ]
}
post/v1/workspaces/{workspace_slug}/policies

Create a managed policy

Path parameters

workspace_slug*string

Request body

name*string

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

Example: "envoi-readonly"

descriptionstring

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

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

policy*array

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

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

Response · 201 Policy created.

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

Example: "envoi-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": "envoi-readonly",
  "description": "Read-only access to all Envoi resources",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "envoi.read",
        "envoi.list"
      ],
      "resources": [
        "*"
      ]
    }
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "name": "envoi-readonly",
  "description": "string",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "agora.read",
        "agora.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: "envoi-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": "envoi-readonly",
  "description": "string",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "agora.read",
        "agora.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

Path parameters

workspace_slug*string
policy_id*string

Request body

namestring

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

Example: "envoi-readonly"

descriptionstring

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

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

policyarray

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

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

Response · 200 Updated policy.

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

Example: "envoi-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": "envoi-readonly",
  "description": "Read-only access to all Envoi resources",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "envoi.read",
        "envoi.list"
      ],
      "resources": [
        "*"
      ]
    }
  ]
}

Response

{
  "id": "00000000-0000-0000-0000-000000000000",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "name": "envoi-readonly",
  "description": "string",
  "policy": [
    {
      "effect": "allow",
      "actions": [
        "agora.read",
        "agora.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.

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

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": "envoi",
      "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. `envoi`, `rally`, `agora`, `heimdall`).

Example: "envoi"

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": "envoi",
  "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. `envoi`, `rally`, `agora`, `heimdall`).

Example: "envoi"

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": "envoi",
  "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. Plaintext tokens are NOT returned — only prefixes + metadata.

Path parameters

workspace_slug*string

Response · 200

Array of 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
created_at*string · date-time
updated_at*string · date-time

Example

Request

GET /v1/workspaces/{workspace_slug}/api-keys
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": "envoi-readonly",
        "description": "string"
      }
    ],
    "last_used_at": "2026-01-01T00:00:00.000Z",
    "revoked_at": "2026-01-01T00:00:00.000Z",
    "created_at": "2026-01-01T00:00:00.000Z",
    "updated_at": "2026-01-01T00:00:00.000Z"
  }
]
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.

Path parameters

workspace_slug*string

Request body

name*string

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

Example: "Production agora 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"]

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
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 agora client",
  "description": "string",
  "policy_ids": [
    "11111111-1111-1111-1111-111111111111"
  ]
}

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": "envoi-readonly",
        "description": "string"
      }
    ],
    "last_used_at": "2026-01-01T00:00:00.000Z",
    "revoked_at": "2026-01-01T00:00:00.000Z",
    "created_at": "2026-01-01T00:00:00.000Z",
    "updated_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
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": "envoi-readonly",
      "description": "string"
    }
  ],
  "last_used_at": "2026-01-01T00:00:00.000Z",
  "revoked_at": "2026-01-01T00:00:00.000Z",
  "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
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": "envoi-readonly",
      "description": "string"
    }
  ],
  "last_used_at": "2026-01-01T00:00:00.000Z",
  "revoked_at": "2026-01-01T00:00:00.000Z",
  "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
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": "envoi-readonly",
      "description": "string"
    }
  ],
  "last_used_at": "2026-01-01T00:00:00.000Z",
  "revoked_at": "2026-01-01T00:00:00.000Z",
  "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

Path parameters

workspace_slug*string

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/{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-feed

Unified workspace audit feed (cursor-paginated)

Merged audit timeline across the workspace surfaces. v1 covers `workspace_audit` (this service); per-app Heimdall and per-community Agora rows arrive in a follow-up. Response shape is stable from v1 — clients can build against it today.

Path parameters

workspace_slug*string

Query parameters

sincestring

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

cursorstring

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

limitnumber

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.

Response · 204

Account deleted. All sessions revoked.


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"
  }
]

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"
    }
  ]
}