# OAuth

The authorization server's routes, and the grants a team has given. For how they fit together, start with the [OAuth guide](https://www.rasket.com/docs/oauth).

## Three credentials

| Routes | Authorization |
| --- | --- |
| `/oauth/register`, `/oauth/authorize`, `/oauth/token`, `/oauth/revoke` | None. A public client calls them before it holds anything, so the samples send no Authorization header. |
| `/oauth/register/{client_id}` | The registration access token, `Bearer rkor_…`. An API key is refused. |
| `/oauth/grants` | A `full_access` `rk_` key. An OAuth access token (`rko_`) is refused, whatever its scopes. |

## Two error shapes

- `/oauth/token`, `/oauth/revoke` and `/oauth/register` answer the standard OAuth shape, `{ error, error_description }` (RFC 6749 §5.2 and RFC 7591 §3.2.2), because that is what an OAuth library parses.
- `/oauth/grants` answers the Rasket error body every other endpoint uses.

Both vocabularies are listed on [Errors](https://www.rasket.com/docs/errors).

## Endpoints

### `POST /oauth/register`

Dynamic client registration (RFC 7591). Unauthenticated; do it once per app.

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `redirect_uris` (required) | string[] | At most 10. `https` URLs, or loopback-address URLs for a native app (RFC 8252 §7.3). Matched exactly at authorize time. |
| `client_name` (required) | string | Shown to the person on the consent page. |
| `scope` | string | Space-separated scopes the client may ask for, from the catalogue on the OAuth guide. An authorize request that names none asks for these. |
| `client_uri` | string | An `https` link to the app, shown on the consent page. |
| `logo_uri` | string | An `https` image, shown on the consent page. |

#### Body, less common

| Field | Type | Description |
| --- | --- | --- |
| `grant_types` | string[] | `authorization_code` and `refresh_token`, which is also the default. |
| `response_types` | string[] | Only `["code"]`. |
| `token_endpoint_auth_method` | string | Only `none`: every client is a public client. |
| `software_id` | string | Your own identifier for the app, stored and returned. |
| `software_version` | string | Your own version string, stored and returned. |

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())
```

#### Response `201`

```json
{
  "client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
  "client_id_issued_at": 1789205400,
  "registration_client_uri": "/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
  "client_name": "Acme Invoices",
  "redirect_uris": ["https://invoices.acme.example/rasket/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "emails:send emails:read",
  "registration_access_token": "rkor_…"
}
```

- `registration_access_token` is shown exactly once. Store it: it is the only credential for reading, replacing or deleting this client.
- Public clients only. A `token_endpoint_auth_method` other than `none`, or `response_types` other than `code`, is `400 invalid_client_metadata`; a redirect URI that is not `https` or loopback is `400 invalid_redirect_uri`.
- At most 10 registrations per IP address per hour and 1,000 a day across the platform; past either, `429`.
- Errors from this route are RFC 7591's `{ error, error_description }`, not the Rasket error body.

### `GET /oauth/authorize`

Send the person's browser here. It comes back to your redirect URI with a code.

#### Query parameters

| Field | Type | Description |
| --- | --- | --- |
| `response_type` (required) | string | Always `code`. |
| `client_id` (required) | string | Your `rkoc_` identifier. |
| `redirect_uri` (required) | string | Exactly one of the client's registered redirect URIs. |
| `code_challenge` (required) | string | `BASE64URL(SHA256(code_verifier))`: 43 characters. |
| `code_challenge_method` (required) | string | Always `S256`. `plain` is refused. |
| `state` (required) | string | An unguessable value you check when the browser comes back. Returned unchanged; at most 512 characters. |
| `scope` | string | Space-separated. The client's registered scopes when omitted. |

#### Query parameters, less common

| Field | Type | Description |
| --- | --- | --- |
| `resource` | string | An RFC 8707 resource indicator. When sent, it must name this API. |

Start authorization:

```sh
curl -X GET "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" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("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", {
  method: "GET",
  headers: {
    "User-Agent": "acme-billing/1.0",
  },
});

console.log(response.status);
```

```python
import os

import requests

response = requests.get(
    "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",
    headers={
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.status_code)
```

- This is a URL to open in the person's browser, not a call your server makes: the `302` goes to our consent page, which asks them to sign in, pick one team and approve the scopes. Server-side, `fetch` and `requests` would follow it to a login page.
- Approval redirects to `redirect_uri?code=rkc_…&state=…`. Refusal, or any other failure, redirects there with `error`, `error_description` and your `state`.
- An unknown or disabled `client_id`, or a `redirect_uri` the client never registered, never redirects: it answers `400` with an error page, because sending the browser to an unverified address is how codes leak (RFC 6749 §4.1.2.1).

### `POST /oauth/token`

Swap an authorization code for tokens, or rotate a refresh token.

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `grant_type` (required) | string | `authorization_code` or `refresh_token`. |
| `client_id` (required) | string | Your client identifier. |
| `code` | string | `authorization_code` only: the `rkc_` code from the redirect. |
| `redirect_uri` | string | `authorization_code` only: the same URI the authorize request named. |
| `code_verifier` | string | `authorization_code` only: the verifier whose hash you sent as the challenge. |
| `refresh_token` | string | `refresh_token` only: the `rkr_` token. |

#### Body, less common

| Field | Type | Description |
| --- | --- | --- |
| `scope` | string | `refresh_token` only: a narrower set. It can never widen the grant. |

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())
```

#### Response `200`

```json
{
  "access_token": "rko_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "rkr_…",
  "scope": "emails:send emails:read"
}
```

- The body may be `application/x-www-form-urlencoded` — what a conforming OAuth library sends — or JSON, as shown.
- A refresh returns a new pair and revokes the refresh token you presented. Presenting a code, or a rotated refresh token, a second time revokes the whole grant and every token under it.
- Errors are RFC 6749 §5.2's `{ error, error_description }`: `400` with `invalid_request`, `invalid_grant`, `unauthorized_client`, `unsupported_grant_type` or `invalid_scope`, and `401 invalid_client` for an unknown or disabled client.
- The response carries `Cache-Control: no-store`.

### `POST /oauth/revoke`

RFC 7009. Sign a person out of your app without revoking the grant.

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `token` (required) | string | The access or refresh token to revoke. |
| `client_id` (required) | string | The client the token was issued to. |

#### Body, less common

| Field | Type | Description |
| --- | --- | --- |
| `token_type_hint` | string | `access_token` or `refresh_token`. Accepted and ignored. |

Revoke a token:

```sh
curl -X POST "https://api.rasket.com/oauth/revoke" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "token": "rkr_…",
  "client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C"
}'
```

```ts
const response = await fetch("https://api.rasket.com/oauth/revoke", {
  method: "POST",
  headers: {
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    token: "rkr_…",
    client_id: "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C"
  }),
});

console.log(response.status);
```

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/oauth/revoke",
    headers={
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "token": "rkr_…",
    "client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C"
  },
)

print(response.status_code)
```

- An access token is revoked alone. A refresh token takes its whole family with it, and every access token of the same grant.
- The grant itself stays live. Only the team revoking it, or the client being deleted, ends a grant.
- A token that is unknown, expired, already revoked or another client's still answers `200` with an empty body, as RFC 7009 §2.2 requires.
- Form-encoded or JSON, as for the token endpoint.

### `GET /oauth/register/{client_id}`

RFC 7592. The metadata stored for your client.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `client_id` (required) | string | The `rkoc_` identifier registration returned. |

#### Headers

| Field | Type | Description |
| --- | --- | --- |
| `Authorization` (required) | string | `Bearer rkor_…` — the registration access token, shown once when the client was registered. An API key is refused. |

Read a registered client:

```sh
curl -X GET "https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C" \
  -H "Authorization: Bearer $RASKET_REGISTRATION_TOKEN" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_REGISTRATION_TOKEN}`,
    "User-Agent": "acme-billing/1.0",
  },
});

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

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_REGISTRATION_TOKEN']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.json())
```

