Meet the new Scripe, live on October 7.Register

API v1 · Getting started

Conventions

This page documents the rules that apply uniformly to every endpoint in the Scripe API. If your client respects these conventions, every new endpoint we ship will Just Work — there are no per-resource surprises.


Versioning

Pin a date-stamped version on every request:

http
Scripe-Api-Version: 2026-08-10
  • Accepted versions, newest first:

    VersionWhat changed
    2026-08-10Current, and the default. Read scopes are enforced: GET notes/posts require notes:read / posts:read, GET /sources/{id} requires sources:read, and POST /uploads requires sources:write or knowledge:write — the scopes this documentation always listed, now checked. A <resource>:write scope satisfies the read requirement. Failing requests get 403 scope_missing with the fix in the message.
    2026-08-01Deprecated, sunset 2026-11-15. The four gated reads above — GET notes/posts (collection and item), GET /sources/{id}, and POST /uploads — accept any valid token of the workspace. Every other endpoint enforces its scope on this version exactly as on the current one, so pinning it is not a general escape hatch. Still served until sunset, but re-mint the key rather than relying on it.
  • Omitted header → we default to the current version. Fine for exploration, dangerous in production — pin explicitly so the next version cut doesn't change your responses under you.

  • Unknown version → 400 version_unsupported. The error message lists the versions we still accept.

  • We accept at least the two most recent versions at any time, with a minimum 90-day overlap when we sunset an old one. Sunset is announced via response headers long before the version stops being served:

http
Scripe-Api-Version: 2026-08-01
Scripe-Deprecation: true
Sunset: Sun, 15 Nov 2026 00:00:00 GMT

Sunset is an RFC 8594 HTTP-date. If you see Scripe-Deprecation, plan a migration before that date — bumping the pinned version is usually a one-line change. The version keeps working until the sunset passes.

We never make breaking changes within a pinned version, with one exception: a check that was letting a request through against the documented contract is tightened for every version at once. Pinning cannot be a way to keep a permission you were never granted — you choose your own Scripe-Api-Version, so a version gate could not close such a hole. That is why, from 2026-08-13, an endpoint listing more than one scope requires all of them: PATCH /v1/posts/{postId}/media had been accepting a key holding only one of posts:write / media:read, though this documentation has always listed both. See scope_missing.

Additive changes (new optional response fields, new endpoints, new webhook events) can ship at any time — write your client to ignore fields and enum values it doesn't recognise.

Everything above — the no-breaking-changes promise and the Scripe-Api-Version pin it rests on — covers the REST surface. The MCP surface has no version pin at all: it accepts no Scripe-Api-Version header and its tool results carry no version marker, so tool JSON shapes are versionless and may change between releases — get_analytics_report renamed topPosts to posts and filename to filenameBase this way. Agent clients are expected to read tool descriptions and result shapes at connection time rather than compile against them.


Pagination

Collections come in three families. Check which one you're calling before you write a loop — the envelope differs, and a cursor loop against an offset endpoint never terminates.

FamilyEndpointsHow you loop
Cursor/notes, /posts, /projects, /jobs, /webhook-endpoints, /ideas, /knowledge, /media, /sourcespagination.next_cursor + has_more (below)
Offset/analytics/postspagination.{total,limit,offset,has_more} — see Offset pagination
Unpaginated/calendar (bounded by its date range), /team (reports totalCount + truncated), /post-statuses, /company-pages, /workspaces, /viral-posts (its limit is a result cap, not a page size)You don't — one response is the whole set

Everything from here to the idiom below describes the cursor family.

Request

GET /v1/notes?projectId=proj_abc&limit=50
GET /v1/notes?projectId=proj_abc&limit=50&cursor=<prev next_cursor>
ParameterDefaultMaxNotes
limitvariesvariesPer-resource; see the table below. Above the max we clamp silently — you get the max, not an error.
cursor——Opaque base64 string. Echo what we returned last time.

