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
# 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-resourceThe 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/register2. 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
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 (orhttp://localhostfor 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 withinvalid_client_metadata.scope— space-separated list, validated against the closed scope list; unknown tokens fail withinvalid_scope.software_id/software_version— deduplication key. When both are sent, re-registering with identical values and the sameredirect_urisreturns the existingclient_idinstead of provisioning a new client. Omit them and every registration creates a fresh client row.
2.2 Response
{
"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
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
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=S256The 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
projectas 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
*:readscopes you requested; Full access grants everything you requested, includingposts:publish,settings:writeand the*:destroyscopes 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
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.)
{
"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_tokenwith a fresh 1-hour TTL. - A new
refresh_tokenwith a fresh sliding window. - A
scope⊆ the previous scope (you can narrow, never widen).
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:
- The entire token family (every access + refresh token chained back to the original consent) is revoked.
- The user has to consent again on the next
/authorize. - 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
| Lifetime | Value |
|---|---|
| Access token TTL | 1 hour |
| Refresh token sliding | 30 days from last use |
| Consent record | 6 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 TTL | 10 minutes, single-use |
| Reuse grace window | 15 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:
| Scope | Implies | What it grants |
|---|---|---|
workspace:read | Workspace 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:read | List + 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:read | Read notes | |
notes:write | notes:read | Create/update notes |
notes:destroy | Permanently delete notes (archive rides notes:write) | |
posts:read | Read post drafts, scheduled posts, custom statuses | |
posts:write | posts:read | Create drafts, edit, schedule |
posts:generate | posts:read | Trigger AI post generation (posts:write covers this on REST; MCP's generate_post requires it by name) |
posts:publish | Publish a post to LinkedIn now (two-phase confirm; posts:write does NOT include this) | |
posts:destroy | Permanently delete posts (two-phase confirm; posts have no archive, so posts:write has no delete at all) | |
calendar:read | Read the content calendar (slots, scheduled posts, planned items) | |
calendar:write | calendar:read | Replace the recurring posting-time template (two-phase confirm). Not implied by calendar:read |
ideas:read | Read the idea board | |
ideas:write | ideas:read | Create/update/plan ideas |
ideas:destroy | Permanently delete ideas | |
sources:read | Read sources / transcriptions | |
sources:write | sources:read | Create text + file sources, mint upload URLs |
sources:destroy | Permanently delete a source with its transcript, topics, and knowledge-base copy (two-phase confirm; posts generated from the source are kept) | |
knowledge:read | Read knowledge-base entries | |
knowledge:write | knowledge:read | Ingest into the knowledge base (text/file/url/youtube), mint upload URLs |
knowledge:destroy | Permanently delete knowledge documents | |
media:read | Search the media library; read an idea's media display URLs (with ideas:read); attach media to an idea (with ideas:write) | |
media:write | media:read | Generate images + carousels, attach media to posts |
media:destroy | Remove 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:write | Curated 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:read | Read the community profile lists of a brand and the LinkedIn people on them | |
community:write | community:read | Add 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:read | Analytics + viral-post search + posting times | |
jobs:read | Read async job status + progress | |
jobs:cancel | jobs:read | Cancel queued jobs |
webhooks:manage | Register/update/remove webhook endpoints | |
offline_access | Issue 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 answersnot_found. - Everything else is refused with
forbidden_projectanddetails.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
- Send the user through
/authorizeagain with the wider scope set (users who previously approved a strict subset land on an "additional permissions requested" branch of the consent screen). - Exchange a new token. Existing tokens are not widened — an access token's scope set is frozen at issuance.
6. Token introspection (RFC 7662)
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{
"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)
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_tokenrevocation kills the entire token family (the refresh chain and every access token from the same consent).access_tokenrevocation 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_idis present and known, the endpoint always returns200 OKregardless 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
| Path | CORS | Auth required | Notes |
|---|---|---|---|
/.well-known/* | * | None | Cacheable, fully public. |
/oauth/register | * | None | Anyone can register a client. |
/oauth/authorize | n/a (HTML) | Scripe sign-in | Not 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 | * | None | No Authorization header is read. The only credential is the token in the request body — see §6. |
/v1/* | * | Authorization: Bearer | Same 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_valueEvery 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:
{
"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:
code | When |
|---|---|
invalid_request | Required parameter missing or malformed (e.g. PKCE challenge missing). |
invalid_client | Unknown client_id. |
invalid_client_metadata | DCR body invalid (e.g. token_endpoint_auth_method other than none). |
invalid_redirect_uri | Redirect URI not registered for this client or malformed. |
invalid_grant | Code expired/used, PKCE verifier mismatch, refresh token invalid or revoked, redirect_uri mismatch. |
unsupported_grant_type | Anything other than authorization_code or refresh_token. |
unsupported_response_type | Anything other than code. |
invalid_scope | Requested scope outside the closed list, or wider than the consent. |
client_suspended | The client was suspended by Scripe. Contact support. |
refresh_token_reuse | Rotation reuse detected — the token family was revoked. Re-consent. |
workspace_unavailable | The consent's workspace is no longer accessible to the consenting user. |
consent_required | Consent missing or revoked. |
access_denied | The user cancelled the consent screen (redirect leg). |
10. Worked example (Node + oauth4webapi)
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-Idheader; see Workspaces § Multi-workspace access. - Status page — status.scripe.io.
12. Need help?
- Email support@scripe.io for integration questions.
- Email security@scripe.io for suspected leaked tokens or stolen consents — we revoke within one business hour.