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:-
Accepted versions, newest first:
- 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:
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.
Everything from here to the idiom below describes the cursor family.
Request
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.
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
next_cursorisnullonce there are no more rows.has_moreis the canonical “loop again?” signal. Don’t compare cursor values — they’re opaque and may change shape across versions.totalis optional and currently ships on/postsand/notesonly — 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 emit400 bad_cursorbecause the keyset reference no longer applies. - A cursor never expires server-side, but your filters might (e.g. you
filter by a
dateTothat’s now in the past). Treat400 bad_cursoras “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:
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:- The single-resource read (
GET /v1/posts/{postId}) always serves the whole field, with the flagfalse. ?content=fullreturns whole bodies for a page;?content=noneomits them. Both are onGET /v1/postsandGET /v1/analytics/postswith the same spelling and the samepreviewdefault.- 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)
/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:
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:codeis the canonical machine-readable identifier. Stable across releases. Switch on this — never onmessageor HTTP status alone.messageis human-readable and may evolve. It states what to do differently, not just what went wrong.request_idis your handle to correlate with our tracing if you open a support ticket. It also matches theX-Request-Idresponse header.docs_urldeep-links to the entry for the code in the error reference.details(optional) carries a code-specific payload — e.g.spend_cap_exceededincludes the cap and current spend, andscope_missingincludesrequired_scopewhen it comes from a handler-level or MCP check (the route-level guard behind most REST 403s sends nodetails). Always fall back tomessage.
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:
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
A 401 always means “fix your authentication”. A 403 always means “grant
more scopes / upgrade the plan / 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.
A 429 names the bucket it throttled, so you know what to slow down:
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:
X-RateLimit-Reset is in seconds, not a timestamp. On a 429 we also
include Retry-After:
Survival tips
- Pre-throttle. Watch
Remainingand 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
write429 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.createdremoves 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.
Headers we set on every response
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.
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 trailingZ:
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 respondsAccess-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.