API Reference
Workspaces API

Workspaces API

Full REST reference for the workspaces domain. All routes are under /api/workspaces, require a Cognito JWT (Authorization: Bearer <token>), and are backed by services/backend/lambda/workspaces_handler.py / workspaces_service.py. See specs/api/workspaces.md in the repo for the underlying spec this page is based on, and the Workspaces feature guide for the product-level behavior.

⚠️

Unlike the Posts and Admin APIs, no endpoint in this domain reads the JWT custom:role claim. Every authorization check here is based solely on the caller's membership record (role: owner / editor / viewer) for the specific workspace being acted on.

Endpoints at a glance

MethodPathDescription
POST/api/workspacesCreate a workspace
GET/api/workspacesList my workspaces
GET/api/workspaces/{id}Get a workspace
PUT/api/workspaces/{id}Update a workspace
DELETE/api/workspaces/{id}Delete a workspace
POST/api/workspaces/{id}/membersAdd / invite a member
GET/api/workspaces/{id}/membersList members
DELETE/api/workspaces/{id}/members/{userId}Remove a member

Every error response has the same shape:

{ "error": "human-readable message" }

Response bodies are the raw DynamoDB item as stored, so workspace and member objects also carry the internal pk, sk, gsi1pk, gsi1sk, and entity_type attributes — omitted from the examples below for readability. Treat them as opaque storage plumbing, not part of the contract.

Create

POST /api/workspaces
// Request
{ "name": "Acme Marketing", "description": "Q3 content calendar" }

Creates the workspace and your owner membership in one call.

// 201 Response
{
  "workspace_id": "ws_123",
  "name": "Acme Marketing",
  "description": "Q3 content calendar",
  "owner_id": "user_1",
  "status": "active",
  "default_prompt_id": null,
  "created_at": "2026-07-17T10:00:00+00:00",
  "updated_at": "2026-07-17T10:00:00+00:00"
}
StatusReason
400name missing/empty after trimming, or over 100 characters

List

GET /api/workspaces

Returns every workspace you're currently a member of, each annotated with your member_role. Soft-deleted workspaces are excluded.

// 200 Response
{
  "workspaces": [
    {
      "workspace_id": "ws_123",
      "name": "Acme Marketing",
      "description": "Q3 content calendar",
      "owner_id": "user_1",
      "status": "active",
      "default_prompt_id": null,
      "created_at": "2026-07-17T10:00:00+00:00",
      "updated_at": "2026-07-17T10:00:00+00:00",
      "member_role": "owner"
    }
  ]
}

Get

GET /api/workspaces/{id}

Same shape as Create's response, plus member_count and your own member_role.

// 200 Response
{
  "workspace_id": "ws_123",
  "name": "Acme Marketing",
  "description": "Q3 content calendar",
  "owner_id": "user_1",
  "status": "active",
  "default_prompt_id": null,
  "created_at": "2026-07-17T10:00:00+00:00",
  "updated_at": "2026-07-17T10:00:00+00:00",
  "member_count": 4,
  "member_role": "owner"
}
StatusReason
403Caller is not a member
404Workspace does not exist or is soft-deleted

Update

PUT /api/workspaces/{id}

Partial update — send only the fields you're changing. Requires your role to be owner or editor.

// Request (any subset)
{
  "name": "Acme Marketing (renamed)",
  "description": "Updated description",
  "default_prompt_id": "prompt_789",
  "publishing_slots": ["FRI 07:30"]
}

Transferring ownership: include owner_id in the same request to hand off ownership. Only the current owner may do this, and the target must already be a member.

// Request — ownership transfer
{ "owner_id": "user_2" }

A transfer downgrades the previous owner to editor and promotes the target to owner — there's no co-owner state. See Transferring ownership.

// 200 Response
{ "workspace_id": "ws_123", "name": "...", "owner_id": "user_2", "...": "..." }
StatusReason
400owner_id given but the target user isn't a member
403Caller's role is not owner/editor
403Caller isn't owner but tried to transfer ownership
404Workspace does not exist or is soft-deleted (checked after the role check — a non-privileged caller against a bogus ID still gets 403, never 404)

Delete

DELETE /api/workspaces/{id}

Soft-deletes the workspace (status → "deleted"). Owner-only.

⚠️

A non-member and a non-owner member get the same 403 message here — this endpoint doesn't distinguish "you're not in this workspace" from "you're in it but not the owner."

Response: 204 No Content

StatusReason
403Caller's role is not owner (or caller is not a member at all)
404Workspace does not exist or is already soft-deleted

Add Member

POST /api/workspaces/{id}/members

Requires your role to be owner or editor.

// Request
{ "email": "new.person@example.com", "role": "editor" }

email is required (trimmed + lowercased). role defaults to "viewer"; if given, must be owner, editor, or viewer.

// 201 Response — existing user, added immediately
{
  "workspace_id": "ws_123",
  "user_id": "user_2",
  "role": "editor",
  "joined_at": "2026-07-17T10:05:00+00:00"
}
// 201 Response — unregistered email, pending invite created
{ "status": "invited", "email": "new.person@example.com", "role": "editor" }

A pending invite is consumed automatically by Cognito's post-confirmation hook the moment someone signs up with that email — see Inviting someone. No email/Slack notification is sent by this endpoint itself.

StatusReason
400email missing, or role isn't one of owner/editor/viewer
403Caller's role is not owner/editor
404Workspace does not exist or is soft-deleted
409Target user is already a member

List Members

GET /api/workspaces/{id}/members

Any member (any role) can call this.

// 200 Response
{
  "members": [
    {
      "workspace_id": "ws_123",
      "user_id": "user_1",
      "role": "owner",
      "joined_at": "2026-07-17T10:00:00+00:00",
      "email": "owner@example.com",
      "name": "Jane Doe"
    }
  ]
}
⚠️

This endpoint does not check whether the workspace is still active — a member of a soft-deleted workspace can still list it, even though Get and List would no longer surface that workspace at all.

StatusReason
403Caller is not a member

Remove Member

DELETE /api/workspaces/{id}/members/{userId}

Two allowed cases:

  • Removing yourself ({userId} = your own ID): allowed for any role except owner.
  • Removing someone else: only allowed if you're the owner.

Response: 204 No Content

StatusReason
400The owner tried to remove themselves — transfer ownership first
403Caller is not a member
403Caller isn't the owner but tried to remove someone else
404{userId} is not a member of the workspace

See also