API Reference
Actions API

Actions API

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

Request bodies are snake_case per lk-wiz convention (workspace_id, assignee_id, ...). Query parameters on this domain are camelCase (workspaceId, assigneeId, linkedPostId) — this is the standard lk-wiz split (bodies snake_case, query params camelCase, see the project's API naming convention). Two response bodies in this domain (POST .../attachments and GET .../attachments) also return camelCase keys (uploadUrl, downloadUrl) rather than the usual snake_case — called out inline below since it's an exception, not the rule.

Endpoints at a glance

MethodPathDescription
POST/api/actionsCreate an action
GET/api/actionsList actions
GET/api/actions/{id}Get an action
PUT/api/actions/{id}Update an action
PUT/api/actions/{id}/statusChange status
PUT/api/actions/{id}/responseAccept/decline (assignee)
DELETE/api/actions/{id}Delete an action
POST/api/actions/{id}/attachmentsGet an upload URL
GET/api/actions/{id}/attachmentsList attachments
DELETE/api/actions/{id}/attachmentsDelete an attachment

Create

POST /api/actions
// Request
{
  "workspace_id": "ws_123",
  "title": "Create hero graphic",
  "assignee_id": "user_456",
  "description": "1200x627, brand colors",
  "linked_post_id": "post_789",
  "priority": "p2_high",
  "due_date": "2026-07-25"
}
// 201 Response
{
  "action_id": "a1b2c3d4-...",
  "workspace_id": "ws_123",
  "title": "Create hero graphic",
  "description": "1200x627, brand colors",
  "assignee_id": "user_456",
  "assignee_name": "Jane Doe",
  "assignee_response": "pending",
  "linked_post_id": "post_789",
  "status": "open",
  "priority": "p2_high",
  "due_date": "2026-07-25",
  "attachments": [],
  "created_by": "user_111",
  "created_at": "2026-07-17T12:00:00+00:00",
  "updated_at": "2026-07-17T12:00:00+00:00",
  "completed_at": null
}

workspace_id, title, and assignee_id are required (400 if missing or empty after trimming). assignee_id must already be a workspace member — there is no auto-invite from this endpoint (that flow lives in the frontend picker, which calls the workspace invite endpoint first).

Sends the "new action" Slack DM to the assignee before returning — a Slack failure fails the request even though the action was already written to DynamoDB.

StatusReason
400Missing workspace_id / title / assignee_id, or assignee_id not a workspace member
403Caller is not a member of workspace_id

List

GET /api/actions?workspaceId=ws_123&status=open&assigneeId=user_456&linkedPostId=post_789

All filter params are optional except workspaceId; all are exact-match.

// 200 Response
{ "items": [ /* action objects */ ], "count": 2 }
⚠️

There is no pagination on this endpoint. It's a single DynamoDB Query over all of a workspace's actions — fine at normal scale, but a workspace with an extremely large action history could see results silently truncated at one DynamoDB query page.

StatusReason
400Missing workspaceId
403Caller is not a member of workspaceId

Get

GET /api/actions/{id}?workspaceId=ws_123

Returns the action object (same shape as Create's response).

StatusReason
400Missing workspaceId
403Caller is not a member of workspaceId
404No action {id} in that workspace

Update

PUT /api/actions/{id}

Partial update — send only the fields you want to change, plus workspace_id (required for the membership check).

// Request — reassign and bump priority
{ "workspace_id": "ws_123", "assignee_id": "user_999", "priority": "p1_urgent" }

Reassigning (assignee_id present and different) resets assignee_response to pending, refreshes assignee_name, and sends a new "action assigned" Slack DM to the new assignee.

FieldBehavior
titletrimmed, replaces title
description, linked_post_id, priority, due_datereplace as-is, no validation
assignee_idmust be a workspace member (400 otherwise); triggers reassignment side effects above
StatusReason
400Missing workspace_id, or assignee_id not a workspace member
403Caller is not a member of workspace_id
404Action not found

Update status

PUT /api/actions/{id}/status
{ "workspace_id": "ws_123", "status": "in_progress" }

status must be one of open, in_progress, completed, cancelled. Any status can transition to any other — there's no enforced workflow graph.

Setting status: "completed" additionally:

  • stamps completed_at,
  • emits the ActionCompleted and ActionTurnaroundSeconds metrics (turnaround = completed_at - created_at; skipped, logged, non-fatal if created_at doesn't parse),
  • Slack-DMs the creator that the assignee finished the work.
StatusReason
400Missing workspace_id, or status not one of the four valid values
403Caller is not a member of workspace_id
404Action not found

Respond

PUT /api/actions/{id}/response
{ "workspace_id": "ws_123", "response": "accepted" }

Only the current assignee may call this. response must be accepted or declined. This sets assignee_response — it does not change status. Slack-DMs the creator with the outcome.

StatusReason
400Missing workspace_id, or response not accepted/declined
403Caller is not a workspace member, or is a member but not the assignee
404Action not found

Delete

DELETE /api/actions/{id}?workspaceId=ws_123

Only the action's creator, or a caller whose JWT custom:role claim is admin, may delete.

StatusReason
204Deleted
400Missing workspaceId
403Caller is not a workspace member; or is a member but neither creator nor admin
404Action not found

Add attachment

POST /api/actions/{id}/attachments
// Request
{ "workspace_id": "ws_123", "filename": "hero.png", "contentType": "image/png" }
// 201 Response
{
  "uploadUrl": "https://<bucket>.s3.eu-central-1.amazonaws.com/...&X-Amz-Signature=...",
  "key": "workspaces/ws_123/actions/a1b2c3d4/hero.png",
  "expiresIn": 3600
}

filename's extension must be one of png, jpg, jpeg, svg, webp, pdf (case-insensitive). Only the basename is used in the storage key — any directory components you send are stripped server-side.

The attachment's metadata (key, filename, uploaded_at, uploaded_by) is recorded on the action immediately, before you've actually performed the PUT to uploadUrl. If your client never completes the upload, the action will still show a broken attachment entry — there is no cleanup job for abandoned presigned URLs.

⚠️

Your client must PUT the file bytes to uploadUrl with a Content-Type header matching the contentType you sent — S3 will reject the upload if they don't match.

StatusReason
400Missing workspace_id / filename, or disallowed file extension
403Caller is not a member of workspace_id
404Action not found
500ASSETS_BUCKET not configured (deployment issue, not a client error)

List attachments

GET /api/actions/{id}/attachments?workspaceId=ws_123
// 200 Response
{
  "items": [
    {
      "key": "workspaces/ws_123/actions/a1b2c3d4/hero.png",
      "filename": "hero.png",
      "uploaded_at": "2026-07-17T12:05:00+00:00",
      "uploaded_by": "user_456",
      "downloadUrl": "https://..."
    }
  ],
  "count": 1
}

downloadUrl is generated fresh on every call (1 hour expiry) — don't cache it beyond that.

StatusReason
400Missing workspaceId
403Caller is not a member of workspaceId
404Action not found

Delete attachment

DELETE /api/actions/{id}/attachments?workspaceId=ws_123&key=workspaces%2Fws_123%2Factions%2Fa1b2c3d4%2Fhero.png

key is sent as a query parameter, not a path segment — the S3 key contains slashes, and a path segment would fragment across extra API Gateway route segments once %2F is decoded.

Deletes the S3 object and removes the matching entry from the action's attachment list in the same call.

StatusReason
400Missing workspaceId or key
403Caller is not a member of workspaceId
404Action not found, or no attachment on the action matches key