Meet the new Scripe, live on October 7.Register

API v1 · Getting started

OAuth 2.1

Status: Public preview. Behaviour and endpoint URLs are stable for v1; new scopes land additively. Breaking changes only ship in a new dated API version with at least 90 days of overlap.

This is the integrator-facing reference for Scripe's OAuth 2.1 authorization server. If you're building your own product that calls the Scripe API on behalf of a Scripe user (a Zapier-style integration, an internal SaaS, an MCP host), this is the contract you code against. OAuth tokens are also the only credential the MCP transport accepts.

If you only need a single-workspace key for your own scripts, mint an API key instead — it's substantially simpler.


TL;DR

http
# 1. Discover.
GET https://api.scripe.io/.well-known/oauth-authorization-server

# 2. Register your client (one-time, programmatic).
POST https://api.scripe.io/oauth/register
Content-Type: application/json
{
  "client_name": "Acme Notion Sync",
  "redirect_uris": ["https://acme.example.com/oauth/scripe/callback"],
  "scope": "notes:read posts:write offline_access",
  "token_endpoint_auth_method": "none"
}

# 3. Send the user to the authorization endpoint with PKCE (S256).
# 4. Exchange ?code=… at the token endpoint.
# 5. Use the access_token as a Bearer credential against /v1/*.
# 6. Rotate the refresh_token as needed.

The grant path is authorization code with PKCE-S256, and v1 supports public clients only: token_endpoint_auth_method must be "none", no client secret is issued, and PKCE is the proof of client identity. We do not support the implicit grant, the password grant, client credentials, or unencrypted PKCE challenges.


1. Hosts and discovery

Fetch the discovery documents unauthenticated; they are cache-friendly and do not require a registered client. They are the source of truth for endpoint URLs — hard-code the discovery URLs below, not the endpoints they name:

GET https://api.scripe.io/.well-known/oauth-authorization-server
GET https://api.scripe.io/.well-known/oauth-protected-resource
GET https://mcp.scripe.io/.well-known/oauth-protected-resource

The first is the RFC 8414 authorization-server metadata (endpoints, supported scopes, grant types, code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"]). The protected-resource documents are RFC 9728 metadata for the REST (https://api.scripe.io) and MCP (https://mcp.scripe.io) resource servers respectively.

The stable public endpoint forms on the API host:

Authorization endpoint: https://api.scripe.io/oauth/authorize
Token endpoint:         https://api.scripe.io/oauth/token
Revocation endpoint:    https://api.scripe.io/oauth/revoke
Introspection endpoint: https://api.scripe.io/oauth/introspect
DCR endpoint:           https://api.scripe.io/oauth/register

2. Dynamic Client Registration (RFC 7591)

You register your OAuth client by POSTing to the registration endpoint. The endpoint is public — anyone can register a client, and the resulting client_id grants no access to any user data until a user completes the consent flow. MCP hosts do this automatically.

2.1 Request

http
POST /oauth/register HTTP/1.1
Host: api.scripe.io
Content-Type: application/json

{
  "client_name": "Acme Notion Sync",
  "client_uri": "https://acme.example.com",
  "logo_uri": "https://acme.example.com/logo.png",
  "redirect_uris": [
    "https://acme.example.com/oauth/scripe/callback"
  ],
  "scope": "notes:read posts:write offline_access",
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "software_id": "com.acme.notion-sync",
  "software_version": "2026.05.27"
}

Field notes:

  • redirect_uris — must use HTTPS (or http://localhost for dev). Custom URI schemes (com.acme://…) are allowed for native and mobile clients.
  • token_endpoint_auth_method — must be "none" (or omitted; it is the default). v1 supports public clients only; any other value is rejected with invalid_client_metadata.
  • scope — space-separated list, validated against the closed scope list; unknown tokens fail with invalid_scope.
  • software_id / software_version — deduplication key. When both are sent, re-registering with identical values and the same redirect_uris returns the existing client_id instead of provisioning a new client. Omit them and every registration creates a fresh client row.

2.2 Response

json
{
  "client_id": "scripe_oac_4Z…",
  "client_id_issued_at": 1748345400,
  "client_name": "Acme Notion Sync",
  "redirect_uris": [
    "https://acme.example.com/oauth/scripe/callback"
  ],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "notes:read posts:write offline_access",
  "token_endpoint_auth_method": "none"
}

No client_secret is issued — public clients prove themselves with PKCE. We treat each registered client as immutable once issued; register again if your metadata changes.


3. Authorization code with PKCE

3.1 Mint a PKCE pair

js
const code_verifier = crypto.randomUUID() + crypto.randomUUID(); // ≥ 43 chars
const code_challenge = base64url(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code_verifier))
);

