> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.scripe.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Posts

# Posts

A **post** is the LinkedIn-ready version of a note. Posts move through
a state machine — draft → waiting approval → approved → scheduled →
published — and the v1 API exposes every state.

The API is **read-only** in v1: triggering post creation, editing
content, scheduling, and publishing are all dashboard-only operations.
Phase 3 introduces the write endpoints.

***

## `GET /v1/posts`

List posts inside a project. `projectId` is required.

```bash theme={null}
curl -i 'https://api.scripe.io/v1/posts?projectId=proj_01J9ZA…&status=draft,scheduled' \
  -H "Authorization: Bearer scripe_sk_live_…" \
  -H "Scripe-Api-Version: 2026-08-01"
```

### Query parameters

| Name        | Type    | Required | Default | Notes                                                                                 |
| ----------- | ------- | -------- | ------- | ------------------------------------------------------------------------------------- |
| `projectId` | string  | yes      | —       | `proj_*` id. 404 if it doesn't belong to the workspace.                               |
| `status`    | string  | no       | —       | Comma-separated list of statuses (see below). Unknown values → `400 invalid_request`. |
| `dateFrom`  | string  | no       | —       | `YYYY-MM-DD`. Filters by post `createdAt`. Inclusive.                                 |
| `dateTo`    | string  | no       | —       | `YYYY-MM-DD`. Inclusive end-of-day UTC.                                               |
| `limit`     | integer | no       | 50      | 1…200.                                                                                |
| `cursor`    | string  | no       | —       | Opaque cursor from a previous response.                                               |

### Status values

The legacy `status` enum carries one of:

| Value               | Meaning                                                       |
| ------------------- | ------------------------------------------------------------- |
| `waitingProcessing` | The post is being generated by the AI pipeline.               |
| `draft`             | Editable draft, not yet sent for approval or scheduling.      |
| `waitingApproval`   | Out for review (only used in workspaces with approval flows). |
| `approved`          | Approved by the reviewer; awaiting scheduling.                |
| `rejected`          | Reviewer rejected the draft. Stays in the dashboard for edit. |
| `scheduled`         | Scheduled to publish at `scheduledAt`.                        |
| `published`         | Published to LinkedIn.                                        |
| `suggested`         | AI-suggested but not yet acted on by the user.                |

We're moving towards a **custom status** system in the dashboard
(`customStatusId`), but v1 only exposes the legacy enum. The two are
synced bidirectionally inside the dashboard, so filtering by the legacy
status here will catch the right rows. Custom statuses will surface in
v1.x as an additive `customStatus` field; existing clients won't break.

### Response

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": [
    {
      "id": "post_01J9ZA…",
      "projectId": "proj_01J9ZA…",
      "status": "draft",
      "platform": "LINKEDIN",
      "contentType": "EDUCATIONAL",
      "title": "Three lessons from rolling out a pricing change",
      "content": "1/ Don't ship in December …",
      "scheduledAt": null,
      "createdAt": "2026-05-15T08:21:00.000Z",
      "updatedAt": "2026-05-15T09:11:42.000Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI2L…",
    "has_more": false
  }
}
```

### Field reference

| Path          | Type                      | Notes                                                                                       |
| ------------- | ------------------------- | ------------------------------------------------------------------------------------------- |
| `id`          | string (`post_*`)         |                                                                                             |
| `projectId`   | string (`proj_*`) \| null | The owning project. `null` only for legacy rows pre-Phase 1; modern posts always carry one. |
| `status`      | string                    | Legacy status enum (see above).                                                             |
| `platform`    | string                    | `LINKEDIN`. New platforms are additive; treat unknown values as opaque.                     |
| `contentType` | string                    | One of `EDUCATIONAL`, `PERSONAL`, `BUSINESS`, `INSPIRATIONAL`, `NEWS`, `UNKNOWN`.           |
| `title`       | string                    | Display title. May be empty for very early drafts.                                          |
| `content`     | string                    | Body of the post. Plain text with newlines; no HTML.                                        |
| `scheduledAt` | string \| null            | ISO 8601 UTC. `null` unless `status === "scheduled"`.                                       |
| `createdAt`   | string                    | ISO 8601 UTC.                                                                               |
| `updatedAt`   | string \| null            | ISO 8601 UTC. `null` if never edited.                                                       |

Internal fields like `customStatusId`, `dfyStatus`, `dfyLiked`,
`chosenHook`, `postLength`, `postCreativity`, `rating`, `reasoning`, the
multi-image asset payloads, and reshare context are intentionally **not**
in the response. They're dashboard-only state and may be removed or
restructured without notice.

### Pagination caveats

* Order is `(createdAt DESC, id DESC)`.
* Changing any filter (`projectId`, `status`, `dateFrom`, `dateTo`,
  `limit`) mid-loop will likely emit `400 bad_cursor`. Restart the loop.

***

## `GET /v1/posts/{postId}`

Single post read. Same shape, wrapped in `data`.

```bash theme={null}
curl -i https://api.scripe.io/v1/posts/post_01J9ZA… \
  -H "Authorization: Bearer scripe_sk_live_…" \
  -H "Scripe-Api-Version: 2026-08-01"
