Skip to main content

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_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:
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:
  • 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)

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

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

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