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
| Method | Path | Description |
|---|---|---|
POST | /api/posts | Create a post |
GET | /api/posts | List posts |
POST | /api/posts/reorder | Reorder 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}/status | Change status |
POST | /api/posts/{id}/schedule | Schedule a post |
POST | /api/posts/{id}/publish | Publish a post |
GET | /api/posts/{id}/versions | List versions |
GET | /api/posts/{id}/versions/{v} | Get a version |
POST | /api/posts/{id}/versions | Create a version |
POST | /api/posts/{id}/images | Get an upload URL |
GET | /api/posts/{id}/images | List 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.
| Status | Reason |
|---|---|
| 400 | workspace_id or title missing |
| 403 | Caller is not a member of workspace_id |
List
GET /api/posts?workspaceId=ws_123&status=draft&authorId=user_1workspaceId is required; status and authorId are optional exact-match
filters.
// 200 Response
{ "items": [ /* post objects */ ], "count": 1 }| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller 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 }| Status | Reason |
|---|---|
| 400 | workspace_id missing, or post_ids missing/empty/not a list |
| 403 | Caller is not a member |
| 404 | Some post_id doesn't exist in the workspace (aborts the whole batch) |
Get
GET /api/posts/{id}?workspaceId=ws_123Returns the post object (same shape as Create's response).
| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller is not a member |
| 404 | Post 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.
| Status | Reason |
|---|---|
| 400 | workspace_id missing, or priority invalid |
| 403 | Caller is not a member |
| 404 | Post not found |
Delete
DELETE /api/posts/{id}?workspaceId=ws_123Non-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 }| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller is not a member |
| 404 | Post not found |
| 409 | Non-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).
| Status | Reason |
|---|---|
| 400 | workspace_id missing; status missing/invalid; missing review_comment on a revision request; missing scheduled_at when scheduling |
| 403 | Caller is not a member |
| 404 | Post not found |
| 409 | The 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.
| Status | Reason |
|---|---|
| 400 | workspace_id or scheduled_at missing |
| 403 | Caller is not a member, or caller's role cannot schedule posts |
| 404 | Post not found |
| 409 | Post 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.
| Status | Reason |
|---|---|
| 400 | Missing workspace_id |
| 403 | Caller is not a member, or caller's role cannot publish posts |
| 404 | Post not found |
| 409 | Post 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.
| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller is not a member, or member is a viewer |
| 404 | Post 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.
| Status | Reason |
|---|---|
| 400 | Missing workspaceId, or {v} isn't an integer |
| 403 | Caller is not a member, or member is a viewer |
| 404 | Post 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" }| Status | Reason |
|---|---|
| 400 | Missing workspace_id |
| 403 | Caller is not a member |
| 404 | Post 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.
| Status | Reason |
|---|---|
| 400 | workspace_id/filename missing, or disallowed extension |
| 403 | Caller is not a member |
| 404 | Post not found |
| 500 | ASSETS_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.
| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller is not a member |
| 404 | Post not found |
Delete Image
DELETE /api/posts/{id}/images/{image_id}?workspaceId=ws_123Deletes 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 }| Status | Reason |
|---|---|
| 400 | Missing workspaceId |
| 403 | Caller is not a member, or not authorized to delete this image |
| 404 | Post or image not found |
See also
- Posts feature guide — product behavior, lifecycle, RBAC.
- Posts MCP tools — read-only
list_posts/get_post/get_pipeline_statusfor AI clients.