# OAuth

Let another app act on a team's account without anyone handing it an API key. The app registers itself, sends a person to approve it, and gets a token for one team, carrying only the scopes they approved.

## The flow, step by step

This is OAuth 2.1's authorization code flow with PKCE. Every client is a **public** client: there is no client secret to keep, and `token_endpoint_auth_method` is always `none`. An OAuth library that supports PKCE and dynamic client registration does all of this for you; the steps are what it does.

### 1. Register the client

Once per app, unauthenticated. The answer carries your `rkoc_` client id and a `rkor_` registration access token, shown exactly once — it is the only credential that can read, replace or delete the client later.

Register a client:

```sh
curl -X POST "https://api.rasket.com/oauth/register" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "client_name": "Acme Invoices",
  "redirect_uris": ["https://invoices.acme.example/rasket/callback"],
  "scope": "emails:send emails:read"
}'
```

```ts
const response = await fetch("https://api.rasket.com/oauth/register", {
  method: "POST",
  headers: {
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    client_name: "Acme Invoices",
    redirect_uris: ["https://invoices.acme.example/rasket/callback"],
    scope: "emails:send emails:read"
  }),
});

const data = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/oauth/register",
    headers={
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "client_name": "Acme Invoices",
    "redirect_uris": ["https://invoices.acme.example/rasket/callback"],
    "scope": "emails:send emails:read"
  },
)

print(response.json())
```

Or skip registering. Host a client metadata document at an https URL and use that URL as your `client_id`. The document must name the same URL as its `client_id` and list your `redirect_uris`. We read it the first time it is used and again when its cache lifetime runs out.

### 2. Make a PKCE pair

A fresh random verifier for every authorization, and its SHA-256 hash as the challenge. The method is `S256`; `plain` is refused.

Making a verifier, a challenge and a state:

```text
import { createHash, randomBytes } from "node:crypto";

const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");

// Keep verifier and state in the person's session until the browser comes back.
```

### 3. Send the person to authorize

Open this URL in their browser. They sign in, choose **one team**, and approve the scopes you asked for.

The authorize URL:

```text
https://api.rasket.com/oauth/authorize?response_type=code&client_id=rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C&redirect_uri=https%3A%2F%2Finvoices.acme.example%2Frasket%2Fcallback&scope=emails%3Asend+emails%3Aread&state=af0ifjsldkj&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
```

The browser comes back to your redirect URI with a code and your state:

The redirect back to the client:

```text
https://invoices.acme.example/rasket/callback?code=rkc_…&state=af0ifjsldkj
```

If you sent a `state`, check it is the one you stored before doing anything else. A refusal comes back the same way with `error=access_denied`.

### 4. Exchange the code

Within 10 minutes, with the verifier from step 2 and the same redirect URI. The code works once.

Exchange a code, or refresh:

```sh
curl -X POST "https://api.rasket.com/oauth/token" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "grant_type": "authorization_code",
  "client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
  "code": "rkc_…",
  "redirect_uri": "https://invoices.acme.example/rasket/callback",
  "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}'
```

```ts
const response = await fetch("https://api.rasket.com/oauth/token", {
  method: "POST",
  headers: {
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    grant_type: "authorization_code",
    client_id: "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
    code: "rkc_…",
    redirect_uri: "https://invoices.acme.example/rasket/callback",
    code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  }),
});

const data = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/oauth/token",
    headers={
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "grant_type": "authorization_code",
    "client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
    "code": "rkc_…",
    "redirect_uri": "https://invoices.acme.example/rasket/callback",
    "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  },
)

print(response.json())
```

The token endpoint also accepts `application/x-www-form-urlencoded`, which is what a conforming OAuth client sends.

### 5. Call the API

The access token goes where an API key goes, and every endpoint its scopes reach answers exactly as it would to a key. There is no team parameter: the token already belongs to the team the person chose.

An API call with an access token:

```text
POST https://api.rasket.com/emails
Authorization: Bearer rko_…
User-Agent: acme-billing/1.0
Content-Type: application/json
```

- A scope the grant does not hold is `403 invalid_permission`, with `WWW-Authenticate: Bearer error="insufficient_scope"`, naming the scope needed when there is one.
- An expired or revoked token is `401 invalid_api_key`. Refresh, or send the person through authorize again.

### 6. Refresh before the hour is up

An access token lasts 1 hour. Trade the refresh token for a new pair, and store the new refresh token: the one you presented stops working at that moment.

A refresh request:

```text
POST https://api.rasket.com/oauth/token
User-Agent: acme-billing/1.0
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&client_id=rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C&refresh_token=rkr_…
```

