# REST API

> Create an API key, make your first request, and learn the rules every request follows.

Source: https://workroad.app/docs/api

Use the REST API to work with your WorkRoad notes, study sets, tasks, subjects and schedules, run practice sessions, and use the copilot and generation features. Every request runs as the user who owns the key and returns only that user's data.

The live base URL is `https://workroad.app/api/v1`. For an agent connection through ChatGPT, Claude or another client, start with the [MCP server guide](https://workroad.app/docs/mcp) instead.

## Create a key and choose its scopes

Open **Settings → AI apps & API → API keys** at `/user/developers/keys`. Give the key a name and tick the scopes your integration needs. WorkRoad shows the secret once, right after you create the key, so copy it then. If you lose it, revoke the key and create a new one.

Each operation in the reference lists the scopes it needs. Reading notes needs `notes:view`, for example, and creating a note needs `notes:create`. The **Full access** template ticks every scope. A request outside the key's scopes returns 403 `forbidden`.

Every account can use the API and the MCP server. The free plan allows 100 requests a month and one API key (see [Monthly requests on the free plan](https://workroad.app/docs/api/limits#monthly-requests-on-the-free-plan)), and WorkRoad Plus has no monthly count. The AI features keep the same plan limits as in the app (see [AI allowance](https://workroad.app/docs/api/limits#ai-allowance)).

## Make your first request

Set your key in the environment and ask for the user who owns it. This needs no scopes:

```bash
export WORKROAD_API_KEY=wr_live_...
curl https://workroad.app/api/v1/me \
   -H "Authorization: Bearer $WORKROAD_API_KEY"
```

The reference's cURL examples read the same environment variable, so you can run them as they are. The other language tabs contain `$WORKROAD_API_KEY` as literal text; replace it or read the variable in your code.

To try an integration without changing your data, send the same requests to `https://workroad.app/api/test/v1`. See [Test mode](#test-mode).

## Find what you're building

The reference has one page per part of WorkRoad: how that part works, then every operation with its inputs, response, scopes and errors.

- [Notes](https://workroad.app/docs/api/notes) and [Files and recordings](https://workroad.app/docs/api/files): writing Markdown, folders, uploads and transcripts.
- [Tasks](https://workroad.app/docs/api/tasks), [Subjects](https://workroad.app/docs/api/subjects) and [Schedules and planning](https://workroad.app/docs/api/schedules): due dates, recurrence, timetables, study blocks and today's plan.
- [Study sets](https://workroad.app/docs/api/sets), [Reviews and practice](https://workroad.app/docs/api/practice) and [Practice tests](https://workroad.app/docs/api/tests): cards, spaced-repetition reviews and tests.
- [Copilot](https://workroad.app/docs/api/copilot) and [Generation and jobs](https://workroad.app/docs/api/generation): copilot messages and actions, AI generation and background jobs.
- [Search and docs](https://workroad.app/docs/api/search), [Account and settings](https://workroad.app/docs/api/account) and [Webhooks](https://workroad.app/docs/api/webhooks).

An agent that writes its own notes, cards or tests can save them through ordinary write operations without spending WorkRoad AI uses. See [Let your AI do the writing](https://workroad.app/docs/api/generation#let-your-ai-do-the-writing).

The rest of this page covers what works the same on every operation.

## Authentication

Send the key in the `Authorization` header:

```http
Authorization: Bearer <key>
```

A missing, unknown or revoked key returns 401 `unauthorized` with `WWW-Authenticate: Bearer realm="workroad-api"`. So does a key whose user is banned, and a key made for the calendar feed.

## Pagination

A list that can grow long returns one page at a time. Its reference entry takes `limit` and `cursor` query parameters, and its response looks like this:

```json
{
   "data": [{ "id": "2b0c6c1e-8f0e-4c55-9a53-0d7f5b1f6a10" }],
   "nextCursor": "eyJrIjoidGFza3MiLCJ2Ijp7ImNyZWF0ZWQiOiIyMDMwLTAxLTAxVDAwOjAwOjAwLjAwMFoiLCJpZCI6IjJiMGM2YzFlLThmMGUtNGM1NS05YTUzLTBkN2Y1YjFmNmExMCJ9fQ"
}
```

- `limit` sets the page size. It defaults to 25 and goes up to 100, unless the list's reference entry gives other bounds.
- `cursor` takes the `nextCursor` value from the previous page. Leave it out for the first page.

`nextCursor` is `null` on the last page. It is opaque. Pass it back unchanged as `cursor`, with the same filters. It stays valid when rows on earlier pages are deleted. A `limit` outside the list's bounds, or a `cursor` that did not come from the same list, returns 400 `invalid_query`. Never build a cursor yourself, and never use a row id as one.

A short list, such as the user's subjects or folders, returns all its rows in one response and takes no `limit` or `cursor`. Sending either returns 400 `invalid_query`. A list's reference entry shows which kind it is.

## Errors

Every error has the same JSON body, with `content-type: application/json; charset=utf-8`:

```json
{
   "error": {
      "code": "invalid_query",
      "message": "Invalid query parameters.",
      "details": {
         "formErrors": [],
         "fieldErrors": { "limit": ["Too big: expected number to be <=100"] }
      }
   }
}
```

Branch on `code`, not on `message`, because messages can change. `details` is optional:

- validation errors list the failing fields
- 409 `conflict` explains the clash, usually with a `reason`
- 402 `ai_limit_reached` gives the `code`, `limit` and `resetsAt` of the limit that was reached
- 402 `api_allowance_reached` gives the free plan's monthly `limit`, the requests `used`, `resetsAt` and `upgradeUrl`

| Status | Code                             | When                                                                                                                                                                                                                                                                                        |
| ------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request`                | The body is missing, is not JSON or fails validation, or it names a `subjectId` or `folderId` the key's user does not own.                                                                                                                                                                  |
| 400    | `invalid_query`                  | A query parameter fails validation or appears twice, or a name the operation does not take is present.                                                                                                                                                                                      |
| 401    | `unauthorized`                   | The key is missing or not valid.                                                                                                                                                                                                                                                            |
| 402    | `ai_limit_reached`               | The user has used this month's AI allowance. See [AI allowance](https://workroad.app/docs/api/limits#ai-allowance).                                                                                                                                                                                             |
| 402    | `api_allowance_reached`          | The user is on the free plan and has used this month's API and MCP requests. Nothing ran. See [Monthly requests on the free plan](https://workroad.app/docs/api/limits#monthly-requests-on-the-free-plan).                                                                                                      |
| 403    | `forbidden`                      | The key lacks a scope the operation needs.                                                                                                                                                                                                                                                  |
| 404    | `not_found`                      | No endpoint exists at the path, or no resource with that id in the path belongs to the key's user. A malformed id also returns 404.                                                                                                                                                         |
| 405    | `method_not_allowed`             | The path exists but not for this method. The `Allow` header lists the methods it takes.                                                                                                                                                                                                     |
| 409    | `conflict`                       | The write clashes with existing data.                                                                                                                                                                                                                                                       |
| 409    | `idempotency_conflict`           | The `Idempotency-Key` was reused with a different body or while its first request is still running, a `reviewId` was reused with a different card, rating or duration, a copilot `requestId` was reused with a different message, or a generation `requestId` was reused with other inputs. |
| 409    | `idempotency_replay_unavailable` | The first request with this `Idempotency-Key` succeeded, but its response was over 256 KB and is not stored. `details` has the `status` and the created resource's `id`, or `null` when the response had none.                                                                              |
| 429    | `rate_limited`                   | The key is over a rate limit, or an operation's own limit was reached, such as the user's [AI rate limit](https://workroad.app/docs/api/limits#ai-rate-limits) or the free plan's daily copilot turns or actions. Wait for the seconds in `Retry-After`.                                                        |
| 500    | `internal_error`                 | WorkRoad failed. Retry later.                                                                                                                                                                                                                                                               |
| 503    | `service_unavailable`            | A dependency WorkRoad relies on, such as the job queue or a timetable integration, is unavailable. Wait for the seconds in `Retry-After`.                                                                                                                                                   |

## Retry safely with an idempotency key

Send an `Idempotency-Key` header on a `POST` to make it safe to retry. Use a new key per logical operation, for example a UUID generated once before the first attempt.

```http
POST /api/v1/tasks
Idempotency-Key: 3f29c1de-9f0a-4b7e-9c0d-2a6e6f1b7a44
```

A retry with the same key and the same body returns the original response again, unchanged, with `Idempotent-Replayed: true` added. It creates nothing new, and it doesn't count toward the free plan's [monthly requests](https://workroad.app/docs/api/limits#monthly-requests-on-the-free-plan).

**The full rules**

- The same key with a different body returns 409 `idempotency_conflict`. A retry must send the byte-identical body.
- A second request with the same key while the first is still running also returns 409 `idempotency_conflict`, with `Retry-After: 1`.
- In-progress protection lasts 30 seconds. A request that takes longer than that can be picked up and run again by a retry with the same key.
- Keys are stored for 24 hours, scoped to your API key and to live or test mode separately, so a test-mode and a live-mode request never collide even with the same `Idempotency-Key` value.
- A replay is still served after the month's requests are spent.
- A response over 256 KB is not replayed. A retry with its key gets 409 `idempotency_replay_unavailable` with the created resource's `id` in `details` when the response had one, otherwise `null`; read the resource with a GET instead.
- Only `2xx` responses are stored. A failed attempt (4xx or 5xx) is not remembered, so retrying with the same key runs the operation again.
- `Idempotency-Key` must be 1 to 255 visible ASCII characters, or the request fails with 400 `invalid_request`. Other methods ignore the header.
- Creating a webhook and rotating its secret refuse the header, because their responses carry a secret (see [Webhooks](https://workroad.app/docs/api/webhooks)).

## Input validation

Resource names are trimmed before their length checks; whitespace-only names are rejected. Strings and property names must not contain NUL bytes or unpaired Unicode surrogates. An own `__proto__` property is rejected at any depth. These failures return 400, including in query filters and MCP arguments.

Task dates accept a valid `YYYY-MM-DD` day, read in the user's time zone as [Tasks](https://workroad.app/docs/api/tasks#due-dates) describes, or an ISO timestamp with `Z` or a numeric offset. Years must be 0001 through 9999, including after offset conversion. Impossible dates, such as `2026-02-30`, and timestamps without an offset are rejected rather than normalized.

Validation errors use prose in `error.message` and stable machine codes in `error.details.reason` when the failure comes from a domain rule.

## Test mode

Send the same request to `/api/test/v1` instead of `/api/v1` to try an integration without touching your data. Test mode checks the key, the scopes, the rate limits and the input exactly as live mode does. Instead of running the operation, it returns the fixed example response shown in each operation's reference. It never changes your data.

Test requests count against the same per-key rate limits as live requests. They don't count toward the free plan's [monthly requests](https://workroad.app/docs/api/limits#monthly-requests-on-the-free-plan).

The playground on each operation saves a key you paste into it in your browser's local storage. Use a test-mode-only key with the playground, or clear the saved key from local storage after you finish.

## Limits and the OpenAPI document

The free plan includes 100 API and MCP requests per UTC calendar month, shared across the user's keys and connected apps. WorkRoad Plus has no monthly request count. AI operations have a separate allowance, and per-key rate limits apply on both plans. [Limits and allowances](https://workroad.app/docs/api/limits) explains what counts and how to handle 402 and 429 responses.

Download the [OpenAPI 3.1 document](https://workroad.app/api/v1/openapi.json) to generate a client. Write clients that ignore unknown response fields.