limit is rejected with 400 bad_pagination only when it isn't a positive integer — a non-numeric value, 0, or a negative. ?limit=500 against /v1/media therefore returns at most 100 rows, not a 400, so read pagination.has_more rather than assuming you got everything you asked for.

EndpointDefaultMax
/notes, /posts, /projects, /jobs, /webhook-endpoints50200
/ideas, /knowledge, /media, /sources25100

The same clamp applies to the two non-cursor endpoints that take a limit: /analytics/posts is 50/200, and /viral-posts caps its result set at 12 by default and 30 at most. Every value is also declared on the operation's limit parameter in the OpenAPI reference tab.

Response envelope

json
{
  "data": [ /* resource objects */ ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI2L…",
    "has_more": true,
    "total": 128
  }
}
  • next_cursor is null once there are no more rows.
  • has_more is the canonical "loop again?" signal. Don't compare cursor values — they're opaque and may change shape across versions.
  • total is optional and currently ships on /posts and /notes only — see Counting without paging.
  • A cursor is bound to the request's filter set. Changing projectId, dateFrom, dateTo, status, folderId, etc. mid-loop will likely emit 400 bad_cursor because the keyset reference no longer applies.
  • A cursor never expires server-side, but your filters might (e.g. you filter by a dateTo that's now in the past). Treat 400 bad_cursor as "start the loop over from the beginning" rather than as a bug.

Counting without paging

pagination.total is how many rows the request's filters match, not how many this page carries. It is the same number on page 1 and on page 4 — the cursor is deliberately excluded from the count — so:

GET /v1/posts?projectId=proj_abc&status=draft&limit=1
→ { "data": [ … 1 row … ],
    "pagination": { "has_more": true, "total": 128 } }

answers "how many drafts do I have" in one request instead of 3 pages and 200 KB of post bodies. Never sum the pages to get a count, and never report the page size as the total.

total is present on GET /v1/posts and GET /v1/notes. The other list endpoints are being converted one at a time, so treat it as optional and fall back to paging when it is absent.

Large text fields in a list

A list row carries an excerpt of the biggest free-text field on the resource, not the whole thing, and says so with a sibling boolean:

GET /v1/posts?projectId=proj_abc
→ { "data": [ { "content": "The junior developer role is dying and here is…",
                "contentTruncated": true, … } ] }
  • The single-resource read (GET /v1/posts/{postId}) always serves the whole field, with the flag false.
  • ?content=full returns whole bodies for a page; ?content=none omits them. Both are on GET /v1/posts and GET /v1/analytics/posts with the same spelling and the same preview default.
  • A shortened field is never served without its flag. Without one, a client cannot tell an excerpt from the resource, which is how clipped text ends up quoted back to a user as something they wrote.
  • When you also pass q, the excerpt is centred on the first matching term, so a row shows why it matched.

Pagination idiom (pseudocode)

ts
let cursor: string | null = null;
while (true) {
  const u = new URL("https://api.scripe.io/v1/notes");
  u.searchParams.set("projectId", projectId);
  u.searchParams.set("limit", "100");
  if (cursor) u.searchParams.set("cursor", cursor);
  const res = await fetch(u, { headers });
  const { data, pagination } = await res.json();
  yield* data;
  if (!pagination.has_more) break;
  cursor = pagination.next_cursor;
}

Do not reuse this loop for /analytics/posts — it has no next_cursor, so cursor would stay null and you would refetch page one forever.

Offset pagination

GET /v1/analytics/posts is the one offset-paginated endpoint. It takes limit (50, max 200) and offset (a non-negative integer; anything else is 400 bad_pagination) and returns:

json
{
  "data": [ /* post analytics */ ],
  "pagination": { "total": 412, "limit": 50, "offset": 100, "has_more": true }
}

Advance with offset += limit while has_more is true. total is the full match count, so you can size the loop up front. Rows are not cursor-stable: a post created mid-loop can shift the window.


Errors

Every non-2xx response returns the same envelope:

json
{
  "error": {
    "code": "key_revoked",
    "message": "This API key has been revoked.",
    "request_id": "req_01J9Z…",
    "docs_url": "https://docs.scripe.io/api/v1/errors#key_revoked"
  }
}
  • code is the canonical machine-readable identifier. Stable across releases. Switch on this — never on message or HTTP status alone.
  • message is human-readable and may evolve. It states what to do differently, not just what went wrong.
  • request_id is your handle to correlate with our tracing if you open a support ticket. It also matches the X-Request-Id response header.
  • docs_url deep-links to the entry for the code in the error reference.
  • details (optional) carries a code-specific payload — e.g. spend_cap_exceeded includes the cap and current spend, and scope_missing includes required_scope when it comes from a handler-level or MCP check (the route-level guard behind most REST 403s sends no details). Always fall back to message.

A 404 not_found names the resource and echoes the id you sent, plus the container it was looked up in when there is one — so a call that carries two ids tells you which one to re-resolve:

json
{
  "error": {
    "code": "not_found",
    "message": "Idea \"idea_9f2c\" was not found in project \"proj_a1b2\". It may have been deleted, or it belongs to a different project — re-resolve the id by listing ideas for this project.",
    "details": { "resource": "idea", "id": "idea_9f2c", "projectId": "proj_a1b2" }
  }
}

Switch on details.resource, not on the message. Every kind of miss — a malformed id, an id from another namespace, a real id in a workspace you can't see — returns the identical body, so the error can't be used to discover which ids exist.

The full catalogue lives in the error reference — one entry per code, with causes and remediation.

Status code summary

HTTPCommon codes
400invalid_request, bad_cursor, bad_pagination, version_unsupported
401unauthenticated, invalid_token, key_revoked, key_expired
402usage_limit_exceeded, spend_cap_exceeded
403scope_missing, plan_not_eligible, admin_required
404not_found
405method_not_allowed
409conflict, idempotency_key_conflict, not_cancellable
413payload_too_large
422unprocessable
429rate_limited
500internal_error
503service_unavailable

A 401 always means "fix your authentication". A 403 always means "grant more scopes / upgrade the plan for a plan-gated capability / use an admin principal". A 429 means "back off and retry"; never treat it as a permanent failure.


Rate limits

Limits are per-principal, per-bucket, sliding 60-second window. The principal is the API key or the OAuth access token you present — not the workspace — so revoking a credential frees its budget immediately.

BucketLimitUsed by
read120 req/minEvery GET / read tool.
write30 req/minEvery mutation (POST / PATCH / DELETE, write tools).
job10 req/minAsync-job submissions — POST /posts/generations, POST /knowledge, POST /media/generations. Lower because each burns queue + AI budget, on top of the workspace's 5-job concurrency cap.
analytics_fanout10 req/minGET /analytics/cross-workspace/{overview,report} and their MCP twins. Each call fans out per reachable workspace — roughly 20x (overview) or 8x (report) the database work of a plain read — so it draws from its own tighter bucket rather than the shared read one.

A 429 names the bucket it throttled, so you know what to slow down:

json
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded for writes (30 per minute). Retry after 21s.",
    "details": { "retryAfterSeconds": 21, "limit": 30, "bucket": "write" }
  }
}

