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.
GET /v1/posts
List posts inside a project. projectId is required.
Status values
status carries the workflow state:
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
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 funnel lane —REACH(top of funnel),TRUST(middle) orCONVERT(bottom) — ornullwhen the post is unrated. Where the post carries a format it is that format’s lane; a lane chosen in the dashboard on its own (no format,contentTypeUNKNOWN) is reported as stored; otherwise the pillar’s lane. It is read-only: no request writes it, so a post’s lane changes in the dashboard — by choosing a lane, or a lane and a format. AcontentTypewritten through this API re-derives it: a pillar that names a lane replaces a lane 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.publishedAt: nullon apublishedpost means “moments ago”: the date comes from LinkedIn’s own measurement row, which lands within the hour.- 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.
Readdeliverybefore 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.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.
Dashboard-internal fields (ratings, hooks, reshare context, DFY state) are intentionally not in the response and may change without notice.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”.
POST /v1/posts
Create a draft post, or schedule one by passing a future scheduledFor.
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:
A
409 conflict means the LinkedIn connection check failed; the post is
left completely untouched.
Rescheduling re-arms auto-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.
The worker already loads the project’s tone-of-voice profile, voice samples, content pillars, and knowledge base before drafting. There is intentionally noGeneration is metered by the AI budget —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.
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 auto-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 auto-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.