API Reference
Posts API

Posts API

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

Response bodies are the raw DynamoDB item as stored, so posts, versions, and images also carry the internal pk, sk, gsi1pk, gsi1sk (and gsi2pk/gsi2sk once scheduled) and entity_type attributes, omitted from the examples below for readability. Treat them as opaque storage plumbing, not part of the contract.

Endpoints at a glance

MethodPathDescription
POST/api/postsCreate a post
GET/api/postsList posts
POST/api/posts/reorderReorder posts
GET/api/posts/{id}Get a post
PUT/api/posts/{id}Update a post
DELETE/api/posts/{id}Delete a post
PUT/api/posts/{id}/statusChange status
POST/api/posts/{id}/scheduleSchedule a post
POST/api/posts/{id}/publishPublish a post
GET/api/posts/{id}/versionsList versions
GET/api/posts/{id}/versions/{v}Get a version
POST/api/posts/{id}/versionsCreate a version
POST/api/posts/{id}/imagesGet an upload URL
GET/api/posts/{id}/imagesList images
DELETE/api/posts/{id}/images/{image_id}Delete an image

Every error response has the same shape:

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

Create

POST /api/posts
// Request
{
  "workspace_id": "ws_123",
  "title": "Why observability matters",
  "content": "<p>optional initial content</p>",
  "source_idea_id": "idea_456",
  "prompt_id": "prompt_789",
  "tags": ["engineering", "observability"],
  "yjs_state": "<base64 raw Yjs update, optional>"
}
// 201 Response
{
  "post_id": "post_abc",
  "workspace_id": "ws_123",
  "title": "Why observability matters",
  "content": "<p>optional initial content</p>",
  "status": "draft",
  "priority": "p3_medium",
  "author_id": "user_1",
  "author_name": "Jane Doe",
  "reviewer_id": null,
  "source_idea_id": "idea_456",
  "prompt_id": "prompt_789",
  "version": 1,
  "scheduled_at": null,
  "published_at": null,
  "review_comment": null,
  "tags": ["engineering", "observability"],
  "tagged_entities": [],
  "rank": 0,
  "created_at": "2026-07-11T10:00:00+00:00",
  "updated_at": "2026-07-11T10:00:00+00:00"
}

status, priority, and version are never caller-supplied — they're always draft, p3_medium, 1 on create.

StatusReason
400workspace_id or title missing
403Caller is not a member of workspace_id

List

GET /api/posts?workspaceId=ws_123&status=draft&authorId=user_1

workspaceId is required; status and authorId are optional exact-match filters.

// 200 Response
{ "items": [ /* post objects */ ], "count": 1 }
StatusReason
400Missing workspaceId
403Caller is not a member of workspaceId

Reorder

POST /api/posts/reorder
// Request
{ "workspace_id": "ws_123", "post_ids": ["post_c", "post_a", "post_b"] }

The order of post_ids becomes the new top-to-bottom kanban rank — the first ID gets the highest rank.

// 200 Response
{ "updated": 3 }
StatusReason
400workspace_id missing, or post_ids missing/empty/not a list
403Caller is not a member
404Some post_id doesn't exist in the workspace (aborts the whole batch)

Get

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

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

StatusReason
400Missing workspaceId
403Caller is not a member
404Post not found in that workspace

Update

PUT /api/posts/{id}

Partial update — only the fields you send are changed. status is rejected here; use Change status instead.

// Request (any subset)
{
  "workspace_id": "ws_123",
  "title": "Updated title",
  "content": "<p>updated content</p>",
  "tags": ["engineering"],
  "reviewer_id": "user_2",
  "tagged_entities": [
    { "type": "person", "name": "Jane Doe", "linkedin_url": "https://linkedin.com/in/janedoe" }
  ],
  "priority": "p1_urgent"
}

priority must be one of p1_urgent, p2_high, p3_medium, p4_low.

StatusReason
400workspace_id missing, or priority invalid
403Caller is not a member
404Post not found

Delete

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

Non-privileged callers (JWT custom:role not admin/owner) may only delete draft or archived posts; privileged callers can delete any status. Hard delete — versions and images are not cascade-deleted.

// 200 Response
{ "deleted": true }
StatusReason
400Missing workspaceId
403Caller is not a member
404Post not found
409Non-privileged caller deleting a post that isn't draft/archived

Update Status

PUT /api/posts/{id}/status
// Request
{
  "workspace_id": "ws_123",
  "status": "in_review",
  "review_comment": "",
  "scheduled_at": ""
}

See Features — Manage for the full transition table. review_comment is required moving in_review → revision_requested; scheduled_at is required moving to scheduled.

