# Authentication

> Authenticate your requests to the Reassign REST API with a personal access token or with OAuth 2.0.

Source: https://reassign.dev/authentication/

Every request to the Reassign REST API must have a bearer token. Use a personal access token for your own scripts. Use OAuth 2.0 for an app that acts for other users.

The base URL of the REST API is `https://reassign.app/api/v1`.

## Choose a method

| Method                 | Use it for                                       | REST API                        | MCP server                 |
| ---------------------- | ------------------------------------------------ | ------------------------------- | -------------------------- |
| Personal access token  | Your own scripts and tools                       | Yes                             | No                         |
| OAuth 2.0 access token | An app that other users connect to their account | Yes, with the REST API audience | Yes, with the MCP audience |

The [MCP server](https://reassign.dev/mcp.md) accepts only OAuth 2.0 access tokens. It does not accept personal access tokens.

`GET /schedule` gives `integrations.sources[].account`, the account name at the calendar provider, only to a personal access token and the Reassign apps. A third-party OAuth 2.0 app does not get it.

## Personal access tokens

A personal access token (a "key") gives access to your own account. Create it in the Reassign app.

### Create a key

1. Sign in to the Reassign app.
2. Go to **Settings** › **Integrations** › **API**.
3. In **New key**, type a name. The name can have 100 characters or less.
4. Choose **Read only** or **Read and write**.
5. Click **Create key**.
6. Copy the key from **Your new key**, and keep it in a safe place.
7. Click **I saved it**.

> The app shows the key one time only. Reassign keeps only a hash of the key.
> If you lose the key, revoke it and create a new key.

The **API** page also shows the base URL of the REST API in the **Base URL** field.

### Access levels

| Access                  | Scopes of the key          |
| ----------------------- | -------------------------- |
| Read only (the default) | `events:read`              |
| Read and write          | `events:read events:write` |

Refer to [Scopes](https://reassign.dev/authentication.md#scopes) for the access that each scope gives.

### Key format

- A key starts with `rsk_live_`.
- 43 characters follow the prefix.
- A key that you create in the app does not expire.

### Revoke a key

1. Go to **Settings** › **Integrations** › **API**.
2. In **Your keys**, find the key.
3. Click **Revoke**.

The app does not ask you to confirm. The key does not work after that. The **Your keys** list also shows the date that you created each key and the date of its last use.

## Send the token

Send the token in the `Authorization` header of each request. Use the same header for a personal access token and for an OAuth 2.0 access token.

**Header**

```http
Authorization: Bearer rsk_live_YOUR_TOKEN
```

- Write `Bearer` with a capital `B`. The API does not accept other letter cases.
- Keep a personal access token on your server. Do not put it in browser code or in a public repository.

To test your token, call `GET /api/v1/me`. This route shows who the token belongs to, the plan, the scopes, and the type of token. It works with all valid tokens on all plans, also with the plan value `none`.

**Test your token** — **GET** `/api/v1/me`

**cURL**

```bash
curl "https://reassign.app/api/v1/me" \
  -H "Authorization: Bearer rsk_live_YOUR_TOKEN"
```

**JavaScript**

```js
const res = await fetch("https://reassign.app/api/v1/me", {
  headers: {
    Authorization: "Bearer rsk_live_YOUR_TOKEN",
  },
});
const data = await res.json();
```

**Response**

```json
{
  "id": "00000000-0000-4000-8000-000000000042",
  "email": "user@example.com",
  "plan": "pro",
  "trialEndsAt": null,
  "offer": {
    "kind": "entitled"
  },
  "subscription": {
    "provider": "web",
    "status": "active",
    "currentPeriodEndsAt": "2026-09-13T08:00:00.000Z",
    "cancelAtPeriodEnd": false
  },
  "emailSubscribed": true,
  "scopes": [
    "events:read",
    "events:write"
  ],
  "auth": {
    "via": "oauth",
    "clientId": "reassign-ios"
  }
}
```

- The example shows an OAuth 2.0 token. For a personal access token, `auth` is `{ "via": "pat" }`.
- `trialEndsAt` is an ISO 8601 instant in UTC, for example `"2026-09-30T08:00:00.000Z"`, on a trial. It is `null` on all other plans.
- `email` is always in the response. It is `null` when the account has no email address.
- `auth.via` is `"pat"` for a personal access token and `"oauth"` for an OAuth 2.0 access token.

## OAuth 2.0

Use OAuth 2.0 when your app acts for other Reassign users. Reassign supports the authorization code flow with PKCE.

| Feature                     | Value                                              |
| --------------------------- | -------------------------------------------------- |
| Grant types                 | `authorization_code`, `refresh_token`              |
| Response type               | `code`                                             |
| PKCE                        | Required. `S256` only. The server rejects `plain`. |
| Client registration         | Dynamic (RFC 7591)                                 |
| Token endpoint auth methods | `none`, `client_secret_post`                       |
| Resource indicators         | Required for the REST API (RFC 8707)               |

### Endpoints

| Endpoint                                         | Method | URL                                                                 |
| ------------------------------------------------ | ------ | ------------------------------------------------------------------- |
| Authorization server metadata (RFC 8414)         | `GET`  | `https://reassign.app/.well-known/oauth-authorization-server`       |
| Protected resource metadata, REST API (RFC 9728) | `GET`  | `https://reassign.app/.well-known/oauth-protected-resource/api/v1`  |
| Protected resource metadata, MCP (RFC 9728)      | `GET`  | `https://reassign.app/.well-known/oauth-protected-resource/api/mcp` |
| Authorization                                    | `GET`  | `https://reassign.app/api/oauth/authorize`                          |
| Token                                            | `POST` | `https://reassign.app/api/oauth/token`                              |
| Registration                                     | `POST` | `https://reassign.app/api/oauth/register`                           |
| Revocation (RFC 7009)                            | `POST` | `https://reassign.app/api/oauth/revoke`                             |

- The metadata documents allow all origins (CORS).
- Reassign does not publish an OpenID Connect document (`/.well-known/openid-configuration`).
- The authorization server metadata has no `jwks_uri`, no `introspection_endpoint`, and no `userinfo_endpoint`.

### Resource and audience

Each access token is for one resource (the audience). A token works only on the API of its audience.

| Resource value                 | The token works on | The token does not work on |
| ------------------------------ | ------------------ | -------------------------- |
| `https://reassign.app/api/v1`  | The REST API       | The MCP server             |
| `https://reassign.app/api/mcp` | The MCP server     | The REST API               |

- For a REST API token, send `resource=https://reassign.app/api/v1` to the authorization endpoint and to the token endpoint.
- If you do not send `resource`, the server gives a token for the MCP server.
- If you send a different value, the server returns the error `invalid_target` with HTTP status 400.
- A refreshed token keeps the audience of the original token.
- A client from dynamic registration can get tokens for the REST API and for the MCP server. The server does not limit a client to one audience.

### Flow steps

1. Register your client one time. You get a `client_id`.
2. Make a PKCE code verifier. Make the `S256` code challenge from it.
3. Send the user to the authorization endpoint.
4. The user signs in and allows access on the Reassign consent screen.
5. Reassign sends the user to your `redirect_uri` with `code`, `state`, and `iss`.
6. Make sure that `state` is the value that you sent, and that `iss` is the issuer.
7. Exchange the code for tokens at the token endpoint in 60 seconds or less.
8. Send the access token in the `Authorization` header.
9. Before the access token expires, use the refresh token to get new tokens. Store the new refresh token each time.

### Register a client

Send a `POST` request with a JSON body to the registration endpoint.

- `redirect_uris` (_string\[]_): Required. 1 to 16 URIs. Each URI can have 2048 characters or less. Use
  `https` on any host, `http` only on `localhost`, `127.0.0.1`, or `[::1]`, or
  a native app scheme (for example `cursor:` or `vscode:`). The server rejects
  `javascript:`, `data:`, `vbscript:`, `file:`, `blob:`, `about:`, and
  `filesystem:`.
- `client_name` (_string_): Optional. The name that the consent screen shows. The server cuts it to 128
  characters.
- `token_endpoint_auth_method` (_string_): Optional. Send `client_secret_post` to get a client secret. All other values
  give `none`, the default.
- `application_type` (_string_): Optional. `web` (the default) or `native`.

**Register a client** — **POST** `/api/oauth/register`

**cURL**

```bash
curl https://reassign.app/api/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My planner app",
    "redirect_uris": ["https://example.com/oauth/callback"]
  }'
```

**JavaScript**

```js
const response = await fetch('https://reassign.app/api/oauth/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    client_name: 'My planner app',
    redirect_uris: ['https://example.com/oauth/callback'],
  }),
})

const client = await response.json()
```

The server returns HTTP status 201 with these fields:

- `client_id` and `client_id_issued_at` (Unix seconds).
- `redirect_uris`, `token_endpoint_auth_method`, `application_type`, and `client_name` (if you sent it).
- `grant_types` (`["authorization_code", "refresh_token"]`) and `response_types` (`["code"]`).
- `client_secret` and `client_secret_expires_at: 0`, for a `client_secret_post` client only.

The server does not return a `registration_access_token`. You cannot read, change, or delete a client after you register it.

### Authorize the user

Send the user's browser to the authorization endpoint with these query parameters.

| Parameter               | Value                                                             |
| ----------------------- | ----------------------------------------------------------------- |
| `response_type`         | `code`                                                            |
| `client_id`             | Your client ID                                                    |
| `redirect_uri`          | One of your registered URIs, exactly                              |
| `code_challenge`        | The base64url SHA-256 hash of your code verifier                  |
| `code_challenge_method` | `S256`                                                            |
| `scope`                 | `events:read`, `events:write`, or both, with a space between them |
| `state`                 | A random value that you check on return                           |
| `resource`              | `https://reassign.app/api/v1` for the REST API                    |

**Authorization URL**

```text
https://reassign.app/api/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback
  &code_challenge=YOUR_CODE_CHALLENGE
  &code_challenge_method=S256
  &scope=events%3Aread%20events%3Awrite
  &state=YOUR_STATE
  &resource=https%3A%2F%2Freassign.app%2Fapi%2Fv1
```

- The authorization request expires after 10 minutes. The user must allow access in this time.
- If the user denies access, your `redirect_uri` gets `error=access_denied`.
- If a parameter is not correct, your `redirect_uri` gets `error` and `error_description`.
- If `client_id` or `redirect_uri` is not correct, the server does not redirect. It returns HTTP status 400 with a JSON error.

### Exchange the code

Send a `POST` request to the token endpoint. The body can be JSON or form-encoded.

| Parameter       | Value                                                               |
| --------------- | ------------------------------------------------------------------- |
| `grant_type`    | `authorization_code`                                                |
| `code`          | The code from your `redirect_uri`                                   |
| `client_id`     | Your client ID                                                      |
| `client_secret` | Your client secret, for a `client_secret_post` client only          |
| `redirect_uri`  | The same `redirect_uri` that you sent to the authorization endpoint |
| `code_verifier` | Your PKCE code verifier                                             |
| `resource`      | The same `resource` that you sent to the authorization endpoint     |

**Exchange the code** — **POST** `/api/oauth/token`

**cURL**

```bash
curl https://reassign.app/api/oauth/token \
  -d grant_type=authorization_code \
  -d code=YOUR_CODE \
  -d client_id=YOUR_CLIENT_ID \
  --data-urlencode redirect_uri=https://example.com/oauth/callback \
  -d code_verifier=YOUR_CODE_VERIFIER \
  --data-urlencode resource=https://reassign.app/api/v1
```

**JavaScript**

```js
const response = await fetch('https://reassign.app/api/oauth/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: 'YOUR_CODE',
    client_id: 'YOUR_CLIENT_ID',
    redirect_uri: 'https://example.com/oauth/callback',
    code_verifier: 'YOUR_CODE_VERIFIER',
    resource: 'https://reassign.app/api/v1',
  }),
})

const tokens = await response.json()
```

**Response**

```json
{
  "access_token": "YOUR_ACCESS_TOKEN",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "YOUR_REFRESH_TOKEN",
  "scope": "events:read events:write"
}
```

- You can use a code one time only. A used code gets HTTP status 400 with `invalid_grant` ("authorization code has already been used").
- A code expires after 60 seconds.
- If the server cannot store the new refresh token, it returns HTTP status 503 with `temporarily_unavailable`. The code stays usable, so you can send the same request again.

## Scopes

Reassign has two scopes.

| Scope          | Access                                                                                 |
| -------------- | -------------------------------------------------------------------------------------- |
| `events:read`  | Read your events, plans, and reflections.                                              |
| `events:write` | Read, create, change and delete events and other plan data. It includes `events:read`. |

- If you do not send `scope`, you get `events:read events:write`.
- `events:read` covers all read calls. `GET /api/v1/me` needs no scope.
- The server ignores a scope value that it does not know.
- If the server knows no value in `scope`, you get `events:read events:write`.
- A request without the necessary scope gets HTTP status 403 with the error code `scope`.
- A write route checks the scope before it reads the body. Thus, a token with only `events:read` gets `scope`, not `validation`, when the body is not correct.

## Token lifetimes and refresh

| Token              | Lifetime              |
| ------------------ | --------------------- |
| Authorization code | 60 seconds            |
| Access token       | 3600 seconds (1 hour) |
| Refresh token      | 30 days               |

To get new tokens, send a `POST` request to the token endpoint with `grant_type=refresh_token`.

| Parameter       | Value                                                      |
| --------------- | ---------------------------------------------------------- |
| `grant_type`    | `refresh_token`                                            |
| `refresh_token` | Your refresh token                                         |
| `client_id`     | Your client ID (optional)                                  |
| `client_secret` | Your client secret, for a `client_secret_post` client only |
| `scope`         | Optional. A smaller set of scopes. You cannot add scopes.  |

**Refresh the tokens** — **POST** `/api/oauth/token`

**cURL**

```bash
curl https://reassign.app/api/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=YOUR_REFRESH_TOKEN \
  -d client_id=YOUR_CLIENT_ID
```

**JavaScript**

```js
const response = await fetch('https://reassign.app/api/oauth/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: 'YOUR_REFRESH_TOKEN',
    client_id: 'YOUR_CLIENT_ID',
  }),
})

const tokens = await response.json()
```

- Each refresh returns a new access token and a new refresh token.
- The new refresh token has a new 30-day lifetime.
- The server ignores `resource` in a refresh request. The new tokens keep the audience of the old token.

### Refresh token rotation

Each refresh spends the refresh token that you send. The server returns a new refresh token of the same family (the same sign-in).

- Store the new refresh token after each refresh. The old refresh token is spent.
- Before a refresh, read your storage again. Another process can have rotated the token already.
- Send one refresh at a time for each sign-in, if you can.

| You send                                              | Result                                                                                                                                                                                                                          |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The live refresh token                                | HTTP status 200. You get a new refresh token, and the token that you sent is spent.                                                                                                                                             |
| A spent token, 60 seconds or less after its refresh   | HTTP status 200. You get the live refresh token of the family. Thus, two parallel refreshes, or a retry after a lost response, are safe. The scope is the scope of the live token, cut to the scope of the token that you sent. |
| A spent token, more than 60 seconds after its refresh | HTTP status 400 with `invalid_grant` ("refresh token is no longer valid"). The server revokes all refresh tokens of the family.                                                                                                 |
| A token of a revoked family                           | HTTP status 400 with `invalid_grant` ("refresh token is no longer valid").                                                                                                                                                      |

- In the 60-second window, the `scope` of the response and the new access token follow the cut scope. Thus, a replay cannot widen a family that a refresh made narrower.
- The server treats the reuse of a spent token after 60 seconds as a possible theft. Thus, it revokes the family.
- After `invalid_grant`, do not try again. Send the user through the authorization flow again.
- A refresh token from before rotation does not work. The server returns HTTP status 400 with `invalid_grant`. Send the user through the authorization flow again.
- If the server cannot read the grant or store the new token, it returns HTTP status 503 with `temporarily_unavailable` and `retry-after: 5`. Keep your current refresh token, wait 5 seconds, and send the request again.

## Revoke access

To revoke access from your app, send the access token or the refresh token to the revocation endpoint.

**Revoke a token** — **POST** `/api/oauth/revoke`

**cURL**

```bash
curl https://reassign.app/api/oauth/revoke \
  -d token=YOUR_REFRESH_TOKEN
```

**JavaScript**

```js
await fetch('https://reassign.app/api/oauth/revoke', {
  method: 'POST',
  body: new URLSearchParams({ token: 'YOUR_REFRESH_TOKEN' }),
})
```

- Revocation removes the full grant for that user and that client. All tokens of that client for that user stop working.
- Revocation also revokes all refresh token families of that client for that user. A new consent does not make an old refresh token work again.
- The endpoint does not need client authentication.
- The endpoint returns HTTP status 200 with `{}`, also for an unknown token.
- If the server cannot store the change, it returns HTTP status 503 with `temporarily_unavailable` and `retry-after: 5`. Send the request again.

A user can also revoke your app in the Reassign app. The path is **Settings** › **Integrations** › **Authorized apps** › **Revoke**.

## Plan requirement

Most requests need Reassign Pro. The 7-day free trial includes it.

| Action                                   | Needs Reassign Pro (the 7-day free trial includes it) |
| ---------------------------------------- | ----------------------------------------------------- |
| Create a personal access token           | Yes                                                   |
| Revoke a personal access token           | No                                                    |
| Allow an app on the OAuth consent screen | Yes                                                   |
| Call `GET /api/v1/me`                    | No                                                    |
| Call most other REST API routes          | Yes                                                   |
| Call the MCP server                      | Yes                                                   |

Without Reassign Pro or the trial, the REST API returns HTTP status 403 with the error code `permission` and `feature: "api"`. The MCP server returns HTTP status 403 with the JSON-RPC code `-32600` and `data: { "code": "permission", "feature": "mcp" }`.

## Authentication errors

If the token is missing, not valid, or expired, the REST API returns HTTP status 401.

**401 response**

```http
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer error="invalid_token", error_description="missing token", resource_metadata="https://reassign.app/.well-known/oauth-protected-resource/api/v1"

{ "error": { "code": "unauthorized", "message": "Authentication required." } }
```

The `error_description` in the `WWW-Authenticate` header has one of these values:

| `error_description`        | Cause                                                                           |
| -------------------------- | ------------------------------------------------------------------------------- |
| `missing token`            | The request has no bearer token.                                                |
| `invalid or expired token` | The token is not valid, is expired, is revoked, or is for a different audience. |

The `resource_metadata` value gives the URL of the protected resource metadata. An OAuth 2.0 client can use it to find the authorization server.

## Rate limits of the OAuth endpoints

The Vercel Firewall limits the requests from each IP address.

| Endpoint                   | Limit for each IP address   |
| -------------------------- | --------------------------- |
| `POST /api/oauth/token`    | 600 requests per minute     |
| `POST /api/oauth/register` | 200 requests per 10 minutes |

- When a client goes above a limit, the API returns `429`. The body can differ from the OAuth error format.
- After a `429`, wait and retry. Use `Retry-After` when it is present.

For the REST API limits and all error codes, refer to [Errors](https://reassign.dev/errors.md).