> Present an old refresh token again and the whole grant is revoked, every token under it with it. That is how a stolen refresh token is caught — so a client that retries a refresh with the token it already spent will lock itself out.

A refresh may ask for a narrower `scope`, never a wider one. However often you refresh, the chain ends 90 days after the code was first exchanged; then the person approves again.

### 7. Revoke when you are done

When the person signs out of your app, send the refresh token to `POST /oauth/revoke`: it and every access token of the grant stop working. The team's own switch is bigger — revoking the grant, from Settings → Team → Authorized apps in the dashboard or with `DELETE /oauth/grants/{oauth_grant_id}`, disconnects your app entirely.

## Tokens

Every token is shown once, stored only as a hash, and recognisable by its prefix — the same rules as an `rk_` key.

| Prefix | What | Lives | Presented twice |
| --- | --- | --- | --- |
| `rkc_` | Authorization code | 10 minutes, used once | Revokes the whole grant (`code_reuse`) |
| `rko_` | Access token | 1 hour | Nothing: use it until it expires |
| `rkr_` | Refresh token | 30 days, replaced on every use; the chain ends 90 days after the code exchange | Revokes the whole grant (`refresh_reuse`) |
| `rkor_` | Registration access token | Until the client is deleted | Nothing: it manages the client |

## Scopes

One scope per group of endpoints. Ask for the fewest your app needs.

| Scope | What it reaches |
| --- | --- |
| `emails:send` | Send an email or a batch. |
| `emails:read` | Retrieve, list, update and cancel sent email; attachments, shares and metrics; read received mail. |
| `domains:read` | Every read under /domains. |
| `domains:write` | Add, verify, update and delete a domain. |
| `templates:read` | Retrieve and list templates. |
| `templates:write` | Create, update, publish, duplicate and delete templates. |
| `contacts:read` | Retrieve and list contacts, contact properties and imports. |
| `contacts:write` | Create, update and delete contacts and their properties. |
| `segments:read` | Retrieve and list segments and their members. |
| `segments:write` | Create, update and delete segments; add and remove members. |
| `topics:read` | Retrieve and list topics. |
| `topics:write` | Create, update and delete topics. |
| `broadcasts:read` | Retrieve and list broadcasts, their recipients and clicked links. |
| `broadcasts:write` | Create, update, send, cancel and delete broadcasts. |
| `suppressions:read` | Retrieve and list suppressions. |
| `suppressions:write` | Add, remove and batch-change suppressions. Reading them needs suppressions:read. |
| `webhooks:read` | Retrieve and list webhooks, the events delivered to them and the events parked while one was off. |
| `webhooks:write` | Create, update, rotate and delete webhooks; replay an event and deliver a parked backlog. Reading them needs webhooks:read. |
| `logs:read` | Retrieve and list request logs. |
| `automations:read` | Retrieve and list automations, their versions and their runs. |
| `automations:write` | Create, update, publish, duplicate, stop and delete automations. |
| `events:read` | Retrieve and list custom event definitions. |
| `events:write` | Create, update and delete custom event definitions. |
| `events:send` | Send a custom event, which can start an automation. |
| `team:read` | Read the team: its plan and limits, sender identity, AI assist and SSO summary. |
| `team:write` | Change the team's sender identity, and turn AI assist on or off. |
| `billing:read` | Read the plan, usage, add-ons and invoices. |
| `ai:use` | Suggest subject lines, draft a body and diagnose an email with AI assist, spending the team's AI credits. |

No scope reaches API keys, the member list, any billing change, deleting received mail, or the OAuth grants themselves. A connected app cannot mint a credential, buy or change a plan, see who is on the team, or see what else is connected, whatever the person approved.

## Discovery

Clients that discover rather than hard-code read two documents from the API host: RFC 8414 authorization-server metadata and RFC 9728 protected-resource metadata. The MCP server has a protected-resource document of its own, whose `resource` is the MCP endpoint.

The discovery documents:

```text
https://api.rasket.com/.well-known/oauth-authorization-server
https://api.rasket.com/.well-known/oauth-protected-resource
https://api.rasket.com/.well-known/oauth-protected-resource/mcp
```

A `401` to an OAuth token carries `WWW-Authenticate: Bearer realm="rasket", error="invalid_token"` and a `resource_metadata` URL pointing at the protected-resource document — the MCP one on `/mcp` — which is how an MCP client finds this server without being told — [MCP server](https://www.rasket.com/docs/mcp) depends on it.

## Where the details are

- Every parameter, response and refusal of the 9 routes: [the OAuth reference](https://www.rasket.com/docs/api-reference/oauth).
- The token and registration routes answer RFC 6749's `{ error, error_description }`, not the Rasket error body: [Errors](https://www.rasket.com/docs/errors) lists both vocabularies.
