API v1 · Resources
Posts
A post is a LinkedIn-ready piece of content. Posts move through a workflow — draft → (approval) → scheduled → published — and the API exposes reads, writes, scheduling, media, and AI generation. Publishing right now is deliberately not on REST: it's irreversible, so it lives on MCP behind a two-phase confirmation.
Scopes: posts:read for reads, posts:write for writes — both
grantable to API keys.
PATCH /v1/posts/{postId}/media additionally needs media:read
(also key-grantable); tick both on the key. Full
schemas: OpenAPI reference → Posts.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/posts | List for a project; status CSV filter. |
| GET | /v1/posts/{postId} | Single read. |
| POST | /v1/posts | Create a draft, or schedule with scheduledFor. |
| PATCH | /v1/posts/{postId} | Edit, move status, schedule/reschedule/unschedule. |
| GET | /v1/post-statuses | The workspace's custom statuses (kanban columns). |
| PATCH | /v1/posts/{postId}/media | Full-state media write — see Media. |
| POST | /v1/posts/generations | Async AI generation — job envelope. |
GET /v1/posts
List posts inside a project. projectId is required.
curl -s "https://api.scripe.io/v1/posts?projectId=proj_01J9ZA…&status=draft,scheduled" \
-H "Authorization: Bearer scripe_sk_live_…" \
-H "Scripe-Api-Version: 2026-08-10"| Query parameter | Required | Notes |
|---|---|---|
projectId | yes | proj_* id. 404 if it doesn't belong to the workspace. |
status | no | Comma-separated workflow statuses (below). Unknown values → 400 invalid_request. |
statusCategory | no | Comma-separated kanban categories (suggested, draft, inProgress, review, scheduled, published). The only way to ask for inProgress. |
statusId | no | Comma-separated board-column ids, from GET /v1/post-statuses. Use when a workspace runs several columns inside one category. An id the workspace does not own → 400 invalid_request, never an empty page. |
q | no | Free-text search over post body, title, and hook (search / query are accepted aliases) — see Searching. |
content | no | preview (default) | full | none — list rows carry an excerpt unless asked otherwise; see Conventions § Large text fields. |
dateFrom / dateTo | no | YYYY-MM-DD. Filter the post's creation date (createdAt) — when the draft was written. Inclusive (UTC). |
publishedFrom / publishedTo | no | YYYY-MM-DD. Filter the publication date (publishedAt) — the day the LinkedIn copy went live, which is what "what did I publish last week" means. Only ever match published posts. Inclusive (UTC). |
review | no | Comma-separated approval states (not_requested, pending, approved, rejected). review=pending is the only correct answer to "what needs my approval?" — the review board column is not. |
delivery | no | Comma-separated delivery states (not_scheduled, scheduled, due, missed, published). delivery=missed is the only way to ask which posts never went out. |
limit / cursor | no | Standard pagination. This list also reports pagination.total. |
Status values
status carries the workflow state:
| Value | Meaning |
|---|---|
waitingProcessing | Being generated by the AI pipeline. |
draft | Editable draft, not yet approved or scheduled. |
waitingApproval | Out for review (workspaces with approval flows). |
approved | Approved; awaiting scheduling. |
rejected | Reviewer rejected the draft. |
scheduled | Scheduled to publish at scheduledAt. |
published | Published to LinkedIn. |
suggested | AI-suggested, not yet acted on. |
The custom status (the board column) is the one the product
actually writes, and it is exposed: every post row carries statusId,
statusTitle and statusCategory, the legacy status above is
DERIVED from the column's category, and both statusCategory and
statusId are filters. GET /v1/post-statuses?projectId=… lists the
columns with a postCount on each — that is the one call that answers
"what's in my pipeline", rather than one request per category.
Two consequences worth knowing before you answer a user: draft covers
two board categories (draft and inProgress both derive to
status: "draft"), so read statusTitle when the user asks where a
post is; and a filter and a read can never disagree, because both match
through the custom status.
Searching with q
q is a case- and accent-insensitive substring match over the post
body, title, and hook. Multiple words are AND-ed, a "quoted phrase" is
matched whole, and % / _ are literal. Ordering is unchanged
(newest-first) — q narrows the page, it does not rank by relevance.
Limits: 200 characters, 6 terms; a longer query is a
400 invalid_request, never a silent truncation. An empty result means
the words are absent, not the topic — there is no stemming and no
synonyms, so retry with fewer or simpler words before concluding
anything.
Response
{
"data": [
{
"id": "post_01J9ZA…",
"projectId": "proj_01J9ZA…",
"status": "draft",
"statusId": "7cae…",
"statusTitle": "Draft",
"statusCategory": "draft",
"platform": "LINKEDIN",
"contentType": "EDUCATIONAL",
"funnelStage": "TRUST",
"title": "Three lessons from rolling out a pricing change",
"content": "1/ Don't ship in December …",
"contentTruncated": true,
"scheduledAt": null,
"publishedAt": null,
"lastPublishError": null,
"lastPublishAttemptAt": null,
"delivery": { "state": "not_scheduled", "willRetry": false, "retryUntil": null, "detail": null },
"review": { "state": "not_requested", "awaitingDecision": false, "requiredByWorkspace": false },
"createdAt": "2026-05-15T08:21:00.000Z",
"updatedAt": "2026-05-15T09:11:42.000Z"
}
],
"pagination": { "next_cursor": "…", "has_more": false, "total": 128 }
}Notes on specific fields:
contentis plain text with newlines; no HTML. On this list it is a ~280-character excerpt cut on a word boundary, withcontentTruncated: truebeside it — passcontent=full(with a smalllimit) for whole bodies,content=nonefor none, and never quote a truncated row back to a user as their post. When you passq, the excerpt is centred on the first matching term. The single read always serves the whole body.contentTypeis one ofPERSONAL,BUSINESS_INTERNAL,BUSINESS_EXTERNAL,EDUCATIONAL,UNKNOWN.funnelStageis the post's strategy (the dashboard's Strategy property) —REACH(top of funnel),TRUST(middle) orCONVERT(bottom) — ornullwhen the post is unrated. Where the post carries a format it is that format's strategy; a strategy chosen in the dashboard on its own (no format,contentTypeUNKNOWN) is reported as stored; otherwise the pillar's strategy. It is read-only: no request writes it, so a post's strategy changes in the dashboard — by choosing a strategy, or a strategy and a format. AcontentTypewritten through this API re-derives it: a pillar that names a strategy replaces a strategy chosen on its own,UNKNOWNleaves it in place.platformisLINKEDINtoday; treat unknown values as opaque.projectIdisnullonly for very old legacy rows.publishedAtis the only field that dates a publish —scheduledAtis what somebody asked for and stays set on posts that never went out, andupdatedAtmoves on any edit. It is never guessed from the schedule. For a post Scripe published, it is set at publish time — the publish commit — and later refined to LinkedIn's own time when the sync lands.publishedAt: nullon apublishedpost means there is no measurement row for it: the publish-time placeholder was not written (e.g. no LinkedIn author to attribute it to), or the post was removed at the source.- Order is
(createdAt DESC, id DESC)— always, including underpublishedFrom/publishedTo. A publication window returns the posts that went live in it, ordered by when they were drafted; sort bypublishedAtclient-side when the order itself matters. deliveryandrevieware derived verdict blocks — full shapes in the OpenAPI reference tab (Post); what they mean is below.
Read
deliverybefore telling a user their post is on its way. A scheduled post that fails to publish keeps itsscheduledAtand itsscheduledstatus: the cron retries every ~2 minutes and gives up after 24 hours, after which the post is skipped on every tick from then on with nothing in the row changing. A failure no retry can fix (an image LinkedIn refused, media that could not be fetched) stops the retries at once, until the post is edited or rescheduled.status+scheduledAttherefore read identically for a post publishing in five minutes and one that will never publish again —delivery.stateis the difference (duevsmissed), anddelivery.detailcarries the reason and the repair.deliveryis also a filter:?delivery=missedlists everything that never went out. A post can also fail and then succeed, so a non-nulllastPublishErroron apublishedpost is history, not a problem.
statusCategory=reviewis not "waiting for approval". A review board column holds four different things at once: across production its 1,018 posts break down as 231 awaiting a decision, 104 already approved, 11 sent back, and 672 that nobody was ever asked to look at. Filter with?review=pending(the two combine) and branch onreview.awaitingDecision. A review also belongs to a board COLUMN rather than to the post, so a post keeps the review rows of the columns it has passed through — 2,144 of production's 2,490 rows are at a stage their post has left, 313 of them still pending on an already-published post. Only the row for the post's current column is reported — which is why a published post readsnot_requested.review.reviewerUserIdis a Clerk user id;GET /v1/teamresolves it to a person.review.requiredByWorkspaceis reported, not enforced, on THIS surface: these REST write paths do not refuse an unreviewed post (deliberately — enforcing here would break existing integrations in the workspaces that set the flag). The agent surfaces — the MCP tools and the in-app chat — CAN enforce it since 2026-09, but only where the workspace has set the flag AND is on a plan that includes content approvals (Advanced and Business among the plans sold today). Below that plan the flag is inert on every surface, because approving a post is itself a plan-gated feature and the approval a refusal would ask for cannot be obtained there at all. SorequiredByWorkspace: truemeans "this workspace may refuse an unreviewed post on the agent surfaces", not "it will".
Dashboard-internal fields (ratings, hooks, reshare context, DFY state) are intentionally not in the response and may change without notice.
POST /v1/posts
Create a draft post, or schedule one by passing a future scheduledFor.
curl -i https://api.scripe.io/v1/posts \
-X POST \
-H "Authorization: Bearer scripe_sk_live_…" \
-H "Scripe-Api-Version: 2026-08-10" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"projectId": "proj_01J9ZA…",
"title": "Three lessons from rolling out a pricing change",
"content": "1/ Don'"'"'t ship in December…",
"scheduledFor": "2026-09-01T13:00:00Z"
}'Request fields, their limits and the contentType enum are in the
OpenAPI reference tab (PostCreate). Two things it doesn't say:
projectId must belong to the workspace or you get 404 not_found, and
a scheduledFor in the past is rejected with 422 unprocessable.
Setting it moves the post to scheduled and creates a calendar slot.
title is optional, and a post with words is never nameless
title is the post's internal name — what it is called in the
dashboard, in a teammate's notification and in GET /v1/posts. It is not
published as post copy. One exception: on a post with an attached media
asset (a document or an image) that carries no filename of its own, a
title you set is used as that asset's display title on LinkedIn. Only a
title you set is eligible — a title Scripe derived is never published, and
such a post's asset goes out titled Document.
On create, omitting title and sending title: "" do the same thing:
Scripe names the post after its own opening line, so a post created with
content is never nameless in someone's notification. There is no way to
create a post with words in it and no name. Send a real title and that
name is yours: nothing in the product overwrites it — not a later PATCH
that only changes content, not the AI passes that rewrite the draft.
On PATCH, the two differ. Omitting title never touches a name
you set, and names a still-unnamed post from the content you are
sending. A name Scripe derived is not frozen, though: it keeps
following your opening line for as long as the content you send still
starts with it — so extending a draft you created with two words renames
it to the fuller sentence — and freezes for good the first time you
rewrite that opening. Sending title: "" clears the name: the post goes
back to being unnamed and Scripe-nameable, exactly as if you had never
named it.
A post created with no content (or content with no text in it) stays
unnamed until it has words to be named after.
LinkedIn pre-flight when scheduledFor is set
Sending scheduledFor runs the same LinkedIn connection check the
dashboard runs before scheduling. A dead connection fails the request
with 409 conflict and creates nothing — the check runs before any
insert, so a failed pre-flight never leaves an orphan draft behind.
Creating an unscheduled draft does not touch LinkedIn.
Posts that pass the pre-flight can still fail later — if the LinkedIn
token dies between scheduling and publish time, the post keeps its
schedule and gets a lastPublishError (see above).
PATCH /v1/posts/{postId}
Edit content, move between statuses, and manage the schedule. This is
the same handler the MCP update_post / schedule_post tools call.
Accepted fields are in the OpenAPI reference tab (PostUpdate),
including which pairs are mutually exclusive. Discover custom status ids
via GET /v1/post-statuses. The scheduling rules below are the part no
schema can express.
Scheduling is a scheduledFor operation, never a status move
Auto-publish requires the post to hold a scheduled-category status
and a scheduledAt, and the two must change together. The API
rejects requests that would separate them, because both failure modes
are invisible to the caller:
| Request | Result |
|---|---|
scheduledFor + a non-scheduled status | 400 invalid_request — would queue a post the publish cron ignores. |
Status change to scheduled without scheduledFor | 400 invalid_request — would skip the LinkedIn pre-flight. Send scheduledFor; it stamps the status for you. |
Status change away from scheduled on a dated post | 400 invalid_request — would leave the post looking scheduled forever. Send scheduledFor: null in the same request. |
A 409 conflict means the LinkedIn connection check failed; the post is
left completely untouched.
Rescheduling re-arms team engagement. Moving a post that already had a time re-stamps its configured auto-like/comment/share actions against the new publish time. A first-time schedule deliberately leaves them unarmed: the publish path activates them against the actual publish time, which is more accurate than the planned one.
POST /v1/posts/generations
Generate a post draft from free-form text, a saved note, a source
topic, or an idea-board card. Returns a
Job envelope — poll (or use wait_for_completion_ms,
capped at 25,000 ms) until status: "DONE", then result.postId points
at the new post.
curl -i https://api.scripe.io/v1/posts/generations \
-X POST \
-H "Authorization: Bearer scripe_sk_live_…" \
-H "Scripe-Api-Version: 2026-08-10" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"projectId": "proj_01J9ZA…",
"source": { "type": "text", "text": "Three lessons we learned shipping a pricing change…" },
"options": { "contentType": "EDUCATIONAL" }
}'| Field | Required | Notes |
|---|---|---|
projectId | yes | Must belong to the workspace. |
source.type | yes | text, note, topic, or idea. |
source.text | when type=text | Up to 100,000 chars. |
source.noteId | when type=note | Must belong to the same project. |
source.topicId | when type=topic | A tpc_… id from GET /v1/sources/{sourceId}. The server reads the topic's transcript chunk itself. 404 = source outside the workspace; 422 = different project. |
source.ideaId | when type=idea | An idea_… id. The server renders the card's whole brief; the card's pillar fills options.contentType unless you send one, and the generated post is linked back to the idea (which sets its status to in_production if no post was linked yet; the idea's board column is not changed). |
options.language | no | Short language code; defaults to the project's preference. |
options.contentType | no | Same enum as post creation. |
wait_for_completion_ms | no | Hold the connection up to this long (server cap 25,000 ms); falls back to polling on timeout. |
The worker already loads the project's tone-of-voice profile, voice samples, content pillars, and knowledge base before drafting. There is intentionally no
toneoption — tone is configured per-project in the dashboard. To steer a specific draft, put the steering text insidesource.text(audience, format hints, example phrases); the worker weaves it in.
Generation is metered by the AI budget — 402
(usage_limit_exceeded / spend_cap_exceeded) when exhausted.
What's NOT on REST
- Publish now. MCP-only, two-phase,
posts:publishscope:publish_post. - Delete. There is no
DELETE /v1/posts/{postId}. Every destroy verb here needs a confirmation step the caller cannot skip, and on this resource that step lives only on MCP — the one REST destroy route today isDELETE /v1/sources/{sourceId}, which carries the same two phases over HTTP. Deleting a post is the MCPdelete_posttool — two-phase (proposal → confirmation token), gated onposts:destroy. It emitspost.deleted. Deleting a published post removes only Scripe's copy: the LinkedIn post stays live. - The team engagement plan.
autoLike/autoCommentonPATCHare the post's own coarse flags. The multi-actor plan behind them — which brands and company pages like, comment or repost this post, with what text and what delay, the rows Rescheduling re-arms team engagement above re-stamps — is composed only on MCP:get_post_engagements/update_post_engagements,posts:read/posts:writeplusprojects:read. - Engagement metrics. Likes/comments/views live on Analytics.
- Approval decisions. Every post read carries the
reviewverdict block and?review=pendingfilters by it, but approving and rejecting stay in the dashboard — nothing on this API can decide a review.