Returns the updated post object, including any side-effect fields (a version snapshot bumps version; entering published sets published_at, etc).

Any workspace member can drive a status transition through this endpoint — the only enforcement is the transition table itself. There is no additional role gate here, unlike Schedule and Publish below.

StatusReason
400workspace_id missing; status missing/invalid; missing review_comment on a revision request; missing scheduled_at when scheduling
403Caller is not a member
404Post not found
409The requested transition isn't allowed from the post's current status

Schedule

POST /api/posts/{id}/schedule
// Request
{ "workspace_id": "ws_123", "scheduled_at": "2026-07-18T07:30:00+00:00" }

Only approved or already-scheduled posts can be (re)scheduled. Caller must resolve to the post's author, an editor, or an owner — a workspace viewer is rejected.

StatusReason
400workspace_id or scheduled_at missing
403Caller is not a member, or caller's role cannot schedule posts
404Post not found
409Post is not approved/scheduled

Publish

POST /api/posts/{id}/publish
// Request
{ "workspace_id": "ws_123" }

Marks the post published immediately — used once the author has manually copied the content into Zoho Social. Same role gate as Schedule.

StatusReason
400Missing workspace_id
403Caller is not a member, or caller's role cannot publish posts
404Post not found
409Post is not approved/scheduled

List Versions

GET /api/posts/{id}/versions?workspaceId=ws_123
// 200 Response
{
  "items": [
    {
      "version": 1,
      "content": "...",
      "char_count": 42,
      "created_by": "user_1",
      "created_by_name": "Jane Doe",
      "created_at": "2026-07-11T10:00:00+00:00",
      "trigger": "manual",
      "genai_instruction": ""
    }
  ],
  "count": 1
}
⚠️

Workspace viewers cannot read version history — this endpoint requires membership and a role above viewer (403 for viewers). Create a version has no such restriction.

StatusReason
400Missing workspaceId
403Caller is not a member, or member is a viewer
404Post not found

Get Version

GET /api/posts/{id}/versions/{v}?workspaceId=ws_123

{v} is the 1-based version number. Same access rule as List Versions.

StatusReason
400Missing workspaceId, or {v} isn't an integer
403Caller is not a member, or member is a viewer
404Post not found, or that version doesn't exist

Create Version

POST /api/posts/{id}/versions
// Request
{ "workspace_id": "ws_123", "content": "optional — full text to snapshot" }

Only requires membership — no viewer restriction on this write. If content is omitted, the post's current content is snapshotted as-is; if provided, the post's live content is also updated to match.

// 201 Response
{ "version": 2, "content": "...", "trigger": "manual", "created_by": "user_1", "created_at": "2026-07-17T12:00:00+00:00" }
StatusReason
400Missing workspace_id
403Caller is not a member
404Post not found

Add Image

POST /api/posts/{id}/images
// Request
{ "workspace_id": "ws_123", "filename": "chart.png", "content_type": "image/png" }

content_type defaults to image/jpeg if omitted. Allowed extensions: png, jpg, jpeg, gif, webp.

// 201 Response
{
  "upload_url": "https://s3.eu-central-1.amazonaws.com/... (presigned PUT, 1h)",
  "image_id": "img_abc",
  "key": "workspaces/ws_123/posts/post_abc/img_abc/chart.png",
  "expires_in": 3600
}

This call only reserves the upload slot — PUT the raw file bytes to upload_url directly from the client; the file never transits Lambda.

StatusReason
400workspace_id/filename missing, or disallowed extension
403Caller is not a member
404Post not found
500ASSETS_BUCKET not configured (deployment issue, not a caller error)

List Images

GET /api/posts/{id}/images?workspaceId=ws_123
// 200 Response
{
  "items": [
    {
      "image_id": "img_abc",
      "post_id": "post_abc",
      "workspace_id": "ws_123",
      "key": "workspaces/ws_123/posts/post_abc/img_abc/chart.png",
      "filename": "chart.png",
      "content_type": "image/png",
      "uploaded_at": "2026-07-11T10:00:00+00:00",
      "uploaded_by": "user_1",
      "download_url": "https://s3.eu-central-1.amazonaws.com/... (presigned GET, 1h)"
    }
  ],
  "count": 1
}

download_url is generated fresh on every call (never persisted), one-hour expiry.

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

Delete Image

DELETE /api/posts/{id}/images/{image_id}?workspaceId=ws_123

Deletes the S3 object first, then the DynamoDB record. Allowed for the image's original uploader, the post's author, or a caller with JWT custom:role admin/owner.

// 200 Response
{ "deleted": true }
StatusReason
400Missing workspaceId
403Caller is not a member, or not authorized to delete this image
404Post or image not found

See also