API conventions
Authentication, scopes, pagination, errors, safe retries and test mode.
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), and WorkRoad Plus has no monthly count. The AI features keep the same plan limits as in the app (see AI allowance).
Authentication
Send the key in the Authorization header:
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 /tasksGET /notesGET /setsGET /sets/{setId}/termsGET /testsGET /copilot/conversationsGET /copilot/conversations/{conversationId}/messagesGET /webhooks/{webhookId}/deliveriesGET /search
A page looks like this:
{
"data": [{ "id": "2b0c6c1e-8f0e-4c55-9a53-0d7f5b1f6a10" }],
"nextCursor": "eyJrIjoidGFza3MiLCJ2Ijp7ImNyZWF0ZWQiOiIyMDMwLTAxLTAxVDAwOjAwOjAwLjAwMFoiLCJpZCI6IjJiMGM2YzFlLThmMGUtNGM1NS05YTUzLTBkN2Y1YjFmNmExMCJ9fQ"
}Two query parameters control the page:
limitsets the page size, from 1 to 100. It defaults to 25.cursortakes thenextCursorvalue 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:
{
"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. |
| 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. |
| 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 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. Use a new key per logical operation, for example a UUID generated once before the first attempt.
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: trueadded. It does not create anything new. - A replay doesn't count toward the free plan's monthly requests, 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, withRetry-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-Keyvalue. - A response over 256 KB is not replayed. A retry with its key gets 409
idempotency_replay_unavailablewith the created resource'sidindetailswhen the response had one, otherwisenull; read the resource with a GET instead. - Only
2xxresponses are stored. A failed attempt (4xx or 5xx) is not remembered, so retrying with the same key runs the operation again. Idempotency-Keymust be 1 to 255 visible ASCII characters, or the request fails with 400invalid_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 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.
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.