POST /v1/sources draws from write even for a file source, though the MCP create_source_file tool is metered against job. The two surfaces differ here; meter against the one you actually call.

Each response carries:

http
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 41

X-RateLimit-Reset is in seconds, not a timestamp. On a 429 we also include Retry-After:

http
HTTP/1.1 429 Too Many Requests
Retry-After: 17
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 17

Survival tips

  • Pre-throttle. Watch Remaining and slow yourself down rather than burning the whole budget.
  • Respect Retry-After. It's the smallest safe sleep value; longer is fine.
  • Buckets are independent. A write 429 doesn't stop you reading — keep answering questions while you pace the mutations.
  • Budget is per credential. Each key and each OAuth grant carries its own bucket, so a CI loop on its own key can't throttle your production integration. That is not licence to shard around the limit: the workspace-level caps (job concurrency, plan usage) still apply.
  • Prefer webhooks over polling. A webhook endpoint on job.completed / post.created removes most polling loops entirely.
  • Test mode counts. A test key has its own bucket but hits the same production data — CI loops shouldn't run against production data without a dedicated test workspace.

The MCP surface shares the same buckets, keyed by the OAuth access token — see MCP § Limits.


Headers we set on every response

http
Scripe-Api-Version: 2026-08-10
X-Request-Id: req_01J9Z…
Access-Control-Allow-Origin: *
Cache-Control: no-store        (default; health probes override)