```

### Response

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {
    "id": "post_01J9ZA…",
    "projectId": "proj_01J9ZA…",
    "status": "scheduled",
    "platform": "LINKEDIN",
    "contentType": "EDUCATIONAL",
    "title": "Three lessons from rolling out a pricing change",
    "content": "1/ Don't ship in December …",
    "scheduledAt": "2026-05-20T13:00:00.000Z",
    "createdAt": "2026-05-15T08:21:00.000Z",
    "updatedAt": "2026-05-19T17:42:11.000Z"
  }
}
```

### Errors

| Status | Code                                                             |
| ------ | ---------------------------------------------------------------- |
| 400    | `invalid_request` (bad `status`), `bad_cursor`, `bad_pagination` |
| 401    | `unauthenticated`, `invalid_token`, `key_revoked`, `key_expired` |
| 403    | `scope_missing` (need `posts:read`), `plan_not_eligible`         |
| 404    | `not_found`                                                      |
| 429    | `rate_limited`                                                   |

***

## `POST /v1/posts`

Create a draft post (or schedule one for a future timestamp). Required
scope: `posts:write`.

```bash theme={null}
curl -i https://api.scripe.io/v1/posts \
  -X POST \
  -H "Authorization: Bearer scripe_sk_live_…" \
  -H "Scripe-Api-Version: 2026-08-01" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: a-unique-id-from-your-side" \
  -d '{
    "projectId": "proj_01J9ZA…",
    "title": "Three lessons from rolling out a pricing change",
    "content": "1/ Don'"'"'t ship in December…",
    "scheduledFor": "2026-06-01T13:00:00Z"
  }'
```

### Request body

| Field          | Type              | Required | Notes                                                                                                                                  |
| -------------- | ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `projectId`    | string (`proj_*`) | yes      | Must belong to the workspace; otherwise `404 not_found`.                                                                               |
| `content`      | string            | no       | TipTap-compatible body. Up to 100,000 chars. Defaults to a single space (TipTap rejects truly-empty content).                          |
| `title`        | string            | no       | Up to 500 chars. Empty by default.                                                                                                     |
| `contentType`  | string            | no       | One of `PERSONAL`, `BUSINESS_INTERNAL`, `BUSINESS_EXTERNAL`, `EDUCATIONAL`, `UNKNOWN`. Defaults to `PERSONAL`.                         |
| `scheduledFor` | string (ISO 8601) | no       | Future timestamp. When set, the post status becomes `scheduled` and a calendar slot is created. Past timestamps → `422 unprocessable`. |

### Response

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json
Idempotent-Replayed: false

{
  "data": {
    "id": "post_01J9ZA…",
    "projectId": "proj_01J9ZA…",
    "status": "scheduled",
    "platform": "LINKEDIN",
    "contentType": "PERSONAL",
    "title": "Three lessons from rolling out a pricing change",
    "content": "1/ Don't ship in December…",
    "scheduledAt": "2026-06-01T13:00:00.000Z",
    "createdAt": "2026-05-15T08:21:00.000Z",
    "updatedAt": null
  }
}
```

### LinkedIn-token caveat

API-driven scheduling does NOT validate the project's LinkedIn token at
create-time. The post-scheduler cron picks the post up at publish time
and refreshes the token. If the refresh permanently fails, the post
moves to `failed_to_publish` (same path as the dashboard). This is the
only intentional behavioural delta between API-scheduled and
dashboard-scheduled posts.

### Errors

| Status | Code                                                             |
| ------ | ---------------------------------------------------------------- |
| 400    | `invalid_request` (missing `projectId`)                          |
| 401    | `unauthenticated`, `invalid_token`, `key_revoked`, `key_expired` |
| 403    | `scope_missing` (need `posts:write`), `plan_not_eligible`        |
| 404    | `not_found` (project not in workspace)                           |
| 409    | `idempotency_key_conflict`                                       |
| 413    | `payload_too_large` (`content` > 100KB or `title` > 500 chars)   |
| 422    | `unprocessable` (bad shape, bad enum, past `scheduledFor`)       |
| 429    | `rate_limited`                                                   |

***

## `POST /v1/posts/generations`

Generate a post draft from a piece of free-form text or a saved Note.
The request kicks off an **async job** — the response is a `Job`
envelope you poll until `status === "DONE"` (then `result.postId`
points to the freshly-created Post). Required scope: `posts:write`.

```bash theme={null}
curl -i https://api.scripe.io/v1/posts/generations \
  -X POST \
  -H "Authorization: Bearer scripe_sk_live_…" \
  -H "Scripe-Api-Version: 2026-08-01" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: a-unique-id-from-your-side" \
  -d '{
    "projectId": "proj_01J9ZA…",
    "source": {
      "type": "text",
      "text": "Three lessons we learned shipping a pricing change…"
    },
    "options": {
      "contentType": "EDUCATIONAL"
    }
  }'