#### Response `200`

```json
{
  "client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
  "client_id_issued_at": 1789205400,
  "registration_client_uri": "/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
  "client_name": "Acme Invoices",
  "redirect_uris": ["https://invoices.acme.example/rasket/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "emails:send emails:read"
}
```

- A missing, malformed or unknown registration token is `401` with `WWW-Authenticate: Bearer error="invalid_token"`.

### `PUT /oauth/register/{client_id}`

RFC 7592. A full replacement: a field you leave out is cleared.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `client_id` (required) | string | The `rkoc_` identifier registration returned. |

#### Headers

| Field | Type | Description |
| --- | --- | --- |
| `Authorization` (required) | string | `Bearer rkor_…` — the registration access token, shown once when the client was registered. An API key is refused. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `redirect_uris` (required) | string[] | At most 10. `https` URLs, or loopback-address URLs for a native app (RFC 8252 §7.3). Matched exactly at authorize time. |
| `client_name` (required) | string | Shown to the person on the consent page. |
| `scope` | string | Space-separated scopes the client may ask for, from the catalogue on the OAuth guide. An authorize request that names none asks for these. |
| `client_uri` | string | An `https` link to the app, shown on the consent page. |
| `logo_uri` | string | An `https` image, shown on the consent page. |

#### Body, less common

| Field | Type | Description |
| --- | --- | --- |
| `grant_types` | string[] | `authorization_code` and `refresh_token`, which is also the default. |
| `response_types` | string[] | Only `["code"]`. |
| `token_endpoint_auth_method` | string | Only `none`: every client is a public client. |
| `software_id` | string | Your own identifier for the app, stored and returned. |
| `software_version` | string | Your own version string, stored and returned. |

