Skip to main content

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

Notes on specific fields:
  • content is plain text with newlines; no HTML. On this list it is a ~280-character excerpt cut on a word boundary, with contentTruncated: true beside it — pass content=full (with a small limit) for whole bodies, content=none for none, and never quote a truncated row back to a user as their post. When you pass q, the excerpt is centred on the first matching term. The single read always serves the whole body.
  • contentType is one of PERSONAL, BUSINESS_INTERNAL, BUSINESS_EXTERNAL, EDUCATIONAL, UNKNOWN.
  • funnelStage is the post’s funnel lane — REACH (top of funnel), TRUST (middle) or CONVERT (bottom) — or null when 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, contentType UNKNOWN) 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. A contentType written through this API re-derives it: a pillar that names a lane replaces a lane chosen on its own, UNKNOWN leaves it in place.
  • platform is LINKEDIN today; treat unknown values as opaque.
  • projectId is null only for very old legacy rows.
  • publishedAt is the only field that dates a publish — scheduledAt is what somebody asked for and stays set on posts that never went out, and updatedAt moves on any edit. publishedAt: null on a published post 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 under publishedFrom/publishedTo. A publication window returns the posts that went live in it, ordered by when they were drafted; sort by publishedAt client-side when the order itself matters.
  • delivery and review are derived verdict blocks — full shapes in the OpenAPI reference tab (Post); what they mean is below.
Read delivery before telling a user their post is on its way. A scheduled post that fails to publish keeps its scheduledAt and its scheduled status: 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 + scheduledAt therefore read identically for a post publishing in five minutes and one that will never publish again — delivery.state is the difference (due vs missed), and delivery.detail carries the reason and the repair. delivery is also a filter: ?delivery=missed lists everything that never went out. A post can also fail and then succeed, so a non-null lastPublishError on a published post is history, not a problem.
statusCategory=review is 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 on review.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 reads not_requested. review.reviewerUserId is a Clerk user id; GET /v1/team resolves it to a person. review.requiredByWorkspace is 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. So requiredByWorkspace: true means “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.
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: 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 no tone option — tone is configured per-project in the dashboard. To steer a specific draft, put the steering text inside source.text (audience, format hints, example phrases); the worker weaves it in.
Generation is metered by the AI budget402 (usage_limit_exceeded / spend_cap_exceeded) when exhausted.

What’s NOT on REST

  • Publish now. MCP-only, two-phase, posts:publish scope: 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 is DELETE /v1/sources/{sourceId}, which carries the same two phases over HTTP. Deleting a post is the MCP delete_post tool — two-phase (proposal → confirmation token), gated on posts:destroy. It emits post.deleted. Deleting a published post removes only Scripe’s copy: the LinkedIn post stays live.
  • The auto-engagement plan. autoLike / autoComment on PATCH are 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:write plus projects:read.
  • Engagement metrics. Likes/comments/views live on Analytics.
  • Approval decisions. Every post read carries the review verdict block and ?review=pending filters by it, but approving and rejecting stay in the dashboard — nothing on this API can decide a review.