# API conventions

> Authentication, scopes, pagination, errors, safe retries and test mode.

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

## Keys and scopes

Create a key in **Settings → Developers → API keys** at `/user/developers/keys`. Give it a name and tick the scopes it 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 page lists its required scopes under the description. A call outside the key's scopes returns 403 `forbidden`.

The **Full access** template ticks every scope. 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)).

## Authentication

Send the key in the `Authorization` header:

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

The API also accepts the key in an `x-key` header, which older integrations use. New code should use `Authorization`.

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

These list operations return one page at a time:

- `GET /tasks`
- `GET /notes`
- `GET /sets`
- `GET /sets/{setId}/terms`
- `GET /tests`
- `GET /copilot/conversations`
- `GET /copilot/conversations/{conversationId}/messages`
- `GET /webhooks/{webhookId}/deliveries`
- `GET /search`

A page looks like this:

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

Two query parameters control the page:

- `limit` sets the page size, from 1 to 100. It defaults to 25.
- `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`. It stays valid when rows on earlier pages are deleted. A `limit` outside 1 to 100, 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.

Every other list, such as `GET /subjects`, `GET /note-folders`, `GET /schedules`, `GET /task-types`, `GET /notes/{noteId}/files` and `GET /webhooks`, returns all its rows in one response. These lists take no `limit` or `cursor` and answer them with 400 `invalid_query`. `GET /webhooks` still includes `nextCursor`, which is always `null`.

`GET /search` takes `cursor` but no `limit`: it always returns 20 hits a page, best match first. Send the same `q` and filters with each page's cursor.

`GET /sets/{setId}/terms` pages a set's terms in the same way. Its page size defaults to 100 and goes up to 500. `GET /sets/{setId}` returns only the first terms of a large set and sets `termsTruncated` when there are more, so use the list to read the rest.

## 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"] }
      }
   }
}
```

`details` is optional. Validation errors use it to list the failing fields, 409 `conflict` responses use it to explain the clash, usually with a `reason`, 402 `ai_limit_reached` gives the `code`, `limit` and `resetsAt` of the limit that was reached, and 402 `api_allowance_reached` gives the free plan's monthly `limit`, the requests `used`, `resetsAt` and `upgradeUrl`. Branch on `code`, not on `message`, because messages can change.

| 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`.                                                                                                                                                   |

## Idempotency

Send an `Idempotency-Key` header on a `POST` to make it safe to retry. Two operations refuse it: `POST /webhooks` and `POST /webhooks/{webhookId}/rotate`. See [Webhooks](https://workroad.app/docs/api/webhooks#webhooks). 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 does not create anything new.
- A replay doesn't count toward the free plan's [monthly requests](https://workroad.app/docs/api/limits#monthly-requests-on-the-free-plan), and it is still served after they are spent.
- The same key with a different body returns 409 `idempotency_conflict`.
- 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 executed again by a retry using the same key.
- A retry must send the byte-identical body for the same key, or it gets 409 `idempotency_conflict`.
- Keys are stored for 24 hours, scoped to your key and to live or test mode separately, so a test-mode key and a live-mode key never collide even if you reuse the same `Idempotency-Key` value.
- 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.

## 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-and-schedules#tasks) 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 on each operation page. 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 page 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.

## Versioning

From this release on, changes within `/api/v1` are additive: new operations, new optional parameters and new response fields. Write clients that ignore fields they do not know. A change that removes or renames a field, changes a type or tightens validation ships as `/api/v2`, and `/api/v1` keeps working alongside it.

For integrations written before the October 2026 release, see the [v1 migration notes](https://workroad.app/docs/api/v1-migration#october-2026-changes-to-v1).