Replace a registered client:

```sh
curl -X PUT "https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C" \
  -H "Authorization: Bearer $RASKET_REGISTRATION_TOKEN" \
  -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", "https://invoices.acme.example/rasket/callback-eu"],
  "scope": "emails:send emails:read"
}'
```

```ts
const response = await fetch("https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_REGISTRATION_TOKEN}`,
    "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", "https://invoices.acme.example/rasket/callback-eu"],
    scope: "emails:send emails:read"
  }),
});

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

```python
import os

import requests

response = requests.put(
    "https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_REGISTRATION_TOKEN']}",
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "client_name": "Acme Invoices",
    "redirect_uris": ["https://invoices.acme.example/rasket/callback", "https://invoices.acme.example/rasket/callback-eu"],
    "scope": "emails:send emails:read"
  },
)

print(response.json())
```

#### Response `200`

```json
{
  "client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
  "client_id_issued_at": 1789205400,
  "registration_client_uri": "/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
  "client_name": "Acme Invoices",
  "redirect_uris": ["https://invoices.acme.example/rasket/callback", "https://invoices.acme.example/rasket/callback-eu"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "emails:send emails:read"
}
```

- The registration access token is not rotated: a client has nowhere to learn a new one.

### `DELETE /oauth/register/{client_id}`

RFC 7592. Revokes every grant the client holds, on every team.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `client_id` (required) | string | The `rkoc_` identifier registration returned. |

#### Headers

| Field | Type | Description |
| --- | --- | --- |
| `Authorization` (required) | string | `Bearer rkor_…` — the registration access token, shown once when the client was registered. An API key is refused. |

Delete a registered client:

```sh
curl -X DELETE "https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C" \
  -H "Authorization: Bearer $RASKET_REGISTRATION_TOKEN" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_REGISTRATION_TOKEN}`,
    "User-Agent": "acme-billing/1.0",
  },
});

console.log(response.status);
```

```python
import os

import requests

response = requests.delete(
    "https://api.rasket.com/oauth/register/rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_REGISTRATION_TOKEN']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.status_code)
```

- Every grant is revoked with `revoked_reason` `client`, and every token under them stops working at once.
- Each team still sees its grant, revoked, in `GET /oauth/grants`: the history is kept.

### `GET /oauth/grants`

Every app this team has connected, live and revoked, newest first.

#### Query parameters

| Field | Type | Description |
| --- | --- | --- |
| `limit` | integer | How many items to return, 1–100. Defaults to 20. |
| `after` | string | Return the page that follows this item ID. Mutually exclusive with `before`. |
| `before` | string | Return the page that precedes this item ID. Mutually exclusive with `after`. |

List OAuth grants:

```sh
curl -X GET "https://api.rasket.com/oauth/grants?limit=20" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/oauth/grants?limit=20", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

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

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/oauth/grants?limit=20",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.json())
```

#### Response `200`

```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "0199e0a4-6b2d-7c1e-8f3a-2d5b9c7e1a40",
      "client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
      "scopes": ["emails:send", "emails:read"],
      "created_at": "2026-09-12T09:30:00.000Z",
      "revoked_at": null,
      "revoked_reason": null,
      "client": {
        "name": "Acme Invoices",
        "logo_uri": null
      }
    }
  ]
}
```

- A revoked grant stays in the list with `revoked_at` and `revoked_reason` — `user`, `client`, `code_reuse`, `refresh_reuse` or `operator` — so an app never silently disappears.
- Needs a `full_access` key. An OAuth access token is refused here whatever its scopes: no scope reaches the grants, so a connected app cannot see or manage connected apps.

### `DELETE /oauth/grants/{oauth_grant_id}`

Disconnect an app: the grant and every token issued under it.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `oauth_grant_id` (required) | string | The grant's ID. |

Revoke an OAuth grant:

```sh
curl -X DELETE "https://api.rasket.com/oauth/grants/0199e0a4-6b2d-7c1e-8f3a-2d5b9c7e1a40" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/oauth/grants/0199e0a4-6b2d-7c1e-8f3a-2d5b9c7e1a40", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.delete(
    "https://api.rasket.com/oauth/grants/0199e0a4-6b2d-7c1e-8f3a-2d5b9c7e1a40",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "oauth_grant",
  "id": "0199e0a4-6b2d-7c1e-8f3a-2d5b9c7e1a40",
  "revoked_at": "2026-09-12T14:05:12.000Z",
  "revoked_reason": "user"
}
```

- The app's next call with any of its tokens is `401 invalid_api_key`.
- Idempotent: revoking a revoked grant answers the revocation it already has.
- Needs a `full_access` key; an OAuth access token is refused.
