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
| Method | Path | Description |
|---|---|---|
POST | /api/actions | Create an action |
GET | /api/actions | List actions |
GET | /api/actions/{id} | Get an action |
PUT | /api/actions/{id} | Update an action |
PUT | /api/actions/{id}/status | Change status |
PUT | /api/actions/{id}/response | Accept/decline (assignee) |
DELETE | /api/actions/{id} | Delete an action |
POST | /api/actions/{id}/attachments | Get an upload URL |
GET | /api/actions/{id}/attachments | List attachments |
DELETE | /api/actions/{id}/attachments | Delete 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.
| Status | Reason |
|---|---|
| 400 | Missing workspace_id / title / assignee_id, or assignee_id not a workspace member |
| 403 | Caller is not a member of workspace_id |
List
GET /api/actions?workspaceId=ws_123&status=open&assigneeId=user_456&linkedPostId=post_789All 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.
| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller is not a member of workspaceId |
Get
GET /api/actions/{id}?workspaceId=ws_123Returns the action object (same shape as Create's response).
| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller is not a member of workspaceId |
| 404 | No 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.
| Field | Behavior |
|---|---|
title | trimmed, replaces title |
description, linked_post_id, priority, due_date | replace as-is, no validation |
assignee_id | must be a workspace member (400 otherwise); triggers reassignment side effects above |
| Status | Reason |
|---|---|
| 400 | Missing workspace_id, or assignee_id not a workspace member |
| 403 | Caller is not a member of workspace_id |
| 404 | Action 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
ActionCompletedandActionTurnaroundSecondsmetrics (turnaround =completed_at - created_at; skipped, logged, non-fatal ifcreated_atdoesn't parse), - Slack-DMs the creator that the assignee finished the work.
| Status | Reason |
|---|---|
| 400 | Missing workspace_id, or status not one of the four valid values |
| 403 | Caller is not a member of workspace_id |
| 404 | Action 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.
| Status | Reason |
|---|---|
| 400 | Missing workspace_id, or response not accepted/declined |
| 403 | Caller is not a workspace member, or is a member but not the assignee |
| 404 | Action not found |
Delete
DELETE /api/actions/{id}?workspaceId=ws_123Only the action's creator, or a caller whose JWT custom:role claim is
admin, may delete.
| Status | Reason |
|---|---|
| 204 | Deleted |
| 400 | Missing workspaceId |
| 403 | Caller is not a workspace member; or is a member but neither creator nor admin |
| 404 | Action 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.
| Status | Reason |
|---|---|
| 400 | Missing workspace_id / filename, or disallowed file extension |
| 403 | Caller is not a member of workspace_id |
| 404 | Action not found |
| 500 | ASSETS_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.
| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller is not a member of workspaceId |
| 404 | Action not found |
Delete attachment
DELETE /api/actions/{id}/attachments?workspaceId=ws_123&key=workspaces%2Fws_123%2Factions%2Fa1b2c3d4%2Fhero.pngkey 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.
| Status | Reason |
|---|---|
| 400 | Missing workspaceId or key |
| 403 | Caller is not a member of workspaceId |
| 404 | Action not found, or no attachment on the action matches key |