> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.scripe.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Oauth

# Scripe API — OAuth 2.1 (v1)

> Status: **Public preview.** The OAuth 2.1 server ships with Phase 5a.
> Behaviour and endpoint URLs are stable for v1; new scopes will land in
> additive minor releases. Breaking changes only ship in v2 with a
> deprecation window of at least 90 days.

This document is the integrator-facing reference for Scripe's OAuth 2.1
authorization server. If you're building **your own product** that wants
to call the Scripe API on behalf of a Scripe user (Zapier, Notion-style
integration, internal SaaS, MCP host), this is the contract you code
against.

If you only need a single-tenant key for your own scripts, mint an API
key from the dashboard and use [Bearer auth](./auth.md) instead — it's
substantially simpler.

***

## TL;DR

```http theme={null}
# 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 /authorize with PKCE.
# 4. Exchange ?code=… at /token.
# 5. Use the access_token as a Bearer credential against /v1/*.
# 6. Rotate the refresh_token as needed.
```

The full grant path is **authorization code with PKCE-S256**. We do not
support the implicit grant, the password grant, or unencrypted PKCE
challenges. Public clients (mobile, SPA, CLI, MCP hosts) MUST use PKCE;
confidential clients SHOULD also use PKCE on top of their client secret.

***

## 1. Hosts and discovery

OAuth and the public REST API both ride on the API subdomain:

```
Issuer:                 https://api.scripe.io
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
```

### 1.1 Discovery documents

Fetch these unauthenticated; they are cache-friendly and do not require
a registered client:

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

The first is the RFC 8414 server metadata document. The second is the
RFC 9728 protected-resource metadata, which advertises which scopes the
`/v1` resource server understands. **Both should be your source of
truth** — the URLs above can change between minor versions, but the
discovery documents will always reflect the current paths.

***

## 2. Dynamic Client Registration (RFC 7591)

You register your OAuth client by POSTing to `/oauth/register`. The
endpoint is **public** — anyone can register a client and the resulting
credentials are scoped only to that client; they don't grant access to
any user data until a user completes the consent flow.

### 2.1 Request

```http theme={null}
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).
  Loopback ports are not pinned. Custom URI schemes (`com.acme://…`)
  are allowed for native and mobile clients.
* **`token_endpoint_auth_method`** — `none` for public clients,
  `client_secret_basic` (default for confidential clients) or
  `client_secret_post`. Phase 5a does **not** support
  `private_key_jwt`; that lands in Phase 5.5.
* **`scope`** — space-separated list. Validated against
  [the closed scope list](#5-scopes); unknown tokens fail with
  `invalid_scope`.
* **`software_id` / `software_version`** — surfaced on the consent
  screen to help users distinguish multiple installs.

### 2.2 Response

```json theme={null}
{
  "client_id": "client_4Z…",
  "client_secret": "cs_…",
  "client_id_issued_at": 1748345400,
  "client_secret_expires_at": 0,
  "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"
}
```

* `client_secret` is **only present for confidential clients**. Public
  clients (`token_endpoint_auth_method: "none"`) get a `client_id` and
  no secret. Store it once; we do not re-issue it.
* `client_secret_expires_at: 0` means "no expiry". We currently never
  rotate secrets server-side; you can re-register or use the
  Configuration endpoint (Phase 5.5) to rotate yourself.

DCR responses are cacheable on your side; we treat each registered
client as immutable once issued.

***

## 3. Authorization code with PKCE

### 3.1 Mint a PKCE pair

```js theme={null}
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 `/token`.

### 3.2 Redirect the user to `/authorize`

```http theme={null}
GET https://api.scripe.io/oauth/authorize?
  response_type=code
  &client_id=client_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, Clerk authenticates them first; once authenticated they see:

* Your client's name + logo.
* The literal scopes requested (with plain-English copy).
* A "Cancel" / "Allow" choice.

If the user has already approved this exact `(client_id, redirect_uri,
scopes)` triple before **and** has not revoked, the screen
short-circuits to a "Welcome back, you've already approved this
integration" view (no second click required, but still a visible page —
no silent flow).

### 3.3 Receive the code

Approved consent redirects back with `?code=…&state=…`. Always verify
`state` matches what you sent.