```

### Request body

| Field                 | Type              | Required         | Notes                                                                                       |
| --------------------- | ----------------- | ---------------- | ------------------------------------------------------------------------------------------- |
| `projectId`           | string (`proj_*`) | yes              | Must belong to the workspace; otherwise `404 not_found`.                                    |
| `source.type`         | string            | yes              | `text` or `note`.                                                                           |
| `source.text`         | string            | when `type=text` | Up to 100,000 chars. Past the cap → `413 payload_too_large`.                                |
| `source.noteId`       | string (`note_*`) | when `type=note` | Note must belong to the same project; otherwise `422 unprocessable`.                        |
| `scheduledFor`        | string (ISO 8601) | no               | Future timestamp. Stored on the resulting post; v1 doesn't auto-schedule via this path yet. |
| `options.language`    | string            | no               | Short language code (≤16 chars). Defaults to the project's saved preference.                |
| `options.contentType` | string            | no               | One of `PERSONAL`, `BUSINESS_INTERNAL`, `BUSINESS_EXTERNAL`, `EDUCATIONAL`, `UNKNOWN`.      |

> The worker already loads the project's tone-of-voice profile, voice
> samples, content pillars, and knowledge base before drafting. There
> is intentionally **no** `tone` or `preferredHookStyle` option — tone
> is configured per-project in the dashboard, and the hook generator
> picks its own top-ranked hook. If you want to steer a specific draft,
> put the steering text inside `source.text` (audience, format hints,
> example phrases, etc.) — the worker weaves it into the prompt.
> \| `wait_for_completion_ms`  | integer           | no       | If set, the API holds the connection up to this many ms (capped server-side at **25,000 ms**) waiting for the job to finish. Useful for short-running flows that want a synchronous result. Falls through to standard polling on timeout. |

### Response

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json
Idempotent-Replayed: false

{
  "data": {
    "id": "job_01J9ZA…",
    "type": "POST_GENERATION",
    "status": "QUEUED",
    "projectId": "proj_01J9ZA…",
    "startedAt": null,
    "completedAt": null,
    "progress": null,
    "result": null,
    "errorCode": null,
    "errorMessage": null,
    "attemptCount": 0,
    "estimatedCompletionMs": 25000,
    "createdAt": "2026-05-15T08:21:00.000Z",
    "updatedAt": "2026-05-15T08:21:00.000Z"
  }
}
```

When the job finishes, `result` becomes `{ "postId": "post_01J9ZA…" }`
and you can fetch the post via `GET /v1/posts/{postId}`.

### Errors

| Status | Code                                                                                                |
| ------ | --------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request` (missing `projectId`, missing `source` fields)                                    |
| 401    | `unauthenticated`, `invalid_token`, `key_revoked`, `key_expired`                                    |
| 402    | `spend_cap_exceeded` (workspace daily AI cap reached)                                               |
| 403    | `scope_missing` (need `posts:write`), `plan_not_eligible`                                           |
| 404    | `not_found` (project / note not in workspace)                                                       |
| 409    | `idempotency_key_conflict`                                                                          |
| 413    | `payload_too_large` (`source.text` > 100KB)                                                         |
| 422    | `unprocessable` (bad shape, unknown enum, note belongs to a different project, past `scheduledFor`) |
| 429    | `rate_limited`                                                                                      |

***

## What's NOT here (yet)

* **Engagement metrics** (likes, comments, views). They live on a
  separate `tPostLinkedIn` row and will surface as an `analytics`
  endpoint in Phase 4.
* **Variations and version history.** v1 returns the canonical post
  body. Per-version reads are queued for the dashboard's content
  studio integration.
* **Approval / reviewer state.** The `status === "waitingApproval"`
  signal exists, but the reviewer queue, decisions, and notes are
  dashboard-only in v1.
* **Update / delete endpoints.** `PATCH` / `DELETE` are out of scope
  for v1. Use the dashboard for edits.