For 4xx and 5xx the same headers apply, plus Retry-After on 429 and Allow on 405. Deprecated-version requests additionally carry Scripe-Deprecation: true and Sunset. Idempotent write replays carry Idempotent-Replayed: true (see Idempotency).


Resource IDs

Every resource exposes a typed string id with a stable prefix. Treat the whole string as opaque — never parse beyond the prefix.

ResourcePrefixReturned by
Workspaceorg_/workspaces* (Clerk organisation id)
Projectproj_/projects
Notenote_/notes
Postpost_/posts
Ideaidea_/ideas
Sourcesrc_/sources
Topic / Hooktpc_ / hk_/sources/{id} once processed
Knowledge documentkb_/knowledge
Media assetimg_/media
Company pagecpg_/company-pages
Post team engagementeng_get_post_engagements — MCP only, no REST route
Content topicwct_list_content_topics, create_content_topic, assign_content_topic, unassign_content_topic — MCP only, no REST route
Profile listplist_list_profile_lists — MCP only, no REST route
Profile-list memberlauth_get_profile_list — a LinkedIn person in Scripe's corpus, not a Scripe brand
Upload handleupl_/uploads
Jobjob_/jobs
Webhook endpointwhe_/webhook-endpoints
Webhook delivery(none)X-Scripe-Delivery header — a bare id, not prefixed
Webhook eventevt_webhook payload id
API keykey_/workspaces/me principal
OAuth access tokenoat_/workspaces/me principal (OAuth callers)

A 404 on GET /v1/projects/proj_does_not_exist is indistinguishable from a 404 on GET /v1/projects/proj_belongs_to_other_workspace. We do not leak the existence of cross-workspace resources via 401/403/404 distinctions.


Date and time

Every timestamp on the wire is ISO 8601 UTC with a trailing Z:

2026-08-01T14:23:11.000Z

Date-only filter parameters (dateFrom, dateTo) accept YYYY-MM-DD and are interpreted as start- and end-of-day in UTC respectively (dateFrom=2026-08-01 → >= 2026-08-01T00:00:00Z, dateTo=2026-08-01 → <= 2026-08-01T23:59:59.999Z).

The one deliberate exception: calendar date ranges are interpreted in the project's calendar timezone (the response echoes which), because "what goes out on Tuesday" is a wall-clock question.


CORS

The API responds Access-Control-Allow-Origin: *, so any browser can call it as long as the user supplies their own Authorization header. We do not echo cookies — no Access-Control-Allow-Credentials, and Cookie never appears in Access-Control-Expose-Headers. The API surface is stateless and never participates in browser session auth.

CORS preflight (OPTIONS) returns 204 without authentication and echoes the headers your client requested via Access-Control-Request-Headers, so custom headers (Scripe-Api-Version, Idempotency-Key, Scripe-Workspace-Id) all pass preflight. Access-Control-Max-Age: 600 keeps repeat preflights off your latency path. Since a cross-origin request carrying Authorization is always preflighted, that response is what makes the API callable from a browser at all.


Anything else?

If a behaviour isn't documented here or in the per-resource pages, file a bug rather than relying on the observed behaviour. The OpenAPI 3.1 spec rendered in the OpenAPI reference tab is the canonical schema; anything you observe but cannot find in the spec is undocumented and may change.