```
https://acme.example.com/oauth/scripe/callback?
  code=ac_8f3a…d401
  &state=opaque_csrf_value
```

Codes are **single-use, 60-second TTL**. Re-using a code raises
`invalid_grant` and we revoke any token previously issued for it.

### 3.4 Exchange at `/token`

```http theme={null}
POST /oauth/token HTTP/1.1
Host: api.scripe.io
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=ac_8f3a…d401
&redirect_uri=https://acme.example.com/oauth/scripe/callback
&client_id=client_4Z…
&code_verifier=…
```

Confidential clients add either `Authorization: Basic …` (HTTP Basic
client\_id:client\_secret) or `client_secret=…` (post body). Public
clients omit credentials entirely — PKCE is the only proof.

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

The `scope` returned may be **narrower** than what you requested if the
user un-ticked some boxes on the consent screen. It will not be wider.

***

## 4. Refresh token rotation + reuse detection

Refresh tokens are **single-use**. Every successful refresh issues:

* A new `access_token` with a fresh 1-hour TTL.
* A new `refresh_token` with a 90-day sliding TTL.
* A new `scope` ⊆ the previous scope (you can downgrade, never upgrade).

```http theme={null}
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=client_4Z…
&scope=notes:read
```

### 4.1 Reuse detection

If the same `refresh_token` is presented twice, we treat the second
attempt as a leaked-token signal and:

1. Revoke the entire token family (every AT + RT chained back to the
   original `code`).
2. Force the user to consent again on next `/authorize`.
3. Surface the revocation in the workspace audit log + the user's
   email-notification stream.

**This is intentionally aggressive.** If your client crashes between
"server returned new refresh\_token" and "I persisted it", you must
treat it as a fresh consent flow — the old token is dead.

### 4.2 Idle and absolute expiry

| Lifetime               | Value                                                  |
| ---------------------- | ------------------------------------------------------ |
| Access token TTL       | 1 hour                                                 |
| Refresh token sliding  | 90 days from last use                                  |
| Refresh token absolute | 365 days from issuance (then forced re-consent)        |
| Auth code TTL          | 60 seconds                                             |
| Consent record TTL     | None — explicit revoke or user/workspace deletion only |

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                                        |
| `projects:read`   |                  | List + read projects                                             |
| `notes:read`      |                  | Read notes                                                       |
| `notes:write`     | `notes:read`     | Create/update notes                                              |
| `posts:read`      |                  | Read post drafts and scheduled posts                             |
| `posts:write`     | `posts:read`     | Create drafts, schedule, edit                                    |
| `posts:generate`  | `posts:read`     | Trigger AI post generation (`posts:write` does not include this) |
| `sources:read`    |                  | Read sources / transcriptions                                    |
| `sources:write`   | `sources:read`   | Create text + file sources                                       |
| `knowledge:read`  |                  | Read knowledge-base entries                                      |
| `knowledge:write` | `knowledge:read` | Ingest into knowledge base (file/url/text/youtube)               |
| `jobs:read`       |                  | Read async job status + progress                                 |
| `jobs:cancel`     | `jobs:read`      | Cancel queued jobs                                               |
| `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.
The token responses always echo the **expanded, deduplicated** form, so
your client never has to special-case aliases at runtime.

### 5.1 Adding a scope

Adding a new scope to your client after registration requires:

1. The user re-authorise via `/authorize` (or land on the
   "additional permissions requested" branch of the consent screen,
   which we offer if the user has previously approved a strict subset).
2. A new token to be exchanged at `/token`. **Existing tokens are not
   automatically widened** — the AT was issued with a frozen scope set.

***

## 6. Token introspection (RFC 7662)

Confidential clients can introspect any token they hold:

```http theme={null}
POST /oauth/introspect HTTP/1.1
Host: api.scripe.io
Authorization: Basic …
Content-Type: application/x-www-form-urlencoded

token=scripe_oat_…
&token_type_hint=access_token
```

```json theme={null}
{
  "active": true,
  "scope": "notes:read posts:write",
  "client_id": "client_4Z…",
  "username": "user_2k…",
  "exp": 1748349000,
  "iat": 1748345400,
  "sub": "user_2k…",
  "aud": "https://api.scripe.io",
  "iss": "https://api.scripe.io",
  "token_type": "Bearer"
}
```

* The endpoint is **only available to confidential clients**. Public
  clients should not need introspection — they hold the AT directly
  and can react to 401s.
* Inactive tokens always come back as `{ "active": false }`. We do not
  leak the reason.

***

## 7. Revocation (RFC 7009)

```http theme={null}
POST /oauth/revoke HTTP/1.1
Host: api.scripe.io
Authorization: Basic …                # confidential clients
Content-Type: application/x-www-form-urlencoded

