# Postiv Public API: full documentation

Machine-readable documentation for the Postiv Public API and hosted MCP server. Canonical docs: https://postiv.ai/api-documentation. Base URL: https://postiv.ai. Authenticate with an organization API key (`pk_postiv_...`) via `Authorization: Bearer` or `x-api-key`.

Scopes: `workspace:read`, `knowledge:read`, `knowledge:write`, `posts:read`, `posts:write`, `posts:schedule`, `images:write`, `profiles:read`, `analytics:read`, `inspiration:read`, `plans:read`, `scratchpad:read`, `scratchpad:write`

---

# Postiv Public API

Connect trusted agents and automations to a Postiv workspace: add knowledge, draft and schedule LinkedIn posts, generate branded visuals, and read cached analytics.

The base URL is `https://postiv.ai`. Every endpoint lives under `/api/public` and authenticates with an organization API key: no OAuth dance, no per-user tokens. The same key also powers the [hosted MCP server](https://postiv.ai/api-documentation/hosted-mcp), so anything an endpoint can do, an MCP client can do too.

```bash
curl https://postiv.ai/api/public/workspace \
  -H "Authorization: Bearer pk_postiv_..."
```

## Quickstart

1. [Generate an API key](https://postiv.ai/api-documentation/api-key) in Settings → Integrations.
2. Call [GET /api/public/workspace](https://postiv.ai/api-documentation/get-workspace) to confirm the key and see its scopes.
3. Follow the [post workflow](https://postiv.ai/api-documentation/post-workflow) to draft and schedule your first post.

## Built for agents

Every page here has a **Copy page** button that copies it as Markdown, ready to paste into a prompt. To give an agent the whole API at once, copy the entire docs from the same menu, or point it straight at [llms-full.txt](https://postiv.ai/api-documentation/llms-full.txt), a single plain-text document containing every page.

- [Hosted MCP server](https://postiv.ai/api-documentation/hosted-mcp): Connect claude.ai, Claude Code, or any MCP client with one URL.
- [llms-full.txt](https://postiv.ai/api-documentation/llms-full.txt): The full documentation as one Markdown file for agents.

---

# Authentication

Authenticate Public API and MCP requests with your organization API key and understand what each scope allows.

Every request is authenticated with an organization API key. Keys start with `pk_postiv_` and are generated in [Settings → Integrations](https://postiv.ai/api-documentation/api-key) inside Postiv. The key identifies your workspace, so requests never need an organization id.

```bash
curl https://postiv.ai/api/public/workspace \
  -H "Authorization: Bearer pk_postiv_..."
```

## Headers

Send the key with either header. If both are present, `x-api-key` wins:

| Header | Format |
| --- | --- |
| Authorization | `Bearer pk_postiv_...` |
| x-api-key | `pk_postiv_...` |

## Scopes

Each key carries a set of scopes, and every endpoint requires one. `GET /api/public/workspace` returns the scopes your key actually has.

| Scope | Grants |
| --- | --- |
| `workspace:read` | Read workspace identity and the scopes granted to the key. |
| `knowledge:read` | Semantically search the workspace knowledge base. |
| `knowledge:write` | Add items to the workspace knowledge base. |
| `posts:read` | List and read posts, templates, carousels, and infographics. |
| `posts:write` | Create and update drafts, carousels, infographics, and slide edits. |
| `posts:schedule` | Schedule, reschedule, and unschedule posts; set Comment Plug defaults. |
| `images:write` | Generate and edit branded post images. |
| `profiles:read` | List LinkedIn profiles and read trained writing styles. |
| `analytics:read` | Read cached analytics: accounts, KPIs, performance, and top posts. |
| `inspiration:read` | Search the moderated LinkedIn inspiration library. |
| `plans:read` | Read Bob content plans, plan items, and content pillars. |
| `scratchpad:read` | List and read scratchpad items. |
| `scratchpad:write` | Create, update, delete, and promote scratchpad items. |

> Legacy knowledge-base keys are limited to `knowledge:write`. New keys receive the full scope set; use **Regenerate Key (adds new scopes)** in Settings → Integrations to explicitly replace a legacy key. Regeneration invalidates the previous key.

## Auth errors

| Status | Meaning |
| --- | --- |
| 401 | The key is missing, malformed, or revoked. |
| 403 | The key is valid but does not carry the scope this endpoint requires. |

> Treat the key like a password: it grants workspace automation access according to its scopes. Never commit it to version control, and regenerate it immediately if it leaks.

---

# Post workflow

The recommended flow for drafting and scheduling a LinkedIn post through the Public API: pick a profile, ground the draft, create, refine, schedule.

The API mirrors how Postiv itself creates content: pick the LinkedIn profile that will publish, ground the draft in the workspace voice and knowledge, then move the draft through the publish queue.

1. Call [GET /api/public/linkedin-profiles](https://postiv.ai/api-documentation/list-linkedin-profiles) and choose a profile `id`.
2. Read that profile's [writing style](https://postiv.ai/api-documentation/get-writing-style) and [search the knowledge base](https://postiv.ai/api-documentation/search-knowledge) before drafting original content. Optionally [search templates](https://postiv.ai/api-documentation/search-templates) or select a [Bob plan item](https://postiv.ai/api-documentation/list-content-plans).
3. Call [POST /api/public/posts](https://postiv.ai/api-documentation/create-post) to create a draft. Pass `templateId` or `planItemId` when applicable so attribution stays linked.
4. Optionally [patch](https://postiv.ai/api-documentation/update-post) the draft content or profile assignment.
5. Call [POST /api/public/posts/:id/schedule](https://postiv.ai/api-documentation/schedule-post) with an ISO scheduled time.

## Verbatim by default, humanize on demand

Content is stored verbatim by default. Pass `"humanize": true` on create to run Postiv's humanizer over the content before saving: the same anti-AI-pattern pass the in-app assistant uses. Updates are always verbatim.

## Scheduling rules

- Scheduling never publishes immediately: `scheduledTime` must be at least **2 minutes** in the future.
- If the target profile has approvers configured, the post waits for their approval; check `requiresApproval` in the response.
- If the post has an image attached in Postiv and you omit `mediaType`/`mediaUrls`, the image is included automatically.

## End-to-end example

```bash
curl -X POST https://postiv.ai/api/public/posts \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Launch lessons",
    "content": "Three things we learned from launch week...",
    "integrationId": 123,
    "humanize": true
  }'
```

```bash
curl -X POST https://postiv.ai/api/public/posts/POST_ID/schedule \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "integrationId": 123,
    "scheduledTime": "2026-08-01T09:30:00.000Z",
    "mediaType": "text"
  }'
```

- [POST /posts](https://postiv.ai/api-documentation/create-post): Create a document post draft.
- [POST /posts/:id/schedule](https://postiv.ai/api-documentation/schedule-post): Schedule through the publish queue.

---

# Analytics workflow

Read cached LinkedIn analytics through the Public API: accounts, KPI summaries, performance series, and top posts.

Public analytics endpoints are read-only and return cached/imported analytics from Postiv tables. They do not force LinkedIn refreshes, so responses are fast and safe to poll.

`accountId` is required on every analytics endpoint: use an account id from [GET /api/public/analytics/accounts](https://postiv.ai/api-documentation/list-analytics-accounts) (each maps to one LinkedIn profile) for per-profile numbers, or `accountId=all` for an explicit workspace-wide aggregate.

## Example

```bash
curl "https://postiv.ai/api/public/analytics/top-posts?accountId=all&limit=10&sort=impressions" \
  -H "Authorization: Bearer pk_postiv_..."
```

- [GET /analytics/accounts](https://postiv.ai/api-documentation/list-analytics-accounts): List cached analytics accounts.
- [GET /analytics/summary](https://postiv.ai/api-documentation/get-analytics-summary): KPI summary for one account or all.
- [GET /analytics/top-posts](https://postiv.ai/api-documentation/get-top-posts): Top posts with signed media URLs.
- [GET /analytics/posts/:id](https://postiv.ai/api-documentation/get-post-analytics): Cached analytics for one post.

---

# Hosted MCP server

Connect ChatGPT, Claude, and other MCP clients to your Postiv workspace: same API key, same scopes, interactive widgets in hosts that support MCP Apps.

The hosted MCP server exposes the same workspace automation surface as the Public API. It uses the same organization API key, the same scopes, and the same server-side services behind the public endpoints. The endpoint is `https://postiv.ai/mcp` (`https://postiv.ai/api/mcp/mcp` also works).

In hosts that support MCP Apps, such as claude.ai, tools render interactive widgets instead of plain text: a post editor card, an image preview, a carousel pager, and inspiration cards.

## Available tools

| Tool | Scope | Description |
| --- | --- | --- |
| get_workspace | `workspace:read` | Read the connected workspace (name, slug) and which scopes this key has. Call first. |
| add_knowledge | `knowledge:write` | Add a text document to the workspace knowledge base for retrieval by Postiv's AI. |
| search_knowledge | `knowledge:read` | Semantically search the workspace knowledge base before drafting, to ground content in real facts. |
| search_templates | `posts:read` | Search Postiv's proven post-writing templates (global + workspace custom). |
| search_inspiration | `inspiration:read` | Search the moderated LinkedIn inspiration library by semantic query and/or exact filters; renders as a swipeable carousel in widget-capable hosts. |
| list_linkedin_profiles | `profiles:read` | List connected LinkedIn profiles/company pages and their integration ids. |
| get_writing_style | `profiles:read` | Read the trained writing style for one profile (voice, structure, examples). |
| list_content_plans | `plans:read` | List Bob's weekly content plans for a profile, with pillar distribution. |
| get_content_plan_item | `plans:read` | Read one plan item in full detail, including saved source material/research. |
| list_pillars | `plans:read` | List a profile's recurring content pillars (summary only). |
| get_pillar | `plans:read` | Read one pillar's full content and 30-day performance. |
| create_scratchpad_item | `scratchpad:write` | Save a quick note/url/image into the org's shared scratchpad. |
| list_scratchpad_items | `scratchpad:read` | List all live scratchpad items. |
| update_scratchpad_item | `scratchpad:write` | Edit a scratchpad item's content and/or status. |
| delete_scratchpad_item | `scratchpad:write` | Permanently delete a scratchpad item. |
| promote_scratchpad_item | `scratchpad:write, knowledge:write` | Promote a scrap into a permanent org knowledge asset (deletes the scrap on success). |
| get_comment_plug_config | `profiles:read` | Read default Comment Plug settings for one or all profiles. |
| set_comment_plug_config | `posts:schedule` | Set a profile's default Comment Plug (auto first-comment) used by future scheduled posts. |
| create_post | `posts:write` | Create a LinkedIn post draft; humanizer + hook-lint always run before saving (no opt-out, unlike REST). |
| list_posts | `posts:read` | List posts filtered by status/profile/search, with 300-char content previews. |
| get_post | `posts:read` | Read one post's full current content and status. |
| update_post | `posts:write` | Update a draft's title/content/profile; fails on scheduled/published posts. |
| schedule_post | `posts:schedule` | Queue a draft for publishing (>=2min in the future); waits for approvers if configured. |
| unschedule_post | `posts:schedule` | Cancel a scheduled post, returning it to draft. |
| reschedule_post | `posts:schedule` | Move a scheduled post to a new time. |
| generate_post_image | `images:write` | Generate a brand-new AI image for a post via style preset or freeform prompt (MCP-ONLY: no REST equivalent exists). Returns a 7-day signed URL + markdown image line to relay. |
| edit_post_image | `images:write` | Edit an existing image with a natural-language instruction, stacking a new version. |
| create_carousel | `posts:write` | Create a multi-slide carousel from a structured content outline (content only, never HTML). |
| create_infographic | `posts:write` | Create a single-canvas infographic (content only, never HTML), publishes as an image post. |
| render_carousel | `posts:write` | Render slide PNGs + PDF (carousel) or canvas PNG (infographic) for the latest version; idempotent. |
| get_carousel | `posts:read` | Read one carousel/infographic's slide summaries and render/PDF readiness. |
| edit_carousel_slide | `posts:write` | Edit one slide (or the infographic canvas) with a natural-language instruction. |
| list_analytics_accounts | `analytics:read` | List tracked LinkedIn analytics accounts, each mapping to one profile. |
| get_analytics_summary | `analytics:read` | Cached KPI totals for one account or 'all', over a period or date range. |
| get_performance_overview | `analytics:read` | Cached day-by-day impressions/engagements time series. |
| get_top_posts | `analytics:read` | Top posts by date/engagements/impressions or outlier-vs-baseline sorts. |
| get_post_analytics | `analytics:read` | Cached analytics for one specific post. |

## ChatGPT (MCP plugin)

ChatGPT connects to Postiv with the hosted MCP URL and an Authorization header. Treat the header value like a password. Leave the Bearer token environment-variable field empty unless you have configured a local environment variable for the key.

```bash
https://postiv.ai/mcp
```

```bash
Authorization: Bearer pk_postiv_...
```

1. In ChatGPT, open Settings, then Plugins, then MCPs.
2. Add an MCP server, name it Postiv, and use https://postiv.ai/mcp as the URL.
3. Add a header named Authorization with the value Bearer followed by your Postiv API key, then save.

## claude.ai (custom connector)

claude.ai custom connectors can only take a URL, they cannot send a header, so the API key goes in the path instead of an Authorization header. Treat this URL as a secret, the same as the key itself.

```bash
https://postiv.ai/mcp/pk_postiv_...
```

1. In claude.ai, go to Settings, then Connectors.
2. Click "Add custom connector".
3. Paste the URL above with your own key in it.

![claude.ai Settings, Connectors page](https://postiv.ai/images/docs/mcp/claude-connectors.png)
![claude.ai Add custom connector dialog with the Postiv URL pasted](https://postiv.ai/images/docs/mcp/claude-add-connector-redacted.png)

## Claude Code

```bash
claude mcp add --transport http postiv https://postiv.ai/mcp \
  --header "Authorization: Bearer pk_postiv_..."
```

## Generic Streamable HTTP client

```json
{
  "postiv": {
    "url": "https://postiv.ai/mcp",
    "headers": {
      "Authorization": "Bearer pk_postiv_..."
    }
  }
}
```

## Claude Desktop with mcp-remote

```json
{
  "mcpServers": {
    "postiv": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://postiv.ai/mcp",
        "--header",
        "Authorization: Bearer pk_postiv_..."
      ]
    }
  }
}
```

---

# Get your API key

Generate the organization API key that authenticates the Postiv Public API and the hosted MCP server.

The Public API and the hosted MCP server share one organization API key. Keys generated after the scoped API update can add knowledge, create and schedule drafts, list LinkedIn profiles, and read cached analytics.

## Generate the key

1. Log in to your [Postiv account](https://postiv.ai/app).
2. Navigate to **Settings**, then **Integrations**.
3. Find the **Public API** section.
4. Click **Generate API Key** if you don't have one, or **Regenerate Key (adds new scopes)** for a legacy key.

![Postiv Settings, Integrations tab, Public API section with a generated key](https://postiv.ai/images/docs/mcp/app-api-key.png)

> Legacy keys may only have `knowledge:write`. Use **Regenerate Key (adds new scopes)** when broader API access is needed; regeneration invalidates the previous key.

## Use the same key for MCP

Connect MCP clients to `https://postiv.ai/mcp` with the key as an Authorization bearer token. See the [Hosted MCP guide](https://postiv.ai/api-documentation/hosted-mcp) for claude.ai, Claude Code, and generic client setup.

```bash
claude mcp add --transport http postiv https://postiv.ai/mcp \
  --header "Authorization: Bearer pk_postiv_..."
```

## Keep the key secure

> Your API key grants access according to its scopes, including workspace automation scopes on new keys. Never share it publicly or commit it to version control. If you believe the key has been compromised, regenerate it immediately from Settings.

---

# API reference

## Workspace

### Get workspace

`GET /api/public/workspace`

Scope: `workspace:read`

Reads the connected workspace's identity (name, slug) and echoes back which scopes the calling API key has. Intended as a first call to confirm the key is pointed at the right org.

#### Request example

```bash
curl "https://postiv.ai/api/public/workspace" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

The organization plus the caller's own scope list.

| Field | Type | Description |
| --- | --- | --- |
| `workspace.id` | uuid | Organization id. |
| `workspace.name` | string | Organization display name. |
| `workspace.slug` | string | Organization slug. |
| `workspace.createdAt` | timestamp | Org creation time. |
| `workspace.scopes` | string[] | Scopes granted to this API key. |

```json
{
  "success": true,
  "workspace": {
    "id": "9e6397ad-0000-0000-0000-000000000000",
    "name": "Postiv AI",
    "slug": "postiv-ai",
    "createdAt": "2026-01-04T10:00:00.000Z",
    "scopes": [
      "workspace:read",
      "posts:read",
      "posts:write"
    ]
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | workspace_not_found: the key's org_id no longer resolves to an organizations row |

#### Notes

- Also exposed as the MCP tool get_workspace, recommended as the very first call in a session.

## Knowledge

### Add knowledge

`POST /api/public/knowledge`

Scope: `knowledge:write`

Ingests a text document into the org-shared knowledge base. Content is chunked (~1500-token paragraphs) and embedded so it becomes retrievable via search_knowledge / GET /api/public/knowledge/search. Not a way to draft posts.

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | Yes | Short title, 1-240 chars (trimmed). |
| `content` | string | Yes | The text to ingest, 1-300000 chars (trimmed). |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/knowledge \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Launch lessons",
    "content": "Three things we learned from launch week..."
  }'
```

#### Response

The created knowledge asset.

| Field | Type | Description |
| --- | --- | --- |
| `asset.id` | uuid | assets.id of the new knowledge document. |
| `asset.title` | string | Stored title. |
| `asset.kind` | string | Auto-classified asset kind (via classifyIntake). |
| `asset.category` | string | Auto-classified category. |
| `asset.chunkCount` | int | Number of embedded chunks created. |
| `asset.createdAt` | timestamp | Creation time. |

```json
{
  "success": true,
  "asset": {
    "id": "b1c2...",
    "title": "Q3 Positioning Notes",
    "kind": "document",
    "category": "positioning",
    "chunkCount": 3,
    "createdAt": "2026-07-19T09:00:00.000Z"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | validation_error: missing/empty title or content, or over length limits |

#### Notes

- Asset is stored owner_id=null, is_shared=true, shared_with_org_id=<caller's org>: org-wide, not tied to the calling user. Tagged meta.source = 'public-api' (or 'mcp' when called through the MCP tool).

### Search knowledge base

`GET /api/public/knowledge/search`

Scope: `knowledge:read`

Semantic (embedding cosine-similarity) search over the org's knowledge base chunks. Only chunks scoring above a 0.3 similarity threshold are returned.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | string | Yes | Search text, 1-2000 chars (trimmed). |
| `limit` | int | No | Max results, 1-20. Default: `5`. |

#### Request example

```bash
curl "https://postiv.ai/api/public/knowledge/search?query=launch%20week&limit=10" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Ranked list of matching chunks.

| Field | Type | Description |
| --- | --- | --- |
| `hits[].assetId` | uuid | Source asset id. |
| `hits[].title` | string | Source asset title. |
| `hits[].kind` | string | Normalized asset kind. |
| `hits[].content` | string | Chunk text, truncated to 500 chars with a trailing ellipsis if longer. |
| `hits[].score` | float | Cosine similarity score (>0.3). |

```json
{
  "success": true,
  "hits": [
    {
      "assetId": "b1c2...",
      "title": "Q3 Positioning Notes",
      "kind": "document",
      "content": "Postiv is an inbound LinkedIn content tool for agencies...",
      "score": 0.81
    }
  ]
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | validation_error: missing/empty query |

#### Notes

- Scoped strictly by the API key's org_id (not the app's getUserOrganization 'most recent org' pattern), so it's correct for multi-org/agency callers.

## Posts

### List posts

`GET /api/public/posts`

Scope: `posts:read`

Lists workspace posts, filterable by status/profile/text search. 'scheduled' only returns posts that actually have a live publish_queue entry (guards against stale status left by legacy bugs).

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string\|string[] | No | draft, scheduled, published, failed, cancelled, pending_approval, approval_denied, changes_requested. Comma-separated or repeated param. Alias: statuses (both are merged). |
| `integrationId` | int | No | Alias: integration_id. |
| `search` | string | No | Case-insensitive title/content search, max 200 chars. |
| `limit` | int | No | 1-100. Default: `50`. |
| `offset` | int | No | 0-5000. Default: `0`. |

#### Request example

```bash
curl "https://postiv.ai/api/public/posts?status=...&search=launch&limit=10" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Paginated post list with content previews (not full content).

| Field | Type | Description |
| --- | --- | --- |
| `posts[].id` | uuid |  |
| `posts[].title` | string\|null |  |
| `posts[].type` | string |  |
| `posts[].status` | string |  |
| `posts[].contentPreview` | string | First 300 chars of content (public API's own trim; the MCP tool trims separately). |
| `posts[].integrationId` | int\|null |  |
| `posts[].templateId` | uuid\|null |  |
| `posts[].planItemId` | uuid\|null |  |
| `posts[].profile` | object\|null | { id, name, headline, picture }: picture uses a cheap non-LinkedIn-CDN-only URL; full resolution happens on GET one post. |
| `posts[].scheduledTime` | timestamp\|null |  |
| `posts[].queueStatus` | string\|null |  |
| `posts[].latestVersion` | int\|null |  |
| `posts[].hasImage` | boolean\|null |  |
| `posts[].tags` | array |  |
| `posts[].author` | object\|null | { name } only: no email. |
| `posts[].openInPostivUrl` | string | Deeplink into the Postiv editor. |
| `posts[].createdAt / updatedAt` | timestamp |  |
| `pagination` | object | { limit, offset, count } |

```json
{
  "success": true,
  "posts": [
    {
      "id": "post1...",
      "title": null,
      "type": "document",
      "status": "draft",
      "contentPreview": "Most founders think content strategy...",
      "integrationId": 1234,
      "profile": {
        "id": 1234,
        "name": "Jan",
        "headline": "Founder",
        "picture": null
      },
      "scheduledTime": null,
      "queueStatus": null,
      "tags": [],
      "openInPostivUrl": "https://postiv.ai/app/create?postId=post1..."
    }
  ],
  "pagination": {
    "limit": 50,
    "offset": 0,
    "count": 1
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | invalid_integration_id |

#### Notes

- REST route response doesn't include contentLength (the MCP tool's version adds that field; REST's list route computes contentPreview server-side identically but the field set differs slightly per code path: see notes above).

### Create post draft

`POST /api/public/posts`

Scope: `posts:write`

Creates a LinkedIn post draft assigned to a profile. Never publishes by itself.

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string (`document`) | No | Only 'document' is accepted; omit to default. |
| `title` | string | No | Max 240 chars. |
| `content` | string | No | Max 100000 chars. Default: ``. |
| `integrationId` | int | Yes | Alias: integration_id. Required: a draft always belongs to a profile. |
| `templateId` | uuid | No | Alias: template_id, from search_templates. |
| `planItemId` | uuid | No | Alias: plan_item_id, from get_content_plan_item. |
| `humanize` | boolean | No | REST-only opt-in: runs the humanizer pass over content before saving. (The MCP create_post tool always runs both the humanizer and hook-lint pass unconditionally, with no opt-out.) Default: `false`. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/posts \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Launch lessons",
    "content": "Three things we learned from launch week...",
    "integrationId": 123,
    "humanize": true
  }'
```

#### Response

The created post.

| Field | Type | Description |
| --- | --- | --- |
| `post` | object | Full post object: same shape as GET /api/public/posts/:id. |
| `humanized` | boolean | Whether the humanizer changed the content. |

```json
{
  "success": true,
  "post": {
    "id": "post1...",
    "status": "draft",
    "content": "Most founders think..."
  },
  "humanized": false
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_integration_id: integrationId omitted |
| 400 | content_too_long_to_humanize: content over 20000 chars with humanize on |
| 402 | subscription_required (from checkSubscriptionAccess inside createPostDraft) |
| 404 | linkedin_profile_not_found / post_template_not_found |

#### Notes

- Markdown emphasis (**bold**, *italic*) is always converted to Unicode glyphs before saving: LinkedIn renders everything else as literal characters. planItemId links the draft back to Bob's plan and marks that item drafted.

### Get post

`GET /api/public/posts/:id`

Scope: `posts:read`

Reads one post's current content, status, assigned profile, and version number.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl "https://postiv.ai/api/public/posts/ID_ID" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Full post object.

| Field | Type | Description |
| --- | --- | --- |
| `post.id / title / type / status / content / orgId / integrationId / templateId / planItemId` | mixed |  |
| `post.profile` | object\|null | { id, name, headline, picture }: picture is fully resolved here (S3 signed URL or LinkedIn CDN upload fallback), unlike the list endpoint. |
| `post.scheduledTime / queueStatus / latestVersion / hasImage / tags / author / openInPostivUrl / createdAt / updatedAt` | mixed |  |

```json
{
  "success": true,
  "post": {
    "id": "post1...",
    "title": null,
    "status": "draft",
    "content": "Most founders think...",
    "integrationId": 1234,
    "profile": {
      "id": 1234,
      "name": "Jan",
      "headline": "Founder",
      "picture": "https://..."
    },
    "latestVersion": 1,
    "hasImage": false,
    "openInPostivUrl": "https://postiv.ai/app/create?postId=post1..."
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | post_not_found |

#### Notes

- Posts with status scheduled/published are locked against edits (see update-post).

### Update post draft

`PATCH /api/public/posts/:id`

Scope: `posts:write`

Updates a draft's title, content (saved as a new version), and/or assigned profile. Fails on scheduled/published posts.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string\|null | No | Max 240 chars; null clears it. |
| `content` | string | No | Max 100000 chars; stored as a new post_versions row. |
| `integrationId` | int\|null | No | Alias: integration_id. null unassigns. |

#### Request example

```bash
curl -X PATCH https://postiv.ai/api/public/posts/ID_ID \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Launch lessons",
    "content": "Three things we learned from launch week..."
  }'
```

#### Response

The updated post plus the new version (if content changed).

| Field | Type | Description |
| --- | --- | --- |
| `post` | object | Full post object, same shape as GET. |
| `version` | object\|null | { id, versionNumber, createdAt }; null if content wasn't provided or was unchanged (no-op save). |

```json
{
  "success": true,
  "post": {
    "id": "post1...",
    "status": "draft",
    "content": "Updated text..."
  },
  "version": {
    "id": "v2...",
    "versionNumber": 2,
    "createdAt": "2026-07-19T09:05:00.000Z"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | validation_error: none of title/content/integrationId provided |
| 404 | post_not_found |
| 409 | post_scheduled_locked: post is scheduled; unschedule first |
| 409 | post_published_immutable: post already published |
| 409 | content_plan_profile_locked: post is linked to a Bob plan item and the requested integrationId doesn't match that plan's profile |
| 404 | linkedin_profile_not_found: new integrationId not in this workspace |

#### Notes

- A no-change content save (identical to the current version) is silently skipped: version returns null rather than creating a duplicate version row.

### Schedule post

`POST /api/public/posts/:id/schedule`

Scope: `posts:schedule`

Queues a draft post for publishing through Postiv's dispatcher. Never publishes immediately; if the profile has human approvers, the post waits for approval first.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `scheduledTime` | string | Yes | ISO-8601 timestamp, must be >= 2 minutes in the future. |
| `integrationId` | int | Yes | Alias: integration_id. Target profile; for plan-linked posts must equal that plan's profile. |
| `mediaType` | string (`text`, `image`, `carousel`) | No | Alias: media_type. Omit to auto-detect (uses the post's attached image if any). |
| `mediaUrls` | string\|string[] | No | Alias: media_urls. Max 20 URLs, each a valid URL. |
| `carouselId` | uuid | No | Alias: carousel_id. Required if mediaType='carousel' or to publish an infographic (auto-detected format). Carousels need a rendered PDF first; infographics render automatically at schedule time. |
| `mediaData` | object | No | Alias: media_data. { title?, altText?, description?, commentPlug? }. |
| `commentPlug` | object | No | Alias: comment_plug. { mode: inherit\|disable\|override, text?, imageSource?, delayMinutes? (min 15) }. Merged into mediaData.commentPlug if not already set there. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/posts/ID_ID/schedule \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "scheduledTime": "2026-08-01T09:30:00.000Z",
    "integrationId": 123,
    "mediaType": "text"
  }'
```

#### Response

The scheduling result.

| Field | Type | Description |
| --- | --- | --- |
| `schedule.queueEntry` | object | { id, postId, integrationId, scheduledTime, status, mediaType } |
| `schedule.requiresApproval` | boolean |  |
| `schedule.approverCount` | int |  |
| `schedule.autoApproved` | boolean | true if the scheduler is themself an approver and auto-approved their own post. |
| `schedule.remainingApprovers` | int |  |
| `schedule.message` | string | Human-readable status message. |

```json
{
  "success": true,
  "schedule": {
    "queueEntry": {
      "id": "q1...",
      "postId": "post1...",
      "integrationId": 1234,
      "scheduledTime": "2026-07-20T09:00:00.000Z",
      "status": "queued",
      "mediaType": "text"
    },
    "requiresApproval": false,
    "approverCount": 0,
    "autoApproved": false,
    "remainingApprovers": 0,
    "message": "Post scheduled successfully"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | unsupported_media_type / missing_carousel_id / scheduled_time_in_past / invalid_integration_id / invalid_scheduled_time / empty_post_content / missing_post_text |
| 400 | linkedin_cmm_reconnect_required: a Comment Plug is in play but the profile's LinkedIn comment permission isn't connected |
| 404 | post_not_found / linkedin_profile_not_found |
| 400 | post_already_published: post status is already published |
| 409 | post_already_scheduled: a live queue entry already exists (duplicate-call guard) |
| 409 | carousel_already_scheduled: the carousel is already scheduled on a different post |
| 409 | carousel_pdf_not_ready: carousel media type requested but no rendered PDF; call render-carousel first |
| 502 | infographic_render_failed: automatic canvas render failed at schedule time |

#### Notes

- If mediaType/mediaUrls are both omitted, an image already attached to the post via generate_post_image/edit_post_image is auto-attached. carouselId + no explicit mediaType auto-detects carousel vs. infographic format from the carousel's saved content and adjusts mediaType/mediaUrls accordingly (infographics publish as an image post of the rendered canvas PNG).

### Unschedule post

`DELETE /api/public/posts/:id/schedule`

Scope: `posts:schedule`

Cancels a scheduled post before it publishes and puts it back into draft (editable again). Also reverts a linked carousel row to draft.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl -X DELETE "https://postiv.ai/api/public/posts/ID_ID/schedule" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

The now-draft post.

| Field | Type | Description |
| --- | --- | --- |
| `post.id / title` | mixed |  |
| `post.status` | string | Always 'draft'. |

```json
{
  "success": true,
  "post": {
    "id": "post1...",
    "title": "My post",
    "status": "draft"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | post_not_found |
| 400 | post_not_scheduled: status isn't currently 'scheduled' |

#### Notes

- Any member of the post's org may unschedule it, not just the creator.

### Reschedule post

`PATCH /api/public/posts/:id/schedule`

Scope: `posts:schedule`

Moves an already-scheduled post to a new publish time, without touching content, media, or approval state.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `scheduledTime` | string | Yes | ISO-8601, must be >= 2 minutes in the future. |

#### Request example

```bash
curl -X PATCH https://postiv.ai/api/public/posts/ID_ID/schedule \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "scheduledTime": "2026-08-01T09:30:00.000Z"
  }'
```

#### Response

The rescheduled post.

| Field | Type | Description |
| --- | --- | --- |
| `post.id / title / status` | mixed |  |
| `newScheduledTime` | timestamp |  |

```json
{
  "success": true,
  "post": {
    "id": "post1...",
    "title": "My post",
    "status": "scheduled"
  },
  "newScheduledTime": "2026-07-21T09:00:00.000Z"
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | invalid_scheduled_time: not a valid timestamp or <2 minutes in the future |
| 404 | post_not_found / queue_entry_not_found |
| 400 | post_not_scheduled |

## Images

### Edit post image

`POST /api/public/images/:id/edit`

Scope: `images:write`

Edits an existing Postiv image with a natural-language instruction, stacking a new version on the same image id. If attached to a post, the post publishes the newest version automatically.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `editPrompt` | string | Yes | 1-4000 chars, trimmed. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/images/ID_ID/edit \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "editPrompt": "Make the background darker and increase headline contrast."
  }'
```

#### Response

The edited image's new version.

| Field | Type | Description |
| --- | --- | --- |
| `image.id` | uuid |  |
| `image.versionId` | uuid |  |
| `image.versionNumber` | int |  |
| `image.postId` | uuid\|null |  |
| `image.style` | string |  |
| `image.width / height` | int |  |
| `image.imageUrl` | string | Signed S3 URL (default TTL, NOT the 7-day widget TTL used by the MCP tool). |
| `image.generationTimeMs` | int |  |

```json
{
  "success": true,
  "image": {
    "id": "img1...",
    "versionId": "v2...",
    "versionNumber": 2,
    "postId": "post1...",
    "style": "notebook_infographic",
    "width": 1080,
    "height": 1350,
    "imageUrl": "https://postiv-user-media.s3...",
    "generationTimeMs": 18342
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_edit_prompt / image_missing_content |
| 403 | image_permission_denied |
| 404 | image_not_found |
| 422 | content_blocked: blocked by the model's content filter |
| 500 | image_edit_failed |

#### Notes

- There is NO REST endpoint to generate a brand-new image: that only exists as the MCP tool generate_post_image. This route is edit-only; requireOwnership is false (any workspace member's image is editable via the API key, not just the creator's).

## Carousels & Infographics

### Create carousel

`POST /api/public/carousels`

Scope: `posts:write`

Creates a multi-slide LinkedIn document-post carousel from a structured content outline (never HTML): Postiv's own carousel agent generates the branded slide HTML using the target profile's saved brand style. Created as a draft with no rendered preview.

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | Yes | 1-240 chars, internal library title. |
| `integrationId` | int | Yes | Profile whose brand style (colors/fonts/templates) generates the slides. |
| `outline.topic` | string | Yes |  |
| `outline.targetAudience` | string | No |  |
| `outline.contentType` | string | No | e.g. educational, listicle, case study, story. |
| `outline.objective` | string | No |  |
| `outline.narrativeArc` | string | No |  |
| `outline.styleInstructions` | string | No | Freeform visual direction layered on the saved brand style. |
| `outline.backgroundStyle` | string (`none`, `grid`, `dots`, `diagonal`, `waves`, `subtle-noise`, `gradient-only`) | No |  |
| `outline.themeOverride.backgroundColor` | string | No | Hex; overrides the saved brand theme for this carousel only. |
| `outline.themeOverride.textColor` | string | No |  |
| `outline.themeOverride.primaryColor` | string | No |  |
| `outline.themeOverride.accentColor` | string | No |  |
| `outline.slides[]` | array | Yes | 1-10 slides (10 max enforced; app-level guidance says 5-8 is the LinkedIn sweet spot). |
| `outline.slides[].slideType` | string | Yes | e.g. cover, content, list, quote, stats, comparison, cta. |
| `outline.slides[].headline` | string | Yes |  |
| `outline.slides[].subheadline` | string | No |  |
| `outline.slides[].body` | string | No |  |
| `outline.slides[].callout` | string | No |  |
| `outline.slides[].items[]` | array | No | Max 8, each { label, description? }. |
| `outline.slides[].dataPoint` | object | No | { value, label } hero stat. |
| `outline.slides[].imageUrls[]` | string[] | No | Max 3 public URLs. |
| `outline.slides[].layout` | string | No |  |
| `outline.slides[].designNotes` | string | No |  |
| `planItemId` | uuid | No | Links to a Bob plan item and marks it drafted. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/carousels \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Launch lessons",
    "integrationId": 123
  }'
```

#### Response

The created carousel with slide text summaries.

| Field | Type | Description |
| --- | --- | --- |
| `carousel.id / title / status / integrationId / planItemId / createdAt` | mixed |  |
| `versionId` | uuid |  |
| `versionNumber` | int | 1 for a new carousel. |
| `slideCount` | int |  |
| `slides[].index / title / textPreview` | mixed | textPreview is HTML-stripped, truncated to 200 chars. |
| `openInPostivUrl` | string |  |
| `warnings` | string[] | Per-slide generation warnings, e.g. 'slide 3: ...'. |

```json
{
  "success": true,
  "carousel": {
    "id": "car1...",
    "title": "5 Content Mistakes",
    "status": "draft",
    "integrationId": 1234,
    "planItemId": null,
    "createdAt": "2026-07-19T09:10:00.000Z"
  },
  "versionId": "cv1...",
  "versionNumber": 1,
  "slideCount": 6,
  "slides": [
    {
      "index": 0,
      "title": "5 Content Mistakes",
      "textPreview": "Most B2B content fails because..."
    }
  ],
  "openInPostivUrl": "https://postiv.ai/app/create?carouselId=car1...",
  "warnings": []
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_title / invalid_outline (0 slides or >10 slides) |
| 404 | linkedin_profile_not_found |
| 502 | carousel_generation_failed: generation produced zero slides |

#### Notes

- Status is 201 Created. Always call render-carousel next: no preview images exist yet. The model must supply CONTENT only; Postiv's own slide-HTML agent (generateCarouselSlides) renders the visuals.

### Create infographic

`POST /api/public/infographics`

Scope: `posts:write`

Creates a single 1080x1350 infographic canvas (stored as a one-slide carousel row with format='infographic') that publishes as a LinkedIn IMAGE post, not a document.

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | Yes | 1-240 chars. |
| `integrationId` | int | Yes |  |
| `outline.topic` | string | Yes |  |
| `outline.targetAudience` | string | No |  |
| `outline.objective` | string | No |  |
| `outline.angle` | string | No |  |
| `outline.styleInstructions` | string | No | Omit unless the user explicitly requested a specific look: otherwise the saved brand theme applies automatically. |
| `outline.backgroundStyle` | string (`none`, `grid`, `dots`, `diagonal`, `waves`, `subtle-noise`, `gradient-only`) | No |  |
| `outline.themeOverride` | object | No | Same shape as create-carousel's themeOverride; omit entirely unless explicit colors were requested. |
| `outline.headline` | string | Yes | Max 8-10 words. |
| `outline.subtitle` | string | No |  |
| `outline.archetype` | string (`stat-grid`, `timeline`, `process`, `comparison`, `funnel`, `donut`, `ranked-bars`, `myth-vs-fact`, `checklist`, `cycle`, `auto`) | No |  |
| `outline.sections[]` | array | Yes | Max 4. Each { title?, kind: stats\|steps\|comparison\|timeline\|list\|chart\|text, items: [{ label, value?, description? }] }. |
| `outline.dataPoints[]` | array | No | Max 6, { value, label }: real numbers only, never invented. |
| `outline.sourceNote` | string | No | Small attribution/citation line. |
| `outline.cta` | string | No |  |
| `outline.imageUrls[]` | string[] | No | Max 3. |
| `planItemId` | uuid | No |  |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/infographics \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Launch lessons",
    "integrationId": 123
  }'
```

#### Response

The created infographic (a carousel row with a single slide).

| Field | Type | Description |
| --- | --- | --- |
| `carousel.id / title / status / integrationId / planItemId / createdAt` | mixed |  |
| `format` | string | Always 'infographic'. |
| `versionId / versionNumber / slideCount (always 1) / slides / openInPostivUrl / warnings` | mixed | Same shape as create-carousel. |

```json
{
  "success": true,
  "carousel": {
    "id": "car2...",
    "title": "State of LinkedIn 2026",
    "status": "draft",
    "integrationId": 1234,
    "planItemId": null,
    "createdAt": "..."
  },
  "format": "infographic",
  "versionId": "cv1...",
  "versionNumber": 1,
  "slideCount": 1,
  "slides": [
    {
      "index": 0,
      "title": "State of LinkedIn 2026",
      "textPreview": "73% of B2B buyers..."
    }
  ],
  "openInPostivUrl": "https://postiv.ai/app/create?carouselId=car2...",
  "warnings": []
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_title / invalid_outline (missing headline) |
| 404 | linkedin_profile_not_found |
| 502 | carousel_generation_failed |

#### Notes

- Status is 201 Created. No PDF concept applies: render-carousel renders a single canvas PNG for infographics.

### Get carousel

`GET /api/public/carousels/:id`

Scope: `posts:read`

Reads one carousel or infographic: per-slide text summaries, render/PDF readiness, and preview image URLs when rendered.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl "https://postiv.ai/api/public/carousels/ID_ID" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Carousel state and (if rendered) previews.

| Field | Type | Description |
| --- | --- | --- |
| `carousel.id / title / status / postId / createdAt / updatedAt` | mixed |  |
| `format` | string |  |
| `versionId / versionNumber / slideCount` | mixed |  |
| `slides[].index / title / textPreview` | mixed |  |
| `rendered` | boolean |  |
| `pdfReady` | boolean | Always false for infographics. |
| `pdfUrl` | string\|null | Signed URL, only for rendered carousels (never infographics). |
| `slideImages` | array\|null | [{ index, imageUrl }], null until rendered. |
| `openInPostivUrl` | string |  |

```json
{
  "success": true,
  "carousel": {
    "id": "car1...",
    "title": "5 Content Mistakes",
    "status": "draft",
    "postId": null
  },
  "format": "carousel",
  "versionId": "cv1...",
  "versionNumber": 1,
  "slideCount": 6,
  "slides": [
    {
      "index": 0,
      "title": "5 Content Mistakes",
      "textPreview": "..."
    }
  ],
  "rendered": false,
  "pdfReady": false,
  "pdfUrl": null,
  "slideImages": null,
  "openInPostivUrl": "https://postiv.ai/app/create?carouselId=car1..."
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | carousel_not_found: wrong id, or owned outside this workspace |
| 400 | carousel_missing_versions: no saved versions (shouldn't normally happen) |

#### Notes

- Slides are visual HTML internally; this only returns extracted text summaries: use render-carousel's slideImages for the actual visual.

### Render carousel

`POST /api/public/carousels/:id/render`

Scope: `posts:write`

Renders (or reuses a cached render of) the latest version: slide PNGs + publish PDF for carousels, or the single canvas PNG for infographics. Idempotent: an already-rendered version returns instantly.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/carousels/ID_ID/render \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{}'
```

#### Response

Render result with preview URLs.

| Field | Type | Description |
| --- | --- | --- |
| `carouselId` | uuid |  |
| `format` | string |  |
| `versionId / versionNumber / slideCount` | mixed |  |
| `cached` | boolean | true if served from an existing rendered PDF (carousels only; always false for infographics). |
| `rendered` | boolean | Always true on success. |
| `pdfReady` | boolean |  |
| `pdfUrl` | string\|null |  |
| `slideImages` | array | [{ index, imageUrl }]: 7-day signed URLs. |
| `openInPostivUrl` | string |  |
| `carousel.id / title / status` | mixed |  |

```json
{
  "success": true,
  "carouselId": "car1...",
  "format": "carousel",
  "versionId": "cv1...",
  "versionNumber": 1,
  "slideCount": 6,
  "cached": false,
  "rendered": true,
  "pdfReady": true,
  "pdfUrl": "https://postiv-user-media.s3.../deck.pdf",
  "slideImages": [
    {
      "index": 0,
      "imageUrl": "https://postiv-user-media.s3.../slide-001.png"
    }
  ],
  "openInPostivUrl": "https://postiv.ai/app/create?carouselId=car1...",
  "carousel": {
    "id": "car1...",
    "title": "5 Content Mistakes",
    "status": "draft"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | carousel_not_found |
| 400 | carousel_empty: 0 slides in the latest version |
| 502 | carousel_render_failed: screenshot/PDF rendering error |

#### Notes

- Each slide is screenshotted via browserless; ~5-10 seconds per slide for a fresh (non-cached) render, hence the 300s route timeout. Must be called after every edit-carousel-slide since edits invalidate the previous render.

### Edit carousel slide

`POST /api/public/carousels/:id/edit-slide`

Scope: `posts:write`

Edits one slide (or, for an infographic, the single canvas at slideIndex 0) via a natural-language instruction. Postiv's slide-edit agent rewrites the HTML; saves as a new carousel version.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `slideIndex` | int | Yes | 0-based. |
| `instruction` | string | Yes | 1-4000 chars, e.g. 'shorten the headline to 5 words'. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/carousels/ID_ID/edit-slide \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "slideIndex": 123,
    "instruction": "..."
  }'
```

#### Response

The edit result.

| Field | Type | Description |
| --- | --- | --- |
| `carouselId` | uuid |  |
| `versionId / versionNumber` | mixed | New version unless deduped. |
| `slideIndex` | int |  |
| `deduped` | boolean | true if the edit produced no meaningful change: no new version was saved. |
| `summary` | string | Agent's own summary of what changed. |
| `slide.index / title / textPreview` | mixed |  |

```json
{
  "success": true,
  "carouselId": "car1...",
  "versionId": "cv2...",
  "versionNumber": 2,
  "slideIndex": 0,
  "deduped": false,
  "summary": "Shortened the headline and darkened the background",
  "slide": {
    "index": 0,
    "title": "5 Content Mistakes",
    "textPreview": "Most B2B content fails..."
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_instruction / invalid_slide_index / slide_not_editable |
| 404 | carousel_not_found / slide_not_found |
| 409 | carousel_locked: carousel is scheduled/published; unschedule first |
| 409 | carousel_version_conflict: carousel changed concurrently during the edit; re-read and retry |

#### Notes

- Only one slide per call. Takes 15-90+ seconds; the route timeout is 180s to allow a validation-repair retry on large imported-style slides (up to 24k output tokens).

## Profiles

### List LinkedIn profiles

`GET /api/public/linkedin-profiles`

Scope: `profiles:read`

Lists the LinkedIn profiles and company pages connected to the workspace: the integrationId values every post/schedule/carousel call needs.

#### Request example

```bash
curl "https://postiv.ai/api/public/linkedin-profiles" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Connected profiles, personal (actor) profile first.

| Field | Type | Description |
| --- | --- | --- |
| `profiles[].id` | int | integrationId used everywhere else. |
| `profiles[].provider` | string | Always 'linkedin'. |
| `profiles[].profileType` | string |  |
| `profiles[].parentIntegrationId` | int\|null | Set for organization_page rows. |
| `profiles[].name` | string\|null |  |
| `profiles[].headline` | string\|null |  |
| `profiles[].picture` | string\|null | Resolved profile picture URL. |
| `profiles[].status` | string | Always 'active' (query filters to active integrations). |

```json
{
  "success": true,
  "profiles": [
    {
      "id": 1234,
      "provider": "linkedin",
      "profileType": "personal",
      "parentIntegrationId": null,
      "name": "Jan van Musscher",
      "headline": "Founder @ Postiv",
      "picture": "https://...",
      "status": "active"
    }
  ]
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | workspace_not_found: org_id doesn't resolve |

#### Notes

- Agency admins in a client org are excluded from the member list this query joins against (their access is auto-granted, not seat-backed), matching the dual-membership seat rules in CLAUDE.md. Deliberately omits connected_at/expires_at: token lifecycle stays internal.

### Get writing style

`GET /api/public/linkedin-profiles/:id/writing-style`

Scope: `profiles:read`

Reads the trained writing style for one LinkedIn profile (priority rules, voice, structure, vocabulary, custom instructions, learnings, examples).

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl "https://postiv.ai/api/public/linkedin-profiles/ID_ID/writing-style" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

The profile's writing style state.

| Field | Type | Description |
| --- | --- | --- |
| `writingStyle.integration.id` | int |  |
| `writingStyle.integration.provider` | string |  |
| `writingStyle.integration.status` | string |  |
| `writingStyle.integration.parentIntegrationId` | int\|null |  |
| `writingStyle.profile` | object | { id, name, headline, picture } |
| `writingStyle.hasWritingStyle` | boolean |  |
| `writingStyle.writingStyle` | object\|null | Raw structured style JSON (agent_profiles.tone_voice), null if untrained. |
| `writingStyle.modelContext` | string | Formatted prose version meant to be fed to an LLM as writing instructions; empty string if untrained. |
| `writingStyle.updatedAt` | timestamp\|null |  |

```json
{
  "success": true,
  "writingStyle": {
    "integration": {
      "id": 1234,
      "provider": "linkedin",
      "status": "active",
      "parentIntegrationId": null
    },
    "profile": {
      "id": 1234,
      "name": "Jan",
      "headline": "Founder",
      "picture": "https://..."
    },
    "hasWritingStyle": true,
    "writingStyle": {
      "tone": "direct"
    },
    "modelContext": "Write in a direct, no-fluff tone...",
    "updatedAt": "2026-06-01T00:00:00.000Z"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | id path param must be a positive integer (zod coercion failure -> validation_error) |
| 404 | linkedin_profile_not_found: integration id not in this workspace |

#### Notes

- hasWritingStyle=false means no trained style yet: callers should write naturally rather than inventing one.

## Comment Plugs

### Get Comment Plug config

`GET /api/public/comment-plugs/config`

Scope: `profiles:read`

Reads the default Comment Plug (automatic first-comment) settings for one or all LinkedIn profiles in the workspace.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `integrationId` | int | No | One profile. Alias: integration_id. Omit to list all profiles. |

#### Request example

```bash
curl "https://postiv.ai/api/public/comment-plugs/config" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Profiles with their Comment Plug config (defaulted when unset).

| Field | Type | Description |
| --- | --- | --- |
| `orgId` | uuid |  |
| `profiles[].id` | int |  |
| `profiles[].profileType` | string |  |
| `profiles[].name` | string\|null |  |
| `profiles[].cmmConnected` | boolean | Whether LinkedIn's comment-management permission is connected; enabled configs require this true. |
| `profiles[].config.enabled` | boolean |  |
| `profiles[].config.delayMinutes` | int | Default/minimum 15. |
| `profiles[].config.selectionMode` | string |  |
| `profiles[].config.variants` | array | [{ id, text, useWhen, imageSource }] |
| `config` | object\|null | Convenience: same as profiles[0].config when integrationId was passed; null otherwise. |
| `warnings` | string[] | e.g. a note if the comment_plug_configs table isn't migrated yet. |

```json
{
  "success": true,
  "orgId": "9e6397ad-...",
  "profiles": [
    {
      "id": 1234,
      "profileType": "personal",
      "name": "Jan",
      "cmmConnected": true,
      "config": {
        "enabled": true,
        "delayMinutes": 30,
        "selectionMode": "random",
        "variants": [
          {
            "id": "v1",
            "text": "Grab the template here: ...",
            "useWhen": null,
            "imageSource": null
          }
        ]
      }
    }
  ],
  "config": null,
  "warnings": []
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | invalid_integration_id: integrationId not a positive integer |
| 404 | linkedin_profile_not_found: integrationId given but not in this workspace |

#### Notes

- Call before relying on schedule_post's commentPlug 'inherit' mode: inherit only actually posts a comment when enabled, has variants, and cmmConnected is true.

### Set Comment Plug config

`POST /api/public/comment-plugs/config`

Scope: `posts:schedule`

Sets the profile-level default Comment Plug used by future scheduled posts that inherit it. Not a per-post override: use schedule_post's commentPlug for that.

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `integrationId` | int | Yes | Alias: integration_id. |
| `enabled` | boolean | Yes |  |
| `delayMinutes` | int | No | Alias: delay_minutes. Minimum 15 (validated server-side). |
| `selectionMode` | string (`random`, `routed`) | No | Alias: selection_mode. |
| `variants` | array | No | Max 5. Each: { id?, text (max 1250), useWhen? (max 240, alias use_when/routeHint), imageSource? (max 2000, alias image_source/imageUrl) }. Default: ``. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/comment-plugs/config \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "integrationId": 123,
    "enabled": true
  }'
```

#### Response

The saved profile + config.

| Field | Type | Description |
| --- | --- | --- |
| `profile` | object | Same shape as a GET profiles[] entry. |
| `config` | object | The saved config row, same shape as GET's profiles[].config plus id/orgId/createdAt/updatedAt. |

```json
{
  "success": true,
  "profile": {
    "id": 1234,
    "profileType": "personal",
    "name": "Jan",
    "cmmConnected": true
  },
  "config": {
    "id": "cfg-1",
    "orgId": "9e6397ad-...",
    "integrationId": 1234,
    "enabled": true,
    "delayMinutes": 30,
    "selectionMode": "random",
    "variants": [],
    "createdAt": "...",
    "updatedAt": "..."
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_integration_id / invalid_comment_plug_config: validation failure (e.g. enabled=true with no variants, delay < 15) |
| 400 | linkedin_cmm_reconnect_required: enabled=true but the profile's LinkedIn comment-management permission isn't connected |
| 404 | linkedin_profile_not_found |
| 503 | comment_plug_tables_missing: migration not yet run in this environment |

#### Notes

- To disable the default, set enabled=false (variants can be empty).

## Plans & Pillars

### List Bob content plans

`GET /api/public/content-plans`

Scope: `plans:read`

Lists Bob's weekly content plans for one LinkedIn profile: every item's topic/angle/format/status/target day/pillar, plus a pillar-distribution aggregate.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `integrationId` | int | Yes | LinkedIn profile id. |
| `weekStart` | string | No | YYYY-MM-DD, normally a Monday. Defaults to the current week (computed server-side). |
| `weeksAhead` | int | No | 1-4 consecutive weeks. Default: `1`. |

#### Request example

```bash
curl "https://postiv.ai/api/public/content-plans?integrationId=123" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Plans for the requested week range.

| Field | Type | Description |
| --- | --- | --- |
| `integrationId` | int |  |
| `weekStart` | string | Resolved first week (YYYY-MM-DD). |
| `weeksRequested` | int |  |
| `plans[].id` | uuid |  |
| `plans[].profileId` | int |  |
| `plans[].weekStart` | string |  |
| `plans[].status` | string |  |
| `plans[].items[].id` | uuid |  |
| `plans[].items[].position` | int |  |
| `plans[].items[].topic` | string |  |
| `plans[].items[].angle` | string\|null |  |
| `plans[].items[].goal` | string\|null |  |
| `plans[].items[].hookPattern` | string\|null |  |
| `plans[].items[].targetDay` | string\|null | YYYY-MM-DD. |
| `plans[].items[].status` | string |  |
| `plans[].items[].postId` | uuid\|null |  |
| `plans[].items[].carouselId` | uuid\|null |  |
| `plans[].items[].contentFormat` | string\|null |  |
| `plans[].items[].pillar` | string\|null |  |
| `pillarDistribution[].pillar` | string |  |
| `pillarDistribution[].count` | int |  |
| `pillarDistribution[].percentage` | float | Across all items in the returned weeks. |
| `hasPreviousWeek` | boolean |  |
| `hasNextWeek` | boolean |  |

```json
{
  "success": true,
  "integrationId": 1234,
  "weekStart": "2026-07-13",
  "weeksRequested": 1,
  "plans": [
    {
      "id": "p1...",
      "profileId": 1234,
      "weekStart": "2026-07-13",
      "status": "active",
      "items": [
        {
          "id": "i1...",
          "position": 0,
          "topic": "Why most B2B content fails",
          "angle": "contrarian",
          "goal": "engagement",
          "hookPattern": "myth-vs-fact",
          "targetDay": "2026-07-14",
          "status": "planned",
          "postId": null,
          "carouselId": null,
          "contentFormat": "text",
          "pillar": "Content Strategy"
        }
      ]
    }
  ],
  "pillarDistribution": [
    {
      "pillar": "Content Strategy",
      "count": 1,
      "percentage": 100
    }
  ],
  "hasPreviousWeek": true,
  "hasNextWeek": false
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | invalid_integration_id / invalid_week_start / invalid_weeks_ahead: malformed params |
| 404 | linkedin_profile_not_found: profile not in this workspace |

#### Notes

- Call get_content_plan_item / GET /api/public/content-plan-items/:id before drafting a selected item to get its exact hook and saved source material.

### Get Bob content plan item

`GET /api/public/content-plan-items/:id`

Scope: `plans:read`

Reads one content-plan item in full detail, including the bounded knowledge/trending sources Bob saved when planning it.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl "https://postiv.ai/api/public/content-plan-items/ID_ID" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

The full plan item.

| Field | Type | Description |
| --- | --- | --- |
| `item.id / position / topic / angle / goal / hookPattern / targetDay / status / postId / carouselId / contentFormat / pillar` | mixed | Same fields as list_content_plans items. |
| `item.planId` | uuid |  |
| `item.profileId` | int |  |
| `item.weekStart` | string |  |
| `item.sourceChunks` | string[] | Up to 8 saved knowledge-base excerpts, each truncated to 2000 chars: use directly instead of re-searching knowledge. |
| `item.contextItems` | array | Up to 12 { type, title, url } context references. |
| `item.research` | object\|null | When present: what the slot was planned from: a chosen fresh signal (source + suggested hook/format), remaining alternatives, or a sourced external object with signed media URLs to react to. |

```json
{
  "success": true,
  "item": {
    "id": "i1...",
    "planId": "p1...",
    "profileId": 1234,
    "weekStart": "2026-07-13",
    "position": 0,
    "topic": "Why most B2B content fails",
    "sourceChunks": [
      "Postiv customer research shows..."
    ],
    "contextItems": [],
    "research": null
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | id path param must be a UUID |
| 404 | content_plan_item_not_found: item id not found or not in this workspace |

#### Notes

- research.sourced media URLs are re-signed on every read via signSourcedMedia.

### List content pillars

`GET /api/public/pillars`

Scope: `plans:read`

Lists the recurring content pillars a profile's weekly plans draw from: summary shape only (name, description, mode, suggested day). Call get_pillar for full content + performance.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `integrationId` | int | Yes |  |

#### Request example

```bash
curl "https://postiv.ai/api/public/pillars?integrationId=123" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Active pillars for the profile.

| Field | Type | Description |
| --- | --- | --- |
| `integrationId` | int |  |
| `postsPerWeek` | int\|null |  |
| `contentGoal` | string\|null |  |
| `pillars[].id` | uuid |  |
| `pillars[].name` | string |  |
| `pillars[].description` | string |  |
| `pillars[].mode` | string |  |
| `pillars[].suggestedDay` | string\|null |  |

```json
{
  "success": true,
  "integrationId": 1234,
  "postsPerWeek": 4,
  "contentGoal": "leads",
  "pillars": [
    {
      "id": "pl1...",
      "name": "Content Strategy",
      "description": "Educational posts on planning content.",
      "mode": "evergreen",
      "suggestedDay": "Tuesday"
    }
  ]
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | invalid_integration_id |
| 404 | linkedin_profile_not_found |

### Get content pillar

`GET /api/public/pillars/:id`

Scope: `plans:read`

Reads one pillar in full: name, description, freeform content, suggested day, mode, version, and 30-day performance vs. the profile average when the pillar has published posts in that window.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl "https://postiv.ai/api/public/pillars/ID_ID" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

The pillar with performance stats.

| Field | Type | Description |
| --- | --- | --- |
| `pillar.id / name / description / suggestedDay / mode` | mixed |  |
| `pillar.content` | string | Freeform guidance on what to cover and how to write for this pillar. |
| `pillar.version` | int |  |
| `pillar.stats` | object\|null | 30-day performance (posts published, avg impressions, engagement rate vs. profile average, top posts); null if no published posts in that window. |

```json
{
  "success": true,
  "pillar": {
    "id": "pl1...",
    "name": "Content Strategy",
    "description": "...",
    "content": "Write about planning frameworks...",
    "suggestedDay": "Tuesday",
    "mode": "evergreen",
    "version": 3,
    "stats": {
      "postsPublished": 4,
      "avgImpressions": 5200,
      "engagementRateVsAverage": 1.15
    }
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | id path param must be a UUID |
| 404 | pillar_not_found: pillar archived, doesn't exist, or not in this workspace |

## Inspiration & Templates

### Search post templates

`GET /api/public/templates`

Scope: `posts:read`

Searches Postiv's global post-writing templates plus the workspace's own custom templates by substring match across name/pattern/bestFor/example.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | string | No | Substring search, max 500 chars. Alias: q. |
| `q` | string | No | Alias for query. |
| `limit` | int | No | Max results, 1-50 (service further caps at 50 regardless of a higher request). Default: `20`. |

#### Request example

```bash
curl "https://postiv.ai/api/public/templates?query=launch%20week&q=launch%20week&limit=10" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Matching templates, ranked by usage count then org-owned-first then sort order.

| Field | Type | Description |
| --- | --- | --- |
| `templates[].id` | uuid |  |
| `templates[].slug` | string |  |
| `templates[].name` | string |  |
| `templates[].pattern` | string | The reusable structural pattern. |
| `templates[].bestFor` | string\|null |  |
| `templates[].example` | string\|null |  |
| `templates[].isCustom` | boolean | true if owned by this workspace, false if a global template. |
| `templates[].usageCount` | int | Times used by this org's posts. |

```json
{
  "success": true,
  "templates": [
    {
      "id": "t1...",
      "slug": "problem-agitate-solve",
      "name": "Problem-Agitate-Solve",
      "pattern": "Open with a pain point...",
      "bestFor": "Educational hooks",
      "example": "Most founders...",
      "isCustom": false,
      "usageCount": 42
    }
  ]
}
```

#### Notes

- Only active templates (is_active=true) that are either global (org_id IS NULL) or owned by the calling org are returned; other workspaces' custom templates never leak.

### Search inspiration library

`GET /api/public/inspiration`

Scope: `inspiration:read`

Searches Postiv's moderated LinkedIn inspiration corpus (pipeline_status='completed', moderation_status='approved', public creators only) with an optional semantic query plus exact filters.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | string | No | Semantic query, 1-2000 chars. Omit for filter-only search. |
| `limit` | int | No | 1-20. Default: `8`. |
| `creator_name` | string | No | Filter by creator name or @handle, 1-200 chars. |
| `lead_magnet_only` | boolean | No | As query string 'true'/'false'. |
| `time_range` | string (`1D`, `7D`, `30D`, `3M`, `12M`) | No |  |
| `sort` | string (`virality`, `recent`, `trending`, `likes`, `comments`) | No |  Default: `virality`. |
| `likes_min` | int | No |  |
| `likes_max` | int | No | Must be >= likes_min. |
| `comments_min` | int | No |  |
| `comments_max` | int | No | Must be >= comments_min. |
| `content_types` | string[] | No | Comma-separated or repeated param, e.g. text/image/video/carousel. |
| `types` | string[] | No | Inspiration categories, e.g. educational/promotional/personal/current_events. |
| `follower_min` | int | No |  |
| `follower_max` | int | No | Must be >= follower_min. |

#### Request example

```bash
curl "https://postiv.ai/api/public/inspiration?query=launch%20week&limit=10&sort=virality" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Matching inspiration posts.

| Field | Type | Description |
| --- | --- | --- |
| `hits[].hook` | string\|null |  |
| `hits[].summary` | string\|null |  |
| `hits[].body` | string\|null | Full post text. |
| `hits[].creator` | string\|null |  |
| `hits[].creatorHandle` | string\|null |  |
| `hits[].creatorHeadline` | string\|null |  |
| `hits[].creatorFollowerCount` | int\|null |  |
| `hits[].engagement` | object | { likes, comments, shares } |
| `hits[].postedAt` | timestamp\|null |  |
| `hits[].linkedinUrl` | string\|null | UTM params stripped. |
| `hits[].categories` | string[]\|null |  |
| `hits[].contentType` | string\|null |  |
| `hits[].type` | string\|null | main_category. |
| `hits[].isLeadMagnet` | boolean |  |
| `hits[].score` | float\|null | Similarity score, null when query was omitted. |
| `hits[].creatorAvatarUrl` | string\|null | Signed S3 URL, only for S3-hosted (creators/ prefixed) avatars. |
| `hits[].imageUrl` | string\|null | Signed S3 URL of the post's first S3-hosted image, if any. |

```json
{
  "success": true,
  "hits": [
    {
      "hook": "I lost my biggest client...",
      "summary": "A founder shares a churn lesson.",
      "creator": "Alex Hormozi",
      "creatorHandle": "@alexhormozi",
      "engagement": {
        "likes": 4200,
        "comments": 310,
        "shares": 90
      },
      "isLeadMagnet": false,
      "score": 0.74
    }
  ]
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | invalid_inspiration_filters: a *_max value is less than its *_min pair |
| 429 | inspiration_embedding_failed: embedding provider rate-limited (only when query is set) |
| 503 | inspiration_embedding_failed / inspiration_search_failed: embedding or DB search failure |

#### Notes

- content_types/types accept comma-separated values within a single query param or repeated params. Selective filters (creator_name, ranges, etc.) skip the fast ANN candidate-limiting path to avoid pgvector's post-filter false-empty behavior on narrow subsets.

## Scratchpad

### List scratchpad items

`GET /api/public/scratchpad`

Scope: `scratchpad:read`

Lists every live (non-expired) item in the org's shared scratchpad: a mid-week capture buffer that decays 30 days after creation and feeds Bob's weekly plan.

#### Request example

```bash
curl "https://postiv.ai/api/public/scratchpad" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Live scratchpad items.

| Field | Type | Description |
| --- | --- | --- |
| `items[]` | array | Each item serialized via serializeScratchpadItemRow: id, kind (note/url/image/file), content, status (fresh/suggested/used), url/imageUrl/fileUrl where applicable, boardPosition, createdBy, createdAt, expiresAt. |

```json
{
  "success": true,
  "items": [
    {
      "id": "sp1...",
      "kind": "note",
      "content": "Competitor just launched a carousel maker",
      "status": "fresh",
      "createdAt": "2026-07-18T10:00:00.000Z"
    }
  ]
}
```

#### Notes

- Every scrap auto-expires 30 days after creation with no action needed.

### Create scratchpad item

`POST /api/public/scratchpad`

Scope: `scratchpad:write`

Drops a new note, link, or image/file into the org scratchpad.

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string (`note`, `url`, `image`, `file`) | Yes |  |
| `content` | string | No | Max 50000 chars. Required for kind=note; optional caption otherwise. |
| `url` | string | No | Required for kind=url; server-side scrapes title/description/image. |
| `imageUrl` | string | No | For kind=image, fetched server-side (SSRF-guarded, size-capped) instead of s3Key. |
| `s3Key` | string | No | For kind=image or kind=file, from create-scratchpad-upload-url. |
| `contentType` | string | No | Required with s3Key. |
| `fileName` | string | No | Required for kind=file. |
| `boardPosition` | object | No | { x, y }. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/scratchpad \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "note",
    "content": "Three things we learned from launch week..."
  }'
```

#### Response

The created item.

| Field | Type | Description |
| --- | --- | --- |
| `item` | object | Same shape as a list-scratchpad-items entry. |

```json
{
  "success": true,
  "item": {
    "id": "sp2...",
    "kind": "url",
    "url": "https://example.com/article",
    "status": "fresh",
    "createdAt": "2026-07-19T09:00:00.000Z"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | invalid_kind: bad/missing kind or missing required field for that kind |
| 422 | invalid_image: image fetch/validation failed |

#### Notes

- Status is 201 Created. Always tagged source='mcp' internally regardless of REST vs. MCP origin, and attributed to the API key's actor.

### Get scratchpad item

`GET /api/public/scratchpad/:id`

Scope: `scratchpad:read`

Reads one scratchpad item, org-scoped. (REST-only: no MCP tool equivalent; the MCP surface only exposes list/create/update/delete/promote.)

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl "https://postiv.ai/api/public/scratchpad/ID_ID" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

One item.

| Field | Type | Description |
| --- | --- | --- |
| `item` | object | Same shape as list-scratchpad-items entries. |

```json
{
  "success": true,
  "item": {
    "id": "sp1...",
    "kind": "note",
    "content": "Competitor just launched a carousel maker",
    "status": "fresh"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | scratchpad_item_not_found |

### Update scratchpad item

`PATCH /api/public/scratchpad/:id`

Scope: `scratchpad:write`

Edits a scratchpad item's content, lifecycle status, and/or board position.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `content` | string | No | Max 50000 chars. |
| `status` | string (`fresh`, `suggested`, `used`) | No |  |
| `boardPosition` | object | No | { x, y }. |

#### Request example

```bash
curl -X PATCH https://postiv.ai/api/public/scratchpad/ID_ID \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Three things we learned from launch week...",
    "status": "fresh"
  }'
```

#### Response

The updated item.

| Field | Type | Description |
| --- | --- | --- |
| `item` | object |  |

```json
{
  "success": true,
  "item": {
    "id": "sp1...",
    "status": "used"
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | validation_error: none of content/status/boardPosition provided |
| 404 | scratchpad_item_not_found |

#### Notes

- boardPosition is REST-only (not exposed on the MCP update_scratchpad_item tool, which only takes content/status).

### Delete scratchpad item

`DELETE /api/public/scratchpad/:id`

Scope: `scratchpad:write`

Permanently deletes a scratchpad item and its stored image/file, if any.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl -X DELETE "https://postiv.ai/api/public/scratchpad/ID_ID" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Bare success flag.

```json
{
  "success": true
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | scratchpad_item_not_found |

#### Notes

- Rarely necessary: every scrap auto-expires after 30 days.

### Promote scratchpad item

`POST /api/public/scratchpad/:id/promote`

Scope: `scratchpad:write, knowledge:write`

Promotes a scrap into a permanent, org-shared knowledge base asset (same ownership convention as add-knowledge). Deletes the scrap on success; leaves it untouched on failure.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/scratchpad/ID_ID/promote \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{}'
```

#### Response

The new knowledge asset reference.

| Field | Type | Description |
| --- | --- | --- |
| `assetId` | uuid |  |
| `kind` | string |  |

```json
{
  "success": true,
  "assetId": "b1c2...",
  "kind": "document"
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | scratchpad_item_not_found |
| 422 | not_promotable: e.g. an empty note, or a file type without a promotion path yet |

#### Notes

- Requires BOTH scratchpad:write (for the delete) and knowledge:write (for the asset/chunk writes) scopes on the same key.

### Create scratchpad upload URL

`POST /api/public/scratchpad/upload-url`

Scope: `scratchpad:write`

Issues a signed S3 upload URL for a kind='image' or kind='file' scratchpad item, to be used before create-scratchpad-item with the returned s3Key.

#### Body parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `contentType` | string | Yes | Image types (jpeg/png/gif/webp/svg+xml) or document types (pdf, docx, pptx, xlsx: legacy .doc/.ppt/.xls are NOT supported here). |

#### Request example

```bash
curl -X POST https://postiv.ai/api/public/scratchpad/upload-url \
  -H "Authorization: Bearer pk_postiv_..." \
  -H "Content-Type: application/json" \
  -d '{
    "contentType": "..."
  }'
```

#### Response

A signed upload target.

| Field | Type | Description |
| --- | --- | --- |
| `uploadUrl` | string | Signed S3 PUT URL. |
| `s3Key` | string | scratchpad/{orgId}/{uuid}/original.{ext}: pass to create-scratchpad-item. |

```json
{
  "success": true,
  "uploadUrl": "https://postiv-user-media.s3.eu-central-1.amazonaws.com/...",
  "s3Key": "scratchpad/9e6397ad-.../a1b2.../original.png"
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | invalid_kind: unsupported contentType for either the image or document allowlist |

#### Notes

- MCP has no direct equivalent: file/image uploads via s3Key are REST-only (the MCP create_scratchpad_item tool only supports kind='image' via a public imageUrl, not direct upload).

## Analytics

### List analytics accounts

`GET /api/public/analytics/accounts`

Scope: `analytics:read`

Lists LinkedIn analytics accounts tracked in the workspace, each mapping to one LinkedIn profile. Call first to resolve accountId for every other analytics endpoint.

#### Request example

```bash
curl "https://postiv.ai/api/public/analytics/accounts" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Tracked accounts with last-import timestamps.

| Field | Type | Description |
| --- | --- | --- |
| `accounts[].id` | uuid |  |
| `accounts[].orgId` | uuid |  |
| `accounts[].integrationId` | int |  |
| `accounts[].accountType` | string |  |
| `accounts[].profileType` | string |  |
| `accounts[].externalRef` | string |  |
| `accounts[].isActive` | boolean |  |
| `accounts[].name / picture` | mixed |  |
| `accounts[].createdAt` | timestamp |  |
| `accounts[].lastImport` | timestamp\|null | Last analytics fetch time. |

```json
{
  "success": true,
  "accounts": [
    {
      "id": "acc1...",
      "orgId": "9e6397ad-...",
      "integrationId": 1234,
      "accountType": "personal",
      "profileType": "personal",
      "externalRef": "urn:li:person:...",
      "isActive": true,
      "name": "Jan van Musscher",
      "picture": "https://...",
      "createdAt": "...",
      "lastImport": "2026-07-19T06:00:00.000Z"
    }
  ]
}
```

#### Notes

- Data is cached/imported from LinkedIn, not live: freshness is indicated by lastImport.

### Get analytics summary

`GET /api/public/analytics/summary`

Scope: `analytics:read`

Cached KPI totals (impressions, engagements, engagement rate, follower growth) for one account or the whole workspace, over a trailing period or explicit date range.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `accountId` | uuid\|'all' | Yes | Alias: account_id. 'all' aggregates every active account in the org. |
| `period` | int | No | Trailing days, 1-365; ignored if start/end given. Default: `30`. |
| `start` | string | No | YYYY-MM-DD, must pair with end. |
| `end` | string | No | YYYY-MM-DD, must pair with start. |

#### Request example

```bash
curl "https://postiv.ai/api/public/analytics/summary?accountId=all" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

KPI totals for the window; exact shape is produced by lib/analytics/queries.getMetricsSummary(ForAccounts): not enumerated here since this route just passes it through.

| Field | Type | Description |
| --- | --- | --- |
| `summary` | object | Impressions/engagements/engagement-rate/follower-growth totals for the resolved account(s) and period. |

```json
{
  "success": true,
  "summary": {
    "impressions": 48210,
    "engagements": 1930,
    "engagementRate": 4,
    "followerGrowth": 62
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_account_id: accountId omitted |
| 400 | validation_error: start given without end (or vice versa) |
| 404 | analytics_account_not_found: accountId not 'all' and not in this org |

#### Notes

- Same accountId resolution and default period (30d) as the MCP tool get_analytics_summary.

### Get performance overview

`GET /api/public/analytics/performance`

Scope: `analytics:read`

Cached day-by-day performance time series (impressions, engagements) for one account or the workspace.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `accountId` | uuid\|'all' | Yes | Alias: account_id. |
| `period` | int | No | Trailing days, 1-365; ignored if start/end given. Default: `28`. |
| `start` | string | No | YYYY-MM-DD, must pair with end. |
| `end` | string | No | YYYY-MM-DD, must pair with start. |

#### Request example

```bash
curl "https://postiv.ai/api/public/analytics/performance?accountId=all" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Day-by-day series, shape from lib/analytics/queries.getPerformanceOverview(ForAccounts).

| Field | Type | Description |
| --- | --- | --- |
| `performance` | object\|array | Time-series of daily impressions/engagements. |

```json
{
  "success": true,
  "performance": [
    {
      "date": "2026-07-18",
      "impressions": 1400,
      "engagements": 62
    },
    {
      "date": "2026-07-19",
      "impressions": 1610,
      "engagements": 74
    }
  ]
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_account_id |
| 400 | validation_error: start/end not paired |
| 404 | analytics_account_not_found |

#### Notes

- Default trailing window is 28 days here (vs. 30 for summary).

### Get top posts

`GET /api/public/analytics/top-posts`

Scope: `analytics:read`

Cached top posts with full metrics and time-limited media URLs, sortable including outlier sorts that surface posts that over-performed the account's own baseline.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `accountId` | uuid\|'all' | Yes | Alias: account_id. |
| `limit` | int | No | 1-100. Default: `25`. |
| `sort` | string (`date`, `engagements`, `impressions`, `engagement_outlier`, `reach_outlier`, `type_outlier`, `type_reach_outlier`) | No |  Default: `date`. |
| `period` | int | No | Trailing days, 1-365. Default is all-time (no filter) if omitted and no start/end. |
| `start` | string | No |  |
| `end` | string | No |  |

#### Request example

```bash
curl "https://postiv.ai/api/public/analytics/top-posts?accountId=all&limit=10&sort=date" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

Top posts with metrics and outlier scores.

| Field | Type | Description |
| --- | --- | --- |
| `posts[].id` | uuid |  |
| `posts[].hook / postUrl / linkedinUrn / createdAt / mediaType / category / isLeadMagnet / lastFetchedAt` | mixed |  |
| `posts[].mediaUrls` | string[] | Refreshed signed URLs. |
| `posts[].metrics` | object | { impressions, engagements, engagementRate, reactions, comments, reshares, membersReached, postSaves, postSends, linkClicks, premiumCtaClicks, followersGainedFromContent, profileViewsFromContent, videoPlays, videoWatchTime, videoViewers } |
| `posts[].outliers` | object | { weightedEngagementScore, impressionsMultiplier, engagementMultiplier, mediaTypeImpressionsMultiplier, mediaTypeEngagementMultiplier, calculatedAt, overallBaselineSampleSize, mediaTypeBaselineSampleSize } |
| `posts[].metadata` | object | { postText, postLink } |

```json
{
  "success": true,
  "posts": [
    {
      "id": "ap1...",
      "hook": "I lost my biggest client...",
      "postUrl": "https://linkedin.com/...",
      "mediaType": "text",
      "isLeadMagnet": false,
      "mediaUrls": [],
      "metrics": {
        "impressions": 12400,
        "engagements": 640,
        "engagementRate": 5.2
      },
      "outliers": {
        "engagementMultiplier": 2.1
      }
    }
  ]
}
```

#### Errors

| Status | When |
| --- | --- |
| 400 | missing_account_id |
| 404 | analytics_account_not_found |

#### Notes

- Rows with no analytics id AND all-null impressions/engagements (queue-only, never imported) are filtered out: this only ranks posts that actually have analytics.

### Get post analytics

`GET /api/public/analytics/posts/:id`

Scope: `analytics:read`

Cached analytics for one specific post. Accepts either a Postiv post id or an analytics post id (from get-top-posts).

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Resource id from the corresponding list or create endpoint. |

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `accountId` | uuid\|'all' | No | Alias: account_id. Disambiguates when omitted the search spans the whole workspace. |

#### Request example

```bash
curl "https://postiv.ai/api/public/analytics/posts/ID_ID?accountId=all" \
  -H "Authorization: Bearer pk_postiv_..."
```

#### Response

One post's analytics, same metric/outlier shape as get-top-posts plus account context.

| Field | Type | Description |
| --- | --- | --- |
| `analytics.* ` | mixed | Same fields as a get-top-posts posts[] entry. |
| `analytics.postivPostId` | uuid\|null |  |
| `analytics.account` | object | { id, integrationId, accountType, profileType, name, picture } |

```json
{
  "success": true,
  "analytics": {
    "id": "ap1...",
    "postivPostId": "post1...",
    "hook": "I lost my biggest client...",
    "metrics": {
      "impressions": 12400,
      "engagements": 640
    },
    "account": {
      "id": "acc1...",
      "integrationId": 1234,
      "accountType": "personal",
      "profileType": "personal",
      "name": "Jan",
      "picture": "https://..."
    }
  }
}
```

#### Errors

| Status | When |
| --- | --- |
| 404 | analytics_account_not_found: accountId given but not in this org |
| 404 | post_analytics_not_found: a matching Postiv post exists but has no analytics yet (not published or not yet imported); details include { postId, status, integrationId } |
| 404 | analytics_post_not_found: id doesn't match any post at all |