Store code_verifier somewhere server-side (or in a secure cookie). You present it later at the token endpoint.

3.2 Redirect the user to the authorization endpoint

http
GET https://api.scripe.io/oauth/authorize?
  response_type=code
  &client_id=scripe_oac_4Z…
  &redirect_uri=https://acme.example.com/oauth/scripe/callback
  &scope=notes:read posts:write offline_access
  &state=opaque_csrf_value
  &code_challenge=…
  &code_challenge_method=S256

The browser lands on the Scripe consent screen. If the user isn't signed in, they authenticate first; once authenticated they see:

  • Your client's name + logo, and its trust state: a Verified by Scripe pill for clients that passed review, or an "unverified" warning for everyone else. Well-known MCP hosts (Claude) are recognised by the redirect URIs they own and render verified with the publisher's canonical name, logo and homepage without any review step — registrant-supplied branding never renders under the verified pill. If you want your client verified, contact us.
  • A workspace picker (which workspace the grant pins as its default) and a default project picker when the workspace has more than one project. The screen labels that picker Default profile: the Scripe UI calls a project a Profile, while the API keeps project as its published noun — see MCP § Workspaces and projects.
  • One access level choice — Read-only or Full access — with the permissions that choice grants listed underneath in plain English. Read-only grants the *:read scopes you requested; Full access grants everything you requested, including posts:publish, settings:write and the *:destroy scopes when you asked for them by name. There are no per-scope toggles. If you request no write-side scope the choice is locked to Read-only.
  • A "Cancel" / "Authorize access" choice.

If the user has already approved this exact client + scope set before and has not revoked, the screen short-circuits to a "Welcome back" view — still a visible page, never a silent flow.

3.3 Receive the code

Approved consent redirects back with ?code=…&state=…&iss=…. Always verify state matches what you sent. A declined consent redirects with ?error=access_denied&state=… — errors on this leg arrive as RFC 6749 redirect query parameters (error, error_description).

Codes are single-use with a 10-minute TTL. Re-using a code raises invalid_grant.

3.4 Exchange at the token endpoint

http
POST /oauth/token HTTP/1.1
Host: api.scripe.io
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=…
&redirect_uri=https://acme.example.com/oauth/scripe/callback
&client_id=scripe_oac_4Z…
&code_verifier=…