token=scripe_ort_…
&token_type_hint=refresh_token
```

* **`refresh_token` revocation** kills the entire token family (AT and
  every chained RT). Any concurrent AT is invalidated within ≤ 5 s at
  the edge.
* **`access_token` revocation** kills only that AT — the RT survives.
  The next refresh works as normal.
* Public clients can revoke without authentication, but only their own
  tokens. We verify the token's `client_id` matches the caller.
* Always returns `200 OK` with an empty body, 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 → Connected
apps**. That path triggers the same email notification + 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) | Clerk session           | Top-level navigation only — `X-Frame-Options: DENY` and `frame-ancestors 'none'` block embedding. |
| `/oauth/token`      | `*`        | Per grant\_type         | No cookies sent.                                                                                  |
| `/oauth/revoke`     | `*`        | Optional (Basic)        |                                                                                                   |
| `/oauth/introspect` | none       | Confidential only       | No CORS — server-to-server only.                                                                  |
| `/v1/*`             | `*`        | `Authorization: Bearer` | Same `X-RateLimit-*` envelope as API-key auth.                                                    |

The consent screen sets `Cache-Control: no-store, no-cache,
must-revalidate, private` and is intentionally not embeddable. If you
need to pre-warm the consent flow, redirect the user there as a
top-level navigation; popups and iframes will fail.

***

## 9. Errors

OAuth errors follow [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2):

```json theme={null}
{
  "error": "invalid_grant",
  "error_description": "The authorization code has already been used.",
  "error_uri": "https://docs.scripe.io/api/v1/errors/invalid_grant"
}
```

| `error`                   | When you see it                                                             |
| ------------------------- | --------------------------------------------------------------------------- |
| `invalid_request`         | Required parameter missing or malformed (e.g. PKCE challenge missing).      |
| `invalid_client`          | Client authentication failed at `/token`, `/revoke`, `/introspect`.         |
| `invalid_grant`           | Code expired/used, refresh token reused or revoked, redirect\_uri mismatch. |
| `unauthorized_client`     | Client not allowed to use this grant type.                                  |
| `unsupported_grant_type`  | Anything other than `authorization_code` or `refresh_token`.                |
| `invalid_scope`           | Requested scope outside the closed list, or wider than the consent.         |
| `access_denied`           | User cancelled the consent screen.                                          |
| `temporarily_unavailable` | Internal — retry with exponential backoff.                                  |

`/v1/*` errors continue to use the [unified error envelope](./conventions.md#errors).

***

## 10. Worked example (Node + `oauth4webapi`)

```ts theme={null}
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", {
  headers: { Authorization: `Bearer ${tokens.access_token}` }
});
```

We've verified this exact path against MCP Inspector, Claude Desktop,
ChatGPT, and Cursor for the MCP transport (see [mcp.md](./mcp.md)).

***

## 11. Operational notes

* **Audit log** — every `/authorize`, `/token`, `/revoke` action
  appears in the workspace's audit log with the originating IP, user
  agent, and `client_id`. Workspace owners can stream this into Datadog
  via the audit-flusher.
* **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.
* **Rate limits** — `/oauth/register` is rate-limited to 10 requests
  per minute per IP; `/oauth/authorize` and `/oauth/token` share the
  workspace bucket. Bypassing requires [support@scripe.io](mailto:support@scripe.io).
* **Status page** — [status.scripe.io](https://status.scripe.io)
  surfaces a dedicated `oauth.api.scripe.io` component. Subscribe for
  incident notifications.

***

## 12. Need help?

* Email **[support@scripe.io](mailto:support@scripe.io)** for
  integration questions.
* Email **[security@scripe.io](mailto:security@scripe.io)** for
  suspected leaked tokens or stolen consents — we revoke within one
  business hour and retroactively bump tokens.
* The Scripe Discord `#api` channel is the fastest path for
  community questions.
