Meet the new Scripe, live on October 7.Register

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.

MethodPathNotes
GET/v1/postsList for a project; status CSV filter.
GET/v1/posts/{postId}Single read.
POST/v1/postsCreate a draft, or schedule with scheduledFor.
PATCH/v1/posts/{postId}Edit, move status, schedule/reschedule/unschedule.
GET/v1/post-statusesThe workspace's custom statuses (kanban columns).
PATCH/v1/posts/{postId}/mediaFull-state media write — see Media.
POST/v1/posts/generationsAsync AI generation — job envelope.

GET /v1/posts

List posts inside a project. projectId is required.

bash
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 parameterRequiredNotes
projectIdyesproj_* id. 404 if it doesn't belong to the workspace.
statusnoComma-separated workflow statuses (below). Unknown values → 400 invalid_request.
statusCategorynoComma-separated kanban categories (suggested, draft, inProgress, review, scheduled, published). The only way to ask for inProgress.
statusIdnoComma-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.
qnoFree-text search over post body, title, and hook (search / query are accepted aliases) — see Searching.
contentnopreview (default) | full | none — list rows carry an excerpt unless asked otherwise; see Conventions § Large text fields.
dateFrom / dateTonoYYYY-MM-DD. Filter the post's creation date (createdAt) — when the draft was written. Inclusive (UTC).
publishedFrom / publishedTonoYYYY-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).
reviewnoComma-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.
deliverynoComma-separated delivery states (not_scheduled, scheduled, due, missed, published). delivery=missed is the only way to ask which posts never went out.
limit / cursornoStandard pagination. This list also reports pagination.total.

Status values

status carries the workflow state:

ValueMeaning
waitingProcessingBeing generated by the AI pipeline.
draftEditable draft, not yet approved or scheduled.
waitingApprovalOut for review (workspaces with approval flows).
approvedApproved; awaiting scheduling.
rejectedReviewer rejected the draft.
scheduledScheduled to publish at scheduledAt.
publishedPublished to LinkedIn.
suggestedAI-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

json
{
  "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:

  • 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 strategy (the dashboard's Strategy property) — 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 strategy; a strategy chosen in the dashboard on its own (no format, contentType UNKNOWN) 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. A contentType written through this API re-derives it: a pillar that names a strategy replaces a strategy 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. 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: null on a published post 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 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. 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 + 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.

bash
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:

RequestResult
scheduledFor + a non-scheduled status400 invalid_request — would queue a post the publish cron ignores.
Status change to scheduled without scheduledFor400 invalid_request — would skip the LinkedIn pre-flight. Send scheduledFor; it stamps the status for you.
Status change away from scheduled on a dated post400 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.

bash
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" }
  }'
FieldRequiredNotes
projectIdyesMust belong to the workspace.
source.typeyestext, note, topic, or idea.
source.textwhen type=textUp to 100,000 chars.
source.noteIdwhen type=noteMust belong to the same project.
source.topicIdwhen type=topicA 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.ideaIdwhen type=ideaAn 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.languagenoShort language code; defaults to the project's preference.
options.contentTypenoSame enum as post creation.
wait_for_completion_msnoHold 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 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 budget — 402 (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 team 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 team 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.