The token endpoint accepts no client authentication — PKCE is the only proof. (application/json bodies are also accepted, for hosts that can't send form encoding.)

json
{
  "access_token": "scripe_oat_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "scripe_ort_…",
  "scope": "notes:read posts:read posts:write offline_access"
}

The scope returned may be narrower than what you requested if the user chose Read-only on the consent screen (every write-side scope is dropped, offline_access is kept). It will never be wider. The returned form is always expanded and de-duplicated (aliases resolved, implied reads included), so your client never has to special-case aliases at runtime.


4. Refresh token rotation + reuse detection

Refresh tokens rotate on every use. A successful refresh issues:

  • A new access_token with a fresh 1-hour TTL.
  • A new refresh_token with a fresh sliding window.
  • A scope ⊆ the previous scope (you can narrow, never widen).
http
POST /oauth/token HTTP/1.1
Host: api.scripe.io
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=scripe_ort_…
&client_id=scripe_oac_4Z…

4.1 Reuse detection

Presenting an already-rotated refresh token is treated as a leaked-token signal:

  1. The entire token family (every access + refresh token chained back to the original consent) is revoked.
  2. The user has to consent again on the next /authorize.
  3. The revocation appears in the workspace audit log and the user's email notifications.

There is one deliberate exception: replaying the same refresh token within a 15-second grace window (a network-glitch retry) returns the same new token pair instead of revoking the family. Outside that window, reuse is theft. If your client crashes between "server returned new refresh_token" and "I persisted it", treat it as a fresh consent flow — the old token is dead.

4.2 Lifetimes

LifetimeValue
Access token TTL1 hour
Refresh token sliding30 days from last use
Consent record6 months from the last (re-)consent — refreshes are capped by it, so an integration the user hasn't re-approved in 6 months re-consents
Authorization code TTL10 minutes, single-use
Reuse grace window15 seconds

Long-lived integrations that need offline access must request the offline_access scope. Without it the consent does not mint refresh tokens and the user has to re-authorise after each access-token expiry.


5. Scopes

The closed list of scopes we honour at v1:

ScopeImpliesWhat it grants
workspace:readWorkspace metadata + plan, the team roster (member names, emails, roles, project assignments), the project overview with stored LinkedIn health + streaks, curated project settings, the engagement policy, usage meters, and the workspace positioning (company + target-audience documents, org-wide tone of voice)
projects:readList + read projects and company pages; a post's team engagement panel, whose actor inventory is workspace brand/company-page data (with posts:read, or posts:write to change it)
notes:readRead notes
notes:writenotes:readCreate/update notes
notes:destroyPermanently delete notes (archive rides notes:write)
posts:readRead post drafts, scheduled posts, custom statuses
posts:writeposts:readCreate drafts, edit, schedule
posts:generateposts:readTrigger AI post generation (posts:write covers this on REST; MCP's generate_post requires it by name)
posts:publishPublish a post to LinkedIn now (two-phase confirm; posts:write does NOT include this)
posts:destroyPermanently delete posts (two-phase confirm; posts have no archive, so posts:write has no delete at all)
calendar:readRead the content calendar (slots, scheduled posts, planned items)
calendar:writecalendar:readReplace the recurring posting-time template (two-phase confirm). Not implied by calendar:read
ideas:readRead the idea board
ideas:writeideas:readCreate/update/plan ideas
ideas:destroyPermanently delete ideas
sources:readRead sources / transcriptions
sources:writesources:readCreate text + file sources, mint upload URLs
sources:destroyPermanently delete a source with its transcript, topics, and knowledge-base copy (two-phase confirm; posts generated from the source are kept)
knowledge:readRead knowledge-base entries
knowledge:writeknowledge:readIngest into the knowledge base (text/file/url/youtube), mint upload URLs
knowledge:destroyPermanently delete knowledge documents
media:readSearch the media library; read an idea's media display URLs (with ideas:read); attach media to an idea (with ideas:write)
media:writemedia:readGenerate images + carousels, attach media to posts
media:destroyRemove images from the media library (two-phase confirm; the dashboard's tombstone delete — posts already carrying an image keep it for 30 days, after which the retention sweeps erase the file, every earlier version of it and the CDN copy, and those references 404)
settings:writeCurated settings writes (project + org tone of voice, engagement policy, workspace positioning, per-brand personal DNA). Admin-only, and every write that would replace text a person already wrote is two-phase — see MCP tools §2 for which calls confirm and which land directly
community:readRead the community profile lists of a brand and the LinkedIn people on them
community:writecommunity:readAdd a LinkedIn person to a profile list, so Scripe collects their posts. Grants NO engagement: liking, commenting and reposting on another person's post happen only in the Scripe dashboard, by an explicit human click
analytics:readAnalytics + viral-post search + posting times
jobs:readRead async job status + progress
jobs:canceljobs:readCancel queued jobs
webhooks:manageRegister/update/remove webhook endpoints
offline_accessIssue refresh tokens (mandatory for long-lived integrations)

The consent screen also accepts the aliases read and write, which expand to the union of the matching read or read+write scopes. Token responses always echo the expanded, deduplicated form.

Never implied. posts:publish, settings:write, and the *:destroy family sit outside both aliases and outside every implication edge — write does not grant them, and neither does the matching *:write scope. A client that needs them must request each one explicitly; the consent screen then lists each under Full access with plain-English copy (and never under Read-only), and consents granted before these scopes existed never gain them.

5.1 Client accounts get the dashboard's client view

A token issued to a user whose workspace role is Client (the brand owner an agency runs a profile for) reaches what that person reaches in the dashboard, whatever scopes it holds:

  • Reads that answer normally: the workspace, its projects, and analytics.
  • Reads that are narrowed: posts (GET /v1/posts, GET /v1/posts/:postId, list_posts, get_post), the status columns and their counts (GET /v1/post-statuses, list_post_statuses) and the calendar (GET /v1/calendar, list_calendar) return only posts in review, scheduled or published. Notes and ideas are left out of the calendar. A post outside that set answers not_found.
  • Everything else is refused with forbidden_project and details.reason: "client_role": every write, and the reads the dashboard withholds from a client (ideas, notes, sources, media, knowledge, positioning and brand settings, engagement, the team roster, usage, jobs, webhooks, community lists, viral posts).

The role is read live from the workspace membership (a change takes effect within a minute). API keys are unaffected: a key is issued by a workspace admin and acts as the workspace, not as a person.

5.2 Adding a scope later

  1. Send the user through /authorize again with the wider scope set (users who previously approved a strict subset land on an "additional permissions requested" branch of the consent screen).
  2. Exchange a new token. Existing tokens are not widened — an access token's scope set is frozen at issuance.

6. Token introspection (RFC 7662)

http
POST /oauth/introspect HTTP/1.1
Host: api.scripe.io
Content-Type: application/x-www-form-urlencoded

token=scripe_oat_…
&token_type_hint=access_token
json
{
  "active": true,
  "scope": "notes:read posts:write",
  "client_id": "scripe_oac_4Z…",
  "exp": 1748349000,
  "iat": 1748345400,
  "sub": "user_2k…",
  "token_type": "Bearer"
}

Introspection in v1 is public: any caller holding a valid token can introspect it (no client authentication required). Inactive tokens always come back as { "active": false } — we do not leak the reason.


7. Revocation (RFC 7009)

http
POST /oauth/revoke HTTP/1.1
Host: api.scripe.io
Content-Type: application/x-www-form-urlencoded

token=scripe_ort_…
&token_type_hint=refresh_token
&client_id=scripe_oac_4Z…

client_id is required — the endpoint rejects a body without it (400 invalid_request) before it looks at the token at all, and an unknown id is invalid_client.

  • refresh_token revocation kills the entire token family (the refresh chain and every access token from the same consent).
  • access_token revocation kills only that access token — the refresh token survives, and the next refresh works as normal.
  • Clients revoke without authentication, but only their own tokens — we verify the token belongs to the presenting client_id.
  • Once client_id is present and known, the endpoint always returns 200 OK regardless of whether the token was active. RFC 7009 mandates this — it's a privacy requirement.

The user can also revoke from the dashboard at Settings → Developer → Connected apps. That path triggers the same email notification and audit-log entry as a programmatic revoke.


8. Security headers and CORS

PathCORSAuth requiredNotes
/.well-known/**NoneCacheable, fully public.
/oauth/register*NoneAnyone can register a client.
/oauth/authorizen/a (HTML)Scripe sign-inNot embeddable: X-Frame-Options: DENY + frame-ancestors 'none' block iframes. A popup or a full-page redirect both work — a popup is its own top-level context, and is how MCP hosts normally run the flow.
/oauth/token*None (PKCE)No cookies sent.
/oauth/revoke*None
/oauth/introspect*NoneNo Authorization header is read. The only credential is the token in the request body — see §6.
/v1/**Authorization: BearerSame X-RateLimit-* envelope as API-key auth.

The consent screen sets Cache-Control: no-store and is intentionally not embeddable. If you need to pre-warm the consent flow, send the user there as a top-level navigation or in a popup window — just not in an iframe.


9. Errors

Two error shapes exist, matching the two legs of the flow:

The authorize redirect leg uses RFC 6749 query parameters on the redirect back to your redirect_uri:

https://acme.example.com/oauth/scripe/callback?
  error=access_denied
  &error_description=The user denied the authorization request.
  &state=opaque_csrf_value

Every JSON endpoint (/oauth/register, /oauth/token, /oauth/revoke, /oauth/introspect) returns the same error envelope as the rest of the API, with the OAuth error name in error.code:

json
{
  "error": {
    "code": "invalid_grant",
    "message": "Authorization code has expired.",
    "request_id": "req_01J9Z…",
    "docs_url": "https://docs.scripe.io/api/v1/errors#invalid_grant"
  }
}

Switch on error.code. The codes you'll see:

codeWhen
invalid_requestRequired parameter missing or malformed (e.g. PKCE challenge missing).
invalid_clientUnknown client_id.
invalid_client_metadataDCR body invalid (e.g. token_endpoint_auth_method other than none).
invalid_redirect_uriRedirect URI not registered for this client or malformed.
invalid_grantCode expired/used, PKCE verifier mismatch, refresh token invalid or revoked, redirect_uri mismatch.
unsupported_grant_typeAnything other than authorization_code or refresh_token.
unsupported_response_typeAnything other than code.
invalid_scopeRequested scope outside the closed list, or wider than the consent.
client_suspendedThe client was suspended by Scripe. Contact support.
refresh_token_reuseRotation reuse detected — the token family was revoked. Re-consent.
workspace_unavailableThe consent's workspace is no longer accessible to the consenting user.
consent_requiredConsent missing or revoked.
access_deniedThe user cancelled the consent screen (redirect leg).

10. Worked example (Node + oauth4webapi)

ts
import * as oauth from "oauth4webapi";

const issuer = new URL("https://api.scripe.io");
const as = await oauth
  .discoveryRequest(issuer)
  .then((r) => oauth.processDiscoveryResponse(issuer, r));

const client: oauth.Client = {
  client_id: process.env.SCRIPE_CLIENT_ID!,
  token_endpoint_auth_method: "none"
};

// Step 1: redirect the user.
const code_verifier = oauth.generateRandomCodeVerifier();
const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier);
const url = new URL(as.authorization_endpoint!);
url.searchParams.set("client_id", client.client_id);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "notes:read posts:write offline_access");
url.searchParams.set("code_challenge", code_challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
res.redirect(url.toString());

// Step 2: callback — exchange code.
const params = new URLSearchParams(req.url.split("?")[1]);
const tokenResponse = await oauth.authorizationCodeGrantRequest(
  as,
  client,
  oauth.None(),
  params,
  redirectUri,
  code_verifier
);
const tokens = await oauth.processAuthorizationCodeResponse(
  as,
  client,
  tokenResponse
);

// Step 3: call the API.
await fetch("https://api.scripe.io/v1/notes?projectId=proj_…", {
  headers: {
    Authorization: `Bearer ${tokens.access_token}`,
    "Scripe-Api-Version": "2026-08-10"
  }
});

This is the same flow every MCP host (Claude, ChatGPT, Cursor) runs automatically when you add the Scripe MCP server — see MCP.


11. Operational notes

  • Audit log — every authorize, token, and revoke action appears in the workspace's audit log with the originating client.
  • Email notifications — the user receives an email when a new consent is granted, when reuse-detection revokes their tokens, and when they revoke from the dashboard.
  • Multi-workspace — one token can reach every workspace its user belongs to, plus every client workspace billed to an agency they own, via the Scripe-Workspace-Id header; see Workspaces § Multi-workspace access.
  • Status page — status.scripe.io.

12. Need help?