# WorkRoad docs > WorkRoad brings notes, lecture recordings, tasks, schedules, study activities, and AI work into one app, organised by subject. The index with each page's own address is https://workroad.app/llms.txt. --- # Getting started > Sign in, set up your workspace, and learn where to find each part of WorkRoad. Source: https://workroad.app/docs WorkRoad brings your notes, tasks, schedule, study activities, and AI work into one app. Subjects connect that work so you can keep material for each area together. ## Sign in and set up Go to [/sign-in](https://workroad.app/sign-in) and use your email and password or sign in with Google. On your first run, onboarding introduces subjects, your schedule, tasks, study sets, note recordings, and the command bar. It then asks you to choose a plan. ## Try a first task 1. Add a subject in [Settings → Subjects](https://workroad.app/user/subjects), such as Biology. 2. [Create a note](https://workroad.app/docs/notes) for that subject and write a few lines from a lesson. 3. Open [Tasks](https://workroad.app/tasks), add a reminder to review the note, and assign it to the same subject. Open the subject to find the note and task together. ## Find your way around The app has six main areas: - [Dashboard](https://workroad.app/dashboard) - [Tasks](https://workroad.app/tasks) - [Schedule](https://workroad.app/schedule) - [Activities](https://workroad.app/activities) - [Notes](https://workroad.app/notes) - [Copilot](https://workroad.app/copilot) Settings are at [/user](https://workroad.app/user). You can manage your account and plan, API keys, subjects, task preferences, and account deletion there. Anything you delete (a note, a folder, a flashcard set, a task, a file, a recording or a copilot chat) goes to the trash at [/trash](https://workroad.app/trash), also in your account menu. Restore it from there, or delete it for good. Items stay in the trash for 30 days. Deleting a folder takes the notes or sets in it along, and restoring the folder brings them back. Files in the trash still count toward your file storage until they are deleted for good. Press **⌘K / Ctrl+K** to open the command palette. Use it to jump to a page, run an action, search your own content with semantic and keyword search, or quickly add a task. Press **⌘J / Ctrl+J** to open the copilot from anywhere. It carries the current page into the conversation as context. Documentation results also appear in the command palette. Type a feature name, then open a matching page or section. ## Organise by subject Subjects are the main way to organise WorkRoad. You can file notes, tasks, flashcard sets, schedules, and copilot threads under a subject. Each subject has its own page with its notes, study sets, tasks and lessons. Open it from **Settings → Subjects**, where you also add, edit and archive subjects. ## Use the API WorkRoad has a public REST API v1. Create and manage bearer API keys in [Settings → Developers → API keys](https://workroad.app/user/developers/keys). The OpenAPI description is available at [OpenAPI](https://workroad.app/api/v1/openapi.json). - [Notes and the editor](https://workroad.app/docs/notes) - [Recording a lecture](https://workroad.app/docs/recording) - [Studying](https://workroad.app/docs/studying) - [Copilot](https://workroad.app/docs/copilot) - [Plans and AI uses](https://workroad.app/docs/plans) --- # Dashboard > The page you land on, and the cards you choose to keep on it. Source: https://workroad.app/docs/dashboard `/dashboard` opens with a greeting, today's date and a quote. If you allow location access, the date line also carries the day's high, low and conditions. ## Choose your cards **Customise** turns on edit mode. While it is on, drag a card by its handle or move it with the arrows beside the handle, make it wide or narrow, and remove the ones you do not want. Changes save as you go. **Add a card** lists everything not currently on the page. | Card | What it shows | | ----------------- | -------------------------------------------------------- | | Schedule | Lessons, study sessions and deadlines for the day shown. | | Tasks | What is due soonest, still open. | | Copilot | Ask about your material, and your recent chats. | | Continue studying | The sets and notes you had open last. | | Study streak | Days with a review in a row. | | Study activity | Reviews per day across recent weeks. | | AI activity | The notes AI has written for you, and AI uses left. | Remove every card and the page offers them back. ## If a card is missing or fails If you removed a card, use **Customise → Add a card** to restore it. An empty card may need content first: add a task in [Tasks](https://workroad.app/tasks) or a timetable in [Schedule](https://workroad.app/schedule). If a card reports a loading error, use its retry button. Other cards remain available. ## How the cards behave The Tasks card shows up to four tasks. Open [Tasks](https://workroad.app/tasks) for the full list. The Schedule card includes your active schedules and schedules shared with you, including connected calendars. It shows today, then tomorrow once today's lessons, study sessions and exams have ended or if there are none today. Check the date in its heading. The source toggles in [Schedule](https://workroad.app/schedule) do not hide items on the dashboard. --- # Tasks > Capture what you have to do, then read it back as a list, a board, or a month. Source: https://workroad.app/docs/tasks Open [Tasks](https://workroad.app/tasks). Type a name in the row above the list and press Enter. For example, `Revise Chapter 4 due Friday @Maths` adds a due date and files the task under your existing Maths subject. Use **⌘N / Ctrl+N** for the full form. Quick-add date phrases use English in every app language, such as `due Friday` or `due tomorrow`. You can also use `due 2026-11-06`. After `@`, use the name of one of your own subjects. ## Views and the toolbar The toolbar switches views and controls what each one shows. - **List** is the default. It groups, sorts, and supports selection. - **Board** puts tasks in lanes taken from the current grouping. Arriving without one, it groups by due. - **Calendar** lays out a month, with a section underneath for tasks that have no due date. Group by status, subject, type or due date. Sort and filter separately in the toolbar. The status filter starts on Open; change it to see finished tasks. View choices are saved in the URL and remembered for your next visit. Choosing **Manual order** is what makes the up and down arrows appear on each row. No other sort rewrites the order. ## Find tasks Combine search terms to narrow the list. For example, `subject:Maths level:overdue` finds overdue tasks in a subject named Maths. Use your own subject name; quote it if it contains spaces, as in `subject:"Applied Maths"`. | Search | Finds | | ----------------------- | ----------------------------------------------- | | `has:dueDate` | Tasks with a due date. | | `during:2026-11-06` | Tasks due on 6 November 2026. | | `before:2026-11-06` | Tasks due before 6 November 2026. | | `essay has:description` | Tasks matching "essay" that have a description. | Operator names and values stay in English. Dates use `YYYY-MM-DD`; `before:` and `after:` exclude the date itself. Other `level:` values are `nearlyDue`, `dueToday` and `normal`. ## Work through the list When focus is in the list rather than a text field: | Shortcut | Action | | ------------- | ---------------------------------- | | ↑ / ↓ | Move to the previous or next row | | Space | Select or deselect the focused row | | Shift + ↑ / ↓ | Extend the selection while moving | | Enter | Open the focused task | | ⌘A / Ctrl+A | Select all tasks shown | | `g` | Cycle the grouping | With tasks selected, use the bottom bar to mark them finished, reopen them or delete them. Each row has its own menu: edit, move to subject, set a date, delete. ## One task `/tasks/` holds the details, the steps, a description, and when the task was added, last edited and finished. **Ask Copilot** opens a thread about that task, with its due date. A name is the only required field. Type, subject, due date and notes are optional. ## Repeat a task 1. Open the task for editing and set **Due date**. 2. Under **Repeats**, choose daily, weekly or monthly. 3. Set an optional end date, then **Save**. Task types, due-date alerts and due-date colours live in **Settings → Task preferences**. --- # Schedule > Build or connect a timetable, read every one of them on one week, and follow it from a calendar app. Source: https://workroad.app/docs/schedule Open [Schedule](https://workroad.app/schedule) to see **All schedules**: your schedules together on one week, plus dated tasks. Choose one schedule from the side list to work on it. On a phone, tap the schedule name at the top to open that list. ## Create a schedule 1. Choose **New schedule** in the schedule list. 2. Pick **A fixed weekly timetable** or **A different list each day**. 3. Enter a name and the first day of the week, then **Continue**. 4. Set up the periods for a fixed timetable, or add timed entries for each day. Fill in the lessons, teachers and rooms. - **A fixed weekly timetable** uses the same periods every day. Period names are required; start and end times are optional. - **A different list each day** gives each lesson its own start and end time. You can also copy the period structure from another schedule you already have. ## Connect an existing calendar 1. Choose **New schedule**, then **Connect a calendar**. 2. Select Google Calendar, or **Anglia Ruskin Evision** if you study at Anglia Ruskin University and have an ARU account. 3. Follow that provider's sign-in steps and select the calendar. Connected schedules are read-only in WorkRoad. Edit lessons in the original calendar. Removing the connection in WorkRoad leaves the original calendar untouched. ## Import a timetable photo Choose **New schedule**, then **Read a timetable photo**. Upload a JPEG, PNG or WebP picture or screenshot. Check the proposed timetable and any uncertain entries before **Apply schedule**. Nothing is saved before you apply it. Discarding the proposal prevents it from being applied. While the image remains in the browser, you can ask Copilot about it. ## Describe a timetable to Copilot Choose **Describe it to Copilot** from the new-schedule choices. Describe your days, times and subjects, or attach a timetable. Each Copilot turn uses one [AI use](https://workroad.app/docs/plans). Check the proposed schedule and confirm it before it is created. ## The merged week Each source gets a colour, shared by the rail and the grid. When there is more than one, the **Showing all** menu above the week lists each source with a checkbox, and your tasks are one of them; untick one to hide it. Arrows step a week back and forward, and **This week** returns. On a narrow screen the week becomes one day at a time. ## Edit and organize a schedule Open a schedule at `/schedule/view/`. Its **…** menu, also available beside its name in the schedule list, lets you: - choose **Edit schedule** to change the timetable, or **Link subjects** to associate lessons with subjects; - change the visible weekdays, or use **Rename…**; - choose **Make default** to list it first in the schedule list and on the dashboard. ## Share a schedule 1. Open the schedule's **…** menu and choose **Share…**. 2. Enter the other person's username or email, then **Share**. They receive read-only access. 3. Use the same dialog to see who has access and remove someone. They lose access the next time they load the schedule. For a schedule someone shared with you, **Remove from my schedules** removes it from your list. ## Archive or remove a schedule Use **Archive…** when a term ends. Lessons are kept, but the schedule leaves your active list and the merged week. Choose **Restore** from the archived list to bring it back; you may first need to rename another active schedule that uses its name. **Delete…** removes the schedule rather than archiving it. ## Follow it from a calendar app **Settings → Calendar feeds** (`/user/calendar`) creates a read-only subscription link for Apple Calendar, Google Calendar or Outlook. It carries your own active schedules and your unfinished dated tasks; connected calendars are not included, because you already have those where they came from. **Add to calendar app** on the merged week opens it, and the same item in a schedule's menu opens it with that schedule already picked, for a feed of that schedule alone. The link is shown once, so copy it then. Anyone holding it can read your schedule. Resetting it replaces the old address immediately, and every subscriber has to be pointed at the new one. --- # Notes and the editor > Create structured notes, connect related work, and see what links back to each note. Source: https://workroad.app/docs/notes Open `/notes` to browse your notes. You can place notes in folders and link each one to a subject. Open a note at `/note/` to edit it. ## Write and format The editor uses rich text blocks. Type `/` to open the slash menu, or drag a block by its handle to move it. Selecting text opens a formatting toolbar. You can add: - headings, paragraphs, and text alignment - bulleted lists, numbered lists, and task lists with checkboxes - tables - code blocks with syntax highlighting - inline and block maths rendered with KaTeX - images pasted or dropped into the editor - inline dates: type `@today`, `@tomorrow`, `@yesterday` or `@2026-10-03`, or pick a day with `/date`. Click a date to change it. Pasted and dropped images are uploaded and stored. The editor also shows a live word count and words-per-minute statistics. ### Maths Type `$x^2$` followed by a space for an inline formula, or use `/math` to insert a formula block with a live preview. See [Maths in notes](https://workroad.app/docs/note-maths) for LaTeX, chemistry, physics and equation numbering. ## Import files Use the import button beside **New folder**, or drop files or folders onto the notes list. WorkRoad turns supported files into notes. See [Importing notes](https://workroad.app/docs/importing-notes) for formats, scanned pages, exports from other apps and import limits. ## Link your work Type `@` to mention one of your notes, tasks, subjects, flashcard sets, recordings, or tests. The editor inserts a live chip for the item. If you rename the item, the chip updates. If you delete it, the chip shows a tombstone instead of pointing to missing content. Under each note, the backlinks panel lists everything that mentions that note. This gives you both directions of a connection: the mention where you inserted it and the backlink on the note it points to. ## Use notes elsewhere Notes can supply material to the copilot. When you build a test, you can also choose notes as sources alongside flashcard sets. A note's **Export** menu downloads it as Markdown, as Markdown with its files (ZIP), as HTML with its images, as a Word document or as a PDF. The Word document and the PDF keep the note's headings, lists, tables, code, images and formulas, each formula drawn as a picture. **Print** opens the note's print view instead, where your browser can also save it as a PDF. --- # Maths in notes > Write formulas, use chemistry and physics notation, and number equations. Source: https://workroad.app/docs/note-maths ## Maths Formulas are written in LaTeX and rendered by [KaTeX](https://katex.org/docs/supported). Every command KaTeX supports works, except those that add links, images or HTML, such as `\href` and `\includegraphics`, which show in red. At the start of a line, `$$x^2$$`, `$$$x^2$$$` or `\[x^2\]` makes a block. Inside a line of text, `$$x^2$$` and `\(x^2\)` make a formula in the line, and so does `$x^2$` once you type a space or punctuation after it. `/math` inserts a block through a dialog with a live preview and a symbol palette instead, and clicking a formula opens it again for editing. Right-click or long-press a formula to copy its LaTeX or a picture of it. In imported Markdown, `$...$` is a formula in the line. `$$...$$` is a block when it has a line to itself or sits between two `$$` lines, and a formula in the line anywhere else. Chemistry comes from [mhchem](https://mhchem.github.io/MathJax-mhchem/): `\ce{H2O}`, `\ce{CO2 + C -> 2CO}`, `\ce{SO4^2-}` and quantities such as `\pu{123 kJ//mol}`. Physics notation comes from part of the LaTeX `physics` package: - derivatives: `\dv{f}{x}`, `\dv[2]{y}{t}`, `\pdv{f}{x}`, `\pdv{f}{x}{y}`, and `\dd{x}` for the d at the end of an integral - brackets that grow with what they hold: `\abs{x}`, `\norm{v}`, `\qty(\frac{a}{b})`, `\qty[x]` and `\qty|x|`. `\abs*{x}` and `\norm*{v}` keep them at text size. - vectors: `\vb{F}` in bold, `\va{r}` with an arrow, `\vu{n}` with a hat, `\vdot`, `\cross`, `\grad`, `\divergence`, `\curl` and `\laplacian` - quantum mechanics: `\bra{\psi}`, `\ket{\phi}`, `\braket{\psi}{\phi}`, `\expval{H}`, `\mel{n}{H}{m}` and `\comm{A}{B}` - also `\eval{x^2}_0^1`, `\order{x^2}`, `\mqty(a & b \\ c & d)` for a matrix, and `\Tr`, `\rank`, `\erf` and `\Res` `\div` stays division, as in plain LaTeX. Your own commands work too: `\newcommand`, `\renewcommand`, `\def` and `\DeclareMathOperator`. A command defined in a formula applies to that formula and to every formula after it in the same note, as in a LaTeX document, so `\newcommand{\R}{\mathbb{R}}` near the top serves the whole note. Moving or deleting the definition changes the formulas below it. Commands never carry over to another note. A formula on a study set card or in a test question stands alone: a command it defines works only inside it. Formulas in `equation`, `align`, `gather` and `alignat` are numbered (1), (2), (3) down the note; the starred forms, such as `align*`, are not. Give a line `\label{name}`, and `\eqref{name}` in any formula shows its number, which follows when equations above it are added or removed. `\tag{A}` gives a line its own label instead of a number, and `\nonumber` leaves a line out. A label no formula has shows as `(??)`. Copying, exporting and the AI still see the LaTeX as you wrote it, `\label` and `\eqref` included. Not supported yet: `multline`, `\cancelto`, `siunitx` (`\SI`) and drawing packages such as TikZ. A command KaTeX doesn't know shows in red. --- # Importing notes > Import files and exports from other apps, check limits and find missing content. Source: https://workroad.app/docs/importing-notes ## Import files The import button beside **New folder** turns files into notes, one note per file. You can also drop files or whole folders onto the notes list. A dropped folder becomes a note folder with the same name, and any folders inside it become a prefix of each note's title, such as `Week 2 / slides`. WorkRoad reads PDF, Word (DOCX), PowerPoint (PPTX), Excel (XLSX), OpenDocument text, spreadsheet and presentation files, RTF, EPUB, Markdown, HTML, CSV, LaTeX, Jupyter notebooks, plain text, and source code. JPEG, PNG, WebP and GIF images each become a note that shows the image. Each file can be up to 50 MiB. Headings, lists, tables, code blocks and maths become the same blocks in the note, as far as the file records them. A PDF or slide deck with more than one page gets a `Page 3` or `Slide 3` heading before each page. Markdown is read as written; HTML inside it shows as text. ## Import limits An import brings in the first 200,000 characters of a file's text, or less if the text is mostly very short list items or styled spans, and **Import notes** says where it stopped. The rest stays readable in the original file, which stays attached to the first note. Text too long for one note is split into numbered notes, such as `Report (1/2)`, in the same folder, at headings or pages where it can. A table, list or code block too long for one note continues in the next, with a table's header row repeated. A single table row or list item too long for any note keeps its text as plain paragraphs. Images stored inside a Markdown or HTML file's text, as `data:` URLs, are left out and reported. ## Missing text or images When the importer knows it lost something, such as an image it could not read, a LaTeX command it does not know, or a notebook output it cannot show, the last note ends with an **Import notes** section that says what. Charts, drawings, embedded objects, and a web page's frames and videos are listed there too. The PDF and Office reader does not report everything else it drops, such as text boxes or a complex layout, so check the original for those. HEIC photos, Keynote, Pages and Numbers files, OpenDocument drawings (ODG), and the old `.doc`, `.ppt` and `.xls` formats can't be read. Export them to JPEG, PDF or the newer Office formats first. For scanned pages, unreadable words and partial scan failures, see [Scans, photos and handwriting](#scans-photos-and-handwriting). ## Scans, photos and handwriting With Plus, WorkRoad reads the words in images and scanned PDFs. An imported photo of notes, a whiteboard or a printed page becomes a note with the image at the top and its text, as headings, lists, tables and maths, underneath. In a PDF, the reader takes only the scanned pages, the ones with no text of their own, and puts each page's words under its `Page N` heading, or in the note itself for a one-page scan. It reads up to 50 scanned pages per file and lists the rest in **Import notes**. The reader writes `[illegible]` for a word it cannot make out. A page it cannot read at all, such as one whose scan is over 60 megapixels, gets a placeholder in the note and a line in **Import notes**. If a scan's pages cannot be read, the rest of the file still imports. Each read counts as one AI operation, and a file can be read at most 3 times. Files already attached to a note can be read too. A scanned PDF shows **Read the pages** in its file list, and a PDF with some scanned pages shows **Read N scanned pages**. An image shows **Read text**, then **Insert text** to put its words at the cursor. Without Plus, an imported image becomes a note that shows it, and a PDF with no text at all is refused with a message saying so. A PDF with some scanned pages imports the text it has and lists the scanned pages in **Import notes**. ## Notes from other apps An export from another note app imports with the same button and becomes a folder of notes. In Notion, Obsidian and Markdown files, links between notes become mentions, so each note's backlinks show where it is linked from. The images the notes show stay in them, each as an image file of its note, so they count toward your file storage like any upload. Once storage is full, the rest of the images are left out of their notes and counted in **Import notes**. The import's own note lists the new notes and keeps the export attached. An export can be up to 200 MiB and bring in up to 1,000 of its notes; a long one splits into numbered notes as any long file does. A zip may hold up to 5,000 files and 1 GiB unpacked. A zip with several folders at its top keeps each as a note folder of its own. - **Notion.** Open the page's **•••** menu, choose **Export**, pick **Markdown & CSV** and include subpages, then import the zip as it is. Page names lose the ID Notion adds to them, a subpage's title starts with its parent's, such as `Biology / Cells`, and each database becomes a note with its table, up to 500 rows. - **Obsidian.** Zip the vault folder and import the zip. `[[Note]]` and `[[Note|alias]]` links become mentions, `![[image.png]]` embeds become images, and `![[Note]]` embeds become links, since notes do not show other notes inside them. The importer omits front matter. A link to a note that is not in the vault stays as text and is counted in **Import notes**. - **Evernote.** Export a notebook as an ENEX file and import it. Each note keeps its title, its creation date and its images, and a checkbox comes through as ☑ or ☐ text. Other attachments are named in the note and stay in the original. Links between Evernote notes stay as they were and do not become mentions. - **OneNote.** In OneNote for Windows, choose **File**, then **Export**, and export the page or section as a Word document. Import the `.docx`. On a Mac or the web, print the page to PDF instead. - **Google Docs.** Choose **File**, then **Download**, then **Microsoft Word (.docx)**, and import that. **Web page (.html, zipped)** imports too: import the zip as it is, and its page becomes a note without its images. Any other zip imports like a dropped folder: each file WorkRoad reads becomes a note, and an image no note links to becomes a note that shows it. A document inside a zip brings its text but not its pictures, and a scan inside one is not read; import those files on their own to keep them. --- # Recording a lecture > Record audio from a note and follow the transcript as it arrives. Source: https://workroad.app/docs/recording Recordings belong to a note. Each recording counts as one [AI use](https://workroad.app/docs/plans), including its translation. ## Start a recording 1. Open a note and its **Recordings** page, then choose **Create recording**. 2. In the recording options, select **Microphone** for your microphone, **Tab audio** for audio playing in another tab, or both. 3. Choose **Start recording** and allow microphone access if asked. For tab audio, choose the tab playing the lecture and enable the browser's **Share tab audio** option. 4. Use **Pause** and **Resume** for a break. Choose **Finish and save** when you are done and wait for the recording to finish saving. ## While you record The saved transcript arrives about a minute at a time, so it can lag behind the speaker. **Live preview**, where supported, shows temporary microphone text sooner. It is not saved and switches off while tab audio is captured. Use the star button to mark an important moment for generated notes. Pausing saves the audio captured so far; silence can also end the current audio segment. A recording session can last up to four hours. ## Reloading and navigating You can move around WorkRoad while the recorder panel keeps running. Avoid reloading during a lecture: saved transcript segments return, but the latest unsaved audio may be lost. After a reload, share tab audio again. ## Review the recording After recording, the transcript stays anchored to the audio timeline. Click a transcript line to seek to that point. During playback, the line currently being spoken is highlighted. ## Turn the recording into notes After the recording finishes, open **Generators** on its note. Select the recording under **Recordings**, choose the generation options, then **Generate note**. WorkRoad writes notes from the selected material and shows progress while it works. A generation counts as one [AI use](https://workroad.app/docs/plans). ## Missing sound or transcript - If microphone access was refused, allow it in the browser's site permissions and start again. Watch the microphone level in the recording panel. - For tab audio, use Chrome, Edge or Opera, choose a tab that is playing sound and tick **Share tab audio**. If the share contains no audio, choose it again. **Add tab audio** adds it to a running recording. - If no text appears immediately, wait for the first minute to be transcribed. Missing live preview does not mean the saved transcription has stopped. - If saving ends with an assembly error, open the recording and choose **Retry assembly**. --- # Studying > Study flashcard sets, take tests, review a feed, and track your performance. Source: https://workroad.app/docs/studying Flashcard sets contain terms and definitions. Create one at `/set/new`, then find your sets under Activities. ## Study a set 1. Open [Review](https://workroad.app/review) from Activities. Choose a subject or set if you want to narrow the queue, then **Start review**. 2. Try to recall the answer before revealing it. 3. Grade your recall honestly. The next review time changes with your rating: - **Again**: you did not remember the answer. - **Hard**: you remembered it with difficulty. - **Good**: you remembered it without much effort. - **Easy**: the answer was immediate and easy. ### Choose a format - **Flashcards** gives you a classic pass over a set. - **Memory** uses pairs for terms that are hard to retain. - **Match** asks you to match terms with definitions. - **Guess the term** gives you a definition and hints, then asks you to choose the term. - **Sentence Scramble** asks you to rebuild a definition from its pieces. - **Mixed study** combines several sets in one sitting and uses a format each term can support. - **Tests** build an assessed attempt from your chosen material. ### Why nothing is due The queue depends on the next review times, your subject or set filters, and your daily new-card and review limits. Review shows how many cards are left today. Check the filters or open **Study settings** to change the limits. **Start cram** lets you practise without changing the review schedule or counting toward your daily goal. ## Take a test 1. Open [Tests](https://workroad.app/activities/tests), then [build a test](https://workroad.app/test/new). 2. Choose notes or sets as sources, question types, the number of questions and an optional time limit. Choose **Build test**. 3. When the test is ready, choose **Start test**. 4. Answer the questions and choose **Hand in**. You cannot change answers after handing in. After you finish, you receive marks and a review for each question. You can chat about answers you got wrong. WorkRoad stores your attempts. It also holds the timer and your progress on the server, so reloading during a test does not lose the attempt. AI marks short answers when you submit. On a test WorkRoad's AI wrote, that is part of the test's [AI use](https://workroad.app/docs/plans). ## Use the feed Opening the feed is free. When you reach the end of the deck, or choose **More cards**, WorkRoad's AI writes more cards from your notes, which counts as one [AI use](https://workroad.app/docs/plans). The feed at `/activities/feed` is a vertical deck made from your own content. Each card contains one quiz, flashcard, or fill-in-the-blank interaction and names its source. Your answers update the same review schedule used by the set activities. ## Check performance Open `/activities/performance` to see login and activity streaks, a review heatmap, and progression for each subject. ## Review scheduling Every study format feeds the same FSRS spaced-repetition schedule. What you see next depends on how you did. FSRS (Free Spaced Repetition Scheduler) is an open algorithm by [Open Spaced Repetition](https://github.com/open-spaced-repetition). WorkRoad runs it through the MIT-licensed [ts-fsrs](https://github.com/open-spaced-repetition/ts-fsrs) library. Its licence notice is in [THIRD_PARTY_NOTICES.txt](https://workroad.app/THIRD_PARTY_NOTICES.txt). --- # Study rooms > Work through your study sets with a tutor, by text or by voice, in a room that keeps its conversation. Source: https://workroad.app/docs/study-room A study room is a saved conversation about your own study sets. Open `/study-room` from Activities, pick your sets, and start one. Your rooms are listed beside it. ## Start a room 1. Open [Study rooms](https://workroad.app/study-room) from Activities and choose **Start a room**. 2. Pick a mode and up to eight of your study sets. 3. Choose **Open room**. The tutor uses the sets selected for this room. - **Tutor** answers questions about one activity or set — hints, explanations, the item you are stuck on. You can name the activity it should focus on, or leave it on any. - **Activity master** proposes a sequence of real activities from your sets, runs you through it, and ends with an assisted review. In a tutor room set to one activity and one set, the header carries a link straight into that activity with the room attached, so the card you are on and a running voice session follow you there. ## Text and voice Type your question in the message box and send it. Text tutoring works without joining voice and does not count as an [AI use](https://workroad.app/docs/plans). ## Start and stop voice 1. Choose the voice model before joining. 2. Choose **Join voice** and allow microphone access. Follow the captions beside the conversation. 3. Use **Mute** or **Unmute** as needed, then **End voice** to finish. Each voice session counts as one [AI use](https://workroad.app/docs/plans). Only one room can hold voice at a time: the panel links to that room and lets you end its session. To change models, end the current session and join again. ## Complete an activity plan 1. In **Activity master** mode, say what you want to practise and how much time you have. 2. Review the proposed activities, sets and card counts. Ask for changes, or choose **Confirm this plan**. Confirming locks the room's sources; start a new room to use different sets. 3. Open the activity from the plan panel and grade the required cards. Each card counts once. 4. Return to the room and choose **Next step** when the required count is complete. Finish the last step to create the assisted review. 5. Choose **Sit the assisted review**, submit your answers and wait for marking. Return to the plan and choose **Record assisted result** to close it. Assisted marks stay separate from unaided test scores. ## If the plan cannot continue - If too few cards are recorded, finish and grade the round, then try **Next step** again. - If the study material is no longer available, start a new room with available sets. - If the review is still being marked, wait before recording its result. If automatic marking fails, finish marking the answers before closing the plan. --- # Copilot > Ask questions grounded in your content and control which proposed actions run. Source: https://workroad.app/docs/copilot Open `/copilot` for the full copilot. Press **⌘J** anywhere in the app to open it with the current note, activity, or subject as context. ## Ask about your material Try a specific request, such as: - "Explain this paragraph using my biology notes." - "Make flashcards from yesterday's lecture." - "Help me plan revision around this week's deadlines." ## Work with threads WorkRoad saves your threads and groups them by subject. You can search, pin, rename, move, or delete them. Copilot searches your notes and cites the material it uses. Open the citations to check an answer against its sources. If it cannot find the material you need, mention the relevant item or provide more context. Type `@` in the composer to mention a note, task, flashcard set, subject, recording, or test. This pulls that specific item into the conversation. ## Review actions The copilot can propose actions. What happens next depends on the action: - Tasks, notes and folders can be created automatically, with undo, subject to action limits. A reply that used web research waits for confirmation instead. - Edits, deletions, timetable changes, study blocks and settings changes wait for **Confirm** on their card. Check the proposed values first. - Generating notes or flashcards also waits for confirmation; the card says when it counts as an AI use. Review generated cards before saving the set. - Text proposed for the open note can be checked with **Preview in note** and accepted in the editor. - Navigation is a link for you to open; Copilot does not follow it for you. ## Change settings Ask the copilot to change a setting, such as "turn off email for task reminders" or "set my daily goal to 30 cards". It reads your settings, then shows the change on a card: each field and its new value. Nothing changes until you confirm it. It can update profile and app preferences, notifications, review goals, task preferences and the dashboard, and delete a calendar feed. A reply that used web search cannot change settings. For a new calendar feed, webhooks or account and security changes, follow its link to Settings and complete the change there. ## Dictate a message 1. Select the microphone in the composer or command palette, allow microphone access and speak. 2. Select stop when you are done. Dictation also stops after one minute. 3. Edit the text added to your draft, then send it yourself. Nothing is sent automatically, and Copilot replies in text. In the command palette, Alt+M starts and stops dictation. In the command palette, dictated text searches like typed text. Search takes up to 200 characters, so for anything longer press Enter to ask Copilot, which takes up to 4,000. ### Dictation limits and retries Dictation doesn't use your monthly AI uses. It turns off once a free plan has used them, because you couldn't send the message. The free plan includes 5 dictations a day and paid plans 20. Cancelling before the audio is sent costs nothing. If sending fails, **Try again** sends the same dictation again without counting it twice. The audio is transcribed and not kept, and the text is only saved once you send it. ## Choose a tier The tier picker in the composer controls how much thinking a turn gets. Scholar is the default. Sage requires a paid plan. | Tier | What it is for | | ------- | --------------------------------------- | | Swift | Fast answers and everyday questions | | Scholar | Balanced thinking and speed | | Sage | Deep reasoning for the hardest material | The same tiers appear when AI work starts in chat, test building, or generation. Note generation offers Swift and Scholar only. --- # Plans and AI uses > What the free and paid plans include, and what counts as one AI use. Source: https://workroad.app/docs/plans WorkRoad has two plans: Free and WorkRoad Plus. You can see your plan in **Settings → Account**, and the AI uses and credits you have left in **Settings → Usage**. ## Free The free plan includes: - tasks, subjects and schedules - timetable import from a photo - study sets you make yourself, every study activity, and FSRS spaced review - 3 AI uses per calendar month, shared across every AI feature, lecture recording included - 100 requests per calendar month from connected AI apps, such as ChatGPT or Claude, and the API, with one connected app and one API key Unused AI uses and requests do not carry over. Both counts reset on the first day of each month (UTC). ## WorkRoad Plus WorkRoad Plus costs €9.99 a month or €79 a year, VAT included. In the US it costs $9.99 a month or $79 a year, plus sales tax. It includes everything in Free, plus unlimited AI. Fair use applies: up to 100 recordings per calendar month. After that, new recordings are refused until the next month unless you have credits. Other AI features keep working. WorkRoad Plus also has full API and AI app access: no monthly request count, up to 10 API keys and up to 20 connected apps. ## AI credits Credits are extra AI uses. Once the month's AI uses are spent, each credit pays for one more. On WorkRoad Plus, each credit pays for one recording past the fair-use limit. Your monthly uses are always spent first. You can buy credits on either plan in **Settings → Usage**: 10 credits for €2.99, 30 for €6.99 or 100 for €17.99, VAT included. Credits never expire. If an AI use that spent a credit fails, you get the credit back. ## What counts as one AI use Each of these counts as one AI use: - one recording, including its translation - one AI note - creating or refreshing one AI study set - processing one image or document - one Copilot turn - one study-room voice session - one practice test WorkRoad's AI writes, including AI grading of its answers - AI grading of short answers in a test WorkRoad's AI didn't write, each time you submit it - one batch of feed cards, made when you reach the end of the deck or ask for more - one hint, title, summary, or inline edit in the editor Typing to the tutor, opening the feed and the timetable photo import never count. An AI use is counted when the work starts. If it fails, it is not counted. If you stop it yourself, it still counts. --- # REST API > Create an API key, make your first request and find the guide for your integration. 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`. Each operation in the sidebar documents its inputs, response, required scopes and errors. For an agent connection through ChatGPT, Claude or another client, start with the [MCP server guide](https://workroad.app/docs/api/mcp). ## Create an API key Open **Settings → Developers → API keys** at `/user/developers/keys`. Give the key a name and select the scopes your integration needs. WorkRoad shows the secret once, immediately after creation, so copy it then. If you lose it, revoke the key and create another. Each operation page lists its required scopes. For example, reading notes needs `notes:view`, while creating a note needs `notes:create`. The **Full access** template selects every scope. A request outside the key's scopes returns 403 `forbidden`. See [keys and scopes](https://workroad.app/docs/api/conventions#keys-and-scopes) for the shared rules. ## Make your first request Set your key in the environment and request the user who owns it. This operation requires no additional scopes: ```bash export WORKROAD_API_KEY=wr_live_... curl https://workroad.app/api/v1/me \ -H "Authorization: Bearer $WORKROAD_API_KEY" ``` The operation pages' cURL examples use the same environment variable, so you can run them after setting it. Other language tabs contain `$WORKROAD_API_KEY` as literal text; replace it or read the environment variable in your code. To try an integration without changing your data, use `https://workroad.app/api/test/v1`. Test mode validates authentication, scopes and input, then returns the operation's fixed example response. It shares live mode's per-key rate limits but uses no monthly requests. See [test mode](https://workroad.app/docs/api/conventions#test-mode) for playground key storage and other details. ## Build your integration Choose the guide for the work you need to do: - [API conventions](https://workroad.app/docs/api/conventions): authentication, pagination, input validation, error responses and safe retries. - [Notes and files](https://workroad.app/docs/api/notes-and-files): writing Markdown, handling conversion losses, organizing folders, uploading files and reading recordings. - [Tasks and schedules](https://workroad.app/docs/api/tasks-and-schedules): task dates and recurrence, subjects, timetables, search and settings. - [Study sets and practice](https://workroad.app/docs/api/study-and-practice): cards, reviews, practice sessions and tests. - [Copilot and generations](https://workroad.app/docs/api/copilot-and-generations): saving content your agent writes, applying copilot actions and following background jobs. - [Webhooks](https://workroad.app/docs/api/webhooks): receiving events, verifying signatures and recovering failed deliveries. 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/copilot-and-generations#let-your-ai-do-the-writing) for the operations to use. ## Check limits and compatibility 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. See [versioning](https://workroad.app/docs/api/conventions#versioning) for the compatibility policy, or the [October 2026 migration notes](https://workroad.app/docs/api/v1-migration) if your integration used the earlier v1. --- # MCP server > Connect WorkRoad to ChatGPT or Claude, check the connection and manage its access. Source: https://workroad.app/docs/api/mcp WorkRoad runs an MCP server at `https://workroad.app/api/mcp`. Add it to ChatGPT or Claude and the assistant can read your notes, make flashcards, quiz you with practice that counts toward your progress, and plan your week, all inside your WorkRoad account. You sign in to WorkRoad and approve the connection once. You don't need an API key, a terminal or anything installed. ## Add WorkRoad to ChatGPT Custom apps work in ChatGPT on the web with a paid plan (Plus, Pro, Business, Enterprise or Edu). On a Business or Enterprise workspace, an admin may have to allow custom apps first. ChatGPT moves these menus from time to time, so the names may differ slightly. 1. Open ChatGPT's [Plugins](https://chatgpt.com/plugins) page, choose **+** and then **Add custom MCP server**. If it isn't there, turn on **Developer mode** in **Settings → Security and login** first. 2. Name it **WorkRoad**, and under **Connection** paste `https://workroad.app/api/mcp` as the server URL. If it asks how to authenticate, choose **OAuth**. 3. Choose **I understand and want to continue**, then **Create as a plugin**, and install it. 4. ChatGPT opens WorkRoad. Sign in if you aren't already, read what ChatGPT will be able to do, and choose **Allow access**. You land back in ChatGPT, connected. 5. In a new chat, type `@` and pick **WorkRoad**. Then ask, for example, "What's due in WorkRoad this week?" ## Add WorkRoad to Claude This works on claude.ai, the Claude desktop app and the Claude mobile apps. The Free plan can add one custom connector; Pro and Max can add more. On a Team or Enterprise plan, an Owner adds the connector under **Organization settings → Connectors** and you then connect it yourself. 1. On claude.ai, go to **Customize → Connectors** (in older versions, **Settings → Connectors**). 2. Click **Add custom connector**. 3. Name it **WorkRoad** and paste `https://workroad.app/api/mcp` as the server URL. If Claude asks how to sign in, choose **Sign in now**, and for the OAuth client choose **Register automatically**. Click **Add**. 4. Click **Connect**. Claude opens WorkRoad: sign in if you aren't already, read what Claude will be able to do, and choose **Allow access**. 5. In a chat, click **+**, open **Connectors** and turn **WorkRoad** on. Then ask, for example, "Quiz me on my biology flashcards." Claude asks before it uses a tool. Choose **Always allow** only for things you're happy for it to do without asking, such as reading your notes. ## Check the connection Start a new chat, enable WorkRoad and ask, "What is due in WorkRoad this week?" The assistant should use WorkRoad to answer. In ChatGPT, select WorkRoad with `@`; in Claude, enable it under **+ → Connectors**. ## Connection help - If the custom app or connector option is missing, check the plan and workspace requirements in the setup steps above. A workspace admin may need to enable it. - If WorkRoad is connected but no tools appear, enable it in the current chat. After changing permissions, reconnect so the client refreshes its tool list. - If an action is refused, check the permissions you approved. Deleting data and spending the AI allowance need separate approval. - If access has ended, connect again and approve it in WorkRoad. Changing your password disconnects all apps. ## What a connected app can do The approval screen lists what the app will be able to do, in plain words: read your notes and files; see your flashcards, practice tests, progress, tasks and timetable; write notes and flashcards; run practice that counts toward your progress; plan your week; use AI features from your AI allowance; change your settings; and delete things. Each line stands for a set of [API scopes](https://workroad.app/docs/api/conventions#keys-and-scopes): a connected app gets every scope except `webhooks:manage`, and the tools it sees are the same ones an API key with those scopes sees. Its calls count toward the same [rate limits](https://workroad.app/docs/api/limits#rate-limits) and AI allowance. Deleting things and using your AI allowance stay off unless you tick them there. The screen names ChatGPT or Claude only when WorkRoad has checked that your approval goes to them. If it says the app is unverified, or the address it shows isn't the app you're using, choose **Deny**. Everything the app does, it does as you. It can't see other people's data or change your password, sign-in methods or plan. ## Costs and AI uses On the free plan, you can connect one app at a time, and your connected apps and API keys share 100 requests a month. Each tool call that does something in WorkRoad, such as reading a note or saving a set, is one request. Listing the tools and reading these docs don't count. WorkRoad Plus has full access, with no monthly count and up to 20 connected apps. **Settings → Usage** shows how many requests are left, and [Monthly requests on the free plan](https://workroad.app/docs/api/limits#monthly-requests-on-the-free-plan) has the details. Your assistant can write notes, cards and tests itself and save them without spending WorkRoad AI uses. WorkRoad AI features use your allowance. Grading short answers in a test your assistant wrote costs one AI use per submit; grading is included when WorkRoad wrote the test. See [agent workflows](https://workroad.app/docs/api/mcp-workflows#let-your-ai-do-the-writing) for examples. ## Disconnect an app To stop an app, open **Settings → Connected apps** and choose **Disconnect**. It loses access straight away. Connecting and disconnecting both show in your security activity under **Settings → Security**. Changing your password disconnects every app, and WorkRoad emails you whenever a new one connects. ## Coding tools and developer guides For Claude Code, Codex, Cursor, VS Code or another client, use [MCP client setup](https://workroad.app/docs/api/mcp-clients). See [agent workflows](https://workroad.app/docs/api/mcp-workflows) for prompts and study sessions, or the [protocol reference](https://workroad.app/docs/api/mcp-reference) to implement a client. --- # MCP client setup > Connect coding tools and other MCP clients with OAuth or an API key. Source: https://workroad.app/docs/api/mcp-clients The server turns each REST API operation into a tool. A tool call uses the same scopes, rate limits, input validation and idempotency rules as its REST operation. The [MCP setup page](https://workroad.app/user/developers/mcp) has client configurations, a permission-filtered tool list and a connection test. ## Add WorkRoad to a coding tool Claude Code, Codex, Cursor and VS Code connect without an API key too: add the address, then sign in to WorkRoad when the tool asks. - **Claude Code.** Run `claude mcp add --transport http workroad --scope user https://workroad.app/api/mcp`, then run `/mcp` in Claude Code to sign in. - **Codex.** Run `codex mcp add workroad --url https://workroad.app/api/mcp`. Codex opens WorkRoad for you to sign in. `codex mcp login workroad` signs in again later. - **Cursor.** Add `"workroad": { "url": "https://workroad.app/api/mcp" }` under `mcpServers` in `~/.cursor/mcp.json`, then sign in to WorkRoad when Cursor asks. - **VS Code.** Run `code --add-mcp '{"name":"workroad","type":"http","url":"https://workroad.app/api/mcp"}'`. VS Code asks you to sign in when it starts the server. To give an agent a fixed key instead, see [Connect a client with an API key](#connect-a-client-with-an-api-key). ## Connect a client with an API key Clients that support OAuth, such as Claude Code, can connect the way ChatGPT and Claude do: add the URL and sign in when asked. To give an agent a fixed key instead, create an API key in **Settings → Developers → API keys**. The **Full access** template gives the agent every tool: it can create subjects, timetables, tasks, notes and sets, practise flashcards, take tests and run AI features. Pick a narrower template or tick scopes yourself to limit it. Put the key in an environment variable once, for example in your shell profile, so no config file holds the key itself: ```bash export WORKROAD_API_KEY=wr_live_... ``` **Claude Code.** Add this to `.mcp.json` in your project. Claude Code expands `${WORKROAD_API_KEY}` from its environment when it uses the server: ```json { "mcpServers": { "workroad": { "type": "http", "url": "https://workroad.app/api/mcp", "headers": { "Authorization": "Bearer ${WORKROAD_API_KEY}" } } } } ``` Or register it from the command line. `claude mcp add-json` takes the server object itself, without the `mcpServers` wrapper. Keep the single quotes so your shell leaves `${WORKROAD_API_KEY}` alone: Claude Code then stores the reference and resolves it when it connects, so the key is not written to its config. ```bash claude mcp add-json workroad '{"type":"http","url":"https://workroad.app/api/mcp","headers":{"Authorization":"Bearer ${WORKROAD_API_KEY}"}}' ``` **Codex.** Run this, or add the table below to `~/.codex/config.toml`. Codex reads the variable named in `bearer_token_env_var` and sends it as the bearer token: ```bash codex mcp add workroad --url https://workroad.app/api/mcp --bearer-token-env-var WORKROAD_API_KEY ``` ```toml [mcp_servers.workroad] url = "https://workroad.app/api/mcp" bearer_token_env_var = "WORKROAD_API_KEY" ``` **Cursor.** Add this to `.cursor/mcp.json` in your project or `~/.cursor/mcp.json` for every project. Cursor reads the variable from the environment it was started in, so launch it from a shell where the variable is set: ```json { "mcpServers": { "workroad": { "url": "https://workroad.app/api/mcp", "headers": { "Authorization": "Bearer ${env:WORKROAD_API_KEY}" } } } } ``` Use a key with only the scopes the agent needs, and revoke it in Settings if it leaks. In Claude Code, run `/mcp` to check the connection. `workroad` should show as connected, with its tools listed. A 401 means the key is missing, wrong or revoked, or the variable was not set when the client started. Other MCP clients connect the same way: point them at `/api/mcp` and send the key as `Authorization: Bearer `, or let them sign in with OAuth as described in the [protocol reference](https://workroad.app/docs/api/mcp-reference#authentication-and-protocol-errors). ## Test mode `https://workroad.app/api/test/mcp` has the same tools, but each one returns the fixed example response from its operation page instead of running, as [test mode](https://workroad.app/docs/api/conventions#test-mode) does for REST. Register it under a second name, such as `workroad-test`, to try an agent without touching your data. In Claude Code: ```bash claude mcp add-json workroad-test '{"type":"http","url":"https://workroad.app/api/test/mcp","headers":{"Authorization":"Bearer ${WORKROAD_API_KEY}"}}' ``` For Codex and Cursor, use the snippets above with the `/api/test/mcp` URL. ## What your AI can change With the matching scopes, an assistant can read and change: - **Profile:** name, username, bio, status, location, and what your public profile shows. - **Language, time zone and privacy:** the app language, your time zone (tasks keep their due date and time on the clock), and whether you appear on leaderboards. - **Notifications:** quiet hours, the email digest, push content, calendar alarms, and each kind's bell, email and push switches and timing. It can turn email back on after a bounce. - **Review settings:** new cards and reviews a day, desired retention, the daily goal, leeches and per-subject overrides. - **Task preferences and dashboard:** task types, due-date colours, and which dashboard cards show, in what order and size. - **Calendar feeds:** list and delete feeds. You create one yourself in **Settings → Calendar feeds**: an address keeps working after the assistant's access ends. - **Webhooks:** list, pause, re-enable, test and delete them, only with an API key that has `webhooks:manage`. Connected apps never get it. You add a webhook or change its URL yourself, in **Settings → Developers → Webhooks**. - Your subjects, timetables, tasks, notes and study sets. The built-in Copilot changes the same settings through the same operations, after you confirm each change on its card. It can't add a webhook or change where one sends, and it can't change your password, email address, two-factor, passkeys, sign-in methods or sessions, API keys or connected apps, plan or payment, or delete your account. Your theme and AI model choice live in your browser, and browser push, profile pictures and Google Calendar or university connections need you in the app. ## Which tools appear The key's scopes decide the tool list. Each tool is named after its operation's id, such as `listTasks` or `createNote`, and appears only when the key's scopes allow that operation. A key with only `tasks:view` sees `listTasks`, `getTask`, `listTaskTypes`, `listSubjects` and `getSubject`, plus `getMe`, `getAiAllowance`, `searchDocs` and `getDoc`, which any key may call. Each operation page lists its required scopes; see [keys and scopes](https://workroad.app/docs/api/conventions#keys-and-scopes). The server builds the list when the client asks for it. After you change a key's scopes, reconnect (in Claude Code, `/mcp`) so the client fetches the new list. Tool calls count against the key's REST [rate limits](https://workroad.app/docs/api/limits#rate-limits): a read tool uses the read bucket and a write tool the write bucket. --- # MCP agent workflows > Write study material, use prompts, practise cards and follow background work. Source: https://workroad.app/docs/api/mcp-workflows ## Let your AI do the writing Your AI can write notes, flashcards, practice tests, task lists and timetables itself and save them to WorkRoad. Ask it, for example, "Make flashcards from yesterday's OS lecture": it reads the lecture's transcript, writes the cards and saves them as a study set. When your AI writes the content itself, it uses none of your WorkRoad AI uses. Only asking WorkRoad's AI to write something, or using Copilot, counts. WorkRoad asks connected AIs to work this way, and the tools that run WorkRoad's AI say that they use an AI use, so your AI calls them only when you ask for WorkRoad's AI. The exception is grading: WorkRoad's AI grades the short answers in a practice test when you submit it. In a test your AI wrote, that counts one AI use per submit; in a test WorkRoad's AI wrote, it is included. In Claude Code, `/mcp__workroad__make-cards ` does it in one step. For developers, [Let your AI do the writing](https://workroad.app/docs/api/copilot-and-generations#let-your-ai-do-the-writing) lists the operations. ## Prompts The server also offers prompts. Claude Code lists them as slash commands: | Prompt | Slash command in Claude Code | What it does | | ------------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `study-now` | `/mcp__workroad__study-now` | Checks what is due, then quizzes you card by card and records each answer as a review. | | `quiz-me` | `/mcp__workroad__quiz-me ` | Finds the sets for a topic with `search` and quizzes you on them. | | `make-cards` | `/mcp__workroad__make-cards ` | Reads a note or lecture and writes flashcards from it itself, saved as a study set, with none of your AI uses. | | `plan-week` | `/mcp__workroad__plan-week` | Reads deadlines, lessons and planned blocks, proposes study blocks and creates them once you agree. | ## Read the docs from an agent An agent can read these docs to answer questions about WorkRoad itself: how a feature works, what a plan includes, or how to call the API. The server's instructions tell it to look the answer up rather than guess. - `searchDocs` finds the pages that match a few words, with the passages that matched, and `getDoc` returns a page as Markdown. Both take a `locale` for a translation, and any key or connected app may call them. - The server also lists every page as a resource, at the page's Markdown address, so a client such as Claude Code can attach one to a conversation. - Without the MCP server, add `.md` to a docs address for the page as Markdown, as in `https://workroad.app/docs/api/mcp.md`, or ask for the page with `Accept: text/markdown`. Add `?lang=de`, `es`, `fr` or `it` for a translation. - [`/llms.txt`](https://workroad.app/llms.txt) lists every page by section, and [`/llms-full.txt`](https://workroad.app/llms-full.txt) has them all in one file. - On each docs page, **Copy page** copies it as Markdown, and **Open** opens it in ChatGPT or Claude. ## Practising from an agent A practice session records real reviews, the same as the app's Review page: 1. `getToday` returns what is due, and a `practice` suggestion with the arguments for the next call. 2. `startPracticeSession`, optionally with `subjectId` or `setIds`, returns the first cards. 3. For each card the agent shows the term, waits for the answer, reveals the definition and calls `answerPracticeCard` with the card's `itemId`, a `rating` (`again`, `hard`, `good` or `easy`) and `durationMs`. 4. When `remaining` reaches 0, `getPracticeSession` refills the session. `finished: true` means nothing is due now. `undoPracticeAnswer` takes back the newest answer. [Practice sessions](https://workroad.app/docs/api/study-and-practice#practice-sessions) has the details. ## Long-running work Tools that start AI work or parse a file, such as `generateNoteFromTopic`, `sendCopilotMessage` or `completeNoteFile`, return a `jobId` straight away. Poll `getJob` with that id as `jobId` until `status` is `succeeded`, `failed` or `cancelled`, waiting the `retryAfter` seconds from the text block between calls. [Jobs](https://workroad.app/docs/api/copilot-and-generations#jobs) describes each kind's `result`. The webhook tools appear only for an API key with `webhooks:manage`, which a connected app never gets. Creating a webhook, changing its URL and rotating its secret are not tools: a webhook sends events to the URL it was given for as long as it exists, and an agent steered by text it read could point it at someone else's server. Do those in the app or over REST. --- # MCP protocol reference > Transport, tool arguments, responses, OAuth and protocol errors. Source: https://workroad.app/docs/api/mcp-reference ## Transport and request limits The server speaks MCP's Streamable HTTP transport without sessions. Every call is a single POST that answers with JSON, so there is no event stream to hold open. Send `Accept: application/json, text/event-stream` on POST requests. The MCP SDK requires both values even though this server returns JSON. `Accept: */*` returns a JSON 406 error. Every authenticated MCP POST, including `initialize`, `tools/list` and `ping`, counts toward a separate 120-request per minute window per key or connected app. Tool calls also use the REST read or write window. Exceeding the request window returns HTTP 429 with `Retry-After`. Tools that run WorkRoad's AI also count toward the user's [AI rate limit](https://workroad.app/docs/api/limits#ai-rate-limits) and 10 calls a minute per key or app. On the free plan, each tool call that runs an operation also takes one of the account's [monthly requests](https://workroad.app/docs/api/limits#monthly-requests-on-the-free-plan); protocol messages such as `initialize` and `tools/list` don't. ## What the server tells the agent On `initialize` the server sends `instructions`: what WorkRoad's records are, where to start (`getToday`, `search`), where to look up how WorkRoad works (`searchDocs`, `getDoc`), the flashcard practice loop, the practice test, planning and course-building flows, and the rules for ids, paging, errors and the AI allowance. They also tell it to write notes, cards, tests and tasks itself and save them with the plain tools, and that a task's type is the kind of work while its subject is the course. Clients such as Claude Code give these to the model before its first tool call. Each tool's description holds the operation's summary and description, then, where it helps, the tool to call next, how to page, the errors the agent can act on, and the required scopes. Its annotations say what it does to the user's data: `readOnlyHint` for reads, `destructiveHint` for writes that can't be taken back (deleting a set or a file, replacing a note's content, undoing a review), `idempotentHint` for calls that change nothing more when repeated with the same arguments (answering a practice card, grading a review, applying a generation), and `openWorldHint` for tools that read a connected external calendar. Moving a note to the trash is not destructive, since `restoreNote` brings it back. ## Arguments and results A tool's arguments are the operation's path parameters, query parameters and body fields, all at the top level. `getTask` takes `{ "taskId": "..." }`, and `updateTask` takes `taskId` next to the fields to change. Its input schema has every field's type and limits. Write tools that create something (the POST operations) also take an optional `idempotencyKey`. It works like the REST `Idempotency-Key` header: retry with the same key and arguments and you get the first result back, without creating anything twice. See [Idempotency](https://workroad.app/docs/api/conventions#idempotency). A successful call returns one text block containing the HTTP status, optional retry timing and REST response body, as `{ "status": 201, "body": { "data": { ... } } }`. The server omits `structuredContent`, so clients that pass only text to the model still receive the full body. The status matters where an operation answers two ways, such as `createReview`, which returns 200 instead of 201 for a review it already recorded. A call the API refuses comes back as a tool error, not a protocol error, so the agent can read it and correct itself. The text block holds the status and the REST error envelope: ```json { "status": 403, "error": { "code": "forbidden", "message": "This API key does not grant this operation." } } ``` Invalid arguments fail the way the matching REST request would. A bad body field returns 400 `invalid_request` with the failing fields in `details`, a bad query argument such as a `limit` of 500 returns 400 `invalid_query`, and a malformed path id returns 404 `not_found`. The [error codes](https://workroad.app/docs/api/conventions#errors) are the same as for REST. When the REST response would carry a `Retry-After` header, the text block adds `retryAfter` in seconds, as in `{ "status": 429, "retryAfter": 3, "error": { ... } }`. Wait that long before calling again. Successful calls carry it too: `getJob` on a `pending` or `running` job returns `{ "status": 200, "retryAfter": 5, "body": { "data": { ... } } }`. ## Authentication and protocol errors `/api/mcp` takes two kinds of credential in `Authorization: Bearer `: an API key, or an OAuth access token from a connected app. `/api/test/mcp` and the REST API take API keys only. A request without a valid credential gets a JSON 401 with a Bearer challenge that names the resource metadata and the scopes to ask for, which leave out deleting and the AI allowance: ```http WWW-Authenticate: Bearer realm="workroad-api", error="invalid_token", resource_metadata="https://workroad.app/.well-known/oauth-protected-resource/api/mcp", scope="tasks:view tasks:create …" ``` `error="invalid_token"` appears only when a credential was sent. An MCP client that supports OAuth follows the challenge: | Step | Where | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | Protected resource metadata (RFC 9728) | `/.well-known/oauth-protected-resource/api/mcp` (also at `/.well-known/oauth-protected-resource`) | | Authorization server metadata (RFC 8414), issuer `https://workroad.app` | `/.well-known/oauth-authorization-server` | | Dynamic client registration (RFC 7591) | `POST /api/oauth/register` | | Authorization, with the user's consent | `/api/oauth/authorize`, then the consent page `/oauth/authorize` | | Token (`authorization_code`, `refresh_token`) | `POST /api/oauth/token` | | Revocation (RFC 7009) | `POST /api/oauth/revoke` | The authorization server follows OAuth 2.1: - PKCE with `S256` is required; `plain` and requests without a challenge are refused. - Redirect URIs are matched exactly, except that a loopback `http://127.0.0.1` or `http://localhost` redirect may use any port. Registered redirects must use `https` or `http` on loopback, at most 5 of them, each up to 512 characters. The one exception is Cursor's documented `cursor://anysphere.cursor-mcp/oauth/callback`. - Clients register as public (`token_endpoint_auth_method: none`) or with a secret (`client_secret_post` or `client_secret_basic`). A registration that leaves the method out gets `none`, not RFC 7591's `client_secret_basic` default, and the response says so. A client that hasn't been approved by anyone within an hour is deleted. A public registration for ChatGPT's or Claude's shared callback with the same redirects as one in the last 12 hours gets that client back, and such a shared client is kept for a day. - Access tokens last an hour. Refresh tokens last 30 days and rotate: each refresh returns a new pair. The same client presenting a spent refresh token or authorization code again within 30 seconds (for a code, with the same verifier) gets the same response back and nothing new is issued, so a retried or raced request doesn't disconnect it. Any later use ends the connection and revokes every token it issued. - Tokens are issued for the resource `https://workroad.app/api/mcp` only, and a `resource` parameter (RFC 8707) naming anything else is refused. - Scopes are the [API-key scopes](https://workroad.app/docs/api/conventions#keys-and-scopes) except `webhooks:manage`. Without a `scope` parameter the app asks for all of them. `offline_access`, `openid`, `profile` and `email` are accepted and ignored: every authorization gets a refresh token, and nothing here is OpenID Connect. A token keeps the scopes it was issued with: approving more later gives new tokens more, not the ones already out. Approving again only adds: the new tokens get everything the connection holds. - The authorization response carries `iss` (RFC 9207). - An app may only call itself ChatGPT, OpenAI, Claude, Anthropic or WorkRoad when its redirects are that product's own callbacks or only loopback, however the name is spelled. An unverified app is shown by the host its access goes to; its name, kept only if it is plain ASCII after NFKC normalization, appears second as what it calls itself. - Approval grants what the app asked for except the `*:delete` scopes and the ones that spend the AI allowance (`copilot:use`, `generation:start`, `tests:generate`, `tests:attempt`): the user ticks those on the consent screen. A `tools/call` the token's scopes don't cover returns HTTP 403 with `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"` naming the scopes the token holds plus the ones it lacks, so the client can ask again without losing any. An approval that would leave the app with no scopes at all is refused. - A user can connect at most 20 apps on WorkRoad Plus and 1 on the free plan. Reconnecting an app that is already connected doesn't add one, and apps connected beyond the cap stay connected. Changing or resetting the password disconnects all of them, and connecting one sends the account an email. Every authorization shows the consent screen to a signed-in user. It names ChatGPT or Claude only when the redirect is that product's own documented callback; any other app appears as unverified, with the address its access goes to. Admins impersonating a user can't approve one, and an account with a second factor confirms it first if it hasn't recently. A token's scopes are checked against the connection on every call, so disconnecting in **Settings → Connected apps** takes effect on the next request. A tools/call request must supply an object for `arguments`, or omit it for an empty object. Explicit null, arrays and scalar values return JSON-RPC -32602 with a readable message. Valid argument objects with invalid operation fields retain the 400 tool-error behavior described above. ## Uploading files The agent uploads a file itself; the MCP server never handles file contents. `createNoteFile` returns the file's `id`, a signed `upload.url` and the `upload.fields`. The agent then POSTs the file to `upload.url` as `multipart/form-data`, with every field from `upload.fields` in order and the file last as `file`, and calls `completeNoteFile` inside the same 10 minutes. [Uploading files](https://workroad.app/docs/api/notes-and-files#uploading-files) has the details and limits. --- # 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 ``` 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). --- # Limits and allowances > Monthly requests, AI uses and rate limits for REST and MCP clients. Source: https://workroad.app/docs/api/limits ## Monthly requests on the free plan On the free plan, the REST API and the MCP server allow 100 requests per UTC calendar month. The count belongs to the user, so every API key and connected app they have takes from the same 100, over REST and MCP alike. WorkRoad Plus and admin accounts have no monthly count, and their responses carry none of the headers below. Each REST operation and each MCP `tools/call` that runs an operation is one request. A call counts once it gets past the key check, its scopes, the per-key [rate limits](#rate-limits), the `Idempotency-Key` check and input validation, and its operation starts. From then on it counts whatever the operation answers, including a 404, a 409, a 402 `ai_limit_reached`, a 429 from the user's [AI rate limit](#ai-rate-limits) or a 500, and the request is not given back. A call refused before that counts nothing: a 401 or 403, a 429 from the per-key rate limits, an `Idempotency-Key` error, a 400 for invalid input or a 404 for a malformed path id. These never count: - MCP protocol messages: `initialize`, `tools/list`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read` and `ping`. - The docs operations, `GET /docs/search` and `GET /docs/page`, and their MCP tools `searchDocs` and `getDoc`. - [Test mode](https://workroad.app/docs/api/conventions#test-mode), at `/api/test/v1` and `/api/test/mcp`. - An [idempotent replay](https://workroad.app/docs/api/conventions#idempotency), a response with `Idempotent-Replayed: true`. A replay is still served after the month's requests are spent. Once the month's requests are spent, a call that would count returns 402 `api_allowance_reached` and runs nothing. `details` has the `limit`, the requests `used`, `resetsAt`, the start of next month (UTC) as an ISO timestamp, and `upgradeUrl`, the page where the user upgrades: ```json { "error": { "code": "api_allowance_reached", "message": "This month's 100 free API and AI app requests are used up. They reset on 2026-11-01 (UTC). WorkRoad Plus gives full API and AI app access: https://workroad.app/user/account/plan", "details": { "limit": 100, "used": 100, "resetsAt": "2026-11-01T00:00:00.000Z", "upgradeUrl": "https://workroad.app/user/account/plan" } } } ``` Over MCP the tool call returns a tool error holding the same envelope, so ChatGPT or Claude shows the user the message. Every counted call of a free account, and every 402 `api_allowance_reached`, carries the month's state: ```http X-WorkRoad-Allowance-Limit: 100 X-WorkRoad-Allowance-Remaining: 63 X-WorkRoad-Allowance-Reset: 1793491200 ``` `X-WorkRoad-Allowance-Remaining` counts the requests left this month, this one already taken. `X-WorkRoad-Allowance-Reset` is the start of next month (UTC) as a Unix timestamp in seconds, the same convention as `X-RateLimit-Reset`. Over MCP the headers are on the HTTP response to the `tools/call`. Upgrading to WorkRoad Plus lifts the limit from the next call. When Plus ends, the limit applies again from the next call. Only calls made on the free plan are counted, so a plan that lapses mid-month usually starts with none used. If WorkRoad can't reach the counter, calls run uncounted. A free account can add 1 API key and connect 1 app; WorkRoad Plus allows 10 keys and 20 apps. Keys and connections made earlier, or while the account had Plus, keep working and take from the same 100 requests. Nothing is revoked. Regenerating a key doesn't add one, and reconnecting an app that is already connected isn't a new connection. **Settings → Usage** (`/user/usage`) and the API keys and Connected apps pages under **Settings → Developers** show the month's count. ## AI allowance Every operation that runs AI counts as one AI use: a copilot message, a generation, a practice test WorkRoad's AI writes (its grading included), or AI grading of the short answers in a `POST /tests/from-questions` test, once per submit. Extracting a document's text is not an AI use, and neither is saving content an agent wrote itself (see [Let your AI do the writing](https://workroad.app/docs/api/copilot-and-generations#let-your-ai-do-the-writing)). The free plan allows 3 AI uses per UTC calendar month. The paid plan has no count limit, only a fair-use limit on recordings. `GET /ai-allowance` returns the plan, this month's uses that the allowance paid for (`used`) and those credits paid for (`creditUses`), the `limit` (`null` on the paid plan) and `resetsAt`, the start of next month. A use holds its slot from the moment the operation starts, so work still running counts. A job that fails or ends without producing anything gives its slot back. A job you stop with `POST /jobs/{jobId}/cancel` still counts, because the AI work up to the stop already ran. When the month's uses are gone, an operation that needs one returns 402 `ai_limit_reached` and starts nothing. A refused practice test is not created either. `details.code` is `ai-limit-reached` for the free plan's count or `fair-use-limit` for the paid plan's recording limit, `details.limit` is the limit that was reached, and `details.resetsAt` is when it resets. Submitting a practice test is the exception: a test WorkRoad's AI wrote is still graded, and one saved with `POST /tests/from-questions` goes through and leaves the short answers for the user to mark, as [Practice tests](https://workroad.app/docs/api/study-and-practice#practice-tests) describes. A copilot message refused with 402 is not saved to its conversation and uses none of the free plan's 10 turns a day, so retrying with new request ids adds nothing to the thread. Once a use is available, sending the same `requestId` again sends the message. When the month's uses and the day's turns are both spent, the copilot returns 402, not 429: the month's limit binds, and `details.resetsAt` says when it lifts. ## Rate limits You can have 1 enabled, unexpired public API key on the free plan and up to 10 on WorkRoad Plus. Calendar-feed keys do not count toward this cap. Regenerating a key reuses its slot and immediately revokes the previous key, so it works even on a key held beyond the cap. If regeneration fails, the previous key remains valid. Keys held beyond the cap, such as ones made before it or while the account had Plus, keep working, but you must get below the cap before creating another. New credentials start with `wr_live_`; existing unprefixed keys continue to work. Each key gets 120 reads (`GET` and `HEAD`) and 30 writes (`POST`, `PUT`, `PATCH` and `DELETE`) per 60-second fixed window. REST and MCP calls share these buckets. Requests do not extend the window, so steady clients can continue when it resets without an idle pause. Going over either limit returns 429 `rate_limited` with a `Retry-After` header in seconds. On the free plan, calls also take from the [monthly requests](#monthly-requests-on-the-free-plan). Responses that get past the scope check carry the state of the bucket they used: ```http RateLimit-Policy: "rest:v1.read";q=120;w=60 RateLimit: "rest:v1.read";r=117;t=42 X-RateLimit-Limit: 120 X-RateLimit-Remaining: 117 X-RateLimit-Reset: 1790000000 ``` `r` and `X-RateLimit-Remaining` count the requests left in the window. `t` is the number of seconds until the window resets, and `X-RateLimit-Reset` is the same moment as a Unix timestamp. The bucket is `rest:v1.read` for reads and `rest:v1.write` for writes. 401, 403 and 405 responses have no rate-limit headers, and neither does a 404 for a path with no endpoint, because the API rejects those requests before it counts them. ### AI rate limits Starting WorkRoad's AI is also limited per user, on top of the monthly [AI allowance](#ai-allowance), and the app's AI features share the same limit. A refused start returns 429 `rate_limited` with `Retry-After` and `details.reason`: `ai-rate` when the bucket is empty, `ai-busy` when too many of the user's AI jobs are still running. Nothing starts and nothing counts toward the AI allowance. Each key or connected app also gets 10 calls a minute to the operations that run AI, which their pages say. Going over returns 429 with `details.reason` `ai-key-burst`, and its rate-limit headers name the `rest:v1.ai` bucket. Operations that don't run AI, such as creating notes, sets and tasks, use neither limit, only the read and write limits above. ### AI throttling details Each new AI operation takes tokens from the user's bucket: 6 for a recording, 4 for a note generation or a practice test, 3 for a study set, a document read or a timetable photo, 2 for a tutor turn or a feed batch, and 1 for a copilot turn, AI grading or a small assist such as hints. The bucket refills every hour: | Plan | Bucket | Refill per hour | AI jobs running at once | | ----- | ------ | --------------- | ----------------------- | | Free | 8 | 8 | 2 | | Plus | 40 | 60 | 4 | | Admin | 200 | 600 | 12 | Tokens spent recently are remembered, halving every six hours. Once they pass 3 hours of refill on the free plan, or 4 hours on Plus, the refill slows in proportion to the excess, down to a quarter of its rate. Sending the same `requestId` again replays the first start and costs nothing. ### Calendar subscription limits Calendar subscription URLs have a separate per-token allowance of 120 reads per 60-second fixed window, shared by all subscribers to that URL. A throttled feed returns 429 with `Retry-After`, not a missing-subscription 404. Retry after the reported wait; the subscription remains valid. --- # Notes and files > Write note content, handle Markdown conversions, organize folders and upload files. Source: https://workroad.app/docs/api/notes-and-files ## Writing notes Note content goes in as Markdown and comes out two ways. `GET /notes/{noteId}` returns `content`, the editor's JSON document, and `markdown`, the same document converted back. The server stores only the editor JSON, so the Markdown you read back is a conversion, not your original text. `POST /notes` takes optional `markdown` for the first version of a note. Two operations write an existing note. `PUT /notes/{noteId}/content` replaces the whole document. `POST /notes/{noteId}/content/append` adds your blocks after the current ones and keeps their ids. Send an `Idempotency-Key` with the append so a retry can't add the blocks twice; without a key, send `baseVersion` and a retry fails with 409 instead. `PUT` takes no `mode`, and a body with one returns 400. An empty or whitespace-only `markdown` on an append returns 400 `invalid_request` with `details.reason` `empty-append`. An append drops one empty paragraph at the end of the note first, so a new or app-saved note doesn't gain a blank block. A note holds at most 200,000 visible characters, so an append that would pass that returns 400 `invalid_request` with `details.reason` `note-too-large`. A replace is not capped by it. Raw HTML in the Markdown is shown as text, except `` and, inside a table cell, `
`. `GET /notes/{noteId}` writes the note's inline images as `![alt](src)`. A replace keeps every image that stays in your Markdown and deletes the ones you leave out from storage, so reading a note and writing its `markdown` back keeps all of them. An append keeps them all. An image that points at an upload the web editor hasn't confirmed yet can still be deleted 24 hours after the upload. Writing a note's `markdown` back with `PUT /notes/{noteId}/content` gives the same document when `markdownLosses` is empty. That includes an image's alt text and title, which stay text even when they look like a number, such as `![2024](x.png)`. An image with an empty alt or title reads back with `null`, and so does a link with an empty title. When it isn't, the listed parts come back simplified, as described in [Markdown in notes](#markdown-in-notes). Block ids exist only in `content`, and a write gives the blocks new ones. A note whose content isn't an editor document returns `markdown: null`, and an append to it returns 400 with `details.reason` `legacy-content`. Every content change raises the note's `version`. To avoid overwriting a save made in the app or by another client, send the `version` you read as `baseVersion`. If the note changed since, the write returns 409 `conflict` with `details.baseVersion` and `details.currentVersion` and writes nothing. Read the note again, merge, and retry with the new version. Without `baseVersion` the write always goes through. A `folderId` in a body that is not one of the user's folders returns 400 `invalid_request` with `details.field` `folderId` and `details.reason` `unknown-folder`. It works the same on `POST /notes` and `PATCH /notes/{noteId}`. `PATCH /notes/{noteId}` renames a note, moves it into a folder or out of one (`folderId: null`), and pins it. `DELETE /notes/{noteId}` moves the note to the trash, where it drops out of every read, and `POST /notes/{noteId}/restore` brings it back within 30 days. Both need `notes:delete`. ## Markdown in notes Notes use CommonMark with GFM tables and strikethrough, plus a few additions for what the editor has and plain Markdown doesn't. `GET /notes/{noteId}` writes them, and every write endpoint reads them. | Content | Markdown | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Task list | `- [ ] open` and `- [x] done`. Every item in the list needs a checkbox, or the list stays a plain list. | | Inline math | `$x^2$`. No space after the opening `$` or before the closing one, and no digit right after it, so `$5 and $10` stays text. | | Block math | `$$` on its own line, the latex, then `$$` on its own line. | | Mention | `[@Cell biology](workroad://note/)`. The kind is one of `note`, `set`, `subject`, `task`, `recording`, `test` and `schedule`. | | Underline | `underlined` | | Line break in a table cell | `
` | | Footnote | `[^1]` in the text and `[^1]: text` on a line of its own. A footnote's further blocks follow, indented four spaces. | | Toggle | `
Summary`, a blank line, the body blocks, then `
` on its own line. The summary is plain text. | | Callout | A quote whose first line is only `[!NOTE]`, `[!TIP]`, `[!WARNING]` or `[!DANGER]`; the rest of the quote is the callout's body. | | Table of contents | A `[TOC]` line. It is written but not read back: writing the Markdown again gives a paragraph with that text. | Headings 5 and 6 become heading 4, the deepest the editor has. Two lists next to each other alternate between `-` and `*`, because CommonMark joins two lists with the same bullet into one. `markdownLosses` lists what a note's `markdown` can't carry. The values are: - `empty-paragraphs`: empty paragraphs are left out. One at the end of the note doesn't count, because the editor adds it back. - `collapsed-spaces`: runs of spaces become one space, and spaces at the start or end of a paragraph or next to a line break are dropped. A line break at the end of a paragraph is dropped too, and one inside a heading becomes a space. - `image-size`: image width and height are left out. - `table-layout`: merged cells, column widths and cell alignment are left out. - `table-cell-blocks`: a cell with a list, a quote, a callout, a toggle, columns, code, a formula or more than one paragraph becomes one line, with `
` between its paragraphs, headings and toggle summaries. A formula becomes inline math and code becomes its text. List bullets, checkboxes, quote and callout markers, images and embeds in the cell are left out. Footnote markers, math, citations, dates and mentions keep their usual form. - `table-header`: Markdown tables always have a header row, so the first row becomes one. - `table-cell-background`: cell background colours are left out. - `ordered-list-type`: letter and Roman numbering become numbers. - `image-upload-state`: an image that never finished uploading is written as its alt text. - `inline-math`: inline math that `$…$` can't hold is written as text, such as math followed directly by a digit. Spaces at either end of the latex are trimmed. - `mention-label`: a mention without a label gets its kind as the label. - `formatting-edges`: bold, italic and strikethrough can't start or end on a space, so the space moves outside. Where Markdown can't start or end them at all, such as bold text that starts with punctuation right after a letter, the paragraph loses its bold, italic and strikethrough. - `highlight`: highlighting is dropped while its text is kept. - `text-colour`: text colour is dropped while the text is kept. - `toggle-summary-formatting`: a toggle's summary becomes plain text, so bold, italic, underline, strikethrough, links and inline code in it keep their text and lose their formatting. - `toggle-heading-level`: a toggle whose summary reads as a heading keeps the heading's text and loses its level, because a `` tag carries the text and nothing else. - `code-block-language`: a code block language that can't fit in a Markdown fence is left out. - `code-block-filename`: a code block's filename is left out, because a fence carries the language and nothing else. - `code-block-wrap`: a code block's soft-wrap setting is left out. The code itself is unchanged. - `block-math`: a formula line containing only `$$` becomes `$ $`. - `image-address`: an image address is normalized, or the image becomes alt text if its address is refused. - `image-loading`: the image loading preference becomes lazy. - `link-address`: a link address is normalized, or the link becomes text if its address is refused. - `link-title`: a numeric or boolean link title comes back as that value instead of text. - `link-attributes`: the link target, rel or CSS class returns to the editor default. - `mention-target`: a mention with an invalid kind or ID becomes plain text. - `date-chip`: an inline date is written as its day in `YYYY-MM-DD` form, as plain text. - `heading-level`: an unsupported heading level becomes level 1 or 4. - `ordered-list-start`: a list start that Markdown can't represent becomes 1. - `callout-kind`: a callout of an unknown kind is written as a note. - `callout-nesting`: a callout directly inside another callout is written as part of the callout around it, and loses its own kind. A callout further down, inside a quote inside a callout, is written as a deeper quote and keeps its kind. - `youtube-embed`: a YouTube block is written as a link to the video; reading the Markdown back gives the link, not the player. - `pdf-embed`: a PDF block is written as a link to the file; reading the Markdown back gives the link, not the viewer. - `recording-embed`: a recording's audio player is written as its label. The recording itself isn't part of the Markdown, so reading it back gives a line of text. - `bookmark-card`: a bookmark card is written as a link, `[title](address)`, using the page title the card shows. Its description and site line are left out, so reading the Markdown back gives the link rather than the card. - `table-of-contents`: a table of contents block is written as a `[TOC]` line. Its rows are the note's own headings, which the Markdown already holds, so nothing in them is lost; reading the Markdown back gives a paragraph of that text rather than the block. - `footnote-orphan`: a footnote marker with no footnote, and a footnote no marker points at, are both left out. The editor pairs them up, so this only happens to a note written through the API. - `columns`: a columns block is written as its columns one after another, left to right, without their widths. Reading the Markdown back gives those blocks in one column. In a table cell, the columns' blocks become part of the cell's one line, as `table-cell-blocks` describes. - `citation-text`: a citation is written as the bracketed text its style reads as, such as `(Smith, 2024, p. 3)`, as plain text. The source behind it isn't written, so reading the Markdown back gives that text rather than the citation. - `bibliography`: a bibliography block is written as a plain bullet list of its entries, each one the reference its style reads as, in the order the note lists them. The sources behind them aren't written, so reading the Markdown back gives a list rather than the note's bibliography. - `invalid-structure`: malformed table, list or code block structure is simplified. - `unsupported-mark`: an unknown mark is dropped while its text is kept. - `unsupported-content`: an unknown node is reduced to its text. - `exceeds-write-limit`: the markdown is longer than `PUT /notes/{noteId}/content` accepts (100,000 characters). Append with `POST /notes/{noteId}/content/append`, or edit the note in the app. ## Folders A folder files notes for quick access and can sit under one of the user's subjects. `GET /note-folders` lists every folder the key's user owns, by name, then id. `POST /note-folders` creates one, and a duplicate name returns 409 `conflict`. Names are compared exactly, so `Bio` and `bio` are different. An `icon` must be one of the icons the folder picker offers, or the request returns 400 `invalid_request`; a folder stored with an older icon reads back as `Folder`. `PATCH /note-folders/{folderId}` changes only the fields you send, and `null` on `subjectId` unfiles the folder. Renaming to a name another of the user's folders has returns 409 `conflict`, as creating one does. `DELETE /note-folders/{folderId}` deletes the folder and unfiles its notes rather than deleting them; their content, files and embeddings are untouched. Folder endpoints reuse the `notes:*` scopes: reading a folder needs `notes:view`, and writing one needs the matching `notes:create`, `notes:update` or `notes:delete`. ## Uploading files A file upload takes three calls. The file itself goes straight to storage and never passes through the API. 1. `POST /notes/{noteId}/files` with `fileName`, `mimeType` and `sizeBytes`. The response has the file's `id`, a signed `upload.url` and the `upload.fields` to send with it. 2. POST the file to `upload.url` as `multipart/form-data`. Send every field from `upload.fields` in the order given, then the file itself as `file`, last. 3. `POST /files/{fileId}/complete`. It checks that storage has the file, starts extracting its text and returns 202 with the `jobId`. Poll `GET /files/{fileId}` until `status` is `ready` or `failed`. ```bash curl "$UPLOAD_URL" \ -F "bucket=workroad" \ -F "key=note//-lecture.pdf" \ -F "Content-Type=application/pdf" \ -F "x-amz-date=" \ -F "x-amz-algorithm=" \ -F "x-amz-credential=" \ -F "policy=" \ -F "x-amz-signature=" \ -F "file=@lecture.pdf" ``` Storage accepts the upload for 10 minutes after step 1 and refuses a file larger than `sizeBytes`, so send the exact size. Complete right after the upload, inside the same 10 minutes. The server keeps an uncompleted file for 24 hours after step 1, then deletes it and its row. Completing before the upload lands returns 409 `conflict` with `details.reason` `upload-missing`. Upload, then complete again. Completing checks the file's first bytes against its type, so a ZIP named `notes.pdf` returns 409 `conflict` with `details.reason` `wrong-type`, and the file is deleted. The API takes documents up to 50 MiB: PDF, DOCX, PPTX, XLSX, ODT, ODS, ODP, RTF, EPUB, Markdown, HTML, CSV, LaTeX, Jupyter notebooks, plain text and source code. The file name's extension must match `mimeType`; the `mimeType` field of the create call lists each type. HTML, LaTeX and source code are sent as `text/plain`. Images are uploaded in the app. Uploads are limited to 200 files and 2 GiB per user, and 25 files per note. Files on notes in the trash still count until permanent deletion. Over a limit, step 1 returns 409 `conflict` with `details.reason` set to `files-per-user`, `files-per-note` or `bytes-per-user`, plus `details.limit` and `details.used` in files or bytes. Restoring a note and deleting a file always remain available above these limits. Deleting a PDF preserves its embedded images in the note, and those images count toward the next upload's quota check. Delete files with `DELETE /files/{fileId}` or permanently delete trashed notes to make room. A `failed` or `expired` file names the cause in `failure`. `upload-expired` and `object-expired` mean the 24 hours ran out and the upload is gone; start a new one. `object-missing` means storage had nothing when parsing started, `unsupported-format` and `too-large` mean the file can't be read, and `not-parsed` means nobody completed the upload. Completing a failed `document` again retries the parse. Completing a `ready` file returns 409 `conflict` with `details.reason` `already-parsed` and queues nothing. If the user already has too many jobs queued, completing returns 429 with `Retry-After: 5`. `GET /notes/{noteId}/files` also lists images added in the app, with `kind` `image`. Only the app parses images, so completing one returns 409 `conflict` with `details.reason` `image-file`. Anyone with a file's `url` can read the file once it is uploaded, without a key. Treat the URL as a secret. ## Recordings `GET /notes/{noteId}/recordings` lists a note's lecture recordings, and `GET /recordings/{recordingId}` returns one with its transcript, a line per entry, once it is `finished`. `PUT /recordings/{recordingId}/summary` saves a `summary` and `keyTerms` you wrote from the transcript in place of the ones the recording had, and keeps its chapters. It returns 409 with `details.reason` `not-finished` while the recording is still coming in, or `summary-running` while WorkRoad's AI is writing a summary. --- # Tasks and schedules > Create tasks, manage subjects and timetables, search data and change settings. Source: https://workroad.app/docs/api/tasks-and-schedules ## Tasks `PATCH /tasks/{taskId}` changes only the fields in the body. A field you leave out keeps its value, and `null` clears `description`, `dueDate`, `subjectId` or `recurrence`. A body without any known field returns 400 `invalid_request`. Unknown fields in a request body or query string are rejected with a 400 that names the field. Dates in a request body must be strings, such as `2030-01-01` or `2030-01-01T09:00:00Z`. A number, a boolean or a `null` where a date is required returns 400 `invalid_request`. `null` is only valid for `dueDate`, where it means no due date. Clearing `dueDate` also stops any recurrence, and a recurring task cannot lose its due date any other way. A recurrence repeats a top-level task when you complete it. Set it with `recurrence` on `POST /tasks` or `PATCH /tasks/{taskId}`, and stop it with `"recurrence": null`. It needs a due date. A subtask cannot have a recurrence, and a `PATCH` that gives one to a subtask returns 400 `invalid_request`. A task's `type` is the kind of work, and must be one of the user's task types from `GET /task-types`, such as `Homework`, `Test` or `Project`, matched without regard to case. The course or module is the subject, never the type. A type the user doesn't have returns 400 `invalid_request` with `details.reason` `unknown-task-type` and their types in `details.taskTypes`; send `createType: true` to add it, as adding a type in the app's task settings does, up to 40 types. An omitted type is `Generic`. Name the subject with `subjectId`, or with `subjectName`, which must match one of the user's active subjects ignoring case and accents. A name that matches none returns 400 with `details.reason` `unknown-subject` and the user's subjects in `details.subjects`. Send `createSubjectIfMissing: true` to create it, which also needs `subjects:create`. Tasks, study sets and note folders all take `subjectName`. To record an exam or test, create a task with type `Test`, its day as `dueDate`, the subject, and the topics and start time in the description. A due date is a day. Send `YYYY-MM-DD`: WorkRoad reads it in the user's time zone (`timeZone` on `GET /me`, UTC when unset) and stores the start of that day, as the app's task form does. A timestamp with `Z` or an offset is accepted too, and the task is due on the day it falls on in that zone. The app shows the day, never a time. Recurrence `endsAt` works the same way. `POST /tasks/batch` creates up to 50 tasks in one call and answers 200 with one result per task, in order: `created` with the task, or `rejected` with the `status`, `code`, `message` and `details` that `POST /tasks` would have returned. A subject or type the call creates for one task is there for the next. Fix the rejected tasks and send only those again. `POST /tasks/{taskId}/subtasks` adds a checklist item under a top-level task. A subtask takes its parent's type and subject and cannot have subtasks of its own. A subtask name may be 1 character long at creation, but renaming it with `PATCH /tasks/{taskId}` needs at least 2. A `subjectId` in a body that is not one of the key's user's subjects returns 400 `invalid_request`, with `details.field` `"subjectId"` and `details.reason` `"unknown-subject"`. This holds on every endpoint that takes a `subjectId`. A 404 always means the resource in the path is missing. ## Subjects A subject tags the tasks, notes, study sets and schedule elements the key's user owns. A subject has at most 20 teachers, each up to 120 characters. `DELETE /subjects/{subjectId}` unfiles it from all of those, then deletes it. It leaves timetable elements on schedules the key's user doesn't own untouched, even when those schedules reference the same subject. ## Today `GET /today` answers "what should I study now?" in one call, from the same data the app's dashboard and planning copilot read. `study` has the cards due now by subject and the sets with the most due, today's goal, streak and remaining new cards. `tasks` has overdue tasks and tasks due in the next 7 days, with exams flagged. `lessons` and `studyBlocks` hold today's timetable and planned study time. Each section needs its own scope and is `null` without it: `study` needs `activity:view` (its `sets` list also needs `sets:view`), `tasks` needs `tasks:view`, and `lessons` and `studyBlocks` need `schedule:view`. `suggestions` lists what to do next, most useful first. Each one has a sentence for the user, the operation that acts on it as `tool`, and its `arguments`, such as `startPracticeSession` with `{ "subjectId": "..." }`. A suggestion only names an operation the key may call. ## Search `GET /search?q=mitosis` searches the user's notes and their headings, lecture recordings, note files, study sets and terms, tasks, timetables, copilot conversations and practice tests, the same search as the app's command menu. Hits come best match first, 20 a page; pass `nextCursor` back as `cursor` with the same query for the next page. `semantic=true` adds matches by meaning. A key searches only the record types it can read: `note` and `recording` need `notes:view`, `file` needs `files:view`, `set` and `term` need `sets:view`, `task` needs `tasks:view`, `schedule` needs `schedule:view`, `conversation` needs `copilot:use` and `test` needs `tests:view`. Without `types`, a search covers every type the key can read. Naming a type it can't read returns 403 `forbidden` with the types in `details.types`. Searches share the app's limit of 60 a minute per user. Each hit's `id` opens with the type's own operation, such as `GET /notes/{noteId}` for a `note`. A `term`'s `parentId` is its study set, and a `recording` or `file`'s is its note. ## Study blocks A study block is study time planned on the user's calendar, the way the planning copilot plans it. `POST /study-blocks` takes a `title`, a future `startsAt` and an `endsAt` 5 minutes to 8 hours later on the same day in the user's time zone, and optionally the `subjectId`, `taskId` or `setId` it is for. A block can't overlap a lesson, including lessons on connected external calendars, or another block, and can't end after a linked task's due day; those return 409 `conflict` with `details.reason` `planning-conflict` or `planning-after-deadline`. With an external calendar connected, a block can only be planned this week or next, so its lessons can be checked (`planning-calendar-incomplete`). `GET /study-blocks?from=2026-10-20&to=2026-10-26` lists blocks by day in the user's time zone, up to 62 days at a time. `PATCH` moves or relinks one and checks it again as a whole; `DELETE` removes it. ## Timetables `POST /schedules/timetable` creates a timetable with its periods and every day's lessons in one call, all or nothing, as the app's setup does. A `defined` timetable has `periods` and one entry per period on each day; a `dynamic` one has none, and each lesson carries `timeStart` and `timeEnd`. A lesson's `subjectName` links it to the user's subject with that name; one that matches none stays as written on the timetable, or becomes a new subject with `createSubjectsIfMissing: true`. `GET /schedules/{scheduleId}` says what a key can change. `owned` is true for the user's own schedules, and `editable` is true when the lesson grid can be written. Only the owner writes. A schedule shared with the user, or anyone else's, returns 404 on every write. `PATCH /schedules/{scheduleId}` renames, archives, restores or sets the default, and changes the days the schedule shows. It works on any schedule the user owns, including one read from an integration. Every field in a call applies, or none does. Archiving the default clears it. To make an archived schedule the default, restore it in the same call with `archived: false`. The grid, meaning the periods and the lessons in them, can only be written on the owner's `DEFINED` schedules kept in WorkRoad. On an external schedule a grid write returns 409 `conflict` with `details.reason` `external-schedule`, and on a `DYNAMIC` one `dynamic-schedule`. Their lessons change in the app or at the provider. To build a timetable: 1. `POST /schedules` with a `name` and the `structure` of periods. Each period has a `name` and either both `start` and `end` or neither. 2. `POST /schedules/{scheduleId}/elements` with a `day` (0 is Sunday), a `period` index and a `lessonName`, once per lesson. The first lesson lays out an empty period on every weekday. If the user has no default schedule, this one becomes it. 3. Change a lesson with `PATCH /schedules/{scheduleId}/elements/{elementId}` and empty its period with `DELETE`. The element keeps its id with `lessonName: null`, the way the app stores an empty period, and a later `POST` for that period fills the same element. Removing a lesson needs `schedule:delete`, the same way deleting a study set term needs `sets:delete`. Don't store element ids. Saving the timetable in the app deletes and recreates every element, so an id from an earlier read can return 404. Read `GET /schedules/{scheduleId}` before each lesson write and take the id from `days[].elements[]`. `PUT /schedules/{scheduleId}/structure` renames and retimes periods at any point. It can change how many periods there are only until the first lesson is added, up to 30; after that it returns 409 with `details.reason` `period-count`. A period's `start` and `end` may be empty strings, which is how the app stores a time still being set up, so a `structure` you read can be sent back unchanged. Adding to a period that doesn't exist returns 409 `no-such-period`, adding to one that has a lesson returns 409 `slot-taken`, and updating an empty one returns 409 `slot-empty`. Every write returns the schedule as `GET /schedules/{scheduleId}` shows it. A `subjectId` must be one of the user's subject ids; any other returns 400 with `details.field` `subjectId` and `details.reason` `unknown-subject`. `PATCH /schedules/{scheduleId}` with `isDefault: true` on an external schedule first asks the provider whether setup finished. If that check fails it returns 503 before saving anything, and retrying the same body is safe. A lesson reads back with `subjectId` when it points at a subject, or with `subjectLabel` when an older timetable stored free text there; at most one is set. Writing `subjectId` replaces the label. When the provider can't be read for the response after a save, the schedule comes back with `issue: "failed"`. ## Settings `/settings/profile`, `/settings/preferences`, `/settings/notifications`, `/settings/review`, `/settings/tasks` and `/settings/dashboard` read with `GET` and change with `PATCH` (`PUT` for the dashboard), the same as the Settings pages. A `PATCH` changes only the fields you send. Each writes through the code the page saves with, so the limits and errors match the app's. - Changing `timeZone` moves every task's due date so it keeps its date and time on the clock. A zone the server doesn't know returns 400, and a change made elsewhere during the request returns 409. - `PATCH /settings/notifications/{kind}` changes one kind's channels and timing. A timing field the kind doesn't use, or a value off its list, returns 400 with `details.field` and the allowed values in `details.reason`. - `subjectOverrides` on `PATCH /settings/review` replaces the whole list, and every `subjectId` must be the user's. - `PUT /settings/dashboard` takes the cards to show, in order. Send the `updatedAt` you read as `baseUpdatedAt` to get 409 instead of overwriting a layout saved elsewhere. `GET /calendar-feeds` lists feed metadata without returning secret subscription URLs. `DELETE /calendar-feeds/{feedId}` revokes a feed. Create feeds or rotate their URLs in [Settings → Calendar](https://workroad.app/user/calendar). Anyone holding a subscription URL can read its calendar data, even after the API key used to list the feed is revoked. A Copilot conversation in which a turn read or proposed the user's settings is only readable with `settings:view`: without it, `GET /copilot/conversations` leaves it out, its messages, its replies' jobs and sending into it return 403 with `details.reason` `settings-in-conversation`, and search skips it. The API can't change the password, the email address, two-factor, passkeys, sign-in methods, sessions, API keys, connected apps, the plan or payment, and can't delete the account. The theme and the AI model choice are kept in the browser. --- # Study sets and practice > Manage cards, record reviews, run practice sessions and take practice tests. Source: https://workroad.app/docs/api/study-and-practice ## Study sets and terms A study set holds a fixed maximum of terms, and each term carries its own flashcard review history. `POST /sets` takes the set's terms in the same call. `POST /sets/{setId}/terms` appends a batch of terms and leaves the existing ones untouched. `PATCH /sets/{setId}/terms/{termId}` changes only the fields you send, and `PATCH /sets/{setId}/terms` does the same for up to 500 terms at once, all or none: a `termId` that isn't the set's returns 400 with `details.reason` `unknown-term`. `POST /sets/{setId}/terms/delete` deletes up to 500 terms and their review history, and refuses with 409 `last-term` to empty the set. `DELETE /sets/{setId}/terms/{termId}` erases the term's review history for good, so it needs `sets:delete`; a set's last term can't be deleted this way, delete the set instead. A term is at most 2000 characters and a definition at most 4000. A term has up to 10 hints of at most 500 characters each, and a hint may be empty. A `subjectId` the key's user doesn't own returns the same 400 `invalid_request` on set endpoints. ## Reviewing flashcards The server decides which cards are due, using its own clock. Study a set like this: 1. Fetch `GET /sets/{setId}/review-queue`. `cards` holds what is due now: learning and relearning cards, then reviews, then new cards. 2. Show a card, then send `POST /reviews` with a fresh `reviewId` (a UUID you generate before the first attempt), the `cardId`, the `rating` (`again`, `hard`, `good` or `easy`) and `durationMs`. 3. Remove the graded card from your local copy of the queue. Do not put it back yourself, even when the response says it is due again soon. 4. When your copy runs out, fetch the queue again. The server refills it. 5. When `finished` is true, nothing is due. If `nextDueAt` is set, fetch again at that time; a learning card becomes due then. Never filter the queue with your own clock. A device clock that runs a few minutes off would hide due cards or keep showing cards the server refuses. `serverTime` in each queue response is the time the server used. Each set serves at most 50 new cards per study day. A study day starts at 4am in the user's time zone, or at 4am UTC when none is set. When nothing else is due, the queue also serves the next learning card due within 20 minutes. `POST /reviews` returns 201 with the card's next `due`. It accepts only a card the queue would serve now. Otherwise it returns 409 `conflict` with `details.reason`: | Reason | Meaning | | ---------------- | ----------------------------------------------- | | `card-not-due` | The card is not due yet. Fetch the queue again. | | `new-card-limit` | The set has reached today's 50 new cards. | | `card-suspended` | The user suspended the card in the app. | | `card-deleted` | The card was deleted. | Grade one card at a time per set. Grades in the same set that run at the same time can return 409 `conflict` with `details.reason` `concurrent-grade` and `Retry-After: 1`; retry those with the same `reviewId`. Retrying a review with the same `reviewId` is always safe. If the first attempt was recorded, the retry returns 200 with `replayed: true` and the card's current schedule, even though the card is no longer due. It grades nothing. The same `reviewId` with a different card, rating or duration returns 409 `idempotency_conflict`; the duration is compared in whole seconds. Reviews count toward the user's streak the same way reviews in the app do. ## Practice sessions A practice session is the app's Review page over the API: one queue across every study set the user owns, or only some, served a batch at a time and answered card by card. Each answer is recorded as an FSRS review exactly as in the app, so it reschedules the card, counts toward the daily goal and the streak, and respects the daily new-card and review limits from the user's review settings. 1. `POST /practice-sessions` starts one. Send `subjectId` or `setIds` to narrow it, or nothing for every set. The response already holds the first `cards`, up to 30: learning and relearning first, then reviews, then new cards, taking turns between sets. 2. Show a card, then send `POST /practice-sessions/{sessionId}/answers` with its `itemId`, a `rating` (`again`, `hard`, `good` or `easy`) and `durationMs`. `remaining` in the response counts the cards from the last read still waiting. 3. When `remaining` is 0, `GET /practice-sessions/{sessionId}` refills the session. Reading it again before then returns the same cards, so it is safe to repeat. 4. `finished: true` means nothing is due and today's new cards are spent. The session stays open for 7 days; read it again at `nextDueAt`. `POST /practice-sessions/{sessionId}/undo` with the `undoableItemId` from the last read takes the newest answer back: its review is removed, the card's schedule is restored, and the card comes back into the session under a new `itemId`. It needs `activity:delete`. Answering the same `itemId` again with the same rating records nothing and returns 200 with `replayed: true`. A different rating for an answered item returns 409 `idempotency_conflict`. If the card was reviewed in the app since the session served it, the answer returns 409 `conflict` with `details.reason` `card-changed-refresh-session`: read the session again. A `cram: true` session walks every term once without changing any schedule; an `again` there queues the term again at the end. Each answer is a write, so one key answers at most 30 cards a minute (see [Rate limits](https://workroad.app/docs/api/limits#rate-limits)). `GET /sets/{setId}/review-queue` and `POST /reviews` still review one set at a time, as described in [Reviewing flashcards](#reviewing-flashcards). ## Practice tests A practice test asks questions written from the user's study sets and notes. Having WorkRoad's AI write one with `POST /tests` is one AI use, and AI grading of its short answers is included. Taking a test is free, except that AI grading of short answers in a test saved with `POST /tests/from-questions` is one AI use per submit. To save questions you wrote yourself, `POST /tests/from-questions` takes a `name` and 1-50 `questions`, each with a `kind` (`multiple-choice`, `true-false`, `fill-blank` or `short-answer`), a `prompt`, the `answer`, an `explanation` and a `difficulty` (`recall`, `apply` or `analyse`), and multiple choice questions their `choices`. That is the shape WorkRoad's own generator writes, and the test is ready at once, with no job and no AI use. A question that can't be graded as written returns 400 with `details.index` and `details.reason`, such as `answer-not-a-choice`. Choices are shuffled once when the test is saved. 1. `POST /tests` with a uuid `requestId`, a `name`, `sourceSetIds` or `sourceNoteIds`, the question `kinds` (`MCQ`, `TRUE_FALSE`, `SHORT` or `FILL_BLANK`), a `length` of 1-50 and an optional `timerSecs`. It answers 202 with the `testId` and the generation's `jobId`. Mint the `requestId` once and keep it for retries: it becomes the job id, and sending it again with the same body returns the same test and counts one AI use. A used `requestId` with different sources, `kinds`, `length`, `timerSecs` or `modelTier` returns 409 `idempotency_conflict`. `name` is not compared, so a replay under another name returns the original test with its original name. A refused start (402 without AI uses left, or 429) creates no test, so there is nothing to clean up and the 402 body has no `testId`. 2. Poll `GET /jobs/{jobId}` or `GET /tests/{testId}` until the test's `status` is `ready`, waiting the seconds in `Retry-After` between job polls (3 for a test). A test that failed has the reason in `error`. 3. `POST /tests/{testId}/attempts` starts an attempt and returns its `attemptId`. A test has one open attempt at a time. If the user already has one open, you get it back with status 200 and `resumed: true`. A test that isn't ready returns 409 with `details.reason` `test-not-ready`. 4. `GET /attempts/{attemptId}` returns the questions. Answer them by index as a string, `"0"` for the first question. For multiple choice, send the text of the choice. For true/false, send `"true"` or `"false"`. 5. `PUT /attempts/{attemptId}/answers` saves the answers so far, replacing what was saved before. Past the time limit it stores nothing and returns `expired: true`. A key must be a question index without leading zeros, and a key past the last question returns 400 with `details.reason` `unknown-question`. 6. `POST /attempts/{attemptId}/submit` ends the attempt, optionally with final `answers`. Submitting twice changes nothing and returns `alreadySubmitted: true`. ```json { "answers": { "0": "Mitochondrion", "1": "It keeps the cell's contents in and controls what enters." } } ``` The answer key stays hidden until the attempt is submitted. While a test is generating, `GET /tests/{testId}` shows the prompts written so far in `generating`, without answers; once it is ready, the questions come only through an attempt. While an attempt is open every question has `answer` and `explanation` set to `null` and `perQuestion` is empty. Once it is submitted, `GET /attempts/{attemptId}` shows the correct answers and a verdict per question. The time limit counts from the attempt's `startedAt` on the server's clock. `deadlineAt` is when it runs out, and `expired` turns true 15 seconds later, a grace period for a slow last request. After that, submitting grades the saved answers and ignores the ones in the body. Multiple choice, true/false and fill-in answers are graded on submit. Short answers go to AI grading: `gradingQueued` is true, `gradingJobId` names the job, and `phase` stays `grading` until it ends. Grading never makes a submit fail. If AI grading can't start, because the engine is busy or, for a test saved with `POST /tests/from-questions`, the month's AI uses are spent, the attempt is still submitted with `gradingQueued: false`, and its `gradingStatus` and `phase` read `failed` and `grading_failed`. The short answers then keep the verdict `manual`, and the user marks them in the app. A wrong answer's `explainRef` asks the copilot to explain it: send it as `context.activityRef` with `context.kind` `activity` to `POST /copilot/messages`. --- # Copilot and generations > Save content your agent writes, send copilot messages and follow generation jobs. Source: https://workroad.app/docs/api/copilot-and-generations ## Let your AI do the writing An agent with its own model, such as Claude or ChatGPT on the user's own subscription, doesn't need WorkRoad's AI to make study material. It reads the material, writes the content itself and saves it with a plain operation, which uses none of the user's [AI uses](https://workroad.app/docs/api/limits#ai-allowance): | To save | Call | Instead of | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | A note | `POST /notes` with `markdown`, then `POST /notes/{noteId}/content/append` | `POST /notes/{noteId}/generate`, `generate-topic` | | A study set with its cards | `POST /sets` with up to 2000 `terms`; `POST`, `PATCH` and `POST …/delete` on `/sets/{setId}/terms` for up to 500 more | `POST /sets/generate` | | A practice test | `POST /tests/from-questions` | `POST /tests` | | Tasks, exams and deadlines | `POST /tasks/batch` | a copilot message | | A timetable with its lessons | `POST /schedules/timetable` | the app's photo import | | A lecture's summary | `PUT /recordings/{recordingId}/summary` | the summary WorkRoad's AI writes | To read the material, `GET /notes/{noteId}` returns a note's Markdown, `GET /recordings/{recordingId}` a lecture's transcript, `GET /sets/{setId}` and `GET /sets/{setId}/terms` a set's cards, and `GET /sets/{setId}/review-queue` or `GET /today` what is due. Call the operations that run WorkRoad's AI only when the user asks for it; each operation page says when it does. ## Copilot `POST /copilot/messages` sends the copilot a message and answers 202 with the `conversationId` and the reply's `jobId`. Omit `conversationId` to start a conversation; `context` and `subjectId` then say what it is about. Poll `GET /jobs/{jobId}` for the reply: a finished `copilot_reply` job's `result` holds the message, its sources and the actions it proposed. Mint a uuid `requestId` for each message and keep it for retries. It is the message's idempotency key: sending the same message with the same `requestId` again returns the same turn and counts one AI use, even if the first request timed out. A different message under a used `requestId` returns 409 `idempotency_conflict`. Each turn is one AI use (see [AI allowance](https://workroad.app/docs/api/limits#ai-allowance)). The free plan also allows 10 turns per UTC day; the 11th returns 429 with `Retry-After: 3600`, or 402 if the month's AI uses are spent as well. The `sage` model tier needs a paid plan. A busy copilot returns 429 with `Retry-After: 15`. Attachments can't be sent through the API yet. The body rejects any key it doesn't list, so a body with `attachmentIds` or any other unknown key returns 400 `invalid_request`. To give the copilot material, write it into a note or upload a note file, then ground the conversation on that note with `context: { "kind": "note", "noteId": "..." }`. `GET /copilot/conversations` lists the user's conversations, pinned ones first and then the most recently updated, with the usual `cursor` and `limit`. `GET /copilot/conversations/{conversationId}/messages` returns the newest messages oldest first; pass the page's `nextCursor` as `cursor` to read further back, with the same `limit`. Both cursors are opaque. A conversation cursor carries the pin state, update time and id it was cut at, so paging continues from the same place. A conversation that gets a new turn or is pinned between pages moves to the front, though, so it can appear twice or be missed on that pass. ### Applying actions When the reply's job succeeds, find the assistant message with that `jobId` in the conversation's messages, then call [`POST /copilot/messages/{messageId}/actions`](https://workroad.app/docs/api/apply-copilot-actions). Send `{}` for the task, note and folder creations the app applies automatically, or send `indices` to select actions. The operation page lists which actions need explicit selection, their scopes and how to follow their jobs. The response has a receipt for every action that has one, by index. An action with a receipt never runs again, so repeating a request is safe and returns the same receipts. A receipt with `error: "in-progress"` belongs to an apply that is still running; `action-retryable` means the action can be applied again. A set whose note doesn't exist gets `note-not-found`. Receipt `error` values are kebab-case codes, unlike the snake_case `error.code` of a response. One reply can run at most 3 actions that change data; the next gets `turn-action-cap`. On the free plan, 20 such actions can run per UTC day, and the 21st request returns 429 with `Retry-After: 3600`. ## Generating notes and study sets A generation writes a note for you from material on the note, or from a topic. Each start is one AI use (see [AI allowance](https://workroad.app/docs/api/limits#ai-allowance)), answers 202 with a `jobId`, and runs as a job. 1. Upload the material with [Uploading files](https://workroad.app/docs/api/notes-and-files#uploading-files) and poll `GET /files/{fileId}` until each file is `ready`. A file that isn't parsed yet makes the start fail with 400 `sources.fileIds` `not-ready`. 2. `POST /notes/{noteId}/generate` with `sources`: the `fileIds`, any `recordingIds` made in the app, and `includeNote` to use the note's own text. `depth` is `standard` or `advanced`, and `instructions` says what to focus on. 3. Poll `GET /jobs/{jobId}` until it ends, waiting the seconds in `Retry-After` between polls (5 for a generation). A finished `note_generation` job's `result.generationId` names the result. 4. `GET /generations/{generationId}` shows the Markdown. `POST /generations/{generationId}/apply` writes it into the note, and `POST /generations/{generationId}/discard` drops it. A `copilot:use` key can do the same for a generation its copilot started; applying also needs `notes:update`. ```json { "requestId": "0f8e3a52-6c1d-4b7e-9a24-5d3c1b0e7f96", "sources": { "fileIds": ["3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f"], "recordingIds": [], "includeNote": false }, "depth": "standard" } ``` The start's `intoNote` says what the result becomes. On an empty note it is `true`, and applying writes the result in as the app does, whatever `mode` you send, as long as the note is still empty and unchanged. Only the generation you name is written, even when several wait on the same note. On a note with text the result is a preview. Apply it with `mode` `replace-content` to swap the note's content, or `insert-end` to append. `insert-end` returns 400 `invalid_request` with `details.field` `markdown` and `details.reason` `note-too-large` when the note would pass 200,000 visible characters; nothing is written and the preview stays applicable. `replace-content` returns 409 `conflict` when the note changed after the generation read it, so the edits aren't lost; append instead, or generate again. The same holds for an empty-note result once the note has changed, so `replace-content` returns 409 and `insert-end` appends. Nothing applies a generation for you. The app writes an empty note's result when the job ends, but an API client must call apply. Reading a generation or its job never changes the note, and applying twice returns `alreadyApplied: true`. `POST /notes/{noteId}/generate-topic` writes about a `topic` instead and always makes a preview. `POST /sets/generate` with a `noteId` makes flashcards from a note; the finished job's `result.setId` is the new set. While a set is being made from a note, another start for that note returns the running job. Mint a uuid `requestId` for each start and keep it for retries; it becomes the job id. Sending it again with the same body returns the same job and counts one AI use, and a different body under a used `requestId` returns 409 `idempotency_conflict`. A note generation from sources also records the note's version and its sources, so once those change, for example after a generation was applied to the note, its `requestId` returns 409 even with the same body. Topic and study set starts return the original job. The `Idempotency-Key` header works on these starts too, but it only covers 24 hours; the `requestId` is the key that lasts. ## Jobs Work that takes longer than a request, such as a copilot reply, a note generation or a file parse, runs as a job. The request that starts it returns 202 with a `jobId`. Poll `GET /jobs/{jobId}` until `status` is `succeeded`, `failed` or `cancelled`, or register a [webhook](https://workroad.app/docs/api/webhooks#webhooks) to hear when it ends. While a job is `pending` or `running`, the response carries a `Retry-After` header with the seconds to wait before the next poll: 2 for a copilot reply or grading, 3 for a practice test or a file parse, 5 for a note, topic or study set generation. The header is absent once the job has ended. `kind` names the work and decides the shape of `result`, which stays `null` until the job succeeds. Practice test, grading and file parse jobs never carry a result; read the test, the attempt or the file instead. A key reads a job when it holds a scope that starts that kind of work or views its results. Only the starting scopes cancel. A job of any other kind returns 403 `forbidden`, and another user's job returns 404. | Job kind | Read with any one of | Cancel with any one of | | ---------------------- | --------------------------------- | --------------------------------- | | `copilot_reply` | `copilot:use` | `copilot:use` | | `note_generation` | `generation:start`, `copilot:use` | `generation:start`, `copilot:use` | | `topic_generation` | `generation:start`, `copilot:use` | not cancellable | | `study_set_generation` | `generation:start`, `copilot:use` | not cancellable | | `file_parse` | `files:upload`, `files:view` | not cancellable | | `test_generation` | `tests:generate`, `tests:view` | not cancellable | | `test_grading` | `tests:attempt`, `tests:view` | not cancellable | A failed job has `error.code`, such as `generation_failed`, and `error.retryable`. When `retryable` is true, starting the same work again may succeed. `POST /jobs/{jobId}/cancel` stops a copilot reply or a note generation, the two jobs the app has a Stop button for. A stopped note generation keeps the sections it already wrote and finishes as `succeeded` with `result.stopped: true`, or as `cancelled` when it had written nothing. Cancelling any other kind returns 409 `conflict` with `details.reason` `not-cancellable`. A key with neither `copilot:use` nor `generation:start` gets 403 `forbidden` instead, even on a kind it can read. --- # Webhooks > Receive job and task events, verify signatures and recover failed deliveries. Source: https://workroad.app/docs/api/webhooks ## Set up a receiver A webhook sends a signed `POST` to your server when a job ends or a task comes due, so an integration can stop polling `GET /jobs/{jobId}`. Create one in **Settings → API keys**, or with `POST /webhooks` using a key with `webhooks:manage`. Creating a webhook, changing its URL and rotating its secret are not MCP tools, so an agent can't choose where events go. A user can have at most 5 webhooks; a sixth returns 409 `conflict` with `details.reason` `webhook-quota`. Webhooks belong to the user, not to the key that created them. They fire for every job the user starts, whether from the app, the API or the MCP server, and they keep firing after the key that created them is revoked. The URL must use `https`, must not contain a username or password, and must be at most 2048 characters. `localhost`, names ending in `.localhost`, `.local` or `.internal`, and private, loopback and link-local IP addresses are refused with 400 `invalid_request`. A host that resolves to a private address, or doesn't resolve within 3 seconds, is also refused on create (400 `invalid_request`, `details.reason` `private-address` or `unresolvable-host`). WorkRoad checks every DNS answer again before forwarding. Hooker then resolves the hostname independently and permits private ranges, so this preflight check cannot prevent DNS rebinding at the relay. Deployments must restrict all eligible relays' egress to public destinations and deny access to sensitive private networks. `POST /webhooks` returns the signing secret, and no other response does, so store it then. `POST /webhooks/{webhookId}/rotate` returns a new secret. The old secret keeps working for 24 hours, so you can update your receiver without dropping events. During that time each delivery, retries of earlier events included, carries two signatures in `webhook-signature`, and a Standard Webhooks library accepts either secret. Rotating a second time inside the 24 hours ends the first old secret at once, and only the newest previous secret is kept. To shorten the window, send `{ "previousSecretExpiresInSeconds": 3600 }`, any whole number from 0 to 86400. Send `0` to revoke the old secret immediately, for example when it leaked. These two operations answer 400 `invalid_request` to a request that carries an `Idempotency-Key`. A stored replay would keep the secret on WorkRoad's servers for 24 hours, so they don't store responses at all. That makes a retry unsafe: if `POST /webhooks` timed out, call `GET /webhooks` before trying again, because a second create makes a second webhook with its own secret, and both count toward the limit of 5. A rotate you retry rotates again: the secret from the response you never saw becomes the old secret, and the one before it stops verifying at once. Use the secret from the last response. ## Events | Event | When | | --------------- | --------------------------------------------------------------------------------------------------------------------------- | | `job.succeeded` | A job ends as `succeeded`. A note generation stopped after writing some sections ends here too, with `result.stopped` true. | | `job.failed` | A job ends as `failed`, including one WorkRoad gave up on after it stopped responding. | | `job.cancelled` | A job ends as `cancelled`, whether you or WorkRoad cancelled it. | | `task.due_soon` | An unfinished task is due within 24 hours. It is sent once per due date, so moving the due date sends it again. | | `webhook.test` | Only from `POST /webhooks/{webhookId}/test`, whatever events the webhook subscribes to. | The job events cover the jobs `GET /jobs/{jobId}` can show: copilot replies, generations, practice test generation and grading, and file parses. Events are thin. They carry ids and the state that changed, never content: ```json { "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "type": "job.succeeded", "timestamp": "2026-01-15T09:31:12.000Z", "data": { "job": { "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "kind": "note_generation", "status": "succeeded", "finishedAt": "2026-01-15T09:31:10.000Z" } } } ``` A `task.due_soon` event has `"data": { "task": { "id": "...", "dueDate": "..." } }`. Fetch the rest with `GET /jobs/{jobId}` or `GET /tasks/{taskId}`. Those calls apply the calling key's scopes, so a webhook never shows a key more than the key could read. `id` is the delivery's id, the same value as the `webhook-id` header. `timestamp` is when WorkRoad recorded the event. Delivery is at least once: the same delivery can arrive more than once, for example when your server handled it but answered after the timeout, and deliveries can arrive out of order. Retries keep the same `webhook-id`, so store the ids you have handled and skip any you have already seen. ## Headers Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) specification, so any Standard Webhooks library can verify them. | Header | Value | | ------------------- | ------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `Workroad-Webhooks/1` | | `X-Workroad-Event` | The event type, such as `job.succeeded`. | | `webhook-id` | The delivery id, the same as the body's `id`. Retries of a delivery keep it. | | `webhook-timestamp` | When this attempt was signed, in Unix seconds. Each retry gets a new one. | | `webhook-signature` | `v1,` and a base64 HMAC-SHA256 signature. Several signatures are separated by spaces. | ## Verifying the signature The secret looks like `whsec_` followed by base64. Pass it to a Standard Webhooks library as it is, together with the raw request body and the headers. Verify before you parse the JSON: parsing and re-serializing changes the bytes, and the signature covers the exact body WorkRoad sent. The libraries reject a `webhook-timestamp` more than 5 minutes from your clock, so a captured delivery can't be replayed later. In JavaScript or TypeScript, install `standardwebhooks` from npm: ```ts import { Webhook } from "standardwebhooks"; const secret = process.env.WORKROAD_WEBHOOK_SECRET; if (secret === undefined) throw new Error("WORKROAD_WEBHOOK_SECRET is not set"); const webhook = new Webhook(secret); // A route handler that receives a standard Request. export const POST = async (request: Request): Promise => { const body = await request.text(); let event: unknown; try { event = webhook.verify(body, Object.fromEntries(request.headers)); } catch { return new Response("Invalid signature", { status: 401 }); } // Queue the event for processing, then answer quickly. return new Response(null, { status: 204 }); }; ``` In Python, install `standardwebhooks` from PyPI. This example uses Flask: ```python import os from flask import Flask, request from standardwebhooks.webhooks import Webhook, WebhookVerificationError app = Flask(__name__) webhook = Webhook(os.environ["WORKROAD_WEBHOOK_SECRET"]) @app.post("/workroad") def workroad_webhook(): try: event = webhook.verify(request.get_data(as_text=True), dict(request.headers)) except WebhookVerificationError: return "Invalid signature", 401 # Queue the event for processing, then answer quickly. return "", 204 ``` Without a library, the check takes a few steps: 1. Decode the base64 after `whsec_` to get the key bytes. The key is those bytes, not the text of the secret. 2. Refuse a `webhook-timestamp` more than 5 minutes from your clock. 3. Compute the HMAC-SHA256 of `..` with that key and base64-encode it. 4. Split `webhook-signature` on spaces. Accept the delivery if any `v1,` entry matches, comparing in constant time. ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export const verifyWorkroadWebhook = (secret: string, headers: Headers, body: string): boolean => { const id = headers.get("webhook-id") ?? ""; const timestamp = headers.get("webhook-timestamp") ?? ""; if (!/^\d{1,15}$/.test(timestamp)) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) return false; const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest(); return (headers.get("webhook-signature") ?? "").split(" ").some((entry) => { const [version, signature = ""] = entry.split(","); const actual = Buffer.from(signature, "base64"); return ( version === "v1" && actual.length === expected.length && timingSafeEqual(actual, expected) ); }); }; ``` ## Retries and disabling Answer with any `2xx` status within 10 seconds. Anything else counts as a failed attempt: another status, a redirect, a timeout or a connection error. WorkRoad doesn't follow redirects, so do slow work after you answer. WorkRoad retries a failed event delivery after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, at the earliest. For a single failing delivery, the eighth attempt comes about 45 hours after the first. If it fails too, the delivery is marked `failed` and never sent again. Deliveries to one webhook go out one at a time. While a webhook is failing, WorkRoad sends it about one probe a minute while it has a backlog of due deliveries, plus any test you queue. The other queued events wait, so they can be sent later than the schedule above says, and some may end as `endpoint-disabled` when the 72-hour disable below lands. The first successful delivery ends the failing run, and the queue drains at the normal pace again. A slow or broken receiver can't delay other users' deliveries: it holds at most one of the six workers that send webhooks. WorkRoad disables a webhook when an event delivery to it fails and no delivery has succeeded since a failure at least 72 hours earlier. A quiet spell with no events does not reset that clock; only a successful delivery does. A disabled webhook gets no new events, and `GET /webhooks` shows `status: disabled`. Its queued deliveries fail with `endpoint-disabled` as each comes due. To start again, call `POST /webhooks/{webhookId}/enable`. The secret stays the same. Retries that were not due yet when you enable it go out on their schedule, but `endpoint-disabled` deliveries stay failed. Events that arrived while the webhook was disabled were never recorded, so they are not sent. The exception is an event from the last 2 minutes before you enable it, which can still reach the webhook. Before that point, one successful delivery ends the failing run and `status` goes back to `active`. `lastDeliveryAt` and `lastStatus` on the webhook describe the last successful delivery only. `GET /webhooks/{webhookId}/deliveries` lists deliveries newest first, 25 a page by default (`limit` sets the page size, and `nextCursor` fetches the next page), with each one's status, attempts, last HTTP status and error. `attempts` counts the POSTs made so far, so a delivery that succeeded on the first try shows 1. `POST /webhooks/{webhookId}/test` queues a `webhook.test` event so you can check your receiver end to end. A test is attempted once: if it fails, it is marked `failed` straight away, without retries, and it never counts toward disabling the webhook. A webhook has at most one pending test: another test request returns 409 `conflict` with `details.reason` `webhook-test-pending` until the first has finished its attempt. On a webhook that isn't failing, events and tests usually go out within about a minute. ## Delivery relay details Normal and test deliveries are forwarded through the Hooker relay. Requests to your receiver originate from the relay's IPs, not WorkRoad's servers. Verify the [Standard Webhooks signature](#verifying-the-signature) to authenticate them. WorkRoad signs the exact body before forwarding and manages delivery retries and endpoint state. Hooker stores that body for its configured retention window, by default 7 days, and keeps event metadata and attempt history for 30 days by default. The relay call waits up to 10 seconds for the receiver's result. If Hooker returns an accepted receipt, WorkRoad polls the event state and latest attempts for the receiver's status. An unfinished poll leaves the delivery pending with `relay-pending`; a relay outage leaves it pending with `relay-error`. These reschedule the same forward using its idempotency key, without another receiver attempt or any change to the webhook's failing run, hold or disabled state. Receiver failures count only after a confirmed attempt. Non-2xx deliveries may show no status because Hooker drops the status on HTTP error attempts. WorkRoad recovers it from error details when available; connection errors and receiver timeouts may also have no status. Polling backs off from 2 to 8 seconds between reads. A delivery still deferred 72 hours after its first deferral fails with `relay-expired`, without counting a receiver attempt or changing endpoint failure state. Lease recovery may produce later attempts, whose confirmed receiver results are recognized. Terminal relay events with no receiver result fail the delivery without penalizing the endpoint. Tests still get one confirmed receiver attempt. --- # October 2026 v1 migration > Update integrations written against the earlier v1 API. Source: https://workroad.app/docs/api/v1-migration ## October 2026 changes to v1 This release changed v1 itself, because few integrations depended on it yet. It did not add `/api/v2`. Clients written against the earlier v1 need these updates: - Reference fields are named `subjectId`, `parentId` and `termId`, and timestamps are `createdAt` and `updatedAt`. Path parameters have matching names such as `{taskId}` and `{setId}`; the URLs are the same. - `cursor` takes only the opaque `nextCursor` value from a previous page. A row id is rejected with 400 `invalid_query`. - Date fields accept only strings. A number or boolean returns 400 `invalid_request`. - Unknown fields in a request body or query string return 400 that names the field. Before, they were ignored. - An unknown or foreign `subjectId` on `POST /tasks` returns 400 `invalid_request`, where it was 404. A `folderId` on `POST /notes` that is not one of the user's folders also returns 400. - A set, note or recording id in a request body that isn't the user's returns 400 `invalid_request` with `details.field` and `details.reason` (`unknown-set`, `unknown-note` or `unknown-recording`), not 404. This covers `sourceSetIds` and `sourceNoteIds` on `POST /tests`, `noteId` on `POST /sets/generate`, and a `start_generation` action that names a recording on `POST /copilot/messages/{messageId}/actions`. A 404 on those endpoints means the path's own resource is missing. - Copilot messages page with `cursor` and `nextCursor`, not `before` and `nextBefore`. Conversations use the same opaque cursor. - `GET /jobs/{jobId}` and `POST /jobs/{jobId}/cancel` check the job's kind against the key's scopes (see [Jobs](https://workroad.app/docs/api/copilot-and-generations#jobs)). A `copilot:use` key reads and cancels its copilot jobs and reads, applies and discards the generations they start. A `tests:view` or `files:view` key reads the jobs of its domain. A key with no scope for a kind gets 403. - `POST /tests` requires a body `requestId` and creates no test when it is refused with 402 or 429. Before, a 402 left a `failed` test. - Study set generation's `result` has `setId` and `termCount`, not `studySetId`. - `insert-end` on a generation refuses a note that would pass 200,000 visible characters. - Terms, definitions, hints and teacher names have length caps, listed under study sets and subjects. - A task's `type` must be one of the user's task types; an unknown one returns 400 `unknown-task-type` unless the request sends `createType: true`. Before, any text was stored as the type. - A task's `dueDate` or recurrence `endsAt` sent as `YYYY-MM-DD` is the start of that day in the user's time zone, not UTC midnight. --- # Get the AI allowance Source: https://workroad.app/docs/api/get-ai-allowance `GET https://workroad.app/api/v1/ai-allowance` This month's AI use and the credit balance for the key's user, the same numbers the app shows. The free plan allows 3 AI uses per UTC calendar month. The paid plan has no count limit but allows 100 recordings a month under fair use. Once those are spent, each AI use spends one credit while any are left. Any valid key may call it. - MCP tool: `getAiAllowance` on `https://workroad.app/api/mcp` - Required scopes: None beyond a valid key. - Test mode: `GET https://workroad.app/api/test/v1/ai-allowance` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: object, required - `plan`: one of `"free"`, `"paid"`, required. `paid` for a subscription or an admin account. - `used`: integer, required. AI uses this month that the monthly allowance paid for, including ones still running. A use that produced nothing is not counted. Uses paid with credits are in `creditUses`. - `creditUses`: integer, required. AI uses this month that credits paid for, because the allowance (or, on the paid plan, the fair-use recordings) was spent. Counted like `used`. `used` plus `creditUses` is every AI use this month. - `limit`: integer or null, required. AI uses allowed per month. Null on the paid plan, which has no count limit. - `recordings`: object, required. This month's recordings. - `used`: integer, required. Recordings this month that the allowance paid for, including ones still running. Recordings that credits paid for count in `creditUses` instead. - `limit`: integer or null, required. Recordings allowed per month: 100 on the paid plan, under fair use. Null for an admin account, which has no cap, and on the free plan, where each recording counts toward `limit` instead. - `resetsAt`: string (date-time), required. When the count starts again: the first of next month, UTC. - `credits`: object, required. AI credits, bought in packs or granted. Buying them is only possible in the app. - `balance`: integer, required. Credits left to spend. Each pays for one AI use once this month's uses (or the paid plan's fair-use recordings) are spent. They never expire. ### Example ```json { "data": { "plan": "free", "used": 1, "creditUses": 0, "limit": 3, "recordings": { "used": 1, "limit": null }, "resetsAt": "2026-02-01T00:00:00.000Z", "credits": { "balance": 7 } } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Delete a calendar feed Source: https://workroad.app/docs/api/delete-calendar-feed `DELETE https://workroad.app/api/v1/calendar-feeds/{feedId}` The address stops working at once. Calendar apps subscribed to it keep the events they already have but get no updates. - MCP tool: `deleteCalendarFeed` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `DELETE https://workroad.app/api/test/v1/calendar-feeds/{feedId}` checks the request and returns the example response without changing anything. ## Path parameters - `feedId`: string, required, 1-128 characters. Calendar feed id, from the list. ## Response `200` OK - `data`: object, required - `id`: string, required. The feed's id. Rotating a feed in the app gives it a new one. - `deleted`: `true`, required ### Example ```json { "data": { "id": "kq0V3mJ8yQe7Lw2nR5tXcB1dF4gH6jK9", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No calendar feed with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List calendar feeds Source: https://workroad.app/docs/api/list-calendar-feeds `GET https://workroad.app/api/v1/calendar-feeds` Lists the user's calendar feed metadata, oldest first. Secret subscription URLs are not returned; each URL is shown only when it is issued. A user can have at most 10 feeds. This list is not paginated. Create feeds or rotate their URLs in the app under Settings → Calendar. - MCP tool: `listCalendarFeeds` on `https://workroad.app/api/mcp` - Required scopes: settings:view and schedule:view - Test mode: `GET https://workroad.app/api/test/v1/calendar-feeds` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: array of objects, required - `id`: string, required. The feed's id. Rotating a feed in the app gives it a new one. - `schedules`: array of objects or null, required. The schedules the feed covers, with null names for deleted ones. Null: every schedule kept in WorkRoad, plus tasks and exams. - `id`: string (uuid), required - `name`: string or null, required - `createdAt`: string (date-time), required. When the address was issued. - `lastUsedAt`: string (date-time) or null, required. When a calendar app last fetched it, or null. ### Example ```json { "data": [ { "id": "kq0V3mJ8yQe7Lw2nR5tXcB1dF4gH6jK9", "schedules": [ { "id": "8c2b5a1e-7d4f-4e3a-9b6c-1f0e2d3c4b5a", "name": "Year 12 timetable" } ], "createdAt": "2026-01-15T09:30:00.000Z", "lastUsedAt": "2026-02-01T12:00:00.000Z" } ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Apply copilot actions Source: https://workroad.app/docs/api/apply-copilot-actions `POST https://workroad.app/api/v1/copilot/messages/{messageId}/actions` Runs the actions an assistant reply proposed and returns a receipt for each. ### Selecting actions The app applies a reply's task, note and folder creations by itself; an API client must call this. Send `{}` for those, or name `indices`. `create_schedule` runs only when named, and naming it also needs the `schedule:create` scope, as `POST /schedules` does. With `subjects:create` it also creates the subjects its lessons name that the user doesn't have yet, as `createSubjectsIfMissing` does on `POST /schedules/timetable`; without it those names stay on the timetable as written. ### Edits, deletes and AI actions Edits and deletes run only when named too, and each needs the scope of the matching endpoint: `update_note` needs `notes:update`, `delete_note` `notes:delete` (the note goes to the trash), `update_task` `tasks:update`, `delete_task` `tasks:delete`, `update_subject` `subjects:update`, `update_set` `sets:update` and `update_schedule` `schedule:update`. An edit or delete whose item changed since the reply fails with a `-stale` error such as `task-stale`. So do `create_set_from_note`, `start_generation` and `create_note_from_files`, and each of those counts as an AI use. ### Generation jobs A `create_set_from_note` applied here writes the set when its job succeeds, and `GET /jobs/{jobId}` then returns it as `result.setId`; in the app the user reviews the cards before any set exists. `create_note_from_files` also needs `notes:create`, `files:upload` and `generation:start`: it copies the files the user attached to the message onto a new note and starts a generation that writes into it: poll the receipt's `jobId`. Poll it with `GET /jobs/{jobId}`. A `copilot:use` key can follow the job and read, apply or discard the resulting generation, and applying also needs `notes:update`. ### Inserting text `insert_into_note` is text for the user to accept in the app's note editor: applying it writes nothing and returns a receipt with the error `applies-in-note`. ### Settings `change_setting` runs only when named: it calls the settings operation it names, such as `updateNotificationKind`, with its `arguments` and only the scopes both this key and the turn that proposed it hold, so it needs that operation's scope (`settings:update`). A refused change fails with an error such as `setting-forbidden` or `setting-invalid`, and a reply that used web research changes no setting (`setting-after-research`). ### Receipts and limits An action with a receipt never runs again, so sending the same request twice is safe. On the free plan, 20 actions that change data can run per UTC day. Runs WorkRoad's AI for the actions that start a generation, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). - MCP tool: `applyCopilotActions` on `https://workroad.app/api/mcp` - Required scopes: copilot:use - Test mode: `POST https://workroad.app/api/test/v1/copilot/messages/{messageId}/actions` checks the request and returns the example response without changing anything. ## Path parameters - `messageId`: string (uuid), required. Copilot message id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `indices`: array of integer, up to 8 items. The positions in the message's `actions` to apply. Omit it to apply what the app applies by itself: the `create_task` and `create_note` actions, unless the reply searched the web. `create_set_from_note`, `start_generation` and `create_note_from_files` run only when named here. ## Response `200` OK - `data`: object, required - `results`: array of objects, required. Every action of the message that has a receipt, including ones applied earlier, by index. - `index`: integer, required. The action's position in `actions`. - `ok`: boolean, required. True when the action ran. - `id`: string or null, required. What it made: the task or note id, including the note a `create_note_from_files` writes into. Null for a set or a recording's note. - `jobId`: string or null, required. The job a `create_set_from_note`, `start_generation` or `create_note_from_files` action started. Poll `GET /jobs/{jobId}`. A `create_set_from_note` applied through the API writes its set when the job succeeds, and the job's `result.setId` names it. - `error`: string or null, required. Why it didn't run, such as `in-progress` (another apply is running it), `action-retryable` (apply it again), `note-not-found`, `turn-action-cap` (a reply can run 3 actions that change data) or `task-already-exists`. Null when `ok` is true. ### Example ```json { "data": { "results": [ { "index": 0, "ok": true, "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "jobId": null, "error": null }, { "index": 1, "ok": true, "id": null, "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "error": null } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request`. `details.field` `messageId` with `details.reason` `answer-incomplete` means the reply's job hasn't succeeded. A `start_generation` action can also fail with `noteRecordingId` `empty-transcript` or `unknown-recording` (the recording isn't one of this key's user's), and a start can fail with `modelTier` `premium-only`. - `401`: Missing, invalid, disabled or expired API key - `402`: `ai_limit_reached`: a set or generation action needs an AI use and none is left this month, or the paid plan reached its fair-use limit. Actions that ran before it keep their receipts; the rest can be applied again later. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The key lacks a scope this request needs: `copilot:use`, or the scope a named index's kind needs (`schedule:create`, `schedule:update`, `notes:create`, `files:upload`, `generation:start`, `notes:update`, `notes:delete`, `tasks:update`, `tasks:delete`, `subjects:update` or `sets:update`). - `404`: The message isn't an assistant reply of this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `idempotency_conflict`: the engine already holds a different job under this action's id. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: The free plan's 20 actions for today are used (`Retry-After` is 3600), or the engine is busy (`Retry-After` is 30). Also `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job engine is unreachable. Apply again after `Retry-After` seconds; the action is retried, not run twice. --- # List copilot conversations Source: https://workroad.app/docs/api/list-copilot-conversations `GET https://workroad.app/api/v1/copilot/conversations` Pinned conversations first, then the most recently updated. A conversation that gets a new turn moves to the front, so paging while the user chats can repeat or skip one. Without `settings:view`, a conversation that read or proposed the user's settings is left out. - MCP tool: `listCopilotConversations` on `https://workroad.app/api/mcp` - Required scopes: copilot:use - Test mode: `GET https://workroad.app/api/test/v1/copilot/conversations` checks the request and returns the example response without changing anything. ## Query parameters - `limit`: integer, 1 to 100, default `25`. Page size, 1-100. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `title`: string or null, required. The user's name for it, or its first message. Null for an untitled one. - `updatedAt`: string (date-time), required. When it last changed, such as a new turn or a rename. - `pinned`: boolean, required - `subjectId`: string (uuid) or null, required. The subject it is filed under. Null when it isn't filed. - `subjectName`: string or null, required - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "title": "Enzymes", "updatedAt": "2026-02-01T12:00:00.000Z", "pinned": false, "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectName": "Biology" } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List copilot messages Source: https://workroad.app/docs/api/list-copilot-messages `GET https://workroad.app/api/v1/copilot/conversations/{conversationId}/messages` The newest messages of a conversation, oldest first within a page. Pass `nextCursor` as `?cursor=` to read further back. An assistant reply's `actions` are what the copilot proposed; apply them with `POST /copilot/messages/{messageId}/actions`. - MCP tool: `listCopilotMessages` on `https://workroad.app/api/mcp` - Required scopes: copilot:use - Test mode: `GET https://workroad.app/api/test/v1/copilot/conversations/{conversationId}/messages` checks the request and returns the example response without changing anything. ## Path parameters - `conversationId`: string (uuid), required. Copilot conversation id, scoped to the key's user. ## Query parameters - `limit`: integer, 1 to 100, default `25`. Page size, 1-100. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `role`: one of `"user"`, `"assistant"`, required - `content`: string, required. The message, as Markdown. - `actions`: array of one of the objects below or null, required. The actions an assistant reply proposed. An action's position is its index for `POST /copilot/messages/{messageId}/actions`. Null marks a stored action this API can't describe. Empty for user messages. - One of `kind`: `"reschedule_task"` - `kind`: `"reschedule_task"`, required - `taskId`: string, required, at least 1 character - `name`: string, required, 1-120 characters - `dueDate`: string, required, at least 1 character - `previousDueDate`: string or null, required - `expectedUpdatedAt`: string, required, at least 1 character - Or `kind`: `"create_study_block"` - `kind`: `"create_study_block"`, required - `name`: string, required, 1-120 characters - `startsAt`: string, required, at least 1 character - `endsAt`: string, required, at least 1 character - `timeZone`: string, required, at least 1 character - `subjectId`: string, at least 1 character - `taskId`: string, at least 1 character - `setId`: string, at least 1 character - Or `kind`: `"create_task"` - `kind`: `"create_task"`, required - `name`: string, required, 1-120 characters - `dueDate`: string - `subject`: string - `subjectId`: string - `type`: string - `recurrence`: object - `unit`: one of `"day"`, `"week"`, `"month"`, required - `interval`: integer, required, 1 to 30 - `endsAt`: string - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"` - Or `kind`: `"create_note"` - `kind`: `"create_note"`, required - `name`: string, required, 1-64 characters - `content`: string, up to 100000 characters - `pinned`: boolean - `folderId`: string, at least 1 character - Or `kind`: `"create_note_from_files"` - `kind`: `"create_note_from_files"`, required - `name`: string, required, 1-64 characters - `attachmentIds`: array of string, required, 1-4 items - `folderId`: string, at least 1 character - Or `kind`: `"create_folder"` - `kind`: `"create_folder"`, required - `name`: string, required, 1-64 characters - `subjectId`: string, at least 1 character - Or `kind`: `"update_note"` - `kind`: `"update_note"`, required - `noteId`: string, required, at least 1 character - `currentName`: string, required, 1-64 characters - `name`: string, 1-64 characters - `folderId`: string or null, at least 1 character - `pinned`: boolean - Or `kind`: `"delete_note"` - `kind`: `"delete_note"`, required - `noteId`: string, required, at least 1 character - `currentName`: string, required, 1-64 characters - Or `kind`: `"update_task"` - `kind`: `"update_task"`, required - `taskId`: string, required, at least 1 character - `currentName`: string, required, 1-200 characters - `expectedUpdatedAt`: string, required, at least 1 character - `name`: string, 1-200 characters - `description`: string or null, up to 512 characters - `dueDate`: string or null - `type`: string - `subjectId`: string or null, at least 1 character - `completed`: boolean - Or `kind`: `"delete_task"` - `kind`: `"delete_task"`, required - `taskId`: string, required, at least 1 character - `currentName`: string, required, 1-200 characters - `expectedUpdatedAt`: string, required, at least 1 character - Or `kind`: `"create_set_from_note"` - `kind`: `"create_set_from_note"`, required - `noteId`: string, required, at least 1 character - Or `kind`: `"start_generation"` - `kind`: `"start_generation"`, required - `noteRecordingId`: string, at least 1 character - `topic`: string, 1-200 characters - Or `kind`: `"create_schedule"` - `kind`: `"create_schedule"`, required - `name`: string, required, 2-64 characters - `scheduleType`: one of `"defined"`, `"dynamic"`, required - `firstDay`: integer, 0 to 6 - `periods`: array of objects, up to 30 items - `periodName`: string, required, up to 200 characters - `time`: object or null, required - `start`: string, required, up to 5 characters - `end`: string, required, up to 5 characters - `days`: array of array of object or null, required, up to 7 items - `lessonName`: string, required, up to 200 characters - `room`: string, required, up to 200 characters - `subject`: string, required, up to 200 characters - `teacher`: string, required, up to 200 characters - `timeStart`: string, up to 5 characters - `timeEnd`: string, up to 5 characters - Or `kind`: `"update_subject"` - `kind`: `"update_subject"`, required - `subjectId`: string, required, at least 1 character - `currentName`: string, required, 1-64 characters - `name`: string, 1-64 characters - `teachers`: array of string, up to 20 items - `period`: string or null, up to 60 characters - `archived`: boolean - Or `kind`: `"update_set"` - `kind`: `"update_set"`, required - `setId`: string, required, at least 1 character - `currentName`: string, required, 1-120 characters - `name`: string, 1-120 characters - `description`: string or null, up to 2000 characters - `subjectId`: string or null, at least 1 character - Or `kind`: `"update_schedule"` - `kind`: `"update_schedule"`, required - `scheduleId`: string, required, at least 1 character - `currentName`: string, required, 1-200 characters - `name`: string, 2-64 characters - `cells`: array of objects, up to 40 items - `day`: integer, required, 0 to 6 - `period`: integer, required, 1 to 30 - `current`: string or null, required, up to 200 characters - `lesson`: object or null, required - `lessonName`: string, required, up to 200 characters - `room`: string, required, up to 200 characters - `subject`: string, required, up to 200 characters - `teacher`: string, required, up to 200 characters - `timeStart`: string, up to 5 characters - `timeEnd`: string, up to 5 characters - Or `kind`: `"change_setting"` - `kind`: `"change_setting"`, required - `operation`: one of `"updateProfile"`, `"updatePreferences"`, `"updateNotificationSettings"`, `"updateNotificationKind"`, `"resumeNotificationEmail"`, `"updateReviewSettings"`, `"updateTaskSettings"`, `"replaceDashboardLayout"`, `"deleteCalendarFeed"`, required. The settings operation of this API that applying runs. - `arguments`: any value, required. That operation's arguments, laid out as its MCP tool takes them: path parameters and body fields in one object. - Or `kind`: `"navigate"` - `kind`: `"navigate"`, required - `path`: string, required, 1-200 characters - Or `kind`: `"insert_into_note"` - `kind`: `"insert_into_note"`, required - `noteId`: string, required, at least 1 character - `markdown`: string, required, 1-20000 characters - `anchor`: object or null, required - `text`: string, required, 1-128 characters - `where`: one of `"before"`, `"after"`, required - `actionResults`: array of objects, required. What happened to each applied action, by index. Unapplied ones are absent. - `index`: integer, required. The action's position in `actions`. - `ok`: boolean, required. True when the action ran. - `id`: string or null, required. What it made: the task or note id, including the note a `create_note_from_files` writes into. Null for a set or a recording's note. - `jobId`: string or null, required. The job a `create_set_from_note`, `start_generation` or `create_note_from_files` action started. Poll `GET /jobs/{jobId}`. A `create_set_from_note` applied through the API writes its set when the job succeeds, and the job's `result.setId` names it. - `error`: string or null, required. Why it didn't run, such as `in-progress` (another apply is running it), `action-retryable` (apply it again), `note-not-found`, `turn-action-cap` (a reply can run 3 actions that change data) or `task-already-exists`. Null when `ok` is true. - `modelTier`: string or null, required. The tier an assistant reply ran on. Null for user messages. - `jobId`: string (uuid) or null, required. The turn's job. A user message and its reply share it. - `jobStatus`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"` or null, required. The status of that job. Null when the message has no job. - `createdAt`: string (date-time), required. When the message was written. - `researchUsed`: boolean, required. True when the turn searched the web. - `sources`: array of objects, required. The web pages the turn cites. - `url`: string, required - `title`: string, required - `host`: string, required - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "role": "assistant", "content": "Enzymes lower the activation energy of a reaction.", "actions": [ { "kind": "create_task", "name": "Revise enzymes", "dueDate": "2026-02-03" }, { "kind": "create_set_from_note", "noteId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" } ], "actionResults": [ { "index": 0, "ok": true, "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "jobId": null, "error": null } ], "modelTier": "scholar", "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "jobStatus": "succeeded", "createdAt": "2026-01-15T09:30:00.000Z", "researchUsed": false, "sources": [] } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: `details.reason` is `settings-in-conversation`: a turn of this conversation read or proposed the user's settings, and this key lacks `settings:view`. - `404`: No conversation with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Send a copilot message Source: https://workroad.app/docs/api/send-copilot-message `POST https://workroad.app/api/v1/copilot/messages` Adds a message to a copilot conversation, or starts one, and queues the reply. The reply runs as a job: poll `GET /jobs/{jobId}` until it ends. A turn counts as an AI use, and on the free plan also as one of 10 turns per UTC day. Attachments are uploaded in the app only; to give the copilot material, write a note or upload a note file and mention or ground on it. Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). If you can write this content yourself, write it and save it with `POST /notes`, `POST /sets`, `POST /tests/from-questions` or `POST /tasks/batch`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work. - MCP tool: `sendCopilotMessage` on `https://workroad.app/api/mcp` - Required scopes: copilot:use - Test mode: `POST https://workroad.app/api/test/v1/copilot/messages` checks the request and returns the example response without changing anything. - Next: poll `getJob` with the `jobId` until it ends, then read the reply with `listCopilotMessages` and apply its proposed actions with `applyCopilotActions`. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `requestId`: string (uuid), required. A uuid you mint for this message. It becomes the reply's job id. Sending the same message with the same `requestId` again returns the same turn and is not counted twice; a different message with a used `requestId` is 409 `idempotency_conflict`. - `conversationId`: string, at least 1 character. Continue this conversation. Omit it to start a new one. - `message`: string, required, 1-4000 characters. The message, 1-4000 characters after trimming. - `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`, default `"scholar"`. `swift`, `scholar` (the default) or `sage`. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`. - `research`: boolean, default `false`. Let the copilot search the web for this reply. - `context`: object. What a new conversation is about: `general`, a `note` (`noteId`), a `subject` (`subjectId`) or a practice test explanation (`activityRef`). Ignored when continuing a conversation, which keeps its own. - `kind`: one of `"general"`, `"note"`, `"activity"`, `"subject"`, `"schedule"`, required - `noteId`: string, up to 200 characters - `activityRef`: string, up to 200 characters - `subjectId`: string, up to 200 characters - `scheduleId`: string, up to 200 characters - `subjectId`: string, at least 1 character. File a new conversation under this subject. An id the user doesn't own is ignored. - `retryJobId`: string, at least 1 character. Retry a failed or stopped reply: that turn's job id, sent with the same `message`, `modelTier` and `research` under a new `requestId`. The retry takes the failed turn's place in the conversation and does not count against the free plan's daily turns. ## Response `202` Accepted - `data`: object, required - `conversationId`: string (uuid), required. The conversation the message was added to. - `jobId`: string (uuid), required. The reply's job, the `requestId` you sent. Poll `GET /jobs/{jobId}` until it ends; its result holds the reply. ### Example ```json { "data": { "conversationId": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request`. `details.field` and `details.reason` say why: `modelTier` `premium-only` (the `sage` tier needs a paid plan), `retryJobId` `not-retryable` (that turn can't be retried with this message, or it was sent with files, which the API can't attach), `requestId` `expired` (this request id's turn already ended; send with a new one), or `stopLimit` (20 replies were stopped today, UTC). - `401`: Missing, invalid, disabled or expired API key - `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. The message is not saved and uses none of the free plan's 10 turns a day. Once a use is available, sending the same `requestId` again sends it. When this month's uses and today's turns are both spent, the response is this 402: the month's limit binds, and `resetsAt` says when it lifts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: `details.reason` is `settings-in-conversation`: `conversationId` names a conversation that read or proposed the user's settings, and this key lacks `settings:view`. - `404`: The `conversationId`, or the practice test in `context.activityRef`, doesn't exist for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `idempotency_conflict`: this `requestId` was already used for a different message. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: The free plan's 10 turns for today are used (`Retry-After` is 3600), or the copilot is busy (`Retry-After` is 15). When this month's AI uses are spent too, the response is 402 instead. Also `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job engine is unreachable. Send the same request again after `Retry-After` seconds. --- # Read a docs page Source: https://workroad.app/docs/api/get-doc `GET https://workroad.app/api/v1/docs/page` Returns a WorkRoad docs page as Markdown: a guide to part of the app, the REST API or MCP guide, or an operation's reference with its parameters, fields, example response and errors. Find the page with `searchDocs`, or pass a docs URL you already have. Any key may call it, and it doesn't count toward the free plan's monthly requests. - MCP tool: `getDoc` on `https://workroad.app/api/mcp` - Required scopes: None beyond a valid key. - Test mode: `GET https://workroad.app/api/test/v1/docs/page` checks the request and returns the example response without changing anything. ## Query parameters - `slug`: string, up to 300 characters, default `""`. The page: its path under `/docs`, such as `studying` or `api/mcp`, or its whole URL, such as `https://workroad.app/docs/api/mcp.md`. Leave it out for the docs' first page. - `locale`: one of `"en"`, `"de"`, `"es"`, `"fr"`, `"it"`. The docs' language: `en`, `de`, `es`, `fr`, `it`. Defaults to `en`. A page with no translation, such as an API reference page, comes back in English. ## Response `200` OK - `data`: object, required - `title`: string, required - `description`: string or null, required. The page's summary, when it has one. - `slug`: string, required - `locale`: one of `"en"`, `"de"`, `"es"`, `"fr"`, `"it"`, required. The language asked for. - `url`: string, required. The page on the docs site. - `markdownUrl`: string, required. The same page as Markdown, readable without a key. - `markdown`: string, required. The whole page as Markdown, starting with its title. Links point at the docs site. ### Example ```json { "data": { "title": "MCP server", "description": "Add WorkRoad to ChatGPT or Claude by pasting one address, or connect Claude Code, Codex or Cursor with an API key.", "slug": "api/mcp", "locale": "en", "url": "https://workroad.app/docs/api/mcp", "markdownUrl": "https://workroad.app/docs/api/mcp.md", "markdown": "# MCP server\n\n> Add WorkRoad to ChatGPT or Claude by pasting one address, or connect Claude Code, Codex or Cursor with an API key.\n\nWorkRoad runs an MCP server at `https://workroad.app/api/mcp`.\n" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `403`: The API key lacks the required scope - `404`: `not_found`: no docs page has that slug. Find it with `searchDocs`. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Search the docs Source: https://workroad.app/docs/api/search-docs `GET https://workroad.app/api/v1/docs/search` Searches the WorkRoad docs: the guides to each part of the app, the REST API guide, the MCP server guide and the reference page of every operation. Use it when the user asks how WorkRoad works, how to do something in the app, or how to use the API, then read a page with `getDoc`. Pages come best match first, each with the passages that matched. Any key may call it, and it doesn't count toward the free plan's monthly requests. - MCP tool: `searchDocs` on `https://workroad.app/api/mcp` - Required scopes: None beyond a valid key. - Test mode: `GET https://workroad.app/api/test/v1/docs/search` checks the request and returns the example response without changing anything. - Next: read the best page with `getDoc` and its `slug`. ## Query parameters - `q`: string, required, 1-200 characters. Words to look for, 1-200 characters, such as `practice flashcards`. - `locale`: one of `"en"`, `"de"`, `"es"`, `"fr"`, `"it"`. The docs' language: `en`, `de`, `es`, `fr`, `it`. Defaults to `en`. A page with no translation, such as an API reference page, comes back in English. - `limit`: integer, 1 to 20, default `5`. Pages to return, 1-20. ## Response `200` OK - `data`: array of objects, required - `title`: string, required - `description`: string or null, required. The page's summary, when it has one. - `slug`: string, required. The page's `slug` for `getDoc`. - `url`: string, required. The page on the docs site. - `markdownUrl`: string, required. The same page as Markdown, readable without a key. - `snippets`: array of objects, required. Up to three passages of the page that match, best first. - `text`: string, required. A matching heading or passage. - `url`: string, required. The page, scrolled to that passage. ### Example ```json { "data": [ { "title": "Studying", "description": "Study sets, take tests, use the feed, and check performance.", "slug": "studying", "url": "https://workroad.app/docs/studying", "markdownUrl": "https://workroad.app/docs/studying.md", "snippets": [ { "text": "Study a set", "url": "https://workroad.app/docs/studying#study-a-set" } ] } ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get the account export Source: https://workroad.app/docs/api/get-export `GET https://workroad.app/api/v1/export` The latest account export: everything the user's account holds in one ZIP (notes as Markdown with their images and files, recordings, study sets with their review history, tasks, schedules, tests, Copilot chats and settings as JSON). Start one with `POST /export`. `data` is null when none was started in the last 7 days. - MCP tool: `getExport` on `https://workroad.app/api/mcp` - Required scopes: account:export - Test mode: `GET https://workroad.app/api/test/v1/export` checks the request and returns the example response without changing anything. - Next: when `status` is `pending` or `running`, call it again in a minute. ## Response `200` OK - `data`: object or null, required - `id`: string (uuid), required. The export's id. - `status`: one of `"pending"`, `"running"`, `"ready"`, `"failed"`, `"expired"`, required. `pending` and `running` while it is built, `ready` while it can be downloaded, `expired` once its download window has passed, `failed` if it could not be built. - `createdAt`: string (date-time), required. When the export was requested. - `downloadUrl`: string or null, required. When `status` is `ready`: a URL that downloads the ZIP for the next 5 minutes, without credentials. Ask again for a fresh one. Null otherwise. - `expiresAt`: string (date-time) or null, required. When `status` is `ready`: when the export stops being available. Null otherwise. - `sizeBytes`: integer or null, required. The ZIP's size when `status` is `ready`, else null. ### Example ```json { "data": { "id": "4f1c2b3a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "status": "ready", "createdAt": "2026-10-28T09:15:00.000Z", "downloadUrl": "https://storage.example.com/export/…/4f1c2b3a.zip?X-Amz-Signature=…", "expiresAt": "2026-11-04T09:21:42.000Z", "sizeBytes": 48213504 } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Start an account export Source: https://workroad.app/docs/api/start-export `POST https://workroad.app/api/v1/export` Starts building the account export, or returns the one already being built. It takes a few minutes for a large account; poll `GET /export` until `status` is `ready`. The user also gets an email with a link when it is ready. - MCP tool: `startExport` on `https://workroad.app/api/mcp` - Required scopes: account:export - Test mode: `POST https://workroad.app/api/test/v1/export` checks the request and returns the example response without changing anything. - Next: poll `getExport` until `status` is `ready`, then download `downloadUrl`. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `202` Accepted - `data`: object, required - `id`: string (uuid), required. The export's id. - `status`: one of `"pending"`, `"running"`, `"ready"`, `"failed"`, `"expired"`, required. `pending` and `running` while it is built, `ready` while it can be downloaded, `expired` once its download window has passed, `failed` if it could not be built. - `createdAt`: string (date-time), required. When the export was requested. - `downloadUrl`: string or null, required. When `status` is `ready`: a URL that downloads the ZIP for the next 5 minutes, without credentials. Ask again for a fresh one. Null otherwise. - `expiresAt`: string (date-time) or null, required. When `status` is `ready`: when the export stops being available. Null otherwise. - `sizeBytes`: integer or null, required. The ZIP's size when `status` is `ready`, else null. ### Example ```json { "data": { "id": "4f1c2b3a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "status": "pending", "createdAt": "2026-10-28T09:15:00.000Z", "downloadUrl": null, "expiresAt": null, "sizeBytes": null } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Too many exports were started in the last hour, or the engine is busy. Wait for the seconds in `Retry-After`. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job engine is unreachable. Send the same request again after `Retry-After` seconds. --- # Complete a file upload Source: https://workroad.app/docs/api/complete-note-file `POST https://workroad.app/api/v1/files/{fileId}/complete` Checks that the upload reached storage and starts extracting the file's text. Poll `GET /files/{fileId}` until `status` is `ready` or `failed`. Calling it again while the file is parsing returns the same job. - MCP tool: `completeNoteFile` on `https://workroad.app/api/mcp` - Required scopes: files:upload - Test mode: `POST https://workroad.app/api/test/v1/files/{fileId}/complete` checks the request and returns the example response without changing anything. ## Path parameters - `fileId`: string (uuid), required. Note file id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `202` Accepted - `data`: object, required - `id`: string (uuid), required - `status`: `"parsing"`, required - `jobId`: string (uuid), required. The `file.parse` job extracting the text. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "status": "parsing", "jobId": "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note file with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `upload-missing` when storage has no upload for this file yet: upload it, then complete again. `conflict` with `details.reason` `image-file` for an image, which only the app parses. `conflict` with `details.reason` `already-parsed` when the file's text was already extracted (its `status` is `ready`). `conflict` with `details.reason` `wrong-type` when the uploaded bytes are not the type the file's extension names, for example a ZIP named `notes.pdf`; the file is deleted, so create it again with the right name. `idempotency_conflict` when the parse job's key collides with an earlier job; retry. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: The user already has too many jobs queued. Wait for the seconds in `Retry-After` (5), then complete again. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job queue is unavailable. Wait for the seconds in `Retry-After`, then complete again. --- # Start a file upload Source: https://workroad.app/docs/api/create-note-file `POST https://workroad.app/api/v1/notes/{noteId}/files` Reserves a file on the note and returns a signed upload. POST the file to `upload.url` with the `upload.fields` within 10 minutes, then call `POST /files/{fileId}/complete`. A file that is never completed is deleted after 24 hours. Each user can hold 200 files and 2 GiB, and each note 25 files. Trashed files, and files on trashed notes, count until they are permanently deleted. - MCP tool: `createNoteFile` on `https://workroad.app/api/mcp` - Required scopes: files:upload - Test mode: `POST https://workroad.app/api/test/v1/notes/{noteId}/files` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `fileName`: string, required, 1-255 characters. The file's name, shown in the app. Its extension must match `mimeType`, for example `notes.pdf` with `application/pdf`. - `mimeType`: string, required, at least 1 character. The type for the file's extension: `application/pdf` (`.pdf`), `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (`.docx`), `application/vnd.openxmlformats-officedocument.presentationml.presentation` (`.pptx`), `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (`.xlsx`), `application/vnd.oasis.opendocument.text` (`.odt`), `application/vnd.oasis.opendocument.spreadsheet` (`.ods`), `application/vnd.oasis.opendocument.presentation` (`.odp`), `application/rtf` (`.rtf`), `application/epub+zip` (`.epub`), `text/markdown` (`.md`, `.markdown`), `text/csv` (`.csv`), `text/plain` (`.html`, `.htm`, `.txt`, `.text`, `.log`, `.tex`, `.py`, `.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.mts`, `.cts`, `.tsx`, `.go`, `.rs`, `.java`, `.c`, `.h`, `.cpp`, `.cc`, `.cxx`, `.hpp`, `.sh`, `.bash`, `.zsh`, `.sql`, `.yaml`, `.yml`, `.json`, `.css`, `.scss`, `.kt`, `.swift`, `.rb`, `.php`, `.cs`, `.r`, `.lua`, `.toml`, `.ini`, `.xml`), `application/x-ipynb+json` (`.ipynb`). HTML, LaTeX and source code are uploaded as `text/plain`. Images are uploaded in the app. - `sizeBytes`: integer, required, 1 to 52428800. The exact size of the file in bytes, at most 52428800 (50 MiB). Storage refuses a larger upload. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `status`: `"awaiting_upload"`, required - `upload`: object, required. A signed storage upload for at most `sizeBytes` bytes of `mimeType`. - `url`: string, required. POST the file here as multipart/form-data. - `fields`: array of objects, required. Form fields to send verbatim, in this order, before the `file` field. - `name`: string, required - `value`: string, required - `expiresAt`: string (date-time), required. Storage refuses the signed upload after this time, 10 minutes after creation. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "status": "awaiting_upload", "upload": { "url": "https://storage.example.com/workroad", "fields": [ { "name": "bucket", "value": "workroad" }, { "name": "key", "value": "note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f-cell-biology-lecture.pdf" }, { "name": "Content-Type", "value": "application/pdf" }, { "name": "x-amz-date", "value": "20260115T093000Z" }, { "name": "x-amz-algorithm", "value": "AWS4-HMAC-SHA256" }, { "name": "x-amz-credential", "value": "EXAMPLEKEY/20260115/us-east-1/s3/aws4_request" }, { "name": "policy", "value": "eyJleHBpcmF0aW9uIjoiMjAyNi0wMS0xNVQwOTo0MDowMFoifQ==" }, { "name": "x-amz-signature", "value": "9b2f0c1d8e7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c" } ] }, "expiresAt": "2026-01-15T09:40:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: The file name's extension doesn't match a supported `mimeType`, or the body fails validation. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: A file quota is reached: `details.reason` is `files-per-user`, `files-per-note` or `bytes-per-user`, `details.limit` the limit and `details.used` what the user holds, in files or bytes. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: The key is over a rate limit, or too many uploads by this user collided at once. Wait for the seconds in `Retry-After`. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Move a file to the trash Source: https://workroad.app/docs/api/delete-note-file `DELETE https://workroad.app/api/v1/files/{fileId}` The file disappears from every read and stops being searchable; `POST /files/{fileId}/restore` brings it back. Extracted images the note still shows are kept as standalone images, so the note doesn't change. If preserving those images would exceed a file or storage quota, nothing is moved. An image file is also removed from the note's content, and a restore doesn't put it back there. A trashed file keeps counting toward the storage limit until the trash is emptied after 30 days. - MCP tool: `deleteNoteFile` on `https://workroad.app/api/mcp` - Required scopes: files:delete - Test mode: `DELETE https://workroad.app/api/test/v1/files/{fileId}` checks the request and returns the example response without changing anything. ## Path parameters - `fileId`: string (uuid), required. Note file id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: An image extracted from the file that the note still shows couldn't be copied out, so nothing was deleted. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note file with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The note changed while the file's images were being preserved, or preserving them would exceed a file or storage quota. Nothing was deleted. For a quota error, details has reason, limit and used. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a file Source: https://workroad.app/docs/api/get-note-file `GET https://workroad.app/api/v1/files/{fileId}` The file's upload and parse status. `failure` explains a `failed` or `expired` file. - MCP tool: `getNoteFile` on `https://workroad.app/api/mcp` - Required scopes: files:view - Test mode: `GET https://workroad.app/api/test/v1/files/{fileId}` checks the request and returns the example response without changing anything. ## Path parameters - `fileId`: string (uuid), required. Note file id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `noteId`: string (uuid), required - `kind`: one of `"document"`, `"image"`, required. `document` for files the API can upload and complete. `image` for images added in the app, which only the app parses. - `name`: string, required - `mimeType`: string, required - `sizeBytes`: integer or null, required. The size declared when the upload was created. Null for files uploaded before sizes were recorded. - `url`: string, required. Where the file is readable once uploaded. Anyone with this URL can read it. - `status`: one of `"awaiting_upload"`, `"parsing"`, `"ready"`, `"failed"`, `"expired"`, required. `awaiting_upload` until the file is uploaded and completed, `parsing` while its text is extracted, then `ready`, `failed` or `expired`. - `failure`: string or null, required. Why the file is `failed` or `expired`, such as `object-missing`, `unsupported-format` or `too-large`. Null otherwise. - `chars`: integer, required. Characters of extracted text. - `imageCount`: integer, required. Images extracted from the document. - `jobId`: string or null, required. The newest parse job for this file, when there is one. - `createdAt`: string (date-time), required. When the upload was created. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "noteId": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "kind": "document", "name": "Cell biology lecture.pdf", "mimeType": "application/pdf", "sizeBytes": 482113, "url": "https://storage.example.com/workroad/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f-cell-biology-lecture.pdf", "status": "ready", "failure": null, "chars": 18240, "imageCount": 3, "jobId": "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d", "createdAt": "2026-01-15T09:30:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note file with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List a note's files Source: https://workroad.app/docs/api/list-note-files `GET https://workroad.app/api/v1/notes/{noteId}/files` Every file attached to the note, newest first, with its upload and parse status. - MCP tool: `listNoteFiles` on `https://workroad.app/api/mcp` - Required scopes: files:view - Test mode: `GET https://workroad.app/api/test/v1/notes/{noteId}/files` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `noteId`: string (uuid), required - `kind`: one of `"document"`, `"image"`, required. `document` for files the API can upload and complete. `image` for images added in the app, which only the app parses. - `name`: string, required - `mimeType`: string, required - `sizeBytes`: integer or null, required. The size declared when the upload was created. Null for files uploaded before sizes were recorded. - `url`: string, required. Where the file is readable once uploaded. Anyone with this URL can read it. - `status`: one of `"awaiting_upload"`, `"parsing"`, `"ready"`, `"failed"`, `"expired"`, required. `awaiting_upload` until the file is uploaded and completed, `parsing` while its text is extracted, then `ready`, `failed` or `expired`. - `failure`: string or null, required. Why the file is `failed` or `expired`, such as `object-missing`, `unsupported-format` or `too-large`. Null otherwise. - `chars`: integer, required. Characters of extracted text. - `imageCount`: integer, required. Images extracted from the document. - `jobId`: string or null, required. The newest parse job for this file, when there is one. - `createdAt`: string (date-time), required. When the upload was created. ### Example ```json { "data": [ { "id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "noteId": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "kind": "document", "name": "Cell biology lecture.pdf", "mimeType": "application/pdf", "sizeBytes": 482113, "url": "https://storage.example.com/workroad/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f-cell-biology-lecture.pdf", "status": "ready", "failure": null, "chars": 18240, "imageCount": 3, "jobId": "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d", "createdAt": "2026-01-15T09:30:00.000Z" } ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Restore a file from the trash Source: https://workroad.app/docs/api/restore-note-file `POST https://workroad.app/api/v1/files/{fileId}/restore` Brings a trashed file back to its note's files and returns it. Needs `files:delete`, the scope that trashed it. The note must be out of the trash. An image file removed from the note's content stays out of it. The file never stopped counting toward the file and storage limits, so a restore always succeeds. - MCP tool: `restoreNoteFile` on `https://workroad.app/api/mcp` - Required scopes: files:delete - Test mode: `POST https://workroad.app/api/test/v1/files/{fileId}/restore` checks the request and returns the example response without changing anything. ## Path parameters - `fileId`: string (uuid), required. Note file id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `noteId`: string (uuid), required - `kind`: one of `"document"`, `"image"`, required. `document` for files the API can upload and complete. `image` for images added in the app, which only the app parses. - `name`: string, required - `mimeType`: string, required - `sizeBytes`: integer or null, required. The size declared when the upload was created. Null for files uploaded before sizes were recorded. - `url`: string, required. Where the file is readable once uploaded. Anyone with this URL can read it. - `status`: one of `"awaiting_upload"`, `"parsing"`, `"ready"`, `"failed"`, `"expired"`, required. `awaiting_upload` until the file is uploaded and completed, `parsing` while its text is extracted, then `ready`, `failed` or `expired`. - `failure`: string or null, required. Why the file is `failed` or `expired`, such as `object-missing`, `unsupported-format` or `too-large`. Null otherwise. - `chars`: integer, required. Characters of extracted text. - `imageCount`: integer, required. Images extracted from the document. - `jobId`: string or null, required. The newest parse job for this file, when there is one. - `createdAt`: string (date-time), required. When the upload was created. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "noteId": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "kind": "document", "name": "Cell biology lecture.pdf", "mimeType": "application/pdf", "sizeBytes": 482113, "url": "https://storage.example.com/workroad/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f-cell-biology-lecture.pdf", "status": "ready", "failure": null, "chars": 18240, "imageCount": 3, "jobId": "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d", "createdAt": "2026-01-15T09:30:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note file with that id is in the trash for this key's user, on a note outside the trash. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Apply a generation Source: https://workroad.app/docs/api/apply-generation `POST https://workroad.app/api/v1/generations/{generationId}/apply` Writes a generation into its note. A generation made for an empty note is written in while the note is still empty and unchanged, as the app does; otherwise `mode` decides. Applying again changes nothing and returns `alreadyApplied` true. Needs `generation:start` or `copilot:use`, and `notes:update`. - MCP tool: `applyGeneration` on `https://workroad.app/api/mcp` - Required scopes: notes:update; generation:start or copilot:use - Test mode: `POST https://workroad.app/api/test/v1/generations/{generationId}/apply` checks the request and returns the example response without changing anything. ## Path parameters - `generationId`: string (uuid), required. Generation id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `mode`: one of `"insert-end"`, `"replace-content"`, required. `replace-content` swaps the note's content for the preview; `insert-end` appends it, unless the note would pass 200,000 visible characters, which is 400 `note-too-large`. A generation made for an empty note is written in as the app writes it while the note is still empty and unchanged; once the note has changed, `mode` applies, so `replace-content` returns 409 and `insert-end` appends. ## Response `200` OK - `data`: object, required - `applied`: boolean, required. True when this request wrote the note. - `alreadyApplied`: boolean, required. True when the generation was applied before; nothing changed. - `noteId`: string (uuid), required ### Example ```json { "data": { "applied": true, "alreadyApplied": false, "noteId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request`. With `details.field` `generationId`, `details.reason` is `discarded` (it was discarded), `inline-editor-artifact` (an editor suggestion, accepted in the app), or `legacy-note-content` (the note's stored content can't be appended to; use `replace-content`). With `details.field` `markdown`, `details.reason` `note-too-large` means `insert-end` would grow the note past 200,000 visible characters; nothing is written and the preview stays applicable. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No generation with that id exists for this key's user. Also returned as "No note with that id exists for this key's user." when the note was deleted while applying. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict`: `replace-content` would drop edits made to the note after the generation read it. `details` has `baseVersion` and `currentVersion`. Apply with `insert-end`, or generate again. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Discard a generation Source: https://workroad.app/docs/api/discard-generation `POST https://workroad.app/api/v1/generations/{generationId}/discard` Drops a preview without touching the note. Discarding again returns `discarded` false. - MCP tool: `discardGeneration` on `https://workroad.app/api/mcp` - Required scopes: generation:start or copilot:use - Test mode: `POST https://workroad.app/api/test/v1/generations/{generationId}/discard` checks the request and returns the example response without changing anything. ## Path parameters - `generationId`: string (uuid), required. Generation id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `discarded`: boolean, required. True when this request discarded it. False when it was discarded before. ### Example ```json { "data": { "discarded": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request` with `details.reason` `already-applied`: the generation is already in the note. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No generation with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Generate a note from sources Source: https://workroad.app/docs/api/generate-note-from-sources `POST https://workroad.app/api/v1/notes/{noteId}/generate` Writes a note from the note's own files, recordings and text, as the app's Generate button does. The generation runs as a job: poll `GET /jobs/{jobId}` until it ends. An empty note gets the result written in when you apply it (`intoNote` is true); a note with text gets a preview to apply or discard. Counts as an AI use. Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). If you can write this content yourself, write it and save it with `POST /notes`, `PUT /notes/{noteId}/content` or `POST /notes/{noteId}/content/append`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work. - MCP tool: `generateNoteFromSources` on `https://workroad.app/api/mcp` - Required scopes: generation:start - Test mode: `POST https://workroad.app/api/test/v1/notes/{noteId}/generate` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `requestId`: string (uuid), required. A uuid you mint for this start. It becomes the job id. Sending it again with the same body returns the same job and is not counted twice while the note and its sources are unchanged. Once they change, for example after a generation was applied to the note, the same `requestId` is 409 `idempotency_conflict`, as is a different body. Mint a new one for each new generation. - `sources`: object, required. What to write from: up to 20 of the note's files (`fileIds`, each `ready`), up to 20 of its recordings (`recordingIds`, made in the app) and, with `includeNote`, the note's own text. Name each source once, and at least one. - `fileIds`: array of string (uuid), required, up to 20 items - `recordingIds`: array of string (uuid), required, up to 20 items - `includeNote`: boolean, required - `depth`: one of `"standard"`, `"advanced"`, required. `standard` for a study note, `advanced` for a longer, more detailed one. - `instructions`: string, up to 1000 characters. What to focus on, up to 1,000 characters after trimming. - `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`. `swift`, `scholar` or `sage`; `scholar` when omitted. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`. ## Response `202` Accepted - `data`: object, required - `jobId`: string (uuid), required. The job, the `requestId` you sent. Poll `GET /jobs/{jobId}` until it ends. - `intoNote`: boolean, required. True when the note was empty: apply the finished generation to write it in. False means it becomes a preview to apply or discard. ### Example ```json { "data": { "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "intoNote": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request`. `details.field` and `details.reason` say why: `sources` `not-found` (a file or recording isn't on this note), `sources.fileIds` `not-ready` (a file isn't parsed yet; wait for `status` `ready`), `sources.recordingIds` `not-ready` (a recording has no transcript), `sources` `empty-note` (only the note was picked and it has no text), `sources` `empty-sources` (none of the picked sources has text), or `modelTier` `premium-only`. A refused start takes no AI use. - `401`: Missing, invalid, disabled or expired API key - `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. Nothing starts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `idempotency_conflict`: this `requestId` already started a job with other inputs. That happens when the body differs, or when the note or its sources changed since, for example once a generation was applied to the note. Send a new `requestId`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Without a `details.reason`, the user has too many jobs queued: retry after `Retry-After` seconds (30). Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job engine is unreachable. Send the same request again after `Retry-After` seconds; the `requestId` keeps it from starting twice. --- # Generate a note from a topic Source: https://workroad.app/docs/api/generate-note-from-topic `POST https://workroad.app/api/v1/notes/{noteId}/generate-topic` Writes a note about a topic into a preview for this note. Poll `GET /jobs/{jobId}` until it ends, then apply or discard the preview named by `result.generationId`. Counts as an AI use. Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). If you can write this content yourself, write it and save it with `POST /notes` or `POST /notes/{noteId}/content/append`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work. - MCP tool: `generateNoteFromTopic` on `https://workroad.app/api/mcp` - Required scopes: generation:start - Test mode: `POST https://workroad.app/api/test/v1/notes/{noteId}/generate-topic` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `requestId`: string (uuid), required. A uuid you mint for this start. It becomes the job id. Sending it again with the same body returns the same job and is not counted twice; a different body under a used `requestId` is 409 `idempotency_conflict`. - `topic`: string, required, 1-200 characters. What to write about, 1-200 characters. - `context`: string, up to 500 characters. More detail on what the note should cover, up to 500 characters. - `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`. `swift`, `scholar` or `sage`; `scholar` when omitted. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`. ## Response `202` Accepted - `data`: object, required - `jobId`: string (uuid), required. The job, the `requestId` you sent. Poll `GET /jobs/{jobId}` until it ends. ### Example ```json { "data": { "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request`: the body fails validation, or `details.reason` is `premium-only` for the `sage` tier. - `401`: Missing, invalid, disabled or expired API key - `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. Nothing starts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `idempotency_conflict`: this `requestId` already started a job with a different body. Send a new `requestId`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Without a `details.reason`, the user has too many jobs queued: retry after `Retry-After` seconds (30). Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job engine is unreachable. Send the same request again after `Retry-After` seconds; the `requestId` keeps it from starting twice. --- # Generate a study set Source: https://workroad.app/docs/api/generate-study-set `POST https://workroad.app/api/v1/sets/generate` Makes a study set of flashcards from a note. Poll `GET /jobs/{jobId}` until it ends; a finished `study_set_generation` job's `result.setId` is the new set. While a set is being made from the note, starting another returns that job instead. Counts as an AI use. Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). If you can write this content yourself, write it and save it with `POST /sets` or `POST /sets/{setId}/terms`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work. - MCP tool: `generateStudySet` on `https://workroad.app/api/mcp` - Required scopes: generation:start - Test mode: `POST https://workroad.app/api/test/v1/sets/generate` checks the request and returns the example response without changing anything. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `requestId`: string (uuid), required. A uuid you mint for this start. It becomes the job id. Sending it again with the same body returns the same job and is not counted twice; a different body under a used `requestId` is 409 `idempotency_conflict`. - `noteId`: string (uuid), required. The note to make cards from. - `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`. `swift`, `scholar` or `sage`; the server's study-set tier when omitted. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`. ## Response `202` Accepted - `data`: object, required - `jobId`: string (uuid), required. The job, the `requestId` you sent. Poll `GET /jobs/{jobId}` until it ends. ### Example ```json { "data": { "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request`: the body fails validation, `details.reason` is `premium-only` for the `sage` tier, `details.field` `noteId` with `details.reason` `unknown-note` says the note isn't one of this key's user's, and `empty-note` says it has no text to make cards from (no AI use is taken). - `401`: Missing, invalid, disabled or expired API key - `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. Nothing starts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `idempotency_conflict`: this `requestId` already started a job with a different body. Send a new `requestId`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Without a `details.reason`, the user has too many jobs queued: retry after `Retry-After` seconds (30). Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job engine is unreachable. Send the same request again after `Retry-After` seconds; the `requestId` keeps it from starting twice. --- # Get a generation Source: https://workroad.app/docs/api/get-generation `GET https://workroad.app/api/v1/generations/{generationId}` A note or topic generation's result. Its id is the id of the job that made it, and a finished `note_generation` or `topic_generation` job's `result.generationId`. Reading it changes nothing: apply it with `POST /generations/{generationId}/apply` or discard it with `POST /generations/{generationId}/discard`. - MCP tool: `getGeneration` on `https://workroad.app/api/mcp` - Required scopes: generation:start or notes:view or copilot:use - Test mode: `GET https://workroad.app/api/test/v1/generations/{generationId}` checks the request and returns the example response without changing anything. ## Path parameters - `generationId`: string (uuid), required. Generation id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required. The generation, the id of the job that made it. - `noteId`: string (uuid), required - `status`: one of `"preview"`, `"applied"`, `"discarded"`, required. `preview` until it is applied or discarded. - `markdown`: string, required. The generated note, as Markdown. - `createdAt`: string (date-time), required. When the generation was saved. ### Example ```json { "data": { "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "noteId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "status": "preview", "markdown": "# Enzymes\n\nEnzymes lower the activation energy of a reaction.", "createdAt": "2026-01-15T09:30:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No generation with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get the key's user Source: https://workroad.app/docs/api/get-me `GET https://workroad.app/api/v1/me` Returns the user who owns the API key. Any valid key may call it. - MCP tool: `getMe` on `https://workroad.app/api/mcp` - Required scopes: None beyond a valid key. - Test mode: `GET https://workroad.app/api/test/v1/me` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `username`: string, required - `subscription`: one of `"FREE"`, `"PAID"`, required - `premium`: boolean, required - `admin`: boolean, required - `timeZone`: string or null, required. The user's IANA time zone, such as `Europe/London`. Task due days are read in it; `null` means UTC. - `createdAt`: string (date-time), required. Account creation timestamp. ### Example ```json { "data": { "id": "0b7d4c3e-1f2a-4b5c-9d6e-7f8a9b0c1d2e", "name": "Ada Lovelace", "username": "ada", "subscription": "PAID", "premium": true, "admin": false, "timeZone": "Europe/London", "createdAt": "2026-01-15T09:30:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Cancel a job Source: https://workroad.app/docs/api/cancel-job `POST https://workroad.app/api/v1/jobs/{jobId}/cancel` Stops a copilot reply or a note generation, the two jobs the app has a Stop button for. A stopped copilot reply ends as `cancelled`. A stopped note generation keeps the sections it already wrote: it finishes as `succeeded` with `result.stopped` true, or as `cancelled` when nothing was written. A stopped job still counts as an AI use. `cancelled` is false when the job had already finished. - MCP tool: `cancelJob` on `https://workroad.app/api/mcp` - Required scopes: copilot:use or generation:start - Test mode: `POST https://workroad.app/api/test/v1/jobs/{jobId}/cancel` checks the request and returns the example response without changing anything. ## Path parameters - `jobId`: string (uuid), required. Job id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `cancelled`: boolean, required. True when the cancel was accepted. False when the job had already finished. ### Example ```json { "data": { "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "cancelled": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: This API key lacks the scope for this kind of job. Reading: copilot replies need `copilot:use`; note generations need `generation:start` or `copilot:use`; topic generations need `generation:start` or `copilot:use`; study set generations need `generation:start` or `copilot:use`; file parses need `files:upload` or `files:view`; practice test generations need `tests:generate` or `tests:view`; grading jobs need `tests:attempt` or `tests:view`. Cancelling: copilot replies need `copilot:use`; note generations need `generation:start` or `copilot:use`. - `404`: No job with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `details.reason` is `not-cancellable`: only copilot replies and note generations can be cancelled. A key with neither `copilot:use` nor `generation:start` gets 403 instead. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job engine is unreachable, so a copilot reply could not be cancelled. Retry after `Retry-After` seconds. --- # Get a job Source: https://workroad.app/docs/api/get-job `GET https://workroad.app/api/v1/jobs/{jobId}` Returns a job started by a copilot message, a generation, a practice test or a file upload. Poll it until `status` is `succeeded`, `failed` or `cancelled`. While it is `pending` or `running` the response has a `Retry-After` header with the seconds to wait between polls. `kind` decides the shape of `result`. Practice test, grading and file jobs have no result: read the test, the attempt or the file. - MCP tool: `getJob` on `https://workroad.app/api/mcp` - Required scopes: copilot:use or generation:start or files:upload or files:view or tests:generate or tests:view or tests:attempt - Test mode: `GET https://workroad.app/api/test/v1/jobs/{jobId}` checks the request and returns the example response without changing anything. - Next: while `status` is `pending` or `running`, wait `retryAfter` seconds and call again. ## Path parameters - `jobId`: string (uuid), required. Job id, scoped to the key's user. ## Response `200` OK - `data`: one of the objects below, required - One of `kind`: `"copilot_reply"` - `id`: string (uuid), required - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final. - `createdAt`: string (date-time), required. When the job was queued. - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then. - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error. - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`. - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed. - `kind`: `"copilot_reply"`, required - `result`: object or null, required. Null until the job succeeds, and when the stored result has an unexpected shape. - `conversationId`: string (uuid), required - `message`: string, required. The assistant's reply, as Markdown. - `actions`: array of one of the objects below or null, required. The actions the reply proposed, as the app shows them. `kind` decides the fields. An action's position is its index for `POST /copilot/messages/{messageId}/actions`; null marks a stored action this API can't describe. - One of `kind`: `"reschedule_task"` - `kind`: `"reschedule_task"`, required - `taskId`: string, required, at least 1 character - `name`: string, required, 1-120 characters - `dueDate`: string, required, at least 1 character - `previousDueDate`: string or null, required - `expectedUpdatedAt`: string, required, at least 1 character - Or `kind`: `"create_study_block"` - `kind`: `"create_study_block"`, required - `name`: string, required, 1-120 characters - `startsAt`: string, required, at least 1 character - `endsAt`: string, required, at least 1 character - `timeZone`: string, required, at least 1 character - `subjectId`: string, at least 1 character - `taskId`: string, at least 1 character - `setId`: string, at least 1 character - Or `kind`: `"create_task"` - `kind`: `"create_task"`, required - `name`: string, required, 1-120 characters - `dueDate`: string - `subject`: string - `subjectId`: string - `type`: string - `recurrence`: object - `unit`: one of `"day"`, `"week"`, `"month"`, required - `interval`: integer, required, 1 to 30 - `endsAt`: string - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"` - Or `kind`: `"create_note"` - `kind`: `"create_note"`, required - `name`: string, required, 1-64 characters - `content`: string, up to 100000 characters - `pinned`: boolean - `folderId`: string, at least 1 character - Or `kind`: `"create_note_from_files"` - `kind`: `"create_note_from_files"`, required - `name`: string, required, 1-64 characters - `attachmentIds`: array of string, required, 1-4 items - `folderId`: string, at least 1 character - Or `kind`: `"create_folder"` - `kind`: `"create_folder"`, required - `name`: string, required, 1-64 characters - `subjectId`: string, at least 1 character - Or `kind`: `"update_note"` - `kind`: `"update_note"`, required - `noteId`: string, required, at least 1 character - `currentName`: string, required, 1-64 characters - `name`: string, 1-64 characters - `folderId`: string or null, at least 1 character - `pinned`: boolean - Or `kind`: `"delete_note"` - `kind`: `"delete_note"`, required - `noteId`: string, required, at least 1 character - `currentName`: string, required, 1-64 characters - Or `kind`: `"update_task"` - `kind`: `"update_task"`, required - `taskId`: string, required, at least 1 character - `currentName`: string, required, 1-200 characters - `expectedUpdatedAt`: string, required, at least 1 character - `name`: string, 1-200 characters - `description`: string or null, up to 512 characters - `dueDate`: string or null - `type`: string - `subjectId`: string or null, at least 1 character - `completed`: boolean - Or `kind`: `"delete_task"` - `kind`: `"delete_task"`, required - `taskId`: string, required, at least 1 character - `currentName`: string, required, 1-200 characters - `expectedUpdatedAt`: string, required, at least 1 character - Or `kind`: `"create_set_from_note"` - `kind`: `"create_set_from_note"`, required - `noteId`: string, required, at least 1 character - Or `kind`: `"start_generation"` - `kind`: `"start_generation"`, required - `noteRecordingId`: string, at least 1 character - `topic`: string, 1-200 characters - Or `kind`: `"create_schedule"` - `kind`: `"create_schedule"`, required - `name`: string, required, 2-64 characters - `scheduleType`: one of `"defined"`, `"dynamic"`, required - `firstDay`: integer, 0 to 6 - `periods`: array of objects, up to 30 items - `periodName`: string, required, up to 200 characters - `time`: object or null, required - `days`: array of array of object or null, required, up to 7 items - `lessonName`: string, required, up to 200 characters - `room`: string, required, up to 200 characters - `subject`: string, required, up to 200 characters - `teacher`: string, required, up to 200 characters - `timeStart`: string, up to 5 characters - `timeEnd`: string, up to 5 characters - Or `kind`: `"update_subject"` - `kind`: `"update_subject"`, required - `subjectId`: string, required, at least 1 character - `currentName`: string, required, 1-64 characters - `name`: string, 1-64 characters - `teachers`: array of string, up to 20 items - `period`: string or null, up to 60 characters - `archived`: boolean - Or `kind`: `"update_set"` - `kind`: `"update_set"`, required - `setId`: string, required, at least 1 character - `currentName`: string, required, 1-120 characters - `name`: string, 1-120 characters - `description`: string or null, up to 2000 characters - `subjectId`: string or null, at least 1 character - Or `kind`: `"update_schedule"` - `kind`: `"update_schedule"`, required - `scheduleId`: string, required, at least 1 character - `currentName`: string, required, 1-200 characters - `name`: string, 2-64 characters - `cells`: array of objects, up to 40 items - `day`: integer, required, 0 to 6 - `period`: integer, required, 1 to 30 - `current`: string or null, required, up to 200 characters - `lesson`: object or null, required - Or `kind`: `"change_setting"` - `kind`: `"change_setting"`, required - `operation`: one of `"updateProfile"`, `"updatePreferences"`, `"updateNotificationSettings"`, `"updateNotificationKind"`, `"resumeNotificationEmail"`, `"updateReviewSettings"`, `"updateTaskSettings"`, `"replaceDashboardLayout"`, `"deleteCalendarFeed"`, required. The settings operation of this API that applying runs. - `arguments`: any value, required. That operation's arguments, laid out as its MCP tool takes them: path parameters and body fields in one object. - Or `kind`: `"navigate"` - `kind`: `"navigate"`, required - `path`: string, required, 1-200 characters - Or `kind`: `"insert_into_note"` - `kind`: `"insert_into_note"`, required - `noteId`: string, required, at least 1 character - `markdown`: string, required, 1-20000 characters - `anchor`: object or null, required - `text`: string, required, 1-128 characters - `where`: one of `"before"`, `"after"`, required - `researchUsed`: boolean, required. True when the reply searched the web. - `sources`: array of objects, required. The web pages the reply cites. - `url`: string, required - `title`: string, required - `host`: string, required - Or `kind`: `"note_generation"` - `id`: string (uuid), required - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final. - `createdAt`: string (date-time), required. When the job was queued. - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then. - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error. - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`. - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed. - `kind`: `"note_generation"`, required - `result`: object or null, required. Null until the job succeeds, and when the stored result has an unexpected shape. - `generationId`: string (uuid), required. The preview to apply or discard. - `title`: string, required - `sectionCount`: integer, required - `failedSections`: array of objects, required. Sections the generation gave up on. The preview leaves them empty. - `index`: integer, required - `title`: string, required - `stopped`: boolean, required. True when a cancel stopped the generation. The preview has only the sections written before it. - Or `kind`: `"topic_generation"` - `id`: string (uuid), required - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final. - `createdAt`: string (date-time), required. When the job was queued. - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then. - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error. - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`. - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed. - `kind`: `"topic_generation"`, required - `result`: object or null, required. Null until the job succeeds, and when the stored result has an unexpected shape. - `generationId`: string (uuid), required. The preview to apply or discard. - `title`: string, required - `sectionCount`: integer, required - Or `kind`: `"study_set_generation"` - `id`: string (uuid), required - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final. - `createdAt`: string (date-time), required. When the job was queued. - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then. - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error. - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`. - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed. - `kind`: `"study_set_generation"`, required - `result`: object or null, required. Null until the job succeeds, and when the stored result has an unexpected shape. A job started through the API (`POST /sets/generate`, or a `create_set_from_note` applied with `POST /copilot/messages/{messageId}/actions`) writes its set, and this names it. A job the user started in the WorkRoad app writes no set: it succeeds with cards the user reviews in the app, so its result stays null. The set they save there takes the job's id, readable with `GET /sets/{setId}` once it exists. - `setId`: string (uuid), required. The new study set. - `termCount`: integer, required. How many terms it has. - Or `kind`: `"test_generation"` - `id`: string (uuid), required - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final. - `createdAt`: string (date-time), required. When the job was queued. - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then. - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error. - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`. - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed. - `kind`: `"test_generation"`, required - `result`: any value, required. Always null. Read the practice test, the attempt or the file instead. - Or `kind`: `"test_grading"` - `id`: string (uuid), required - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final. - `createdAt`: string (date-time), required. When the job was queued. - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then. - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error. - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`. - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed. - `kind`: `"test_grading"`, required - `result`: any value, required. Always null. Read the practice test, the attempt or the file instead. - Or `kind`: `"file_parse"` - `id`: string (uuid), required - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final. - `createdAt`: string (date-time), required. When the job was queued. - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then. - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error. - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`. - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed. - `kind`: `"file_parse"`, required - `result`: any value, required. Always null. Read the practice test, the attempt or the file instead. ### Example ```json { "data": { "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "kind": "note_generation", "status": "succeeded", "createdAt": "2026-01-15T09:30:00.000Z", "finishedAt": "2026-01-15T09:31:10.000Z", "result": { "generationId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "title": "Photosynthesis", "sectionCount": 4, "failedSections": [], "stopped": false }, "error": null } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: This API key lacks the scope for this kind of job. Reading: copilot replies need `copilot:use`; note generations need `generation:start` or `copilot:use`; topic generations need `generation:start` or `copilot:use`; study set generations need `generation:start` or `copilot:use`; file parses need `files:upload` or `files:view`; practice test generations need `tests:generate` or `tests:view`; grading jobs need `tests:attempt` or `tests:view`. Cancelling: copilot replies need `copilot:use`; note generations need `generation:start` or `copilot:use`. Also `details.reason` `settings-in-conversation`: a copilot reply from a conversation that read or proposed the user's settings, for a key without `settings:view`. - `404`: No job with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a note folder Source: https://workroad.app/docs/api/create-note-folder `POST https://workroad.app/api/v1/note-folders` Creates a folder, optionally filed under one of the user's subjects by `subjectId` or `subjectName`. - MCP tool: `createNoteFolder` on `https://workroad.app/api/mcp` - Required scopes: notes:create - Test mode: `POST https://workroad.app/api/test/v1/note-folders` checks the request and returns the example response without changing anything. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 1-64 characters - `subjectId`: string (uuid). Subject id owned by the user. - `subjectName`: string, 1-64 characters. The subject (the course or module, such as `Software Engineering`) by name instead of by id. It must match one of the user's active subjects, ignoring case and accents. A name that matches none is refused with 400 `unknown-subject`, whose `details.subjects` lists the user's subjects, unless `createSubjectIfMissing` is true. Send `subjectId` or `subjectName`, not both. - `createSubjectIfMissing`: boolean. With `subjectName`, creates the subject when the user has none by that name. Needs the `subjects:create` scope. Only send it when the user named a course they haven't added yet. - `icon`: one of `"Folder"`, `"File"`, `"Book"`, `"Bookmark"`, `"Star"`, `"Heart"`, `"Flag"`, `"Tag"`, `"Inbox"`, `"Archive"`, `"GraduationCap"`, `"School"`, `"Backpack"`, `"Notebook"`, `"Pencil"`, `"Ruler"`, `"Calculator"`, `"Globe"`, `"Microscope"`, `"TestTube"`, `"Dna"`, `"Atom"`, `"Rocket"`, `"Telescope"`, `"Code"`, `"Terminal"`, `"Database"`, `"Server"`, `"Cpu"`, `"Wifi"`, `"Cloud"`, `"Bug"`, `"QrCode"`, `"PlusSquare"`, `"MinusSquare"`, `"DivideSquare"`, `"Percent"`, `"Sigma"`, `"Pi"`, `"Infinity"`, `"BarChart"`, `"LineChart"`, `"Magnet"`, `"Thermometer"`, `"Leaf"`, `"Droplet"`, `"Flame"`, `"Zap"`, `"Wind"`, `"Palette"`, `"Music"`, `"Camera"`, `"Video"`, `"Brush"`, `"Pen"`, `"Scissors"`, `"Shapes"`, `"Briefcase"`, `"TrendingUp"`, `"PieChart"`, `"DollarSign"`, `"CreditCard"`, `"ShoppingCart"`, `"Activity"`, `"Stethoscope"`, `"Apple"`, `"Dumbbell"`, `"Calendar"`, `"Clock"`, `"Timer"`, `"Hourglass"`, `"AlarmClock"`, `"MessageSquare"`, `"Mail"`, `"Phone"`, `"Share"`, `"Link"`, `"Coffee"`, `"Lightbulb"`, `"Map"`, `"Compass"`, `"Target"`, `"Trophy"`, `"Puzzle"`, `"Gift"`, `"Smile"`, default `"Folder"`. Icon name shown in the UI. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `icon`: one of `"Folder"`, `"File"`, `"Book"`, `"Bookmark"`, `"Star"`, `"Heart"`, `"Flag"`, `"Tag"`, `"Inbox"`, `"Archive"`, `"GraduationCap"`, `"School"`, `"Backpack"`, `"Notebook"`, `"Pencil"`, `"Ruler"`, `"Calculator"`, `"Globe"`, `"Microscope"`, `"TestTube"`, `"Dna"`, `"Atom"`, `"Rocket"`, `"Telescope"`, `"Code"`, `"Terminal"`, `"Database"`, `"Server"`, `"Cpu"`, `"Wifi"`, `"Cloud"`, `"Bug"`, `"QrCode"`, `"PlusSquare"`, `"MinusSquare"`, `"DivideSquare"`, `"Percent"`, `"Sigma"`, `"Pi"`, `"Infinity"`, `"BarChart"`, `"LineChart"`, `"Magnet"`, `"Thermometer"`, `"Leaf"`, `"Droplet"`, `"Flame"`, `"Zap"`, `"Wind"`, `"Palette"`, `"Music"`, `"Camera"`, `"Video"`, `"Brush"`, `"Pen"`, `"Scissors"`, `"Shapes"`, `"Briefcase"`, `"TrendingUp"`, `"PieChart"`, `"DollarSign"`, `"CreditCard"`, `"ShoppingCart"`, `"Activity"`, `"Stethoscope"`, `"Apple"`, `"Dumbbell"`, `"Calendar"`, `"Clock"`, `"Timer"`, `"Hourglass"`, `"AlarmClock"`, `"MessageSquare"`, `"Mail"`, `"Phone"`, `"Share"`, `"Link"`, `"Coffee"`, `"Lightbulb"`, `"Map"`, `"Compass"`, `"Target"`, `"Trophy"`, `"Puzzle"`, `"Gift"`, `"Smile"`, required. Icon name shown in the UI. ### Example ```json { "data": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null, "icon": "Folder" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or a `subjectId` that is not one of the user's subjects (`details.reason` is `unknown-subject`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: Another folder of the user's already has that name (`details.reason` is `name-taken`). The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Move a note folder to the trash Source: https://workroad.app/docs/api/delete-note-folder `DELETE https://workroad.app/api/v1/note-folders/{folderId}` Moves the folder and every note in it to the trash. `POST /note-folders/{folderId}/restore` brings back the folder and the notes that went with it. The trash is emptied after 30 days. - MCP tool: `deleteNoteFolder` on `https://workroad.app/api/mcp` - Required scopes: notes:delete - Test mode: `DELETE https://workroad.app/api/test/v1/note-folders/{folderId}` checks the request and returns the example response without changing anything. - Next: `restoreNoteFolder` brings it back from the trash. ## Path parameters - `folderId`: string (uuid), required. Note folder id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note folder with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List note folders Source: https://workroad.app/docs/api/list-note-folders `GET https://workroad.app/api/v1/note-folders` Every folder owned by the key's user, sorted by name, then id. - MCP tool: `listNoteFolders` on `https://workroad.app/api/mcp` - Required scopes: notes:view - Test mode: `GET https://workroad.app/api/test/v1/note-folders` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `icon`: one of `"Folder"`, `"File"`, `"Book"`, `"Bookmark"`, `"Star"`, `"Heart"`, `"Flag"`, `"Tag"`, `"Inbox"`, `"Archive"`, `"GraduationCap"`, `"School"`, `"Backpack"`, `"Notebook"`, `"Pencil"`, `"Ruler"`, `"Calculator"`, `"Globe"`, `"Microscope"`, `"TestTube"`, `"Dna"`, `"Atom"`, `"Rocket"`, `"Telescope"`, `"Code"`, `"Terminal"`, `"Database"`, `"Server"`, `"Cpu"`, `"Wifi"`, `"Cloud"`, `"Bug"`, `"QrCode"`, `"PlusSquare"`, `"MinusSquare"`, `"DivideSquare"`, `"Percent"`, `"Sigma"`, `"Pi"`, `"Infinity"`, `"BarChart"`, `"LineChart"`, `"Magnet"`, `"Thermometer"`, `"Leaf"`, `"Droplet"`, `"Flame"`, `"Zap"`, `"Wind"`, `"Palette"`, `"Music"`, `"Camera"`, `"Video"`, `"Brush"`, `"Pen"`, `"Scissors"`, `"Shapes"`, `"Briefcase"`, `"TrendingUp"`, `"PieChart"`, `"DollarSign"`, `"CreditCard"`, `"ShoppingCart"`, `"Activity"`, `"Stethoscope"`, `"Apple"`, `"Dumbbell"`, `"Calendar"`, `"Clock"`, `"Timer"`, `"Hourglass"`, `"AlarmClock"`, `"MessageSquare"`, `"Mail"`, `"Phone"`, `"Share"`, `"Link"`, `"Coffee"`, `"Lightbulb"`, `"Map"`, `"Compass"`, `"Target"`, `"Trophy"`, `"Puzzle"`, `"Gift"`, `"Smile"`, required. Icon name shown in the UI. ### Example ```json { "data": [ { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null, "icon": "Folder" } ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Restore a note folder from the trash Source: https://workroad.app/docs/api/restore-note-folder `POST https://workroad.app/api/v1/note-folders/{folderId}/restore` Brings a trashed folder back with the notes that went to the trash with it, and returns the folder. Needs `notes:delete`, the scope that trashed it. If a live folder took its name meanwhile, the restored folder is renamed `name (2)`, `name (3)` and so on. Notes trashed on their own before the folder stay in the trash. - MCP tool: `restoreNoteFolder` on `https://workroad.app/api/mcp` - Required scopes: notes:delete - Test mode: `POST https://workroad.app/api/test/v1/note-folders/{folderId}/restore` checks the request and returns the example response without changing anything. ## Path parameters - `folderId`: string (uuid), required. Note folder id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `icon`: one of `"Folder"`, `"File"`, `"Book"`, `"Bookmark"`, `"Star"`, `"Heart"`, `"Flag"`, `"Tag"`, `"Inbox"`, `"Archive"`, `"GraduationCap"`, `"School"`, `"Backpack"`, `"Notebook"`, `"Pencil"`, `"Ruler"`, `"Calculator"`, `"Globe"`, `"Microscope"`, `"TestTube"`, `"Dna"`, `"Atom"`, `"Rocket"`, `"Telescope"`, `"Code"`, `"Terminal"`, `"Database"`, `"Server"`, `"Cpu"`, `"Wifi"`, `"Cloud"`, `"Bug"`, `"QrCode"`, `"PlusSquare"`, `"MinusSquare"`, `"DivideSquare"`, `"Percent"`, `"Sigma"`, `"Pi"`, `"Infinity"`, `"BarChart"`, `"LineChart"`, `"Magnet"`, `"Thermometer"`, `"Leaf"`, `"Droplet"`, `"Flame"`, `"Zap"`, `"Wind"`, `"Palette"`, `"Music"`, `"Camera"`, `"Video"`, `"Brush"`, `"Pen"`, `"Scissors"`, `"Shapes"`, `"Briefcase"`, `"TrendingUp"`, `"PieChart"`, `"DollarSign"`, `"CreditCard"`, `"ShoppingCart"`, `"Activity"`, `"Stethoscope"`, `"Apple"`, `"Dumbbell"`, `"Calendar"`, `"Clock"`, `"Timer"`, `"Hourglass"`, `"AlarmClock"`, `"MessageSquare"`, `"Mail"`, `"Phone"`, `"Share"`, `"Link"`, `"Coffee"`, `"Lightbulb"`, `"Map"`, `"Compass"`, `"Target"`, `"Trophy"`, `"Puzzle"`, `"Gift"`, `"Smile"`, required. Icon name shown in the UI. ### Example ```json { "data": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null, "icon": "Folder" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note folder with that id is in the trash for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update a note folder Source: https://workroad.app/docs/api/update-note-folder `PATCH https://workroad.app/api/v1/note-folders/{folderId}` Changes only the fields you send; `null` on `subjectId` unfiles the folder. Renaming to a name another of the user's folders already has is refused, as when creating one. - MCP tool: `updateNoteFolder` on `https://workroad.app/api/mcp` - Required scopes: notes:update - Test mode: `PATCH https://workroad.app/api/test/v1/note-folders/{folderId}` checks the request and returns the example response without changing anything. ## Path parameters - `folderId`: string (uuid), required. Note folder id, scoped to the key's user. ## Request body JSON. - `name`: string, 1-64 characters - `subjectId`: string (uuid) or null. Subject id owned by the user. `null` unfiles the folder. - `icon`: one of `"Folder"`, `"File"`, `"Book"`, `"Bookmark"`, `"Star"`, `"Heart"`, `"Flag"`, `"Tag"`, `"Inbox"`, `"Archive"`, `"GraduationCap"`, `"School"`, `"Backpack"`, `"Notebook"`, `"Pencil"`, `"Ruler"`, `"Calculator"`, `"Globe"`, `"Microscope"`, `"TestTube"`, `"Dna"`, `"Atom"`, `"Rocket"`, `"Telescope"`, `"Code"`, `"Terminal"`, `"Database"`, `"Server"`, `"Cpu"`, `"Wifi"`, `"Cloud"`, `"Bug"`, `"QrCode"`, `"PlusSquare"`, `"MinusSquare"`, `"DivideSquare"`, `"Percent"`, `"Sigma"`, `"Pi"`, `"Infinity"`, `"BarChart"`, `"LineChart"`, `"Magnet"`, `"Thermometer"`, `"Leaf"`, `"Droplet"`, `"Flame"`, `"Zap"`, `"Wind"`, `"Palette"`, `"Music"`, `"Camera"`, `"Video"`, `"Brush"`, `"Pen"`, `"Scissors"`, `"Shapes"`, `"Briefcase"`, `"TrendingUp"`, `"PieChart"`, `"DollarSign"`, `"CreditCard"`, `"ShoppingCart"`, `"Activity"`, `"Stethoscope"`, `"Apple"`, `"Dumbbell"`, `"Calendar"`, `"Clock"`, `"Timer"`, `"Hourglass"`, `"AlarmClock"`, `"MessageSquare"`, `"Mail"`, `"Phone"`, `"Share"`, `"Link"`, `"Coffee"`, `"Lightbulb"`, `"Map"`, `"Compass"`, `"Target"`, `"Trophy"`, `"Puzzle"`, `"Gift"`, `"Smile"`. Icon name shown in the UI. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `icon`: one of `"Folder"`, `"File"`, `"Book"`, `"Bookmark"`, `"Star"`, `"Heart"`, `"Flag"`, `"Tag"`, `"Inbox"`, `"Archive"`, `"GraduationCap"`, `"School"`, `"Backpack"`, `"Notebook"`, `"Pencil"`, `"Ruler"`, `"Calculator"`, `"Globe"`, `"Microscope"`, `"TestTube"`, `"Dna"`, `"Atom"`, `"Rocket"`, `"Telescope"`, `"Code"`, `"Terminal"`, `"Database"`, `"Server"`, `"Cpu"`, `"Wifi"`, `"Cloud"`, `"Bug"`, `"QrCode"`, `"PlusSquare"`, `"MinusSquare"`, `"DivideSquare"`, `"Percent"`, `"Sigma"`, `"Pi"`, `"Infinity"`, `"BarChart"`, `"LineChart"`, `"Magnet"`, `"Thermometer"`, `"Leaf"`, `"Droplet"`, `"Flame"`, `"Zap"`, `"Wind"`, `"Palette"`, `"Music"`, `"Camera"`, `"Video"`, `"Brush"`, `"Pen"`, `"Scissors"`, `"Shapes"`, `"Briefcase"`, `"TrendingUp"`, `"PieChart"`, `"DollarSign"`, `"CreditCard"`, `"ShoppingCart"`, `"Activity"`, `"Stethoscope"`, `"Apple"`, `"Dumbbell"`, `"Calendar"`, `"Clock"`, `"Timer"`, `"Hourglass"`, `"AlarmClock"`, `"MessageSquare"`, `"Mail"`, `"Phone"`, `"Share"`, `"Link"`, `"Coffee"`, `"Lightbulb"`, `"Map"`, `"Compass"`, `"Target"`, `"Trophy"`, `"Puzzle"`, `"Gift"`, `"Smile"`, required. Icon name shown in the UI. ### Example ```json { "data": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null, "icon": "Folder" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or a `subjectId` that is not one of the user's subjects (`details.reason` is `unknown-subject`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note folder with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: Another folder of the user's already has that name (`details.reason` is `name-taken`). - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Append Markdown to a note Source: https://workroad.app/docs/api/append-note-content `POST https://workroad.app/api/v1/notes/{noteId}/content/append` Converts `markdown` (up to 100000 characters) to the editor format and adds it after the note's existing blocks. Send `Idempotency-Key` so a retry can't append twice. Sending `baseVersion` too makes a retry without a key fail with 409 instead of appending again. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `appendNoteContent` on `https://workroad.app/api/mcp` - Required scopes: notes:update - Test mode: `POST https://workroad.app/api/test/v1/notes/{noteId}/content/append` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `markdown`: string, required, up to 100000 characters. Markdown, up to 100000 characters. Stored as editor JSON, not as Markdown. - `baseVersion`: integer, at least 0. The `version` you last read. When the note has changed since, nothing is written and the response is 409. Omit it to write regardless. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `folder`: object or null, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectId`: string or null, required. The note's subject id: its own when it has one, otherwise its folder's. `null` when neither has a subject. - `icon`: string or null, required. The single emoji shown beside the note's name, or `null`. - `cover`: string or null, required. The note's cover image, as a URL this key's user can load with their session or key, or `null`. Not part of the note's content, and not one of its files. - `tags`: array of objects, required. The note's labels, by name. Tags belong to the user, not to one note. - `id`: string (uuid), required - `name`: string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last content update timestamp. - `fullWidth`: boolean, required - `contentFormat`: `"tiptap"`, required. Current serialization of `content`. - `contentUnstable`: `true`, required. Editor-specific TipTap JSON. Node and attribute shapes may change. - `content`: any value, required. Editor document, returned verbatim. See `contentUnstable`. - `markdown`: string or null, required. The note as markdown. Writing it back with `PUT /notes/{noteId}/content` reproduces the note exactly when `markdownLosses` is empty. Otherwise the listed parts come back simplified. Block ids are not in the markdown; a write mints new ones. An empty image alt or title reads back as null. Null when the note's content isn't an editor document. - `markdownLosses`: array of one of `"empty-paragraphs"`, `"collapsed-spaces"`, `"image-size"`, `"table-layout"`, `"table-cell-blocks"`, `"table-header"`, `"table-cell-background"`, `"ordered-list-type"`, `"image-upload-state"`, `"inline-math"`, `"mention-label"`, `"formatting-edges"`, `"highlight"`, `"text-colour"`, `"toggle-summary-formatting"`, `"toggle-heading-level"`, `"code-block-language"`, `"code-block-filename"`, `"code-block-wrap"`, `"block-math"`, `"image-address"`, `"image-loading"`, `"link-address"`, `"link-title"`, `"link-attributes"`, `"mention-target"`, `"date-chip"`, `"heading-level"`, `"ordered-list-start"`, `"callout-kind"`, `"callout-nesting"`, `"columns"`, `"table-of-contents"`, `"footnote-orphan"`, `"youtube-embed"`, `"pdf-embed"`, `"recording-embed"`, `"bookmark-card"`, `"citation-text"`, `"bibliography"`, `"invalid-structure"`, `"unsupported-mark"`, `"unsupported-content"`, `"exceeds-write-limit"`, required. What `markdown` simplifies, each value once. Empty when it reproduces the note exactly. - `empty-paragraphs`: Empty paragraphs are left out. One empty paragraph at the end of the note is not reported, because the editor adds it back. - `collapsed-spaces`: Runs of spaces, tabs and line breaks inside text become one space. Spaces at the start or end of a paragraph, heading or cell, or next to a hard break, are dropped. A hard break at the end of a paragraph is dropped, and one in a heading becomes a space. - `image-size`: Image width and height are left out. - `table-layout`: Merged cells, column widths and cell alignment are left out. - `table-cell-blocks`: A cell holding a list, a quote, a callout, a toggle, columns, code, a formula or more than one paragraph is written as one line: each paragraph, heading or toggle summary in it on a line of its own, separated by `
`, with a formula as inline math and code as its text. List bullets and checkboxes, quote and callout markers, images and embeds in the cell are left out, and reading the Markdown back gives one paragraph with line breaks. Footnote markers, math, citations, dates and mentions are written as anywhere else. - `table-header`: The first row is written as the header even when its cells aren't header cells, and header cells in later rows become normal cells. - `table-cell-background`: Cell background colours are left out. - `ordered-list-type`: Letter and Roman numbering become plain numbers. - `image-upload-state`: An image that never finished uploading is written as its alt text, and its upload details are left out. - `inline-math`: Inline math that `$…$` can't hold is written as text: empty math, latex with an unescaped `$` or a trailing backslash, and math followed directly by a digit. Spaces at either end of the latex are trimmed. - `mention-label`: A mention without a label gets its kind as the label, and line breaks in a label become spaces. Label-only export turns mentions into plain text. - `formatting-edges`: Bold, italic and strikethrough can't start or end on a space, so the space moves outside the formatting. Where they can't start or end at all, such as bold text that starts with punctuation right after a letter, the paragraph's bold, italic and strikethrough are dropped. - `highlight`: Highlighting is dropped while its text is kept. - `text-colour`: Text colour is dropped while the text is kept. - `toggle-summary-formatting`: A toggle's summary is written as plain text, so bold, italic, underline, strikethrough, links and inline code in it keep their text and lose their formatting. Whether the toggle was open is never stored, so there is nothing to report for it. - `toggle-heading-level`: A toggle whose summary reads as a heading keeps the heading's text and loses its level. The summary is written inside a `` tag, which carries the text and nothing else. - `code-block-language`: A code block language that cannot fit in a Markdown fence is left out. - `code-block-filename`: A code block's filename is left out. A Markdown fence carries the language and nothing else. - `code-block-wrap`: A code block's soft-wrap setting is left out. It is a view preference, so the code itself is unchanged. - `block-math`: A formula line containing only $$ is changed to $ $. - `image-address`: An image address is normalized, or the image becomes alt text when its address is refused. - `image-loading`: The image loading preference is reset to lazy. - `link-address`: A link address is normalized, or the link becomes text when its address is refused. - `link-title`: A numeric or boolean link title is read back as that value instead of text. - `link-attributes`: The link target, rel or CSS class is reset to the editor default. - `mention-target`: A mention with an invalid kind or ID becomes plain text. - `date-chip`: An inline date is written as its day in `YYYY-MM-DD` form, as plain text. Reading the Markdown back gives that text rather than the chip. - `heading-level`: An unsupported heading level becomes level 1 or 4. - `ordered-list-start`: A list start that Markdown cannot represent becomes 1. - `callout-kind`: A callout of an unknown kind is written as a note. - `callout-nesting`: A callout directly inside another callout is written as part of the callout around it, and loses its own kind. A callout further down, inside a quote inside a callout, is written as a deeper quote and keeps its kind. - `columns`: Markdown has no columns, so a columns block is written as its columns one after another, left to right, and the widths are left out. Each block in them is written as it would be on its own, in a quote or a toggle too, and reading the Markdown back gives those blocks in one column. A columns block in a table cell is written as part of the cell's one line instead, as `table-cell-blocks` describes. - `table-of-contents`: A table of contents block is written as a `[TOC]` line. Its rows are the note's own headings, which the Markdown already holds, so nothing in them is lost; reading the Markdown back gives a paragraph of that text rather than the block. - `footnote-orphan`: A footnote marker with no footnote, and a footnote no marker points at, are both left out. The editor pairs them up, so this only happens to a note written through the API. - `youtube-embed`: A YouTube block is written as a link to the video. Reading the Markdown back gives the link, not the player. - `pdf-embed`: A PDF block is written as a link to the file. Reading the Markdown back gives the link, not the viewer. - `recording-embed`: A recording's audio player is written as its label. The recording itself is not part of the Markdown, so reading it back gives a line of text. - `bookmark-card`: A bookmark card is written as a link, `[title](address)`, using the page title the card shows. Its description and site line are left out, and reading the Markdown back gives the link rather than the card. - `citation-text`: A citation is written as the bracketed text its style reads as, such as `(Smith, 2024, p. 3)`, as plain text. The source behind it is not written, so reading the Markdown back gives that text rather than the citation. - `bibliography`: A bibliography is written as a plain bullet list of its entries, each one the reference its style reads as, in the order the note lists them. The sources behind them are not written, so reading the Markdown back gives a list rather than the note's bibliography. - `invalid-structure`: Malformed table, list or code block structure is simplified. - `unsupported-mark`: An unknown mark is dropped while its text is kept. - `unsupported-content`: An unknown node is reduced to its text. - `exceeds-write-limit`: The markdown is longer than `PUT /notes/{noteId}/content` accepts (100,000 characters). Append with `POST /notes/{noteId}/content/append`, or edit the note in the app. - `version`: integer, required. Increases on every content change. Send it as `baseVersion` to avoid overwriting a newer save. - `pinned`: boolean, required. Pinned on the notes home. - `summary`: object or null, required - `summary`: string, required - `keywords`: array of string, required - `createdAt`: string (date-time), required. When the summary was generated. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "name": "Photosynthesis", "folder": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null }, "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "icon": "🌱", "cover": "https://workroad.example/api/storage/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/cover-1a2b3c4d.jpg", "tags": [ { "id": "5f4e3d2c-1b0a-4938-8776-655443322110", "name": "Exam" } ], "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "fullWidth": false, "contentFormat": "tiptap", "contentUnstable": true, "content": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "id": "6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a" }, "content": [ { "type": "text", "text": "Light becomes chemical energy." } ] } ] }, "markdown": "Light becomes chemical energy.", "markdownLosses": [], "version": 3, "pinned": false, "summary": { "summary": "How plants turn light into sugar.", "keywords": [ "chlorophyll", "glucose", "light reactions" ], "createdAt": "2026-02-01T12:00:00.000Z" } } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, the note's content is not an editor document (`details.field` is `content`, `details.reason` is `legacy-content`), the `markdown` is empty or only whitespace (`details.field` is `markdown`, `details.reason` is `empty-append`), or the append would take the note past 200,000 visible characters (`details.field` is `markdown`, `details.reason` is `note-too-large`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The note changed since `baseVersion`. Nothing was written. `details` holds `baseVersion` and `currentVersion`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a note Source: https://workroad.app/docs/api/create-note `POST https://workroad.app/api/v1/notes` Creates a note, optionally inside one of the user's folders and under one of their subjects. Send `markdown` to give it content; without it the note is empty. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `createNote` on `https://workroad.app/api/mcp` - Required scopes: notes:create - Test mode: `POST https://workroad.app/api/test/v1/notes` checks the request and returns the example response without changing anything. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 1-64 characters - `folderId`: string (uuid) or null. Must be a folder owned by the key's user. - `subjectId`: string (uuid) or null. The note's own subject, which wins over its folder's. Must be a subject owned by the key's user (`GET /subjects`); any other id is refused with 400 `unknown-subject`. Omit it to file the note under its folder's subject. - `markdown`: string, up to 100000 characters. Markdown, up to 100000 characters. Stored as editor JSON, not as Markdown. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `folder`: object or null, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectId`: string or null, required. The note's subject id: its own when it has one, otherwise its folder's. `null` when neither has a subject. - `icon`: string or null, required. The single emoji shown beside the note's name, or `null`. - `cover`: string or null, required. The note's cover image, as a URL this key's user can load with their session or key, or `null`. Not part of the note's content, and not one of its files. - `tags`: array of objects, required. The note's labels, by name. Tags belong to the user, not to one note. - `id`: string (uuid), required - `name`: string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last content update timestamp. - `fullWidth`: boolean, required - `contentFormat`: `"tiptap"`, required. Current serialization of `content`. - `contentUnstable`: `true`, required. Editor-specific TipTap JSON. Node and attribute shapes may change. - `content`: any value, required. Editor document, returned verbatim. See `contentUnstable`. - `markdown`: string or null, required. The note as markdown. Writing it back with `PUT /notes/{noteId}/content` reproduces the note exactly when `markdownLosses` is empty. Otherwise the listed parts come back simplified. Block ids are not in the markdown; a write mints new ones. An empty image alt or title reads back as null. Null when the note's content isn't an editor document. - `markdownLosses`: array of one of `"empty-paragraphs"`, `"collapsed-spaces"`, `"image-size"`, `"table-layout"`, `"table-cell-blocks"`, `"table-header"`, `"table-cell-background"`, `"ordered-list-type"`, `"image-upload-state"`, `"inline-math"`, `"mention-label"`, `"formatting-edges"`, `"highlight"`, `"text-colour"`, `"toggle-summary-formatting"`, `"toggle-heading-level"`, `"code-block-language"`, `"code-block-filename"`, `"code-block-wrap"`, `"block-math"`, `"image-address"`, `"image-loading"`, `"link-address"`, `"link-title"`, `"link-attributes"`, `"mention-target"`, `"date-chip"`, `"heading-level"`, `"ordered-list-start"`, `"callout-kind"`, `"callout-nesting"`, `"columns"`, `"table-of-contents"`, `"footnote-orphan"`, `"youtube-embed"`, `"pdf-embed"`, `"recording-embed"`, `"bookmark-card"`, `"citation-text"`, `"bibliography"`, `"invalid-structure"`, `"unsupported-mark"`, `"unsupported-content"`, `"exceeds-write-limit"`, required. What `markdown` simplifies, each value once. Empty when it reproduces the note exactly. - `empty-paragraphs`: Empty paragraphs are left out. One empty paragraph at the end of the note is not reported, because the editor adds it back. - `collapsed-spaces`: Runs of spaces, tabs and line breaks inside text become one space. Spaces at the start or end of a paragraph, heading or cell, or next to a hard break, are dropped. A hard break at the end of a paragraph is dropped, and one in a heading becomes a space. - `image-size`: Image width and height are left out. - `table-layout`: Merged cells, column widths and cell alignment are left out. - `table-cell-blocks`: A cell holding a list, a quote, a callout, a toggle, columns, code, a formula or more than one paragraph is written as one line: each paragraph, heading or toggle summary in it on a line of its own, separated by `
`, with a formula as inline math and code as its text. List bullets and checkboxes, quote and callout markers, images and embeds in the cell are left out, and reading the Markdown back gives one paragraph with line breaks. Footnote markers, math, citations, dates and mentions are written as anywhere else. - `table-header`: The first row is written as the header even when its cells aren't header cells, and header cells in later rows become normal cells. - `table-cell-background`: Cell background colours are left out. - `ordered-list-type`: Letter and Roman numbering become plain numbers. - `image-upload-state`: An image that never finished uploading is written as its alt text, and its upload details are left out. - `inline-math`: Inline math that `$…$` can't hold is written as text: empty math, latex with an unescaped `$` or a trailing backslash, and math followed directly by a digit. Spaces at either end of the latex are trimmed. - `mention-label`: A mention without a label gets its kind as the label, and line breaks in a label become spaces. Label-only export turns mentions into plain text. - `formatting-edges`: Bold, italic and strikethrough can't start or end on a space, so the space moves outside the formatting. Where they can't start or end at all, such as bold text that starts with punctuation right after a letter, the paragraph's bold, italic and strikethrough are dropped. - `highlight`: Highlighting is dropped while its text is kept. - `text-colour`: Text colour is dropped while the text is kept. - `toggle-summary-formatting`: A toggle's summary is written as plain text, so bold, italic, underline, strikethrough, links and inline code in it keep their text and lose their formatting. Whether the toggle was open is never stored, so there is nothing to report for it. - `toggle-heading-level`: A toggle whose summary reads as a heading keeps the heading's text and loses its level. The summary is written inside a `` tag, which carries the text and nothing else. - `code-block-language`: A code block language that cannot fit in a Markdown fence is left out. - `code-block-filename`: A code block's filename is left out. A Markdown fence carries the language and nothing else. - `code-block-wrap`: A code block's soft-wrap setting is left out. It is a view preference, so the code itself is unchanged. - `block-math`: A formula line containing only $$ is changed to $ $. - `image-address`: An image address is normalized, or the image becomes alt text when its address is refused. - `image-loading`: The image loading preference is reset to lazy. - `link-address`: A link address is normalized, or the link becomes text when its address is refused. - `link-title`: A numeric or boolean link title is read back as that value instead of text. - `link-attributes`: The link target, rel or CSS class is reset to the editor default. - `mention-target`: A mention with an invalid kind or ID becomes plain text. - `date-chip`: An inline date is written as its day in `YYYY-MM-DD` form, as plain text. Reading the Markdown back gives that text rather than the chip. - `heading-level`: An unsupported heading level becomes level 1 or 4. - `ordered-list-start`: A list start that Markdown cannot represent becomes 1. - `callout-kind`: A callout of an unknown kind is written as a note. - `callout-nesting`: A callout directly inside another callout is written as part of the callout around it, and loses its own kind. A callout further down, inside a quote inside a callout, is written as a deeper quote and keeps its kind. - `columns`: Markdown has no columns, so a columns block is written as its columns one after another, left to right, and the widths are left out. Each block in them is written as it would be on its own, in a quote or a toggle too, and reading the Markdown back gives those blocks in one column. A columns block in a table cell is written as part of the cell's one line instead, as `table-cell-blocks` describes. - `table-of-contents`: A table of contents block is written as a `[TOC]` line. Its rows are the note's own headings, which the Markdown already holds, so nothing in them is lost; reading the Markdown back gives a paragraph of that text rather than the block. - `footnote-orphan`: A footnote marker with no footnote, and a footnote no marker points at, are both left out. The editor pairs them up, so this only happens to a note written through the API. - `youtube-embed`: A YouTube block is written as a link to the video. Reading the Markdown back gives the link, not the player. - `pdf-embed`: A PDF block is written as a link to the file. Reading the Markdown back gives the link, not the viewer. - `recording-embed`: A recording's audio player is written as its label. The recording itself is not part of the Markdown, so reading it back gives a line of text. - `bookmark-card`: A bookmark card is written as a link, `[title](address)`, using the page title the card shows. Its description and site line are left out, and reading the Markdown back gives the link rather than the card. - `citation-text`: A citation is written as the bracketed text its style reads as, such as `(Smith, 2024, p. 3)`, as plain text. The source behind it is not written, so reading the Markdown back gives that text rather than the citation. - `bibliography`: A bibliography is written as a plain bullet list of its entries, each one the reference its style reads as, in the order the note lists them. The sources behind them are not written, so reading the Markdown back gives a list rather than the note's bibliography. - `invalid-structure`: Malformed table, list or code block structure is simplified. - `unsupported-mark`: An unknown mark is dropped while its text is kept. - `unsupported-content`: An unknown node is reduced to its text. - `exceeds-write-limit`: The markdown is longer than `PUT /notes/{noteId}/content` accepts (100,000 characters). Append with `POST /notes/{noteId}/content/append`, or edit the note in the app. - `version`: integer, required. Increases on every content change. Send it as `baseVersion` to avoid overwriting a newer save. - `pinned`: boolean, required. Pinned on the notes home. - `summary`: object or null, required - `summary`: string, required - `keywords`: array of string, required - `createdAt`: string (date-time), required. When the summary was generated. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "name": "Photosynthesis", "folder": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null }, "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "icon": "🌱", "cover": "https://workroad.example/api/storage/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/cover-1a2b3c4d.jpg", "tags": [ { "id": "5f4e3d2c-1b0a-4938-8776-655443322110", "name": "Exam" } ], "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "fullWidth": false, "contentFormat": "tiptap", "contentUnstable": true, "content": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "id": "6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a" }, "content": [ { "type": "text", "text": "Light becomes chemical energy." } ] } ] }, "markdown": "Light becomes chemical energy.", "markdownLosses": [], "version": 3, "pinned": false, "summary": { "summary": "How plants turn light into sugar.", "keywords": [ "chlorophyll", "glucose", "light reactions" ], "createdAt": "2026-02-01T12:00:00.000Z" } } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, a `folderId` that is not one of the user's folders (`details.reason` is `unknown-folder`), or a `subjectId` that is not one of their subjects (`details.reason` is `unknown-subject`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Move a note to the trash Source: https://workroad.app/docs/api/delete-note `DELETE https://workroad.app/api/v1/notes/{noteId}` The note disappears from every read and stops being searchable. Its files stay, and `POST /notes/{noteId}/restore` brings it back. The trash is emptied after 30 days. - MCP tool: `deleteNote` on `https://workroad.app/api/mcp` - Required scopes: notes:delete - Test mode: `DELETE https://workroad.app/api/test/v1/notes/{noteId}` checks the request and returns the example response without changing anything. - Next: `restoreNote` brings it back from the trash. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a note with content Source: https://workroad.app/docs/api/get-note `GET https://workroad.app/api/v1/notes/{noteId}` Returns a note with its editor content and the same content as Markdown. `subjectId` is the note's own subject when set; otherwise it inherits its folder's subject. `folder` is `null` when the note is not in a folder. See the response schema for the remaining fields. - MCP tool: `getNote` on `https://workroad.app/api/mcp` - Required scopes: notes:view - Test mode: `GET https://workroad.app/api/test/v1/notes/{noteId}` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `folder`: object or null, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectId`: string or null, required. The note's subject id: its own when it has one, otherwise its folder's. `null` when neither has a subject. - `icon`: string or null, required. The single emoji shown beside the note's name, or `null`. - `cover`: string or null, required. The note's cover image, as a URL this key's user can load with their session or key, or `null`. Not part of the note's content, and not one of its files. - `tags`: array of objects, required. The note's labels, by name. Tags belong to the user, not to one note. - `id`: string (uuid), required - `name`: string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last content update timestamp. - `fullWidth`: boolean, required - `contentFormat`: `"tiptap"`, required. Current serialization of `content`. - `contentUnstable`: `true`, required. Editor-specific TipTap JSON. Node and attribute shapes may change. - `content`: any value, required. Editor document, returned verbatim. See `contentUnstable`. - `markdown`: string or null, required. The note as markdown. Writing it back with `PUT /notes/{noteId}/content` reproduces the note exactly when `markdownLosses` is empty. Otherwise the listed parts come back simplified. Block ids are not in the markdown; a write mints new ones. An empty image alt or title reads back as null. Null when the note's content isn't an editor document. - `markdownLosses`: array of one of `"empty-paragraphs"`, `"collapsed-spaces"`, `"image-size"`, `"table-layout"`, `"table-cell-blocks"`, `"table-header"`, `"table-cell-background"`, `"ordered-list-type"`, `"image-upload-state"`, `"inline-math"`, `"mention-label"`, `"formatting-edges"`, `"highlight"`, `"text-colour"`, `"toggle-summary-formatting"`, `"toggle-heading-level"`, `"code-block-language"`, `"code-block-filename"`, `"code-block-wrap"`, `"block-math"`, `"image-address"`, `"image-loading"`, `"link-address"`, `"link-title"`, `"link-attributes"`, `"mention-target"`, `"date-chip"`, `"heading-level"`, `"ordered-list-start"`, `"callout-kind"`, `"callout-nesting"`, `"columns"`, `"table-of-contents"`, `"footnote-orphan"`, `"youtube-embed"`, `"pdf-embed"`, `"recording-embed"`, `"bookmark-card"`, `"citation-text"`, `"bibliography"`, `"invalid-structure"`, `"unsupported-mark"`, `"unsupported-content"`, `"exceeds-write-limit"`, required. What `markdown` simplifies, each value once. Empty when it reproduces the note exactly. - `empty-paragraphs`: Empty paragraphs are left out. One empty paragraph at the end of the note is not reported, because the editor adds it back. - `collapsed-spaces`: Runs of spaces, tabs and line breaks inside text become one space. Spaces at the start or end of a paragraph, heading or cell, or next to a hard break, are dropped. A hard break at the end of a paragraph is dropped, and one in a heading becomes a space. - `image-size`: Image width and height are left out. - `table-layout`: Merged cells, column widths and cell alignment are left out. - `table-cell-blocks`: A cell holding a list, a quote, a callout, a toggle, columns, code, a formula or more than one paragraph is written as one line: each paragraph, heading or toggle summary in it on a line of its own, separated by `
`, with a formula as inline math and code as its text. List bullets and checkboxes, quote and callout markers, images and embeds in the cell are left out, and reading the Markdown back gives one paragraph with line breaks. Footnote markers, math, citations, dates and mentions are written as anywhere else. - `table-header`: The first row is written as the header even when its cells aren't header cells, and header cells in later rows become normal cells. - `table-cell-background`: Cell background colours are left out. - `ordered-list-type`: Letter and Roman numbering become plain numbers. - `image-upload-state`: An image that never finished uploading is written as its alt text, and its upload details are left out. - `inline-math`: Inline math that `$…$` can't hold is written as text: empty math, latex with an unescaped `$` or a trailing backslash, and math followed directly by a digit. Spaces at either end of the latex are trimmed. - `mention-label`: A mention without a label gets its kind as the label, and line breaks in a label become spaces. Label-only export turns mentions into plain text. - `formatting-edges`: Bold, italic and strikethrough can't start or end on a space, so the space moves outside the formatting. Where they can't start or end at all, such as bold text that starts with punctuation right after a letter, the paragraph's bold, italic and strikethrough are dropped. - `highlight`: Highlighting is dropped while its text is kept. - `text-colour`: Text colour is dropped while the text is kept. - `toggle-summary-formatting`: A toggle's summary is written as plain text, so bold, italic, underline, strikethrough, links and inline code in it keep their text and lose their formatting. Whether the toggle was open is never stored, so there is nothing to report for it. - `toggle-heading-level`: A toggle whose summary reads as a heading keeps the heading's text and loses its level. The summary is written inside a `` tag, which carries the text and nothing else. - `code-block-language`: A code block language that cannot fit in a Markdown fence is left out. - `code-block-filename`: A code block's filename is left out. A Markdown fence carries the language and nothing else. - `code-block-wrap`: A code block's soft-wrap setting is left out. It is a view preference, so the code itself is unchanged. - `block-math`: A formula line containing only $$ is changed to $ $. - `image-address`: An image address is normalized, or the image becomes alt text when its address is refused. - `image-loading`: The image loading preference is reset to lazy. - `link-address`: A link address is normalized, or the link becomes text when its address is refused. - `link-title`: A numeric or boolean link title is read back as that value instead of text. - `link-attributes`: The link target, rel or CSS class is reset to the editor default. - `mention-target`: A mention with an invalid kind or ID becomes plain text. - `date-chip`: An inline date is written as its day in `YYYY-MM-DD` form, as plain text. Reading the Markdown back gives that text rather than the chip. - `heading-level`: An unsupported heading level becomes level 1 or 4. - `ordered-list-start`: A list start that Markdown cannot represent becomes 1. - `callout-kind`: A callout of an unknown kind is written as a note. - `callout-nesting`: A callout directly inside another callout is written as part of the callout around it, and loses its own kind. A callout further down, inside a quote inside a callout, is written as a deeper quote and keeps its kind. - `columns`: Markdown has no columns, so a columns block is written as its columns one after another, left to right, and the widths are left out. Each block in them is written as it would be on its own, in a quote or a toggle too, and reading the Markdown back gives those blocks in one column. A columns block in a table cell is written as part of the cell's one line instead, as `table-cell-blocks` describes. - `table-of-contents`: A table of contents block is written as a `[TOC]` line. Its rows are the note's own headings, which the Markdown already holds, so nothing in them is lost; reading the Markdown back gives a paragraph of that text rather than the block. - `footnote-orphan`: A footnote marker with no footnote, and a footnote no marker points at, are both left out. The editor pairs them up, so this only happens to a note written through the API. - `youtube-embed`: A YouTube block is written as a link to the video. Reading the Markdown back gives the link, not the player. - `pdf-embed`: A PDF block is written as a link to the file. Reading the Markdown back gives the link, not the viewer. - `recording-embed`: A recording's audio player is written as its label. The recording itself is not part of the Markdown, so reading it back gives a line of text. - `bookmark-card`: A bookmark card is written as a link, `[title](address)`, using the page title the card shows. Its description and site line are left out, and reading the Markdown back gives the link rather than the card. - `citation-text`: A citation is written as the bracketed text its style reads as, such as `(Smith, 2024, p. 3)`, as plain text. The source behind it is not written, so reading the Markdown back gives that text rather than the citation. - `bibliography`: A bibliography is written as a plain bullet list of its entries, each one the reference its style reads as, in the order the note lists them. The sources behind them are not written, so reading the Markdown back gives a list rather than the note's bibliography. - `invalid-structure`: Malformed table, list or code block structure is simplified. - `unsupported-mark`: An unknown mark is dropped while its text is kept. - `unsupported-content`: An unknown node is reduced to its text. - `exceeds-write-limit`: The markdown is longer than `PUT /notes/{noteId}/content` accepts (100,000 characters). Append with `POST /notes/{noteId}/content/append`, or edit the note in the app. - `version`: integer, required. Increases on every content change. Send it as `baseVersion` to avoid overwriting a newer save. - `pinned`: boolean, required. Pinned on the notes home. - `summary`: object or null, required - `summary`: string, required - `keywords`: array of string, required - `createdAt`: string (date-time), required. When the summary was generated. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "name": "Photosynthesis", "folder": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null }, "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "icon": "🌱", "cover": "https://workroad.example/api/storage/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/cover-1a2b3c4d.jpg", "tags": [ { "id": "5f4e3d2c-1b0a-4938-8776-655443322110", "name": "Exam" } ], "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "fullWidth": false, "contentFormat": "tiptap", "contentUnstable": true, "content": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "id": "6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a" }, "content": [ { "type": "text", "text": "Light becomes chemical energy." } ] } ] }, "markdown": "Light becomes chemical energy.", "markdownLosses": [], "version": 3, "pinned": false, "summary": { "summary": "How plants turn light into sugar.", "keywords": [ "chlorophyll", "glucose", "light reactions" ], "createdAt": "2026-02-01T12:00:00.000Z" } } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No resource with that id exists for this key's user - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List notes Source: https://workroad.app/docs/api/list-notes `GET https://workroad.app/api/v1/notes` Newest first. Note content is omitted; fetch a single note for content. Trashed notes and templates are not listed. Each note carries its `icon`, its `cover` image URL and its `tags`. - MCP tool: `listNotes` on `https://workroad.app/api/mcp` - Required scopes: notes:view - Test mode: `GET https://workroad.app/api/test/v1/notes` checks the request and returns the example response without changing anything. ## Query parameters - `limit`: integer, 1 to 100, default `25`. Page size, 1-100. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. - `folderId`: `"none"` or string (uuid). `none` for notes outside any folder, or a folder id for that folder's notes. - Or `"none"` - Or string (uuid) ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `name`: string, required - `folder`: object or null, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectId`: string or null, required. The note's subject id: its own when it has one, otherwise its folder's. `null` when neither has a subject. - `icon`: string or null, required. The single emoji shown beside the note's name, or `null`. - `cover`: string or null, required. The note's cover image, as a URL this key's user can load with their session or key, or `null`. Not part of the note's content, and not one of its files. - `tags`: array of objects, required. The note's labels, by name. Tags belong to the user, not to one note. - `id`: string (uuid), required - `name`: string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last content update timestamp. - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "id": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "name": "Photosynthesis", "folder": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null }, "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "icon": "🌱", "cover": "https://workroad.example/api/storage/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/cover-1a2b3c4d.jpg", "tags": [ { "id": "5f4e3d2c-1b0a-4938-8776-655443322110", "name": "Exam" } ], "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Write note content as Markdown Source: https://workroad.app/docs/api/replace-note-content `PUT https://workroad.app/api/v1/notes/{noteId}/content` Converts `markdown` (up to 100000 characters) to the editor format and replaces the note's content with it. To add blocks after the existing ones, use `POST /notes/{noteId}/content/append`. A replace keeps the inline images that are still in the Markdown, written as `![alt](src)` the way `GET /notes/{noteId}` returns them, and deletes the ones it leaves out. An image whose upload the web editor hasn't confirmed yet can still be deleted 24 hours after the upload. Send `baseVersion` to write only if nobody saved since you read the note. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `replaceNoteContent` on `https://workroad.app/api/mcp` - Required scopes: notes:update - Test mode: `PUT https://workroad.app/api/test/v1/notes/{noteId}/content` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Request body JSON. - `markdown`: string, required, up to 100000 characters. Markdown, up to 100000 characters. Stored as editor JSON, not as Markdown. - `baseVersion`: integer, at least 0. The `version` you last read. When the note has changed since, nothing is written and the response is 409. Omit it to write regardless. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `folder`: object or null, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectId`: string or null, required. The note's subject id: its own when it has one, otherwise its folder's. `null` when neither has a subject. - `icon`: string or null, required. The single emoji shown beside the note's name, or `null`. - `cover`: string or null, required. The note's cover image, as a URL this key's user can load with their session or key, or `null`. Not part of the note's content, and not one of its files. - `tags`: array of objects, required. The note's labels, by name. Tags belong to the user, not to one note. - `id`: string (uuid), required - `name`: string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last content update timestamp. - `fullWidth`: boolean, required - `contentFormat`: `"tiptap"`, required. Current serialization of `content`. - `contentUnstable`: `true`, required. Editor-specific TipTap JSON. Node and attribute shapes may change. - `content`: any value, required. Editor document, returned verbatim. See `contentUnstable`. - `markdown`: string or null, required. The note as markdown. Writing it back with `PUT /notes/{noteId}/content` reproduces the note exactly when `markdownLosses` is empty. Otherwise the listed parts come back simplified. Block ids are not in the markdown; a write mints new ones. An empty image alt or title reads back as null. Null when the note's content isn't an editor document. - `markdownLosses`: array of one of `"empty-paragraphs"`, `"collapsed-spaces"`, `"image-size"`, `"table-layout"`, `"table-cell-blocks"`, `"table-header"`, `"table-cell-background"`, `"ordered-list-type"`, `"image-upload-state"`, `"inline-math"`, `"mention-label"`, `"formatting-edges"`, `"highlight"`, `"text-colour"`, `"toggle-summary-formatting"`, `"toggle-heading-level"`, `"code-block-language"`, `"code-block-filename"`, `"code-block-wrap"`, `"block-math"`, `"image-address"`, `"image-loading"`, `"link-address"`, `"link-title"`, `"link-attributes"`, `"mention-target"`, `"date-chip"`, `"heading-level"`, `"ordered-list-start"`, `"callout-kind"`, `"callout-nesting"`, `"columns"`, `"table-of-contents"`, `"footnote-orphan"`, `"youtube-embed"`, `"pdf-embed"`, `"recording-embed"`, `"bookmark-card"`, `"citation-text"`, `"bibliography"`, `"invalid-structure"`, `"unsupported-mark"`, `"unsupported-content"`, `"exceeds-write-limit"`, required. What `markdown` simplifies, each value once. Empty when it reproduces the note exactly. - `empty-paragraphs`: Empty paragraphs are left out. One empty paragraph at the end of the note is not reported, because the editor adds it back. - `collapsed-spaces`: Runs of spaces, tabs and line breaks inside text become one space. Spaces at the start or end of a paragraph, heading or cell, or next to a hard break, are dropped. A hard break at the end of a paragraph is dropped, and one in a heading becomes a space. - `image-size`: Image width and height are left out. - `table-layout`: Merged cells, column widths and cell alignment are left out. - `table-cell-blocks`: A cell holding a list, a quote, a callout, a toggle, columns, code, a formula or more than one paragraph is written as one line: each paragraph, heading or toggle summary in it on a line of its own, separated by `
`, with a formula as inline math and code as its text. List bullets and checkboxes, quote and callout markers, images and embeds in the cell are left out, and reading the Markdown back gives one paragraph with line breaks. Footnote markers, math, citations, dates and mentions are written as anywhere else. - `table-header`: The first row is written as the header even when its cells aren't header cells, and header cells in later rows become normal cells. - `table-cell-background`: Cell background colours are left out. - `ordered-list-type`: Letter and Roman numbering become plain numbers. - `image-upload-state`: An image that never finished uploading is written as its alt text, and its upload details are left out. - `inline-math`: Inline math that `$…$` can't hold is written as text: empty math, latex with an unescaped `$` or a trailing backslash, and math followed directly by a digit. Spaces at either end of the latex are trimmed. - `mention-label`: A mention without a label gets its kind as the label, and line breaks in a label become spaces. Label-only export turns mentions into plain text. - `formatting-edges`: Bold, italic and strikethrough can't start or end on a space, so the space moves outside the formatting. Where they can't start or end at all, such as bold text that starts with punctuation right after a letter, the paragraph's bold, italic and strikethrough are dropped. - `highlight`: Highlighting is dropped while its text is kept. - `text-colour`: Text colour is dropped while the text is kept. - `toggle-summary-formatting`: A toggle's summary is written as plain text, so bold, italic, underline, strikethrough, links and inline code in it keep their text and lose their formatting. Whether the toggle was open is never stored, so there is nothing to report for it. - `toggle-heading-level`: A toggle whose summary reads as a heading keeps the heading's text and loses its level. The summary is written inside a `` tag, which carries the text and nothing else. - `code-block-language`: A code block language that cannot fit in a Markdown fence is left out. - `code-block-filename`: A code block's filename is left out. A Markdown fence carries the language and nothing else. - `code-block-wrap`: A code block's soft-wrap setting is left out. It is a view preference, so the code itself is unchanged. - `block-math`: A formula line containing only $$ is changed to $ $. - `image-address`: An image address is normalized, or the image becomes alt text when its address is refused. - `image-loading`: The image loading preference is reset to lazy. - `link-address`: A link address is normalized, or the link becomes text when its address is refused. - `link-title`: A numeric or boolean link title is read back as that value instead of text. - `link-attributes`: The link target, rel or CSS class is reset to the editor default. - `mention-target`: A mention with an invalid kind or ID becomes plain text. - `date-chip`: An inline date is written as its day in `YYYY-MM-DD` form, as plain text. Reading the Markdown back gives that text rather than the chip. - `heading-level`: An unsupported heading level becomes level 1 or 4. - `ordered-list-start`: A list start that Markdown cannot represent becomes 1. - `callout-kind`: A callout of an unknown kind is written as a note. - `callout-nesting`: A callout directly inside another callout is written as part of the callout around it, and loses its own kind. A callout further down, inside a quote inside a callout, is written as a deeper quote and keeps its kind. - `columns`: Markdown has no columns, so a columns block is written as its columns one after another, left to right, and the widths are left out. Each block in them is written as it would be on its own, in a quote or a toggle too, and reading the Markdown back gives those blocks in one column. A columns block in a table cell is written as part of the cell's one line instead, as `table-cell-blocks` describes. - `table-of-contents`: A table of contents block is written as a `[TOC]` line. Its rows are the note's own headings, which the Markdown already holds, so nothing in them is lost; reading the Markdown back gives a paragraph of that text rather than the block. - `footnote-orphan`: A footnote marker with no footnote, and a footnote no marker points at, are both left out. The editor pairs them up, so this only happens to a note written through the API. - `youtube-embed`: A YouTube block is written as a link to the video. Reading the Markdown back gives the link, not the player. - `pdf-embed`: A PDF block is written as a link to the file. Reading the Markdown back gives the link, not the viewer. - `recording-embed`: A recording's audio player is written as its label. The recording itself is not part of the Markdown, so reading it back gives a line of text. - `bookmark-card`: A bookmark card is written as a link, `[title](address)`, using the page title the card shows. Its description and site line are left out, and reading the Markdown back gives the link rather than the card. - `citation-text`: A citation is written as the bracketed text its style reads as, such as `(Smith, 2024, p. 3)`, as plain text. The source behind it is not written, so reading the Markdown back gives that text rather than the citation. - `bibliography`: A bibliography is written as a plain bullet list of its entries, each one the reference its style reads as, in the order the note lists them. The sources behind them are not written, so reading the Markdown back gives a list rather than the note's bibliography. - `invalid-structure`: Malformed table, list or code block structure is simplified. - `unsupported-mark`: An unknown mark is dropped while its text is kept. - `unsupported-content`: An unknown node is reduced to its text. - `exceeds-write-limit`: The markdown is longer than `PUT /notes/{noteId}/content` accepts (100,000 characters). Append with `POST /notes/{noteId}/content/append`, or edit the note in the app. - `version`: integer, required. Increases on every content change. Send it as `baseVersion` to avoid overwriting a newer save. - `pinned`: boolean, required. Pinned on the notes home. - `summary`: object or null, required - `summary`: string, required - `keywords`: array of string, required - `createdAt`: string (date-time), required. When the summary was generated. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "name": "Photosynthesis", "folder": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null }, "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "icon": "🌱", "cover": "https://workroad.example/api/storage/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/cover-1a2b3c4d.jpg", "tags": [ { "id": "5f4e3d2c-1b0a-4938-8776-655443322110", "name": "Exam" } ], "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "fullWidth": false, "contentFormat": "tiptap", "contentUnstable": true, "content": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "id": "6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a" }, "content": [ { "type": "text", "text": "Light becomes chemical energy." } ] } ] }, "markdown": "Light becomes chemical energy.", "markdownLosses": [], "version": 3, "pinned": false, "summary": { "summary": "How plants turn light into sugar.", "keywords": [ "chlorophyll", "glucose", "light reactions" ], "createdAt": "2026-02-01T12:00:00.000Z" } } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The note changed since `baseVersion`. Nothing was written. `details` holds `baseVersion` and `currentVersion`. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Restore a note from the trash Source: https://workroad.app/docs/api/restore-note `POST https://workroad.app/api/v1/notes/{noteId}/restore` Brings a trashed note back and returns it. Needs `notes:delete`, the scope that trashed it. Restoration succeeds even above the file or storage limit. Restored files count toward the limits on the next upload. - MCP tool: `restoreNote` on `https://workroad.app/api/mcp` - Required scopes: notes:delete - Test mode: `POST https://workroad.app/api/test/v1/notes/{noteId}/restore` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `folder`: object or null, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectId`: string or null, required. The note's subject id: its own when it has one, otherwise its folder's. `null` when neither has a subject. - `icon`: string or null, required. The single emoji shown beside the note's name, or `null`. - `cover`: string or null, required. The note's cover image, as a URL this key's user can load with their session or key, or `null`. Not part of the note's content, and not one of its files. - `tags`: array of objects, required. The note's labels, by name. Tags belong to the user, not to one note. - `id`: string (uuid), required - `name`: string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last content update timestamp. - `fullWidth`: boolean, required - `contentFormat`: `"tiptap"`, required. Current serialization of `content`. - `contentUnstable`: `true`, required. Editor-specific TipTap JSON. Node and attribute shapes may change. - `content`: any value, required. Editor document, returned verbatim. See `contentUnstable`. - `markdown`: string or null, required. The note as markdown. Writing it back with `PUT /notes/{noteId}/content` reproduces the note exactly when `markdownLosses` is empty. Otherwise the listed parts come back simplified. Block ids are not in the markdown; a write mints new ones. An empty image alt or title reads back as null. Null when the note's content isn't an editor document. - `markdownLosses`: array of one of `"empty-paragraphs"`, `"collapsed-spaces"`, `"image-size"`, `"table-layout"`, `"table-cell-blocks"`, `"table-header"`, `"table-cell-background"`, `"ordered-list-type"`, `"image-upload-state"`, `"inline-math"`, `"mention-label"`, `"formatting-edges"`, `"highlight"`, `"text-colour"`, `"toggle-summary-formatting"`, `"toggle-heading-level"`, `"code-block-language"`, `"code-block-filename"`, `"code-block-wrap"`, `"block-math"`, `"image-address"`, `"image-loading"`, `"link-address"`, `"link-title"`, `"link-attributes"`, `"mention-target"`, `"date-chip"`, `"heading-level"`, `"ordered-list-start"`, `"callout-kind"`, `"callout-nesting"`, `"columns"`, `"table-of-contents"`, `"footnote-orphan"`, `"youtube-embed"`, `"pdf-embed"`, `"recording-embed"`, `"bookmark-card"`, `"citation-text"`, `"bibliography"`, `"invalid-structure"`, `"unsupported-mark"`, `"unsupported-content"`, `"exceeds-write-limit"`, required. What `markdown` simplifies, each value once. Empty when it reproduces the note exactly. - `empty-paragraphs`: Empty paragraphs are left out. One empty paragraph at the end of the note is not reported, because the editor adds it back. - `collapsed-spaces`: Runs of spaces, tabs and line breaks inside text become one space. Spaces at the start or end of a paragraph, heading or cell, or next to a hard break, are dropped. A hard break at the end of a paragraph is dropped, and one in a heading becomes a space. - `image-size`: Image width and height are left out. - `table-layout`: Merged cells, column widths and cell alignment are left out. - `table-cell-blocks`: A cell holding a list, a quote, a callout, a toggle, columns, code, a formula or more than one paragraph is written as one line: each paragraph, heading or toggle summary in it on a line of its own, separated by `
`, with a formula as inline math and code as its text. List bullets and checkboxes, quote and callout markers, images and embeds in the cell are left out, and reading the Markdown back gives one paragraph with line breaks. Footnote markers, math, citations, dates and mentions are written as anywhere else. - `table-header`: The first row is written as the header even when its cells aren't header cells, and header cells in later rows become normal cells. - `table-cell-background`: Cell background colours are left out. - `ordered-list-type`: Letter and Roman numbering become plain numbers. - `image-upload-state`: An image that never finished uploading is written as its alt text, and its upload details are left out. - `inline-math`: Inline math that `$…$` can't hold is written as text: empty math, latex with an unescaped `$` or a trailing backslash, and math followed directly by a digit. Spaces at either end of the latex are trimmed. - `mention-label`: A mention without a label gets its kind as the label, and line breaks in a label become spaces. Label-only export turns mentions into plain text. - `formatting-edges`: Bold, italic and strikethrough can't start or end on a space, so the space moves outside the formatting. Where they can't start or end at all, such as bold text that starts with punctuation right after a letter, the paragraph's bold, italic and strikethrough are dropped. - `highlight`: Highlighting is dropped while its text is kept. - `text-colour`: Text colour is dropped while the text is kept. - `toggle-summary-formatting`: A toggle's summary is written as plain text, so bold, italic, underline, strikethrough, links and inline code in it keep their text and lose their formatting. Whether the toggle was open is never stored, so there is nothing to report for it. - `toggle-heading-level`: A toggle whose summary reads as a heading keeps the heading's text and loses its level. The summary is written inside a `` tag, which carries the text and nothing else. - `code-block-language`: A code block language that cannot fit in a Markdown fence is left out. - `code-block-filename`: A code block's filename is left out. A Markdown fence carries the language and nothing else. - `code-block-wrap`: A code block's soft-wrap setting is left out. It is a view preference, so the code itself is unchanged. - `block-math`: A formula line containing only $$ is changed to $ $. - `image-address`: An image address is normalized, or the image becomes alt text when its address is refused. - `image-loading`: The image loading preference is reset to lazy. - `link-address`: A link address is normalized, or the link becomes text when its address is refused. - `link-title`: A numeric or boolean link title is read back as that value instead of text. - `link-attributes`: The link target, rel or CSS class is reset to the editor default. - `mention-target`: A mention with an invalid kind or ID becomes plain text. - `date-chip`: An inline date is written as its day in `YYYY-MM-DD` form, as plain text. Reading the Markdown back gives that text rather than the chip. - `heading-level`: An unsupported heading level becomes level 1 or 4. - `ordered-list-start`: A list start that Markdown cannot represent becomes 1. - `callout-kind`: A callout of an unknown kind is written as a note. - `callout-nesting`: A callout directly inside another callout is written as part of the callout around it, and loses its own kind. A callout further down, inside a quote inside a callout, is written as a deeper quote and keeps its kind. - `columns`: Markdown has no columns, so a columns block is written as its columns one after another, left to right, and the widths are left out. Each block in them is written as it would be on its own, in a quote or a toggle too, and reading the Markdown back gives those blocks in one column. A columns block in a table cell is written as part of the cell's one line instead, as `table-cell-blocks` describes. - `table-of-contents`: A table of contents block is written as a `[TOC]` line. Its rows are the note's own headings, which the Markdown already holds, so nothing in them is lost; reading the Markdown back gives a paragraph of that text rather than the block. - `footnote-orphan`: A footnote marker with no footnote, and a footnote no marker points at, are both left out. The editor pairs them up, so this only happens to a note written through the API. - `youtube-embed`: A YouTube block is written as a link to the video. Reading the Markdown back gives the link, not the player. - `pdf-embed`: A PDF block is written as a link to the file. Reading the Markdown back gives the link, not the viewer. - `recording-embed`: A recording's audio player is written as its label. The recording itself is not part of the Markdown, so reading it back gives a line of text. - `bookmark-card`: A bookmark card is written as a link, `[title](address)`, using the page title the card shows. Its description and site line are left out, and reading the Markdown back gives the link rather than the card. - `citation-text`: A citation is written as the bracketed text its style reads as, such as `(Smith, 2024, p. 3)`, as plain text. The source behind it is not written, so reading the Markdown back gives that text rather than the citation. - `bibliography`: A bibliography is written as a plain bullet list of its entries, each one the reference its style reads as, in the order the note lists them. The sources behind them are not written, so reading the Markdown back gives a list rather than the note's bibliography. - `invalid-structure`: Malformed table, list or code block structure is simplified. - `unsupported-mark`: An unknown mark is dropped while its text is kept. - `unsupported-content`: An unknown node is reduced to its text. - `exceeds-write-limit`: The markdown is longer than `PUT /notes/{noteId}/content` accepts (100,000 characters). Append with `POST /notes/{noteId}/content/append`, or edit the note in the app. - `version`: integer, required. Increases on every content change. Send it as `baseVersion` to avoid overwriting a newer save. - `pinned`: boolean, required. Pinned on the notes home. - `summary`: object or null, required - `summary`: string, required - `keywords`: array of string, required - `createdAt`: string (date-time), required. When the summary was generated. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "name": "Photosynthesis", "folder": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null }, "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "icon": "🌱", "cover": "https://workroad.example/api/storage/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/cover-1a2b3c4d.jpg", "tags": [ { "id": "5f4e3d2c-1b0a-4938-8776-655443322110", "name": "Exam" } ], "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "fullWidth": false, "contentFormat": "tiptap", "contentUnstable": true, "content": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "id": "6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a" }, "content": [ { "type": "text", "text": "Light becomes chemical energy." } ] } ] }, "markdown": "Light becomes chemical energy.", "markdownLosses": [], "version": 3, "pinned": false, "summary": { "summary": "How plants turn light into sugar.", "keywords": [ "chlorophyll", "glucose", "light reactions" ], "createdAt": "2026-02-01T12:00:00.000Z" } } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id is in the trash for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update a note Source: https://workroad.app/docs/api/update-note `PATCH https://workroad.app/api/v1/notes/{noteId}` Renames the note, moves it into or out of a folder, files it under a subject, pins it, or sets its `icon`. Only the fields you send change. Content is written with `PUT /notes/{noteId}/content`. The cover image and the tags are read here but set in the app. - MCP tool: `updateNote` on `https://workroad.app/api/mcp` - Required scopes: notes:update - Test mode: `PATCH https://workroad.app/api/test/v1/notes/{noteId}` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Request body JSON. - `name`: string, 1-64 characters - `folderId`: string (uuid) or null. A folder owned by the key's user. `null` takes the note out of its folder. - `pinned`: boolean. Pins the note on the notes home. Templates cannot be pinned. - `subjectId`: string (uuid) or null. The note's own subject, which wins over its folder's. Must be a subject owned by the key's user (`GET /subjects`); any other id is refused with 400 `unknown-subject`. `null` clears it, so the note takes its folder's subject again. - `icon`: string or null. One emoji shown beside the note's name, such as `📘` or `🇫🇷`: a single emoji, not text or several emoji. `null` removes it. The note's cover image and its tags are set in the app, not through this endpoint. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `folder`: object or null, required - `id`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectId`: string or null, required. The note's subject id: its own when it has one, otherwise its folder's. `null` when neither has a subject. - `icon`: string or null, required. The single emoji shown beside the note's name, or `null`. - `cover`: string or null, required. The note's cover image, as a URL this key's user can load with their session or key, or `null`. Not part of the note's content, and not one of its files. - `tags`: array of objects, required. The note's labels, by name. Tags belong to the user, not to one note. - `id`: string (uuid), required - `name`: string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last content update timestamp. - `fullWidth`: boolean, required - `contentFormat`: `"tiptap"`, required. Current serialization of `content`. - `contentUnstable`: `true`, required. Editor-specific TipTap JSON. Node and attribute shapes may change. - `content`: any value, required. Editor document, returned verbatim. See `contentUnstable`. - `markdown`: string or null, required. The note as markdown. Writing it back with `PUT /notes/{noteId}/content` reproduces the note exactly when `markdownLosses` is empty. Otherwise the listed parts come back simplified. Block ids are not in the markdown; a write mints new ones. An empty image alt or title reads back as null. Null when the note's content isn't an editor document. - `markdownLosses`: array of one of `"empty-paragraphs"`, `"collapsed-spaces"`, `"image-size"`, `"table-layout"`, `"table-cell-blocks"`, `"table-header"`, `"table-cell-background"`, `"ordered-list-type"`, `"image-upload-state"`, `"inline-math"`, `"mention-label"`, `"formatting-edges"`, `"highlight"`, `"text-colour"`, `"toggle-summary-formatting"`, `"toggle-heading-level"`, `"code-block-language"`, `"code-block-filename"`, `"code-block-wrap"`, `"block-math"`, `"image-address"`, `"image-loading"`, `"link-address"`, `"link-title"`, `"link-attributes"`, `"mention-target"`, `"date-chip"`, `"heading-level"`, `"ordered-list-start"`, `"callout-kind"`, `"callout-nesting"`, `"columns"`, `"table-of-contents"`, `"footnote-orphan"`, `"youtube-embed"`, `"pdf-embed"`, `"recording-embed"`, `"bookmark-card"`, `"citation-text"`, `"bibliography"`, `"invalid-structure"`, `"unsupported-mark"`, `"unsupported-content"`, `"exceeds-write-limit"`, required. What `markdown` simplifies, each value once. Empty when it reproduces the note exactly. - `empty-paragraphs`: Empty paragraphs are left out. One empty paragraph at the end of the note is not reported, because the editor adds it back. - `collapsed-spaces`: Runs of spaces, tabs and line breaks inside text become one space. Spaces at the start or end of a paragraph, heading or cell, or next to a hard break, are dropped. A hard break at the end of a paragraph is dropped, and one in a heading becomes a space. - `image-size`: Image width and height are left out. - `table-layout`: Merged cells, column widths and cell alignment are left out. - `table-cell-blocks`: A cell holding a list, a quote, a callout, a toggle, columns, code, a formula or more than one paragraph is written as one line: each paragraph, heading or toggle summary in it on a line of its own, separated by `
`, with a formula as inline math and code as its text. List bullets and checkboxes, quote and callout markers, images and embeds in the cell are left out, and reading the Markdown back gives one paragraph with line breaks. Footnote markers, math, citations, dates and mentions are written as anywhere else. - `table-header`: The first row is written as the header even when its cells aren't header cells, and header cells in later rows become normal cells. - `table-cell-background`: Cell background colours are left out. - `ordered-list-type`: Letter and Roman numbering become plain numbers. - `image-upload-state`: An image that never finished uploading is written as its alt text, and its upload details are left out. - `inline-math`: Inline math that `$…$` can't hold is written as text: empty math, latex with an unescaped `$` or a trailing backslash, and math followed directly by a digit. Spaces at either end of the latex are trimmed. - `mention-label`: A mention without a label gets its kind as the label, and line breaks in a label become spaces. Label-only export turns mentions into plain text. - `formatting-edges`: Bold, italic and strikethrough can't start or end on a space, so the space moves outside the formatting. Where they can't start or end at all, such as bold text that starts with punctuation right after a letter, the paragraph's bold, italic and strikethrough are dropped. - `highlight`: Highlighting is dropped while its text is kept. - `text-colour`: Text colour is dropped while the text is kept. - `toggle-summary-formatting`: A toggle's summary is written as plain text, so bold, italic, underline, strikethrough, links and inline code in it keep their text and lose their formatting. Whether the toggle was open is never stored, so there is nothing to report for it. - `toggle-heading-level`: A toggle whose summary reads as a heading keeps the heading's text and loses its level. The summary is written inside a `` tag, which carries the text and nothing else. - `code-block-language`: A code block language that cannot fit in a Markdown fence is left out. - `code-block-filename`: A code block's filename is left out. A Markdown fence carries the language and nothing else. - `code-block-wrap`: A code block's soft-wrap setting is left out. It is a view preference, so the code itself is unchanged. - `block-math`: A formula line containing only $$ is changed to $ $. - `image-address`: An image address is normalized, or the image becomes alt text when its address is refused. - `image-loading`: The image loading preference is reset to lazy. - `link-address`: A link address is normalized, or the link becomes text when its address is refused. - `link-title`: A numeric or boolean link title is read back as that value instead of text. - `link-attributes`: The link target, rel or CSS class is reset to the editor default. - `mention-target`: A mention with an invalid kind or ID becomes plain text. - `date-chip`: An inline date is written as its day in `YYYY-MM-DD` form, as plain text. Reading the Markdown back gives that text rather than the chip. - `heading-level`: An unsupported heading level becomes level 1 or 4. - `ordered-list-start`: A list start that Markdown cannot represent becomes 1. - `callout-kind`: A callout of an unknown kind is written as a note. - `callout-nesting`: A callout directly inside another callout is written as part of the callout around it, and loses its own kind. A callout further down, inside a quote inside a callout, is written as a deeper quote and keeps its kind. - `columns`: Markdown has no columns, so a columns block is written as its columns one after another, left to right, and the widths are left out. Each block in them is written as it would be on its own, in a quote or a toggle too, and reading the Markdown back gives those blocks in one column. A columns block in a table cell is written as part of the cell's one line instead, as `table-cell-blocks` describes. - `table-of-contents`: A table of contents block is written as a `[TOC]` line. Its rows are the note's own headings, which the Markdown already holds, so nothing in them is lost; reading the Markdown back gives a paragraph of that text rather than the block. - `footnote-orphan`: A footnote marker with no footnote, and a footnote no marker points at, are both left out. The editor pairs them up, so this only happens to a note written through the API. - `youtube-embed`: A YouTube block is written as a link to the video. Reading the Markdown back gives the link, not the player. - `pdf-embed`: A PDF block is written as a link to the file. Reading the Markdown back gives the link, not the viewer. - `recording-embed`: A recording's audio player is written as its label. The recording itself is not part of the Markdown, so reading it back gives a line of text. - `bookmark-card`: A bookmark card is written as a link, `[title](address)`, using the page title the card shows. Its description and site line are left out, and reading the Markdown back gives the link rather than the card. - `citation-text`: A citation is written as the bracketed text its style reads as, such as `(Smith, 2024, p. 3)`, as plain text. The source behind it is not written, so reading the Markdown back gives that text rather than the citation. - `bibliography`: A bibliography is written as a plain bullet list of its entries, each one the reference its style reads as, in the order the note lists them. The sources behind them are not written, so reading the Markdown back gives a list rather than the note's bibliography. - `invalid-structure`: Malformed table, list or code block structure is simplified. - `unsupported-mark`: An unknown mark is dropped while its text is kept. - `unsupported-content`: An unknown node is reduced to its text. - `exceeds-write-limit`: The markdown is longer than `PUT /notes/{noteId}/content` accepts (100,000 characters). Append with `POST /notes/{noteId}/content/append`, or edit the note in the app. - `version`: integer, required. Increases on every content change. Send it as `baseVersion` to avoid overwriting a newer save. - `pinned`: boolean, required. Pinned on the notes home. - `summary`: object or null, required - `summary`: string, required - `keywords`: array of string, required - `createdAt`: string (date-time), required. When the summary was generated. ### Example ```json { "data": { "id": "3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a", "name": "Photosynthesis", "folder": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Biology", "subjectId": null }, "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "icon": "🌱", "cover": "https://workroad.example/api/storage/note/3c2b1a09-8f7e-4d6c-9b5a-4f3e2d1c0b9a/cover-1a2b3c4d.jpg", "tags": [ { "id": "5f4e3d2c-1b0a-4938-8776-655443322110", "name": "Exam" } ], "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "fullWidth": false, "contentFormat": "tiptap", "contentUnstable": true, "content": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "id": "6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a" }, "content": [ { "type": "text", "text": "Light becomes chemical energy." } ] } ] }, "markdown": "Light becomes chemical energy.", "markdownLosses": [], "version": 3, "pinned": false, "summary": { "summary": "How plants turn light into sugar.", "keywords": [ "chlorophyll", "glucose", "light reactions" ], "createdAt": "2026-02-01T12:00:00.000Z" } } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, no field to update, a `folderId` that is not the user's (`details.reason` is `unknown-folder`), a `subjectId` that is not the user's (`details.reason` is `unknown-subject`), or `pinned` sent for a template (`details.reason` is `template-note`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Answer a practice card Source: https://workroad.app/docs/api/answer-practice-card `POST https://workroad.app/api/v1/practice-sessions/{sessionId}/answers` Records the user's answer to one card from the session as an FSRS review, schedules the card's next due time, and counts toward the daily goal and the streak, exactly like an answer in the app. Show faces.front, let the user answer, reveal faces.back, then send how well they recalled it. Answering the same `itemId` again with the same rating records nothing and returns `replayed: true`, so a retry is safe. `remaining` says how many cards from the last read are left; at 0, call `getPracticeSession`. - MCP tool: `answerPracticeCard` on `https://workroad.app/api/mcp` - Required scopes: activity:create - Test mode: `POST https://workroad.app/api/test/v1/practice-sessions/{sessionId}/answers` checks the request and returns the example response without changing anything. - Next: when `remaining` is 0, call `getPracticeSession` for more cards. ## Path parameters - `sessionId`: string (uuid), required. Practice session id from `startPracticeSession`, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `itemId`: string (uuid), required. `itemId` of a card from `getPracticeSession`. - `rating`: one of `"again"`, `"hard"`, `"good"`, `"easy"`, required. How well the user recalled the card: `again` (forgot), `hard`, `good` or `easy`. Ask the user, or judge their answer against the definition. - `durationMs`: integer, required, 0 to 600000. Time the user spent on the card, 0-600000 ms. Stored in whole seconds, and counted by the daily minutes goal. ## Response `201` Created `200` This item was already answered. The body has `replayed: true`. - `data`: object, required - `itemId`: string (uuid), required - `cardId`: string or null, required. Null for a cram answer on a new term. - `recorded`: one of `"review"`, `"cram"`, required. `review`: an FSRS review was recorded and the card rescheduled, exactly as in the app. `cram`: a cram session, nothing rescheduled; an `again` answer queues the term again. - `replayed`: boolean, required. True when this item was already answered. Nothing changed. - `queue`: one of `"new"`, `"learning"`, `"relearning"`, `"review"` or null, required. The card's queue after the review. Null for cram. - `due`: string (date-time) or null, required. When the card is next due. Null for cram. - `suspended`: boolean, required. True when the answer suspended the card as a leech. - `remaining`: integer, required. Cards from the last read still waiting for an answer. At 0, call `getPracticeSession` to refill. ### Example ```json { "data": { "itemId": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b", "cardId": "3e2d1c0b-9a8f-4e7d-8c6b-5a4f3e2d1c0b", "recorded": "review", "replayed": false, "queue": "review", "due": "2026-02-05T12:00:00.000Z", "suspended": false, "remaining": 0 } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: `not_found`: the session doesn't exist, expired, or has no such `itemId`. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason`: `card-changed-refresh-session` (the card was reviewed or changed elsewhere; read the session again), `card-not-due`, `card-suspended`, `card-deleted`, `new-card-limit` or `review-card-limit`; `concurrent-grade` with `Retry-After: 1` (retry the same call). `idempotency_conflict` when the item was already answered with a different rating. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a practice session's next cards Source: https://workroad.app/docs/api/get-practice-session `GET https://workroad.app/api/v1/practice-sessions/{sessionId}` Returns the cards still waiting for an answer. When none are left, reading the session refills it with up to 30 more, decided by the server clock, and drops cards that were reviewed or suspended elsewhere since. `finished: true` means nothing is due and today's new cards are spent; read it again at `nextDueAt`. Reading never records an answer, so it is safe to repeat. - MCP tool: `getPracticeSession` on `https://workroad.app/api/mcp` - Required scopes: activity:view and sets:view - Test mode: `GET https://workroad.app/api/test/v1/practice-sessions/{sessionId}` checks the request and returns the example response without changing anything. - Next: answer each card with `answerPracticeCard`; when `finished` is true, stop and come back at `nextDueAt`. ## Path parameters - `sessionId`: string (uuid), required. Practice session id from `startPracticeSession`, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required. Pass as `sessionId` to the other practice tools. - `cram`: boolean, required - `setIds`: array of string, required. The sets the session is limited to. Empty means every set. - `subjectId`: string or null, required. The subject it is limited to. - `createdAt`: string (date-time), required. When the session started. - `expiresAt`: string (date-time), required. When the session stops accepting answers, 7 days after it started. Start a new one then. - `cards`: array of objects, required. Cards to answer now, in order, up to 30: learning and relearning first, then reviews, then new cards, taking turns between sets. Answer them with `answerPracticeCard`, then read the session again to refill. - `itemId`: string (uuid), required. Send this as `itemId` to `answerPracticeCard`. - `cardId`: string or null, required. The FSRS card. Null in a cram session for a term never practised, which gets no card. - `termId`: string (uuid), required. The term's id in its study set. - `setId`: string (uuid), required - `setName`: string, required - `subjectId`: string or null, required. The set's subject, when set. - `activity`: one of `"flashcards"`, `"memory"`, `"match"`, `"guess_the_term"`, `"sentence_scramble"`, required. How the app quizzes this card: `flashcards` (recall the definition), `memory` (fill the blanks), `match`, `guess_the_term` (pick the term for the definition) or `sentence_scramble`. Quiz using faces.front and reveal faces.back; term and definition remain source content. - `term`: string, required - `definition`: string, required - `variant`: integer, required. Forward 0, reverse -1, or cloze deletion N. Grade the returned card/item ID. - `faces`: object, required - `front`: string, required - `back`: string, required - `hints`: array of string, required - `imageUrl`: string or null, required. The term's image, when it has one. - `queue`: one of `"new"`, `"learning"`, `"relearning"`, `"review"`, required. The card's scheduling queue. - `due`: string (date-time), required. When the card became or becomes due. - `answeredCount`: integer, required. Answers recorded in this session. - `finished`: boolean, required. True when nothing is due now and today's new-card limit is spent. The session stays open: read it again at `nextDueAt`. - `nextDueAt`: string (date-time) or null, required. When `finished` is true, the next time a card in the session's scope becomes due. Null otherwise. - `undoableItemId`: string or null, required. The newest answer that `undoPracticeAnswer` can take back. Null in a cram session or when nothing can be undone. - `serverTime`: string (date-time), required. The server clock that decided which cards are due. ### Example ```json { "data": { "id": "8b7a6f5e-4d3c-4b2a-9f1e-0d9c8b7a6f5e", "cram": false, "setIds": [], "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "createdAt": "2026-02-01T12:00:00.000Z", "expiresAt": "2026-02-08T12:00:00.000Z", "cards": [ { "itemId": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b", "cardId": "3e2d1c0b-9a8f-4e7d-8c6b-5a4f3e2d1c0b", "termId": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "setName": "Spanish verbs", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "activity": "flashcards", "variant": 0, "faces": { "front": "tener (yo)", "back": "tuve" }, "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ], "imageUrl": null, "queue": "review", "due": "2026-01-15T09:30:00.000Z" } ], "answeredCount": 0, "finished": false, "nextDueAt": null, "undoableItemId": null, "serverTime": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No practice session with that id exists for this key's user, or it expired. Start a new one with `startPracticeSession`. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Start a practice session Source: https://workroad.app/docs/api/start-practice-session `POST https://workroad.app/api/v1/practice-sessions` Starts flashcard practice the way the app's Review page does, over every study set the user owns or only some, and answers with the first cards. A normal session serves what is due now (learning and relearning first, then reviews, then new cards within the user's daily new-card limit), taking turns between sets, up to 30 at a time. Forward is variant 0, reverse -1 and cloze N. Quiz from faces.front and reveal faces.back; term and definition are source content. Answer each card with `answerPracticeCard`, then call `getPracticeSession` for more. `cram: true` walks every term once without touching the schedule. A session lasts 7 days and needs no finishing. - MCP tool: `startPracticeSession` on `https://workroad.app/api/mcp` - Required scopes: activity:create and sets:view - Test mode: `POST https://workroad.app/api/test/v1/practice-sessions` checks the request and returns the example response without changing anything. - Next: show faces.front, reveal faces.back, and answer each card with `answerPracticeCard`. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `setIds`: array of string (uuid), up to 200 items. Only these study sets, up to 200. Omit to practise every set the user owns. - `subjectId`: string (uuid). Only sets filed under this subject. Combines with `setIds`. - `cram`: boolean. Walk every term once, due or not, without changing any card's schedule. Defaults to false: a normal session serves only due and new cards and records each answer as an FSRS review. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required. Pass as `sessionId` to the other practice tools. - `cram`: boolean, required - `setIds`: array of string, required. The sets the session is limited to. Empty means every set. - `subjectId`: string or null, required. The subject it is limited to. - `createdAt`: string (date-time), required. When the session started. - `expiresAt`: string (date-time), required. When the session stops accepting answers, 7 days after it started. Start a new one then. - `cards`: array of objects, required. Cards to answer now, in order, up to 30: learning and relearning first, then reviews, then new cards, taking turns between sets. Answer them with `answerPracticeCard`, then read the session again to refill. - `itemId`: string (uuid), required. Send this as `itemId` to `answerPracticeCard`. - `cardId`: string or null, required. The FSRS card. Null in a cram session for a term never practised, which gets no card. - `termId`: string (uuid), required. The term's id in its study set. - `setId`: string (uuid), required - `setName`: string, required - `subjectId`: string or null, required. The set's subject, when set. - `activity`: one of `"flashcards"`, `"memory"`, `"match"`, `"guess_the_term"`, `"sentence_scramble"`, required. How the app quizzes this card: `flashcards` (recall the definition), `memory` (fill the blanks), `match`, `guess_the_term` (pick the term for the definition) or `sentence_scramble`. Quiz using faces.front and reveal faces.back; term and definition remain source content. - `term`: string, required - `definition`: string, required - `variant`: integer, required. Forward 0, reverse -1, or cloze deletion N. Grade the returned card/item ID. - `faces`: object, required - `front`: string, required - `back`: string, required - `hints`: array of string, required - `imageUrl`: string or null, required. The term's image, when it has one. - `queue`: one of `"new"`, `"learning"`, `"relearning"`, `"review"`, required. The card's scheduling queue. - `due`: string (date-time), required. When the card became or becomes due. - `answeredCount`: integer, required. Answers recorded in this session. - `finished`: boolean, required. True when nothing is due now and today's new-card limit is spent. The session stays open: read it again at `nextDueAt`. - `nextDueAt`: string (date-time) or null, required. When `finished` is true, the next time a card in the session's scope becomes due. Null otherwise. - `undoableItemId`: string or null, required. The newest answer that `undoPracticeAnswer` can take back. Null in a cram session or when nothing can be undone. - `serverTime`: string (date-time), required. The server clock that decided which cards are due. ### Example ```json { "data": { "id": "8b7a6f5e-4d3c-4b2a-9f1e-0d9c8b7a6f5e", "cram": false, "setIds": [], "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "createdAt": "2026-02-01T12:00:00.000Z", "expiresAt": "2026-02-08T12:00:00.000Z", "cards": [ { "itemId": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b", "cardId": "3e2d1c0b-9a8f-4e7d-8c6b-5a4f3e2d1c0b", "termId": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "setName": "Spanish verbs", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "activity": "flashcards", "variant": 0, "faces": { "front": "tener (yo)", "back": "tuve" }, "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ], "imageUrl": null, "queue": "review", "due": "2026-01-15T09:30:00.000Z" } ], "answeredCount": 0, "finished": false, "nextDueAt": null, "undoableItemId": null, "serverTime": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: `not_found`: a `subjectId` or one of the `setIds` isn't one of this key's user's. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Undo a practice answer Source: https://workroad.app/docs/api/undo-practice-answer `POST https://workroad.app/api/v1/practice-sessions/{sessionId}/undo` Takes back an answer, as the app's undo does: the review is removed from the card's history, the card's schedule goes back to what it was, and the card is queued in the session again under a new `itemId`. Use `undoableItemId` from `getPracticeSession`. Undoing the same answer again changes nothing. - MCP tool: `undoPracticeAnswer` on `https://workroad.app/api/mcp` - Required scopes: activity:delete - Test mode: `POST https://workroad.app/api/test/v1/practice-sessions/{sessionId}/undo` checks the request and returns the example response without changing anything. ## Path parameters - `sessionId`: string (uuid), required. Practice session id from `startPracticeSession`, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `itemId`: string (uuid), required. `undoableItemId` from `getPracticeSession`: the session's newest answer. ## Response `200` OK - `data`: object, required - `itemId`: string (uuid), required - `undone`: `true`, required. The answer's review was removed and the card restored. It is queued in the session again under a new `itemId`. ### Example ```json { "data": { "itemId": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b", "undone": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: `not_found`: no answered card with that `itemId` in this session, or it was a cram session. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict`: the card was reviewed again since this answer, so it is no longer the newest review and can't be taken back. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a practice test Source: https://workroad.app/docs/api/create-test `POST https://workroad.app/api/v1/tests` Writes a practice test from study sets and notes, as the app's New test dialog does. The test starts as `generating`: poll `GET /jobs/{jobId}` or `GET /tests/{testId}` until its `status` is `ready`. Counts as one AI use, which also covers AI grading of its short answers. Send the same `requestId` again to retry safely: it returns the same test. A refused start leaves no test behind. Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). If you can write this content yourself, write it and save it with `POST /tests/from-questions`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work. - MCP tool: `createTest` on `https://workroad.app/api/mcp` - Required scopes: tests:generate - Test mode: `POST https://workroad.app/api/test/v1/tests` checks the request and returns the example response without changing anything. - Next: poll `getJob` with the `jobId` until `succeeded`, then `startTestAttempt`. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `requestId`: string (uuid), required. A uuid you mint for this start. It becomes the job id. Sending it again with the same sets, notes, `kinds`, `length`, `timerSecs` and `modelTier` returns the original test and is not counted twice. `name` is not compared, so a replay under another name still returns the original test with its original name. Any other change under a used `requestId` is 409 `idempotency_conflict`. - `name`: string, required, 1-120 characters. The test's name, 1-120 characters. - `sourceSetIds`: array of string, up to 10 items, default `[]`. Study sets to write questions from, up to 10. - `sourceNoteIds`: array of string, up to 10 items, default `[]`. Notes to write questions from, up to 10. Name at least one set or note, and they must have text. - `kinds`: array of one of `"MCQ"`, `"TRUE_FALSE"`, `"SHORT"`, `"FILL_BLANK"`, required, at least 1 item. The kinds of question to ask, at least one. - `length`: integer, required, 1 to 50. How many questions, 1-50. - `timerSecs`: integer or null, 60 to 7200, default `null`. A time limit in seconds, 60-7200, counted from the start of each attempt. Null or omitted for no limit. - `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`. `swift`, `scholar` (the default) or `sage`. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`. ## Response `202` Accepted - `data`: object, required - `testId`: string (uuid), required. The new test. Its `status` is `generating`. - `jobId`: string (uuid), required. The generation job. Poll `GET /jobs/{jobId}` until it ends. ### Example ```json { "data": { "testId": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request`: the body fails validation, or `details.field` and `details.reason` say why: `sources` `no-sources` (name a set or a note), `sources` `empty-sources` (they have no text), `sourceSetIds` `unknown-set` or `sourceNoteIds` `unknown-note` (a set or note in the body isn't the user's), or `modelTier` `premium-only`. - `401`: Missing, invalid, disabled or expired API key - `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. Nothing starts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `idempotency_conflict`: this `requestId` already started a test from other sets, notes, question kinds, length, time limit or `modelTier`. Send a new `requestId`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Without a `details.reason`, the user has too many jobs queued: retry after `Retry-After` seconds (30). Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: The job engine is unreachable. Send the same request again after `Retry-After` seconds; the `requestId` keeps it from starting twice. --- # Create a practice test from your questions Source: https://workroad.app/docs/api/create-test-from-questions `POST https://workroad.app/api/v1/tests/from-questions` Saves a practice test whose questions you wrote, in the shape WorkRoad's own generator writes them, and answers 201 with the test, ready to take: start it with `POST /tests/{testId}/attempts`. Nothing is generated, so it uses no AI. Multiple choice, true/false and fill-in questions are graded without AI too; short answers go to AI grading when an attempt is submitted, which counts as an AI use on each submit, so prefer the other kinds unless the user wants written answers. Read the material first (`GET /notes/{noteId}`, `GET /sets/{setId}`) and write questions it answers. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `createTestFromQuestions` on `https://workroad.app/api/mcp` - Required scopes: tests:create - Test mode: `POST https://workroad.app/api/test/v1/tests/from-questions` checks the request and returns the example response without changing anything. - Next: start it with `startTestAttempt`, or give the user the test's name to take it in the app. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 1-120 characters. The test's name, 1-120 characters. - `questions`: array of objects, required, 1-50 items. 1-50 questions in the order they are asked, written in the shape WorkRoad's own test generator writes them. - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. `multiple-choice`, `true-false`, `fill-blank` or `short-answer`. Short answers are graded by WorkRoad's AI when the attempt is submitted, which counts as an AI use; the other kinds are graded without AI. - `prompt`: string, required, 1-2000 characters. The question as the student reads it. - `choices`: array of string, 2-8 items. Multiple choice only: 8 or fewer distinct options, one of them exactly `answer`. WorkRoad shuffles them once when it saves the test. - `answer`: string, required, 1-2000 characters. The correct answer: the right choice's exact text, `true` or `false`, the word or phrase that fills the blank, or a model answer for a short answer. - `explanation`: string, required, 1-4000 characters. Why the answer is right, shown after the attempt is submitted. - `difficulty`: one of `"recall"`, `"apply"`, `"analyse"`, required. `recall`, `apply` or `analyse`. - `timerSecs`: integer or null, 60 to 7200, default `null`. A time limit in seconds, 60-7200, counted from the start of each attempt. Null or omitted for no limit. - `sourceSetIds`: array of string, up to 10 items, default `[]`. The user's study sets the questions cover, up to 10. Optional; they label the test. - `sourceNoteIds`: array of string, up to 10 items, default `[]`. The user's notes the questions cover, up to 10. Optional. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `assisted`: boolean, required - `status`: one of `"draft"`, `"generating"`, `"ready"`, `"failed"`, required. `generating` until the questions are written, then `ready`. `failed` when generation failed or could not start; `error` says why. - `error`: string or null, required. Why generation failed. - `createdAt`: string (date-time), required. When the test was created. - `generationJobId`: string or null, required. The latest generation job. Poll `GET /jobs/{jobId}` while `status` is `generating`. - `questionsVersion`: integer, required. Goes up each time the questions are generated again. - `questionCount`: integer, required - `kindCounts`: array of objects, required. How many questions of each kind the test has. - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI. - `count`: integer, required - `generating`: object or null, required. While `status` is `generating`, the questions written so far, without answers. Null otherwise. - `total`: integer, required. Questions asked for. - `questions`: array of objects, required - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI. - `prompt`: string, required - `difficulty`: string, required - `choiceCount`: integer, required - `requestedLength`: integer, required - `kinds`: array of one of `"MCQ"`, `"TRUE_FALSE"`, `"SHORT"`, `"FILL_BLANK"`, required - `timerSecs`: integer or null, required. The time limit, or null. - `modelTier`: string, required - `sourceSetIds`: array of string, required - `sourceNoteIds`: array of string, required - `attempts`: array of objects, required. The 25 most recent attempts, newest first. - `id`: string (uuid), required - `assisted`: boolean, required - `score`: object or null, required. Correct answers out of all questions. Null until grading ends. - `num`: integer, required - `den`: integer, required - `gradingStatus`: one of `"not_required"`, `"pending"`, `"graded"`, `"failed"`, required. `not_required` when every answer was graded on submit, `pending` while AI grades the short answers, `graded` once it has, and `failed` when AI grading could not run. The app then lets the user mark those answers themselves. - `questionsVersion`: integer, required. The version of the questions the attempt answered. - `startedAt`: string (date-time), required. When the attempt started. - `finishedAt`: string (date-time) or null, required. When it was submitted. Null while it is open. - `gradedAt`: string (date-time) or null, required. When grading ended. ### Example ```json { "data": { "id": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "name": "Cell biology quiz", "assisted": false, "status": "ready", "error": null, "createdAt": "2026-01-15T09:30:00.000Z", "generationJobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "questionsVersion": 1, "questionCount": 2, "kindCounts": [ { "kind": "multiple-choice", "count": 1 }, { "kind": "short-answer", "count": 1 } ], "generating": null, "requestedLength": 2, "kinds": [ "MCQ", "SHORT" ], "timerSecs": 600, "modelTier": "scholar", "sourceSetIds": [], "sourceNoteIds": [ "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" ], "attempts": [ { "id": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b", "assisted": false, "score": { "num": 1, "den": 2 }, "gradingStatus": "graded", "questionsVersion": 1, "startedAt": "2026-01-15T09:30:00.000Z", "finishedAt": "2026-02-01T12:00:00.000Z", "gradedAt": "2026-02-01T12:00:00.000Z" } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request`: the body fails validation, or `details.field` is `questions`, `details.index` the question and `details.reason` why it can't be graded as written: `choices-missing`, `choices-unexpected`, `choice-empty`, `choices-duplicate`, `answer-not-a-choice` or `true-false-answer`. Also `sourceSetIds` `unknown-set` and `sourceNoteIds` `unknown-note`. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a practice test Source: https://workroad.app/docs/api/get-test `GET https://workroad.app/api/v1/tests/{testId}` The test's settings, how many questions of each kind it has, and its recent attempts. The questions themselves are read through an attempt. While the test is generating, `generating` lists the questions written so far without their answers. - MCP tool: `getTest` on `https://workroad.app/api/mcp` - Required scopes: tests:view - Test mode: `GET https://workroad.app/api/test/v1/tests/{testId}` checks the request and returns the example response without changing anything. ## Path parameters - `testId`: string (uuid), required. Practice test id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `assisted`: boolean, required - `status`: one of `"draft"`, `"generating"`, `"ready"`, `"failed"`, required. `generating` until the questions are written, then `ready`. `failed` when generation failed or could not start; `error` says why. - `error`: string or null, required. Why generation failed. - `createdAt`: string (date-time), required. When the test was created. - `generationJobId`: string or null, required. The latest generation job. Poll `GET /jobs/{jobId}` while `status` is `generating`. - `questionsVersion`: integer, required. Goes up each time the questions are generated again. - `questionCount`: integer, required - `kindCounts`: array of objects, required. How many questions of each kind the test has. - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI. - `count`: integer, required - `generating`: object or null, required. While `status` is `generating`, the questions written so far, without answers. Null otherwise. - `total`: integer, required. Questions asked for. - `questions`: array of objects, required - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI. - `prompt`: string, required - `difficulty`: string, required - `choiceCount`: integer, required - `requestedLength`: integer, required - `kinds`: array of one of `"MCQ"`, `"TRUE_FALSE"`, `"SHORT"`, `"FILL_BLANK"`, required - `timerSecs`: integer or null, required. The time limit, or null. - `modelTier`: string, required - `sourceSetIds`: array of string, required - `sourceNoteIds`: array of string, required - `attempts`: array of objects, required. The 25 most recent attempts, newest first. - `id`: string (uuid), required - `assisted`: boolean, required - `score`: object or null, required. Correct answers out of all questions. Null until grading ends. - `num`: integer, required - `den`: integer, required - `gradingStatus`: one of `"not_required"`, `"pending"`, `"graded"`, `"failed"`, required. `not_required` when every answer was graded on submit, `pending` while AI grades the short answers, `graded` once it has, and `failed` when AI grading could not run. The app then lets the user mark those answers themselves. - `questionsVersion`: integer, required. The version of the questions the attempt answered. - `startedAt`: string (date-time), required. When the attempt started. - `finishedAt`: string (date-time) or null, required. When it was submitted. Null while it is open. - `gradedAt`: string (date-time) or null, required. When grading ended. ### Example ```json { "data": { "id": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "name": "Cell biology quiz", "assisted": false, "status": "ready", "error": null, "createdAt": "2026-01-15T09:30:00.000Z", "generationJobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "questionsVersion": 1, "questionCount": 2, "kindCounts": [ { "kind": "multiple-choice", "count": 1 }, { "kind": "short-answer", "count": 1 } ], "generating": null, "requestedLength": 2, "kinds": [ "MCQ", "SHORT" ], "timerSecs": 600, "modelTier": "scholar", "sourceSetIds": [], "sourceNoteIds": [ "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" ], "attempts": [ { "id": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b", "assisted": false, "score": { "num": 1, "den": 2 }, "gradingStatus": "graded", "questionsVersion": 1, "startedAt": "2026-01-15T09:30:00.000Z", "finishedAt": "2026-02-01T12:00:00.000Z", "gradedAt": "2026-02-01T12:00:00.000Z" } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No test with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a practice test attempt Source: https://workroad.app/docs/api/get-test-attempt `GET https://workroad.app/api/v1/attempts/{attemptId}` The attempt's questions, the user's answers and, once submitted, the correct answers and a verdict for each question. While the attempt is open no question has an `answer` or `explanation`. After a submit, poll this until `phase` is no longer `grading`. - MCP tool: `getTestAttempt` on `https://workroad.app/api/mcp` - Required scopes: tests:view or tests:attempt - Test mode: `GET https://workroad.app/api/test/v1/attempts/{attemptId}` checks the request and returns the example response without changing anything. ## Path parameters - `attemptId`: string (uuid), required. Practice test attempt id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `testId`: string (uuid), required - `phase`: one of `"open"`, `"grading"`, `"grading_failed"`, `"graded"`, required. `open` until submitted, `grading` while AI grades short answers, `grading_failed` when AI grading could not run, and `graded` once every answer has a verdict. - `startedAt`: string (date-time), required. When the attempt started. The time limit counts from here. - `deadlineAt`: string (date-time) or null, required. When the time limit runs out, while the attempt is open. Null without a limit or once submitted. - `expired`: boolean, required. True 15 seconds after `deadlineAt`, while the attempt is open. Saved answers no longer change; submit to grade them. - `finishedAt`: string (date-time) or null, required. When it was submitted. Null while it is open. - `score`: object or null, required. Correct answers out of all questions. Null until grading ends. - `num`: integer, required - `den`: integer, required - `gradingStatus`: one of `"not_required"`, `"pending"`, `"graded"`, `"failed"`, required. `not_required` when every answer was graded on submit, `pending` while AI grades the short answers, `graded` once it has, and `failed` when AI grading could not run. The app then lets the user mark those answers themselves. - `questions`: array of objects, required. The questions as they were when the attempt started, in order. - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI. - `prompt`: string, required - `choices`: array of string or null, required. The choices of a multiple choice question. Null otherwise. - `difficulty`: string, required - `answer`: string or null, required. The correct answer. Null until the attempt is submitted. - `explanation`: string or null, required. Why it is correct. Null until the attempt is submitted. - `answers`: array of string or null, required. The user's answer to each question, aligned with `questions`. Null where there is none. - `perQuestion`: array of objects, required. The verdict for each question. Empty until the attempt is submitted. - `questionIndex`: integer, required - `verdict`: one of `"correct"`, `"wrong"`, `"manual"`, required. `manual` while a short answer waits for AI grading or for the user to mark it. - `feedback`: string or null, required. The grader's comment. - `explainRef`: string or null, required. For a wrong answer, a reference to ask the copilot about it: send it as `context.activityRef` with `context.kind` `activity` to `POST /copilot/messages`. ### Example ```json { "data": { "id": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b", "testId": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "phase": "open", "startedAt": "2026-01-15T09:30:00.000Z", "deadlineAt": "2026-01-15T09:40:00.000Z", "expired": false, "finishedAt": null, "score": null, "gradingStatus": "not_required", "questions": [ { "kind": "multiple-choice", "prompt": "Which organelle makes ATP?", "choices": [ "Nucleus", "Mitochondrion", "Ribosome" ], "difficulty": "recall", "answer": null, "explanation": null }, { "kind": "short-answer", "prompt": "Why do cells need a membrane?", "choices": null, "difficulty": "understanding", "answer": null, "explanation": null } ], "answers": [ "Mitochondrion", null ], "perQuestion": [] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No test attempt with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List practice tests Source: https://workroad.app/docs/api/list-tests `GET https://workroad.app/api/v1/tests` Newest first, with each test's best score and the attempt in progress. Questions are not listed. - MCP tool: `listTests` on `https://workroad.app/api/mcp` - Required scopes: tests:view - Test mode: `GET https://workroad.app/api/test/v1/tests` checks the request and returns the example response without changing anything. ## Query parameters - `limit`: integer, 1 to 100, default `25`. Page size, 1-100. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `name`: string, required - `assisted`: boolean, required. True for a test made with the copilot's help. - `status`: one of `"draft"`, `"generating"`, `"ready"`, `"failed"`, required. `generating` until the questions are written, then `ready`. `failed` when generation failed or could not start; `error` says why. - `error`: string or null, required. Why generation failed. - `createdAt`: string (date-time), required. When the test was created. - `questionCount`: integer, required. Questions written so far. - `requestedLength`: integer, required. Questions asked for. - `kinds`: array of one of `"MCQ"`, `"TRUE_FALSE"`, `"SHORT"`, `"FILL_BLANK"`, required - `timerSecs`: integer or null, required. The time limit, or null. - `attemptCount`: integer, required. Submitted attempts with a score. - `bestRatio`: number or null, required. The best score of an unassisted attempt, 0-1. Null without one. - `bestAssistedRatio`: number or null, required. The best score of an assisted attempt, 0-1. Null without one. - `openAttemptId`: string (uuid) or null, required. The attempt in progress, which `POST /tests/{testId}/attempts` resumes. Null when there is none. - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "id": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "name": "Cell biology quiz", "assisted": false, "status": "ready", "error": null, "createdAt": "2026-01-15T09:30:00.000Z", "questionCount": 2, "requestedLength": 2, "kinds": [ "MCQ", "SHORT" ], "timerSecs": 600, "attemptCount": 1, "bestRatio": 0.5, "bestAssistedRatio": null, "openAttemptId": null } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Save practice test answers Source: https://workroad.app/docs/api/save-test-answers `PUT https://workroad.app/api/v1/attempts/{attemptId}/answers` Stores the answers so far without submitting, as the app's autosave does. Once the time limit has passed nothing is stored and `expired` is true; submit the attempt to grade what was saved. - MCP tool: `saveTestAnswers` on `https://workroad.app/api/mcp` - Required scopes: tests:attempt - Test mode: `PUT https://workroad.app/api/test/v1/attempts/{attemptId}/answers` checks the request and returns the example response without changing anything. ## Path parameters - `attemptId`: string (uuid), required. Practice test attempt id, scoped to the key's user. ## Request body JSON. - `answers`: object of string, required. Every answer so far, keyed by question index as a string (`"0"` is the first question), each up to 4000 characters. Only indexes of the attempt's questions, without leading zeros. Replaces the answers saved before, so send them all. For a multiple choice question send the choice's text, and for a true/false question `"true"` or `"false"`. ## Response `200` OK - `data`: object, required - `saved`: boolean, required. True when the answers were stored. - `expired`: boolean, required. True when the time limit had passed, so nothing was stored. ### Example ```json { "data": { "saved": true, "expired": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request` with `details.field` `answers` and `details.reason` `unknown-question`: a key is past the attempt's last question. `details.keys` lists those keys. Nothing is stored. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No test attempt with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `already-submitted`: the attempt was submitted, so its answers can't change. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Start a practice test attempt Source: https://workroad.app/docs/api/start-test-attempt `POST https://workroad.app/api/v1/tests/{testId}/attempts` Starts an attempt at a ready test, or returns the attempt the user already has open (status 200, `resumed` true). A test has at most one open attempt. The time limit, if any, counts from the attempt's start. - MCP tool: `startTestAttempt` on `https://workroad.app/api/mcp` - Required scopes: tests:attempt - Test mode: `POST https://workroad.app/api/test/v1/tests/{testId}/attempts` checks the request and returns the example response without changing anything. - Next: save answers with `saveTestAnswers`, then `submitTestAttempt`. ## Path parameters - `testId`: string (uuid), required. Practice test id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `201` Created `200` The user's open attempt was resumed. The body has `resumed: true`. - `data`: object, required - `attemptId`: string (uuid), required - `resumed`: boolean, required. True when the user's open attempt was returned instead of a new one. ### Example ```json { "data": { "attemptId": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b", "resumed": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No test with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `test-not-ready`: the test is still generating, or it failed. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Submit a practice test attempt Source: https://workroad.app/docs/api/submit-test-attempt `POST https://workroad.app/api/v1/attempts/{attemptId}/submit` Ends the attempt and grades it. Multiple choice, true/false and fill-in answers are graded at once. Short answers go to AI grading, and `gradingJobId` is its job. On a test WorkRoad's AI wrote (`POST /tests`), grading is part of that test's AI use; on a test saved with `POST /tests/from-questions`, it counts as one AI use. Grading never refuses a submit: when AI grading can't start, for example because the month's AI uses are spent on a `POST /tests/from-questions` test, the attempt is still submitted with `gradingQueued` false and `gradingStatus` `failed`, and the user marks the short answers themselves in the app. Submitting again changes nothing and returns `alreadySubmitted` true. Runs WorkRoad's AI to grade short answers in a test you saved yourself, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). - MCP tool: `submitTestAttempt` on `https://workroad.app/api/mcp` - Required scopes: tests:attempt - Test mode: `POST https://workroad.app/api/test/v1/attempts/{attemptId}/submit` checks the request and returns the example response without changing anything. - Next: poll `getTestAttempt` until it is graded. ## Path parameters - `attemptId`: string (uuid), required. Practice test attempt id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `answers`: object of string, default `{}`. Final answers, keyed as in `PUT /attempts/{attemptId}/answers`. Each replaces the saved answer for its index. Ignored once the time limit has passed; the saved answers are graded instead. ## Response `200` OK - `data`: object, required - `alreadySubmitted`: boolean, required. True when the attempt was submitted before. Nothing changed. - `gradingQueued`: boolean, required. True when short answers went to AI grading. False when every answer was graded on submit, or when AI grading could not start; then `gradingStatus` is `failed`. - `gradingJobId`: string or null, required. The grading job while `gradingQueued` is true. Poll it or the attempt. ### Example ```json { "data": { "alreadySubmitted": false, "gradingQueued": true, "gradingJobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request` with `details.field` `answers` and `details.reason` `unknown-question`: a key is past the attempt's last question. `details.keys` lists those keys. Nothing is stored. Also `invalid_request` with `details.reason` `empty` when the attempt has no questions to grade. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No test attempt with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: `rate_limited` with `details.reason` `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing is submitted; retry after `Retry-After` seconds. The user's own AI rate limit never refuses a submit: it leaves the short answers for the user to mark, like a spent allowance. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a recording with its transcript Source: https://workroad.app/docs/api/get-recording `GET https://workroad.app/api/v1/recordings/{recordingId}` The recording's transcript, one line per entry, and its summary, key terms and chapters when it has them. Write notes, flashcards or a practice test from the transcript with your own model, then save them with `POST /notes`, `POST /sets` or `POST /tests/from-questions`. - MCP tool: `getRecording` on `https://workroad.app/api/mcp` - Required scopes: notes:view - Test mode: `GET https://workroad.app/api/test/v1/recordings/{recordingId}` checks the request and returns the example response without changing anything. ## Path parameters - `recordingId`: string (uuid), required. Recording id from `GET /notes/{noteId}/recordings`. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `noteId`: string (uuid), required - `title`: string or null, required - `status`: one of `"recording"`, `"assembling"`, `"finished"`, required. `recording` while audio is still coming in, `assembling` while WorkRoad puts the transcript together, `finished` once it is final. - `summaryStatus`: one of `"none"`, `"queued"`, `"running"`, `"done"`, `"failed"`, required. Whether the recording has a summary: `done` when one is saved, `queued` or `running` while WorkRoad's AI writes one. - `createdAt`: string (date-time), required. When the recording started. - `transcript`: array of string, required. The transcript, one entry per line, in order. Empty until the recording is finished. - `summary`: string or null, required. The saved summary, or `null`. - `keyTerms`: array of objects, required - `term`: string, required - `definition`: string, required - `chapters`: array of objects, required - `startMs`: integer, required - `title`: string, required - `summary`: string, required ### Example ```json { "data": { "id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382716", "noteId": "2f1e0d9c-8b7a-4c6d-9e5f-4a3b2c1d0e9f", "title": "Lecture 7: CPU scheduling", "status": "finished", "summaryStatus": "done", "createdAt": "2026-01-15T09:30:00.000Z", "transcript": [ "Today we look at how the scheduler picks the next process.", "Round robin gives each process a fixed time slice, in turn." ], "summary": "How a CPU scheduler chooses the next process, with round robin as the main example.", "keyTerms": [ { "term": "Round robin", "definition": "Each process runs for a fixed time slice, in turn." } ], "chapters": [ { "startMs": 0, "title": "Scheduling", "summary": "Why the CPU needs a scheduler." } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No recording with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List a note's recordings Source: https://workroad.app/docs/api/list-note-recordings `GET https://workroad.app/api/v1/notes/{noteId}/recordings` The lecture recordings on a note, newest first, without their transcripts. Read one with `GET /recordings/{recordingId}`. - MCP tool: `listNoteRecordings` on `https://workroad.app/api/mcp` - Required scopes: notes:view - Test mode: `GET https://workroad.app/api/test/v1/notes/{noteId}/recordings` checks the request and returns the example response without changing anything. ## Path parameters - `noteId`: string (uuid), required. Note id, scoped to the key's user. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `noteId`: string (uuid), required - `title`: string or null, required - `status`: one of `"recording"`, `"assembling"`, `"finished"`, required. `recording` while audio is still coming in, `assembling` while WorkRoad puts the transcript together, `finished` once it is final. - `summaryStatus`: one of `"none"`, `"queued"`, `"running"`, `"done"`, `"failed"`, required. Whether the recording has a summary: `done` when one is saved, `queued` or `running` while WorkRoad's AI writes one. - `createdAt`: string (date-time), required. When the recording started. ### Example ```json { "data": [ { "id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382716", "noteId": "2f1e0d9c-8b7a-4c6d-9e5f-4a3b2c1d0e9f", "title": "Lecture 7: CPU scheduling", "status": "finished", "summaryStatus": "done", "createdAt": "2026-01-15T09:30:00.000Z" } ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No note with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Save a recording's summary Source: https://workroad.app/docs/api/put-recording-summary `PUT https://workroad.app/api/v1/recordings/{recordingId}/summary` Saves a summary and key terms you wrote from the transcript, replacing the ones the recording had. Chapters stay as they are. The recording page shows them as it shows the ones WorkRoad's AI writes. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `putRecordingSummary` on `https://workroad.app/api/mcp` - Required scopes: notes:update - Test mode: `PUT https://workroad.app/api/test/v1/recordings/{recordingId}/summary` checks the request and returns the example response without changing anything. ## Path parameters - `recordingId`: string (uuid), required. Recording id from `GET /notes/{noteId}/recordings`. ## Request body JSON. - `summary`: string, required, 1-4000 characters. What the lecture covered, in plain text, up to 4000 characters. - `keyTerms`: array of objects, up to 40 items, default `[]`. Up to 40 terms the lecture defines, each once. The app can make a study set from them. - `term`: string, required, 1-80 characters - `definition`: string, required, 1-400 characters ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `noteId`: string (uuid), required - `title`: string or null, required - `status`: one of `"recording"`, `"assembling"`, `"finished"`, required. `recording` while audio is still coming in, `assembling` while WorkRoad puts the transcript together, `finished` once it is final. - `summaryStatus`: one of `"none"`, `"queued"`, `"running"`, `"done"`, `"failed"`, required. Whether the recording has a summary: `done` when one is saved, `queued` or `running` while WorkRoad's AI writes one. - `createdAt`: string (date-time), required. When the recording started. - `transcript`: array of string, required. The transcript, one entry per line, in order. Empty until the recording is finished. - `summary`: string or null, required. The saved summary, or `null`. - `keyTerms`: array of objects, required - `term`: string, required - `definition`: string, required - `chapters`: array of objects, required - `startMs`: integer, required - `title`: string, required - `summary`: string, required ### Example ```json { "data": { "id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382716", "noteId": "2f1e0d9c-8b7a-4c6d-9e5f-4a3b2c1d0e9f", "title": "Lecture 7: CPU scheduling", "status": "finished", "summaryStatus": "done", "createdAt": "2026-01-15T09:30:00.000Z", "transcript": [ "Today we look at how the scheduler picks the next process.", "Round robin gives each process a fixed time slice, in turn." ], "summary": "How a CPU scheduler chooses the next process, with round robin as the main example.", "keyTerms": [ { "term": "Round robin", "definition": "Each process runs for a fixed time slice, in turn." } ], "chapters": [ { "startMs": 0, "title": "Scheduling", "summary": "Why the CPU needs a scheduler." } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No recording with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `not-finished` while the recording is still coming in or assembling, or `summary-running` while WorkRoad's AI is writing its summary. Try again once it is done. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Grade a flashcard Source: https://workroad.app/docs/api/create-review `POST https://workroad.app/api/v1/reviews` Records one review of a card from the review queue and schedules its next due time. Quiz from faces.front and reveal faces.back. Forward is variant 0, reverse -1, cloze N. Grade the returned cardId. Only a card the queue would serve now can be graded. Sending the same `reviewId` again grades nothing and returns status 200 with `replayed: true` and the card's current schedule, even when the card is no longer due. Reviews count toward the user's streak like reviews in the app. - MCP tool: `createReview` on `https://workroad.app/api/mcp` - Required scopes: activity:create - Test mode: `POST https://workroad.app/api/test/v1/reviews` checks the request and returns the example response without changing anything. - Next: drop the card from your copy of the queue and call `getReviewQueue` again when it runs out. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `reviewId`: string (uuid), required. A uuid you generate once per review. Sending the same review again grades nothing and returns the card's current schedule with `replayed: true`. - `cardId`: string (uuid), required. `cardId` from the review queue. - `rating`: one of `"again"`, `"hard"`, `"good"`, `"easy"`, required. How well the user recalled the card. - `durationMs`: number, required, 0 to 600000. Time spent on the card, 0-600000 ms. Stored in whole seconds. ## Response `201` Created `200` This `reviewId` was already recorded. The body has `replayed: true`. - `data`: object, required - `reviewId`: string (uuid), required - `cardId`: string (uuid), required - `replayed`: boolean, required. True when this `reviewId` was already recorded and nothing changed. - `queue`: one of `"new"`, `"learning"`, `"relearning"`, `"review"`, required. The card's queue after this review. - `due`: string (date-time), required. When the card is next due. - `suspended`: boolean, required ### Example ```json { "data": { "reviewId": "9d8c7b6a-5f4e-4d3c-9b2a-1f0e9d8c7b6a", "cardId": "3e2d1c0b-9a8f-4e7d-8c6b-5a4f3e2d1c0b", "replayed": false, "queue": "review", "due": "2026-02-05T12:00:00.000Z", "suspended": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No card with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The card cannot be graded now: `conflict` with `details.reason` `card-suspended`, `card-deleted`, `card-not-due` or `new-card-limit`. `conflict` with `details.reason` `concurrent-grade` and `Retry-After: 1` when another grade in the same set ran at the same time; retry with the same `reviewId`. Also `idempotency_conflict` when the `reviewId` or `Idempotency-Key` was already used with a different card, rating or duration. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get the flashcard review queue Source: https://workroad.app/docs/api/get-review-queue `GET https://workroad.app/api/v1/sets/{setId}/review-queue` Returns the set's flashcards due now, decided by the server clock. New cards are limited to 50 per set per study day, which starts at 4am in the user's time zone. When nothing else is due, the queue includes the next learning card due within 20 minutes. Grade a card with `POST /reviews`, drop it from your local copy, and fetch the queue again when your copy runs out. When `finished` is true, fetch again at `nextDueAt`. - MCP tool: `getReviewQueue` on `https://workroad.app/api/mcp` - Required scopes: activity:view and sets:view - Test mode: `GET https://workroad.app/api/test/v1/sets/{setId}/review-queue` checks the request and returns the example response without changing anything. - Next: grade each card with `createReview`. To practise several sets at once, use `startPracticeSession` instead. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `cards`: array of objects, required. Cards to review now: learning and relearning first, then reviews, then new cards. - `cardId`: string (uuid), required. Send this as `cardId` to grade the card. - `termId`: string (uuid), required. The term's id in its study set. - `term`: string, required - `definition`: string, required - `variant`: integer, required. Forward 0, reverse -1, or cloze deletion N. Grade the returned card/item ID. - `faces`: object, required - `front`: string, required - `back`: string, required - `hints`: array of string, required - `imageUrl`: string or null, required. The term's image, when it has one. - `queue`: one of `"new"`, `"learning"`, `"relearning"`, `"review"`, required. The card's scheduling queue. - `due`: string (date-time), required. When the card became or becomes due. - `finished`: boolean, required. True when no card is left to review now. - `nextDueAt`: string (date-time) or null, required. When `finished` is true, the earliest time a learning or relearning card becomes due. Fetch the queue again then. `null` otherwise, or when no card is waiting. - `serverTime`: string (date-time), required. The server clock that decided which cards are due. ### Example ```json { "data": { "cards": [ { "cardId": "3e2d1c0b-9a8f-4e7d-8c6b-5a4f3e2d1c0b", "termId": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "variant": 0, "faces": { "front": "tener (yo)", "back": "tuve" }, "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ], "imageUrl": null, "queue": "review", "due": "2026-01-15T09:30:00.000Z" } ], "finished": false, "nextDueAt": null, "serverTime": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a schedule Source: https://workroad.app/docs/api/create-schedule `POST https://workroad.app/api/v1/schedules` Creates a `DEFINED` timetable kept in WorkRoad, optionally with its periods. It has no lessons yet: add them with `POST /schedules/{scheduleId}/elements`. The first lesson lays out every weekday and, if the user has no default schedule, makes this one the default. - MCP tool: `createSchedule` on `https://workroad.app/api/mcp` - Required scopes: schedule:create - Test mode: `POST https://workroad.app/api/test/v1/schedules` checks the request and returns the example response without changing anything. - Next: add lessons with `createScheduleElement`. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 2-64 characters. 2-64 characters. Unique among the user's active schedules. - `firstDay`: integer, 0 to 6. The weekday the timetable starts on. Defaults to 1 (Monday). - `selectedDays`: array of integer, up to 7 items. Weekdays the schedule shows. Defaults to all seven. - `structure`: object. The periods. Omit to send them later with `PUT /schedules/{scheduleId}/structure`. - `periods`: array of objects, required, 1-30 items. 1-30 periods, in the order of the day. Elements refer to a period by its position. - `name`: string, required, up to 200 characters. Shown in the timetable, like `Period 1` or `Lunch`. - `start`: string or null, required, matches `^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$`. Start time (HH:MM), an empty string while unset, or null. - `end`: string or null, required, matches `^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$`. End time (HH:MM), an empty string while unset, or null. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false. - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise. - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads. - `periods`: array of objects, required - `index`: integer, required. The period number elements refer to. - `name`: string, required - `start`: string or null, required. Start time (HH:MM), or null. - `end`: string or null, required. End time (HH:MM), or null. - `days`: array of objects, required. An external schedule's days are the current week's. - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday). - `elements`: array of objects, required - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones. - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day. - `lessonName`: string or null, required. Null for an empty period. - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set. - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it. - `room`: string or null, required - `teacher`: string or null, required - `timeStart`: string or null, required. Start time (HH:MM), when set. - `timeEnd`: string or null, required. End time (HH:MM), when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "editable": true, "firstDay": 1, "selectedDays": [ 1, 2, 3, 4, 5 ], "issue": null, "structure": { "periods": [ { "index": 0, "name": "Period 1", "start": "09:00", "end": "10:00" }, { "index": 1, "name": "Period 2", "start": "10:00", "end": "11:00" } ] }, "days": [ { "day": 1, "elements": [ { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "period": 0, "lessonName": "Biology", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectLabel": null, "room": "S2", "teacher": "Dr Patel", "timeStart": null, "timeEnd": null }, { "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928", "period": 1, "lessonName": null, "subjectId": null, "subjectLabel": null, "room": null, "teacher": null, "timeStart": null, "timeEnd": null } ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The user already has an active schedule with this name (`details.reason` is `name-taken`). The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Add a lesson Source: https://workroad.app/docs/api/create-schedule-element `POST https://workroad.app/api/v1/schedules/{scheduleId}/elements` Puts a lesson into an empty period of a `DEFINED` schedule kept in WorkRoad. Set the periods first with `PUT /schedules/{scheduleId}/structure`. Returns the schedule; the lesson is the entry in `days[]` for `day`, at `period` in its `elements`. Element ids change whenever the timetable is saved in the app, so read `GET /schedules/{scheduleId}` for the current id instead of keeping one. - MCP tool: `createScheduleElement` on `https://workroad.app/api/mcp` - Required scopes: schedule:update - Test mode: `POST https://workroad.app/api/test/v1/schedules/{scheduleId}/elements` checks the request and returns the example response without changing anything. ## Path parameters - `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `day`: integer, required, 0 to 6. Weekday, 0 (Sunday) to 6 (Saturday). - `period`: integer, required, at least 0. The period's `index` in `structure`. - `lessonName`: string, required, 1-200 characters - `subjectId`: string (uuid) or null. Subject id owned by the user, or null. - `room`: string or null, up to 200 characters - `teacher`: string or null, up to 200 characters ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false. - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise. - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads. - `periods`: array of objects, required - `index`: integer, required. The period number elements refer to. - `name`: string, required - `start`: string or null, required. Start time (HH:MM), or null. - `end`: string or null, required. End time (HH:MM), or null. - `days`: array of objects, required. An external schedule's days are the current week's. - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday). - `elements`: array of objects, required - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones. - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day. - `lessonName`: string or null, required. Null for an empty period. - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set. - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it. - `room`: string or null, required - `teacher`: string or null, required - `timeStart`: string or null, required. Start time (HH:MM), when set. - `timeEnd`: string or null, required. End time (HH:MM), when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "editable": true, "firstDay": 1, "selectedDays": [ 1, 2, 3, 4, 5 ], "issue": null, "structure": { "periods": [ { "index": 0, "name": "Period 1", "start": "09:00", "end": "10:00" }, { "index": 1, "name": "Period 2", "start": "10:00", "end": "11:00" } ] }, "days": [ { "day": 1, "elements": [ { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "period": 0, "lessonName": "Biology", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectLabel": null, "room": "S2", "teacher": "Dr Patel", "timeStart": null, "timeEnd": null }, { "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928", "period": 1, "lessonName": null, "subjectId": null, "subjectLabel": null, "room": null, "teacher": null, "timeStart": null, "timeEnd": null } ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or a `subjectId` that is not one of the user's subjects (`details.reason` is `unknown-subject`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No schedule with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `external-schedule` for a schedule read from an integration, or `dynamic-schedule` for a `DYNAMIC` one. `no-such-period` when the schedule has no period with that index, and `slot-taken` when the period already has a lesson. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a timetable with its lessons Source: https://workroad.app/docs/api/create-timetable `POST https://workroad.app/api/v1/schedules/timetable` Creates a timetable kept in WorkRoad with its periods and every day's lessons in one call, as the app's setup does: the first timetable becomes the default, and the days with lessons are the days it shows. Use it to save a timetable you read from a photo, a PDF or what the user typed; it uses no AI. Link lessons to the user's subjects with `subjectId` or `subjectName`, and list the subjects first (`GET /subjects`) so the names match. The timetable is saved whole or not at all. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `createTimetable` on `https://workroad.app/api/mcp` - Required scopes: schedule:create - Test mode: `POST https://workroad.app/api/test/v1/schedules/timetable` checks the request and returns the example response without changing anything. - Next: read it back for the week with `getScheduleWeek`. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 2-64 characters. 2-64 characters. Unique among the user's active schedules. - `scheduleType`: one of `"defined"`, `"dynamic"`, required. `defined`: every day shares one grid of `periods`, and each day lists one entry per period (`null` for a free period). `dynamic`: no periods; each day lists its lessons, each with `timeStart` and `timeEnd`. - `firstDay`: integer, 0 to 6. The weekday the timetable starts on, which is the first entry of `days`. Defaults to 1 (Monday). - `periods`: array of objects, 1-30 items. `defined` only: 1-30 periods, in order, not overlapping. - `periodName`: string, required, up to 200 characters. Such as `Period 1` or `Break`. - `time`: object or null, required. `HH:MM` start and end, or `null` when the timetable shows no time. - `start`: string, required, up to 5 characters - `end`: string, required, up to 5 characters - `days`: array of array of object or null, required, 1-7 items. One entry per weekday from `firstDay`, up to 7: the day's lessons in order. For a `defined` timetable every day has exactly one entry per period. A day with no lessons is an empty list (`dynamic`) or all `null` (`defined`), and days left off the end are free. - `lessonName`: string, up to 200 characters, default `""`. What the timetable calls the lesson, such as `Maths` or `Lunch`. Empty for a free period. - `subjectId`: string (uuid). The user's subject the lesson is for. - `subjectName`: string, 1-200 characters. The course by name. It links the lesson to the user's active subject with that name, ignoring case and accents. A name that matches none is created as a subject when `createSubjectsIfMissing` is true, and otherwise kept as written on the timetable without a link. - `room`: string, up to 200 characters, default `""`. The room, or empty. - `teacher`: string, up to 200 characters, default `""`. The teacher, or empty. - `timeStart`: string, up to 5 characters. A `dynamic` lesson's start. `HH:MM`, 24-hour. - `timeEnd`: string, up to 5 characters. A `dynamic` lesson's end. `HH:MM`, 24-hour. - `createSubjectsIfMissing`: boolean. Creates a subject for each `subjectName` the user doesn't have yet, so the lessons link to it. Needs the `subjects:create` scope. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false. - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise. - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads. - `periods`: array of objects, required - `index`: integer, required. The period number elements refer to. - `name`: string, required - `start`: string or null, required. Start time (HH:MM), or null. - `end`: string or null, required. End time (HH:MM), or null. - `days`: array of objects, required. An external schedule's days are the current week's. - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday). - `elements`: array of objects, required - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones. - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day. - `lessonName`: string or null, required. Null for an empty period. - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set. - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it. - `room`: string or null, required - `teacher`: string or null, required - `timeStart`: string or null, required. Start time (HH:MM), when set. - `timeEnd`: string or null, required. End time (HH:MM), when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "editable": true, "firstDay": 1, "selectedDays": [ 1, 2, 3, 4, 5 ], "issue": null, "structure": { "periods": [ { "index": 0, "name": "Period 1", "start": "09:00", "end": "10:00" }, { "index": 1, "name": "Period 2", "start": "10:00", "end": "11:00" } ] }, "days": [ { "day": 1, "elements": [ { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "period": 0, "lessonName": "Biology", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectLabel": null, "room": "S2", "teacher": "Dr Patel", "timeStart": null, "timeEnd": null }, { "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928", "period": 1, "lessonName": null, "subjectId": null, "subjectLabel": null, "room": null, "teacher": null, "timeStart": null, "timeEnd": null } ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request` with `details.reason`: `invalid-timetable`, `time-invalid`, `grid-mismatch` or `unknown-subject`. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: `createSubjectsIfMissing` was sent and the key lacks `subjects:create` (`details.missingScopes`). - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The user already has an active schedule with this name (`details.reason` is `name-taken`). The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Remove a lesson Source: https://workroad.app/docs/api/delete-schedule-element `DELETE https://workroad.app/api/v1/schedules/{scheduleId}/elements/{elementId}` Empties one period of a `DEFINED` schedule kept in WorkRoad. The element keeps its id and stays in the grid with `lessonName: null`, as when a lesson is removed in the app. Emptying an empty period succeeds. Returns the schedule. Element ids change whenever the timetable is saved in the app, so read `GET /schedules/{scheduleId}` for the current id instead of keeping one. - MCP tool: `deleteScheduleElement` on `https://workroad.app/api/mcp` - Required scopes: schedule:delete - Test mode: `DELETE https://workroad.app/api/test/v1/schedules/{scheduleId}/elements/{elementId}` checks the request and returns the example response without changing anything. ## Path parameters - `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user. - `elementId`: string (uuid), required. Element id from the schedule's `days[].elements[]`. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false. - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise. - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads. - `periods`: array of objects, required - `index`: integer, required. The period number elements refer to. - `name`: string, required - `start`: string or null, required. Start time (HH:MM), or null. - `end`: string or null, required. End time (HH:MM), or null. - `days`: array of objects, required. An external schedule's days are the current week's. - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday). - `elements`: array of objects, required - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones. - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day. - `lessonName`: string or null, required. Null for an empty period. - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set. - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it. - `room`: string or null, required - `teacher`: string or null, required - `timeStart`: string or null, required. Start time (HH:MM), when set. - `timeEnd`: string or null, required. End time (HH:MM), when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "editable": true, "firstDay": 1, "selectedDays": [ 1, 2, 3, 4, 5 ], "issue": null, "structure": { "periods": [ { "index": 0, "name": "Period 1", "start": "09:00", "end": "10:00" }, { "index": 1, "name": "Period 2", "start": "10:00", "end": "11:00" } ] }, "days": [ { "day": 1, "elements": [ { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "period": 0, "lessonName": "Biology", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectLabel": null, "room": "S2", "teacher": "Dr Patel", "timeStart": null, "timeEnd": null }, { "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928", "period": 1, "lessonName": null, "subjectId": null, "subjectLabel": null, "room": null, "teacher": null, "timeStart": null, "timeEnd": null } ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No schedule with that id exists for this key's user, or the schedule has no element with that id. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `external-schedule` for a schedule read from an integration, or `dynamic-schedule` for a `DYNAMIC` one. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a schedule Source: https://workroad.app/docs/api/get-schedule `GET https://workroad.app/api/v1/schedules/{scheduleId}` The schedule's periods and its days with their lessons. The owner can also open an archived schedule. An external schedule shows the current week, read from its provider. `editable` covers the lesson grid only. Metadata changes (name, days, archived, default) apply to any schedule the user owns, external ones included. - MCP tool: `getSchedule` on `https://workroad.app/api/mcp` - Required scopes: schedule:view - Test mode: `GET https://workroad.app/api/test/v1/schedules/{scheduleId}` checks the request and returns the example response without changing anything. ## Path parameters - `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false. - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise. - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads. - `periods`: array of objects, required - `index`: integer, required. The period number elements refer to. - `name`: string, required - `start`: string or null, required. Start time (HH:MM), or null. - `end`: string or null, required. End time (HH:MM), or null. - `days`: array of objects, required. An external schedule's days are the current week's. - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday). - `elements`: array of objects, required - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones. - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day. - `lessonName`: string or null, required. Null for an empty period. - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set. - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it. - `room`: string or null, required - `teacher`: string or null, required - `timeStart`: string or null, required. Start time (HH:MM), when set. - `timeEnd`: string or null, required. End time (HH:MM), when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "editable": true, "firstDay": 1, "selectedDays": [ 1, 2, 3, 4, 5 ], "issue": null, "structure": { "periods": [ { "index": 0, "name": "Period 1", "start": "09:00", "end": "10:00" }, { "index": 1, "name": "Period 2", "start": "10:00", "end": "11:00" } ] }, "days": [ { "day": 1, "elements": [ { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "period": 0, "lessonName": "Biology", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectLabel": null, "room": "S2", "teacher": "Dr Patel", "timeStart": null, "timeEnd": null }, { "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928", "period": 1, "lessonName": null, "subjectId": null, "subjectLabel": null, "room": null, "teacher": null, "timeStart": null, "timeEnd": null } ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No schedule with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a week of lessons Source: https://workroad.app/docs/api/get-schedule-week `GET https://workroad.app/api/v1/schedules/week` Every lesson in one week across the user's active schedules, or one schedule, as dated occurrences in the user's time zone. External schedules are read from their provider, up to 7 seconds each; one that fails or times out is listed in `sources` with an `issue` and adds no lessons. Archived schedules are not part of the week, so a `scheduleId` naming one returns 404. - MCP tool: `getScheduleWeek` on `https://workroad.app/api/mcp` - Required scopes: schedule:view - Test mode: `GET https://workroad.app/api/test/v1/schedules/week` checks the request and returns the example response without changing anything. ## Query parameters - `date`: string (date). Any day (YYYY-MM-DD) in the week to read. Defaults to today in the user's time zone. Only the previous, current and next week can be read. - `scheduleId`: string (uuid). Only this schedule. Omit for every active schedule the user can read. ## Response `200` OK - `data`: object, required - `weekStart`: string, required. The Monday the week starts on (YYYY-MM-DD). - `timeZone`: string, required. The user's time zone, or UTC when none is set. Dates and times use it. - `sources`: array of objects, required. Every schedule read for the week, including ones that failed. - `scheduleId`: string (uuid), required - `name`: string, required - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `issue`: one of `"unavailable"`, `"failed"`, `"timeout"` or null, required. Why an external schedule has no lessons this week: `unavailable` (the integration cannot read that week), `failed`, or `timeout` after 7 seconds. Null when it was read. - `occurrences`: array of objects, required - `scheduleId`: string (uuid), required - `date`: string, required. The day (YYYY-MM-DD). - `endDate`: string or null, required. The last day of an event that spans several days, or null. - `time`: one of the objects below, required. When the lesson runs, in the week's time zone. - One of `kind`: `"allDay"` - `kind`: `"allDay"`, required - Or `kind`: `"timed"` - `kind`: `"timed"`, required - `startMinute`: integer, required. Minutes after midnight. - `endMinute`: integer, required. Minutes after midnight. - Or `kind`: `"period"` - `kind`: `"period"`, required - `period`: integer, required. The period index. The period has no times. - `name`: string, required - `timeZone`: string or null, required. The event's own time zone, when an integration reports one. - `title`: string, required - `subtitle`: string or null, required. Room and teacher, when known. ### Example ```json { "data": { "weekStart": "2026-01-12", "timeZone": "Europe/London", "sources": [ { "scheduleId": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "external": null, "owned": true, "issue": null } ], "occurrences": [ { "scheduleId": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "date": "2026-01-12", "endDate": null, "time": { "kind": "timed", "startMinute": 540, "endMinute": 600 }, "timeZone": null, "title": "Biology", "subtitle": "S2 · Dr Patel" } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Only the previous, current and next week can be read. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No schedule with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List schedules Source: https://workroad.app/docs/api/list-schedules `GET https://workroad.app/api/v1/schedules` Every active schedule the user owns, default first, then the ones other users shared with them. Archived schedules are left out. - MCP tool: `listSchedules` on `https://workroad.app/api/mcp` - Required scopes: schedule:view - Test mode: `GET https://workroad.app/api/test/v1/schedules` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `isDefault`: boolean, required. The user's default schedule. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `archived`: boolean, required. Always false here: archived schedules are left out of this list. ### Example ```json { "data": [ { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "isDefault": true, "selectedDays": [ 1, 2, 3, 4, 5 ], "archived": false } ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Set a schedule's periods Source: https://workroad.app/docs/api/put-schedule-structure `PUT https://workroad.app/api/v1/schedules/{scheduleId}/structure` Replaces the periods of a `DEFINED` schedule kept in WorkRoad. Once the first lesson is added, the number of periods is fixed; names and times can still change. The app stores a time that is still being set up as an empty string, so the `structure` from `GET /schedules/{scheduleId}` can be sent back unchanged. - MCP tool: `putScheduleStructure` on `https://workroad.app/api/mcp` - Required scopes: schedule:update - Test mode: `PUT https://workroad.app/api/test/v1/schedules/{scheduleId}/structure` checks the request and returns the example response without changing anything. ## Path parameters - `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user. ## Request body JSON. - `periods`: array of objects, required, at least 1 item. The periods, in the order of the day. Elements refer to a period by its position. At most 30 while the number of periods can still change. - `name`: string, required, up to 200 characters. Shown in the timetable, like `Period 1` or `Lunch`. - `start`: string or null, required, matches `^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$`. Start time (HH:MM), an empty string while unset, or null. - `end`: string or null, required, matches `^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$`. End time (HH:MM), an empty string while unset, or null. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false. - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise. - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads. - `periods`: array of objects, required - `index`: integer, required. The period number elements refer to. - `name`: string, required - `start`: string or null, required. Start time (HH:MM), or null. - `end`: string or null, required. End time (HH:MM), or null. - `days`: array of objects, required. An external schedule's days are the current week's. - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday). - `elements`: array of objects, required - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones. - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day. - `lessonName`: string or null, required. Null for an empty period. - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set. - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it. - `room`: string or null, required - `teacher`: string or null, required - `timeStart`: string or null, required. Start time (HH:MM), when set. - `timeEnd`: string or null, required. End time (HH:MM), when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "editable": true, "firstDay": 1, "selectedDays": [ 1, 2, 3, 4, 5 ], "issue": null, "structure": { "periods": [ { "index": 0, "name": "Period 1", "start": "09:00", "end": "10:00" }, { "index": 1, "name": "Period 2", "start": "10:00", "end": "11:00" } ] }, "days": [ { "day": 1, "elements": [ { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "period": 0, "lessonName": "Biology", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectLabel": null, "room": "S2", "teacher": "Dr Patel", "timeStart": null, "timeEnd": null }, { "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928", "period": 1, "lessonName": null, "subjectId": null, "subjectLabel": null, "room": null, "teacher": null, "timeStart": null, "timeEnd": null } ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or more than 30 periods while the number of periods can still change (`details.reason` is `too-many-periods`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No schedule with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `external-schedule` for a schedule read from an integration, or `dynamic-schedule` for a `DYNAMIC` one. `period-count` when the schedule has lessons laid out and the number of periods would change. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update a schedule Source: https://workroad.app/docs/api/update-schedule `PATCH https://workroad.app/api/v1/schedules/{scheduleId}` Renames, archives or restores a schedule the user owns, changes the days it shows, or makes it the default. Works on external schedules too, and the response then reads the current week from the provider. Everything in one call succeeds or fails together. Retrying with the same body is safe: every field sets a value, so a second call changes nothing more. - MCP tool: `updateSchedule` on `https://workroad.app/api/mcp` - Required scopes: schedule:update - Test mode: `PATCH https://workroad.app/api/test/v1/schedules/{scheduleId}` checks the request and returns the example response without changing anything. ## Path parameters - `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user. ## Request body JSON. - `name`: string, 2-64 characters. 2-64 characters. Unique among the user's active schedules. - `selectedDays`: array of integer, up to 7 items. Weekdays the schedule shows, each once. - `archived`: boolean. `true` archives the schedule and, if it was the default, clears the default. `false` restores it. - `isDefault`: boolean. `true` makes it the user's default schedule. `false` clears the default if this schedule is it. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false. - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise. - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads. - `periods`: array of objects, required - `index`: integer, required. The period number elements refer to. - `name`: string, required - `start`: string or null, required. Start time (HH:MM), or null. - `end`: string or null, required. End time (HH:MM), or null. - `days`: array of objects, required. An external schedule's days are the current week's. - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday). - `elements`: array of objects, required - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones. - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day. - `lessonName`: string or null, required. Null for an empty period. - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set. - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it. - `room`: string or null, required - `teacher`: string or null, required - `timeStart`: string or null, required. Start time (HH:MM), when set. - `timeEnd`: string or null, required. End time (HH:MM), when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "editable": true, "firstDay": 1, "selectedDays": [ 1, 2, 3, 4, 5 ], "issue": null, "structure": { "periods": [ { "index": 0, "name": "Period 1", "start": "09:00", "end": "10:00" }, { "index": 1, "name": "Period 2", "start": "10:00", "end": "11:00" } ] }, "days": [ { "day": 1, "elements": [ { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "period": 0, "lessonName": "Biology", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectLabel": null, "room": "S2", "teacher": "Dr Patel", "timeStart": null, "timeEnd": null }, { "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928", "period": 1, "lessonName": null, "subjectId": null, "subjectLabel": null, "room": null, "teacher": null, "timeStart": null, "timeEnd": null } ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or no field to update. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No schedule with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `name-taken` when another active schedule has the name, `archived-schedule` when an archived schedule would become the default, or `integration-not-ready` when an external schedule that isn't connected yet would become the default. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error - `503`: Setting `isDefault` on an external schedule asks its integration whether setup finished, and that check failed. Nothing was saved, so the same request can be retried. --- # Update a lesson Source: https://workroad.app/docs/api/update-schedule-element `PATCH https://workroad.app/api/v1/schedules/{scheduleId}/elements/{elementId}` Changes the lesson in one period of a `DEFINED` schedule kept in WorkRoad. Only the fields you send change. Returns the schedule. Element ids change whenever the timetable is saved in the app, so read `GET /schedules/{scheduleId}` for the current id instead of keeping one. - MCP tool: `updateScheduleElement` on `https://workroad.app/api/mcp` - Required scopes: schedule:update - Test mode: `PATCH https://workroad.app/api/test/v1/schedules/{scheduleId}/elements/{elementId}` checks the request and returns the example response without changing anything. ## Path parameters - `scheduleId`: string (uuid), required. Schedule id, scoped to the key's user. - `elementId`: string (uuid), required. Element id from the schedule's `days[].elements[]`. ## Request body JSON. - `lessonName`: string, 1-200 characters - `subjectId`: string (uuid) or null. Subject id owned by the user, or null. - `room`: string or null, up to 200 characters - `teacher`: string or null, up to 200 characters ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `type`: one of `"DEFINED"`, `"DYNAMIC"`, required. `DEFINED` timetables fill numbered periods from `structure`. `DYNAMIC` ones hold lessons with their own start and end times. - `external`: string or null, required. The integration an external schedule is read from, such as `aru-evision` or `google-calendar`. Null for a schedule kept in WorkRoad. - `owned`: boolean, required. False when another user shared the schedule with this user. - `editable`: boolean, required. Whether the lesson grid (`structure` and elements) can be written: true only for the owner's `DEFINED` schedules kept in WorkRoad. Shared, external and `DYNAMIC` schedules are false. - `firstDay`: integer, required. The weekday (0 = Sunday) the timetable starts on. `days` start there. Always 1 (Monday) for external schedules. - `selectedDays`: array of integer, required. Weekdays the schedule shows, 0 (Sunday) to 6 (Saturday). - `issue`: one of `"failed"` or null, required. `failed` when an external schedule's integration could not be read. `days` is then empty. Null otherwise. - `structure`: object or null, required. The periods of a `DEFINED` schedule. Null for `DYNAMIC` ones and failed reads. - `periods`: array of objects, required - `index`: integer, required. The period number elements refer to. - `name`: string, required - `start`: string or null, required. Start time (HH:MM), or null. - `end`: string or null, required. End time (HH:MM), or null. - `days`: array of objects, required. An external schedule's days are the current week's. - `day`: integer, required. Weekday, 0 (Sunday) to 6 (Saturday). - `elements`: array of objects, required - `id`: string, required. Element id. A uuid on schedules kept in WorkRoad; the provider's id on external ones. - `period`: integer, required. The period index on a `DEFINED` schedule, or the lesson's order in the day. - `lessonName`: string or null, required. Null for an empty period. - `subjectId`: string or null, required. The subject the lesson is filed under. On an external schedule, the subject this user linked the lesson to. Null when it has none or when `subjectLabel` is set. - `subjectLabel`: string or null, required. Free text from older timetables, kept as typed. Null when the lesson has a `subjectId` or no subject. It cannot be written through the API; sending `subjectId` replaces it. - `room`: string or null, required - `teacher`: string or null, required - `timeStart`: string or null, required. Start time (HH:MM), when set. - `timeEnd`: string or null, required. End time (HH:MM), when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "name": "Year 12", "type": "DEFINED", "external": null, "owned": true, "editable": true, "firstDay": 1, "selectedDays": [ 1, 2, 3, 4, 5 ], "issue": null, "structure": { "periods": [ { "index": 0, "name": "Period 1", "start": "09:00", "end": "10:00" }, { "index": 1, "name": "Period 2", "start": "10:00", "end": "11:00" } ] }, "days": [ { "day": 1, "elements": [ { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "period": 0, "lessonName": "Biology", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectLabel": null, "room": "S2", "teacher": "Dr Patel", "timeStart": null, "timeEnd": null }, { "id": "7f6e5d4c-3b2a-4190-8f8e-7d6c5b4a3928", "period": 1, "lessonName": null, "subjectId": null, "subjectLabel": null, "room": null, "teacher": null, "timeStart": null, "timeEnd": null } ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, no field to update, or a `subjectId` that is not one of the user's subjects (`details.reason` is `unknown-subject`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No schedule with that id exists for this key's user, or the schedule has no element with that id. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason` `external-schedule` for a schedule read from an integration, or `dynamic-schedule` for a `DYNAMIC` one. `slot-empty` when the period has no lesson to update. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Search everything Source: https://workroad.app/docs/api/search `GET https://workroad.app/api/v1/search` Finds notes (and their headings), lecture recordings, note files, study sets and their terms, tasks, timetables, copilot conversations and practice tests by their words, the same search as the app's command menu. Use it to turn something the user names ("my mitosis notes", "the Spanish verbs set") into an id, then read or change the record with its own tool. Hits come best match first, 20 a page, and only for the types this key may read: `note` and `recording` need `notes:view`, `file` `files:view`, `set` and `term` `sets:view`, `task` `tasks:view`, `schedule` `schedule:view`, `conversation` `copilot:use`, `test` `tests:view`. Without `settings:view`, a copilot conversation that read or proposed the user's settings is skipped. Shares the app's search limit of 60 a minute per user. - MCP tool: `search` on `https://workroad.app/api/mcp` - Required scopes: notes:view or files:view or sets:view or tasks:view or schedule:view or copilot:use or tests:view - Test mode: `GET https://workroad.app/api/test/v1/search` checks the request and returns the example response without changing anything. - Next: read a hit with its own tool, such as `getNote` with the hit's `id` as `noteId`. ## Query parameters - `q`: string, required, 1-200 characters. Words to find, 1-200 characters. Matches whole words and prefixes, ignores accents and case, and tolerates a typo in titles. `"exact phrase"` and `-word` work as in the app. - `types`: string, up to 200 characters. Comma-separated record types to search: `note`, `recording`, `file`, `set`, `term`, `task`, `schedule`, `conversation`, `test`. Omit for every type the key may read. - `subjectId`: string (uuid). Only records filed under this subject. - `semantic`: one of `"true"`, `"false"`. `true` also matches by meaning (hybrid search), for questions like `how do cells make energy`: the first page adds meaning matches after the keyword hits. Defaults to `false`, keyword search only. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. ## Response `200` OK - `data`: array of objects, required - `type`: one of `"note"`, `"recording"`, `"file"`, `"set"`, `"term"`, `"task"`, `"schedule"`, `"conversation"`, `"test"`, required. What kind of record matched. - `id`: string (uuid), required. The record's id: pass it to the matching get tool, such as `getNote` for a `note` or `getTask` for a `task`. For a `conversation` it is the message id. - `parentId`: string or null, required. What the record belongs to: the note of a `recording` or `file`, the study set of a `term`, the copilot conversation of a `conversation` message. Null otherwise. - `title`: string, required - `snippet`: string, required. The matching passage, shortened. - `task`: object or null, required. A task's status and due day. Null for other types. - `status`: one of `"done"`, `"open"`, `"overdue"`, required - `dueDate`: string or null, required. The due day (YYYY-MM-DD) in the user's time zone. - `url`: string, required. Where the record opens in the app, at the match when it can. - `updatedAt`: string (date-time), required. When the record last changed. - `nextCursor`: string or null, required. Opaque. Pass as `cursor` with the same query for the next 20 hits. Null on the last page. - `total`: integer, required. Hits across every page. - `mode`: one of `"keyword"`, `"hybrid"`, required. `hybrid` when meaning matches were added. A `semantic` search falls back to `keyword` when embeddings are unavailable. - `correctedQuery`: string or null, required. The query as searched when a misspelt word was matched to one of the user's own words, such as `krebs cycle` for `krebs cylce`. The misspelling still counts. Null when nothing was corrected. ### Example ```json { "data": [ { "type": "term", "id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "parentId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "title": "Spanish verbs", "snippet": "tener (yo): tuve", "task": null, "url": "https://workroad.app/set/get/7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b?element=2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "updatedAt": "2026-02-01T12:00:00.000Z" } ], "nextCursor": null, "total": 1, "mode": "keyword", "correctedQuery": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: `forbidden`: `types` names a type this key may not read. `details.types` lists them. - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: `rate_limited`: 60 searches a minute per user. Wait `Retry-After`. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Add terms to a study set Source: https://workroad.app/docs/api/add-set-terms `POST https://workroad.app/api/v1/sets/{setId}/terms` Appends up to 500 terms per call. Existing terms and their review history stay as they are. A set holds at most 2000 terms, and a call that would pass that adds nothing. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `addSetTerms` on `https://workroad.app/api/mcp` - Required scopes: sets:update - Test mode: `POST https://workroad.app/api/test/v1/sets/{setId}/terms` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `terms`: array of objects, required, 1-500 items. 1-500 terms per call. - `term`: string, required, 1-2000 characters - `definition`: string, required, 1-4000 characters - `hints`: array of string, up to 10 items, default `[]`. Hints shown while studying the term. ## Response `201` Created - `data`: object, required - `terms`: array of objects, required. The new terms. - `id`: string (uuid), required - `term`: string, required - `definition`: string, required - `hints`: array of string, required - `termCount`: integer, required. The set's term count after the append. ### Example ```json { "data": { "terms": [ { "id": "6c5b4a39-2817-4f06-9e5d-4c3b2a190817", "term": "ser (yo)", "definition": "fui", "hints": [] } ], "termCount": 2 } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or the set would hold more than 2000 terms (`details.reason` is `too-many-terms`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a study set Source: https://workroad.app/docs/api/create-set `POST https://workroad.app/api/v1/sets` Creates a set with 1-2000 terms in one call and returns it as `GET /sets/{setId}` would. File it under the course with `subjectId` or `subjectName`. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `createSet` on `https://workroad.app/api/mcp` - Required scopes: sets:create - Test mode: `POST https://workroad.app/api/test/v1/sets` checks the request and returns the example response without changing anything. - Next: add more terms with `addSetTerms`, then practise them with `startPracticeSession` and the set's `id` in `setIds`. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 1-120 characters - `description`: string, up to 2000 characters - `subjectId`: string (uuid). Subject id owned by the user. - `subjectName`: string, 1-64 characters. The subject (the course or module, such as `Software Engineering`) by name instead of by id. It must match one of the user's active subjects, ignoring case and accents. A name that matches none is refused with 400 `unknown-subject`, whose `details.subjects` lists the user's subjects, unless `createSubjectIfMissing` is true. Send `subjectId` or `subjectName`, not both. - `createSubjectIfMissing`: boolean. With `subjectName`, creates the subject when the user has none by that name. Needs the `subjects:create` scope. Only send it when the user named a course they haven't added yet. - `terms`: array of objects, required, 1-2000 items. 1-2000 terms, each a flashcard: the `term` on the front, the `definition` on the back. - `term`: string, required, 1-2000 characters - `definition`: string, required, 1-4000 characters - `hints`: array of string, up to 10 items, default `[]`. Hints shown while studying the term. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `subjectId`: string or null, required. Subject id, when set. - `termCount`: integer, required - `createdAt`: string (date-time), required. Creation timestamp. - `lastUsed`: string (date-time) or null, required. Last study session, if any. - `updatedAt`: string (date-time), required. Last update timestamp. - `terms`: array of objects, required. The first 500 terms by id. When `termsTruncated` is true, page the rest with `GET /sets/{setId}/terms`. - `id`: string (uuid), required - `term`: string, required - `definition`: string, required - `hints`: array of string, required - `termsTruncated`: boolean, required ### Example ```json { "data": { "id": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "name": "Spanish verbs", "description": "Irregular preterite forms.", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "termCount": 1, "createdAt": "2026-01-15T09:30:00.000Z", "lastUsed": "2026-02-01T12:00:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "terms": [ { "id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ] } ], "termsTruncated": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or the subject is not one of the user's subjects (`details.reason` `unknown-subject`, with their names in `details.subjects` when you sent `subjectName`). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: `createSubjectIfMissing` was sent and the key lacks `subjects:create` (`details.missingScopes`). - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Move a study set to the trash Source: https://workroad.app/docs/api/delete-set `DELETE https://workroad.app/api/v1/sets/{setId}` The set disappears from every read. Its terms, images and review history stay as they are, and `POST /sets/{setId}/restore` brings it back. The trash is emptied after 30 days, which removes the set's review history for good. - MCP tool: `deleteSet` on `https://workroad.app/api/mcp` - Required scopes: sets:delete - Test mode: `DELETE https://workroad.app/api/test/v1/sets/{setId}` checks the request and returns the example response without changing anything. - Next: `restoreSet` brings it back from the trash. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Delete a term Source: https://workroad.app/docs/api/delete-set-term `DELETE https://workroad.app/api/v1/sets/{setId}/terms/{termId}` Deletes the term and its image. This permanently removes the term's flashcard review history and scheduling; nothing can restore them. To change a term's wording, update it instead. Requires `sets:delete` because it erases review history. A set's last term cannot be deleted; delete the set instead. - MCP tool: `deleteSetTerm` on `https://workroad.app/api/mcp` - Required scopes: sets:delete - Test mode: `DELETE https://workroad.app/api/test/v1/sets/{setId}/terms/{termId}` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. - `termId`: string (uuid), required. Term id within that set. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user, or the set has no term with that id. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The term is the set's last one (`details.reason` is `last-term`). - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Delete several terms Source: https://workroad.app/docs/api/delete-set-terms `POST https://workroad.app/api/v1/sets/{setId}/terms/delete` Deletes up to 500 terms of one set in one call, with their images. This permanently removes each term's flashcard review history. The set keeps at least one term: a call that would delete them all deletes nothing; delete the set instead. Nothing is deleted when a `termId` isn't the set's. - MCP tool: `deleteSetTerms` on `https://workroad.app/api/mcp` - Required scopes: sets:delete - Test mode: `POST https://workroad.app/api/test/v1/sets/{setId}/terms/delete` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `termIds`: array of string (uuid), required, 1-500 items. 1-500 ids of this set's terms. ## Response `200` OK - `data`: object, required - `termIds`: array of string (uuid), required. The deleted terms. - `deleted`: `true`, required ### Example ```json { "data": { "termIds": [ "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d" ], "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or `details.reason` `unknown-term` with the ids that aren't the set's in `details.termIds`. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The call would delete every term (`details.reason` is `last-term`). The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a study set with its terms Source: https://workroad.app/docs/api/get-set `GET https://workroad.app/api/v1/sets/{setId}` Returns the set and up to 500 of its terms. `termsTruncated` is true when the set has more. - MCP tool: `getSet` on `https://workroad.app/api/mcp` - Required scopes: sets:view - Test mode: `GET https://workroad.app/api/test/v1/sets/{setId}` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `subjectId`: string or null, required. Subject id, when set. - `termCount`: integer, required - `createdAt`: string (date-time), required. Creation timestamp. - `lastUsed`: string (date-time) or null, required. Last study session, if any. - `updatedAt`: string (date-time), required. Last update timestamp. - `terms`: array of objects, required. The first 500 terms by id. When `termsTruncated` is true, page the rest with `GET /sets/{setId}/terms`. - `id`: string (uuid), required - `term`: string, required - `definition`: string, required - `hints`: array of string, required - `termsTruncated`: boolean, required ### Example ```json { "data": { "id": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "name": "Spanish verbs", "description": "Irregular preterite forms.", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "termCount": 1, "createdAt": "2026-01-15T09:30:00.000Z", "lastUsed": "2026-02-01T12:00:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "terms": [ { "id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ] } ], "termsTruncated": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No resource with that id exists for this key's user - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List a set's terms Source: https://workroad.app/docs/api/list-set-terms `GET https://workroad.app/api/v1/sets/{setId}/terms` Ordered by id, 100 per page by default and up to 500. Use this to read terms past the 500 that `GET /sets/{setId}` returns. - MCP tool: `listSetTerms` on `https://workroad.app/api/mcp` - Required scopes: sets:view - Test mode: `GET https://workroad.app/api/test/v1/sets/{setId}/terms` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Query parameters - `limit`: integer, 1 to 500, default `100`. Page size, 1-500. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `term`: string, required - `definition`: string, required - `hints`: array of string, required - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ] } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List study sets Source: https://workroad.app/docs/api/list-sets `GET https://workroad.app/api/v1/sets` Newest first. Terms are omitted; fetch a single set for its terms. - MCP tool: `listSets` on `https://workroad.app/api/mcp` - Required scopes: sets:view - Test mode: `GET https://workroad.app/api/test/v1/sets` checks the request and returns the example response without changing anything. ## Query parameters - `limit`: integer, 1 to 100, default `25`. Page size, 1-100. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `subjectId`: string or null, required. Subject id, when set. - `termCount`: integer, required - `createdAt`: string (date-time), required. Creation timestamp. - `lastUsed`: string (date-time) or null, required. Last study session, if any. - `updatedAt`: string (date-time), required. Last update timestamp. - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "id": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "name": "Spanish verbs", "description": "Irregular preterite forms.", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "termCount": 1, "createdAt": "2026-01-15T09:30:00.000Z", "lastUsed": "2026-02-01T12:00:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Restore a study set from the trash Source: https://workroad.app/docs/api/restore-set `POST https://workroad.app/api/v1/sets/{setId}/restore` Brings a trashed set back with its terms, images and review history, and returns it as `GET /sets/{setId}` would. Needs `sets:delete`, the scope that trashed it. A set whose folder is still in the trash comes back unfiled. - MCP tool: `restoreSet` on `https://workroad.app/api/mcp` - Required scopes: sets:delete - Test mode: `POST https://workroad.app/api/test/v1/sets/{setId}/restore` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `subjectId`: string or null, required. Subject id, when set. - `termCount`: integer, required - `createdAt`: string (date-time), required. Creation timestamp. - `lastUsed`: string (date-time) or null, required. Last study session, if any. - `updatedAt`: string (date-time), required. Last update timestamp. - `terms`: array of objects, required. The first 500 terms by id. When `termsTruncated` is true, page the rest with `GET /sets/{setId}/terms`. - `id`: string (uuid), required - `term`: string, required - `definition`: string, required - `hints`: array of string, required - `termsTruncated`: boolean, required ### Example ```json { "data": { "id": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "name": "Spanish verbs", "description": "Irregular preterite forms.", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "termCount": 1, "createdAt": "2026-01-15T09:30:00.000Z", "lastUsed": "2026-02-01T12:00:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "terms": [ { "id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ] } ], "termsTruncated": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id is in the trash for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update a study set Source: https://workroad.app/docs/api/update-set `PATCH https://workroad.app/api/v1/sets/{setId}` Changes the name, description or subject. Only the fields you send change; `null` clears a nullable field. Terms and their review history are never touched. - MCP tool: `updateSet` on `https://workroad.app/api/mcp` - Required scopes: sets:update - Test mode: `PATCH https://workroad.app/api/test/v1/sets/{setId}` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Request body JSON. - `name`: string, 1-120 characters - `description`: string or null, up to 2000 characters. `null` or an empty string clears it. - `subjectId`: string (uuid) or null. Subject id owned by the user. `null` unfiles the set. - `subjectName`: string, 1-64 characters. The subject (the course or module, such as `Software Engineering`) by name instead of by id. It must match one of the user's active subjects, ignoring case and accents. A name that matches none is refused with 400 `unknown-subject`, whose `details.subjects` lists the user's subjects, unless `createSubjectIfMissing` is true. Send `subjectId` or `subjectName`, not both. - `createSubjectIfMissing`: boolean. With `subjectName`, creates the subject when the user has none by that name. Needs the `subjects:create` scope. Only send it when the user named a course they haven't added yet. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `subjectId`: string or null, required. Subject id, when set. - `termCount`: integer, required - `createdAt`: string (date-time), required. Creation timestamp. - `lastUsed`: string (date-time) or null, required. Last study session, if any. - `updatedAt`: string (date-time), required. Last update timestamp. ### Example ```json { "data": { "id": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "name": "Spanish verbs", "description": "Irregular preterite forms.", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "termCount": 1, "createdAt": "2026-01-15T09:30:00.000Z", "lastUsed": "2026-02-01T12:00:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, no field to update, or the subject is not one of the user's subjects. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update a term Source: https://workroad.app/docs/api/update-set-term `PATCH https://workroad.app/api/v1/sets/{setId}/terms/{termId}` Changes the term, definition or hints. Only the fields you send change. The term keeps its id, image and review history. - MCP tool: `updateSetTerm` on `https://workroad.app/api/mcp` - Required scopes: sets:update - Test mode: `PATCH https://workroad.app/api/test/v1/sets/{setId}/terms/{termId}` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. - `termId`: string (uuid), required. Term id within that set. ## Request body JSON. - `term`: string, 1-2000 characters - `definition`: string, 1-4000 characters - `hints`: array of string, up to 10 items. Replaces the full list when sent. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `term`: string, required - `definition`: string, required - `hints`: array of string, required ### Example ```json { "data": { "id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or no field to update. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user, or the set has no term with that id. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update several terms Source: https://workroad.app/docs/api/update-set-terms `PATCH https://workroad.app/api/v1/sets/{setId}/terms` Changes up to 500 terms of one set in one call. Send each term's `termId` with only the fields to change. Every term keeps its id, image and review history. All the changes are saved together, or none when a `termId` isn't the set's. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `updateSetTerms` on `https://workroad.app/api/mcp` - Required scopes: sets:update - Test mode: `PATCH https://workroad.app/api/test/v1/sets/{setId}/terms` checks the request and returns the example response without changing anything. ## Path parameters - `setId`: string (uuid), required. Study set id, scoped to the key's user. ## Request body JSON. - `terms`: array of objects, required, 1-500 items. 1-500 terms, each with its `termId` and only the fields to change. - `termId`: string (uuid), required. A term of this set. - `term`: string, 1-2000 characters - `definition`: string, 1-4000 characters - `hints`: array of string, up to 10 items. Replaces the full list when sent. ## Response `200` OK - `data`: object, required - `terms`: array of objects, required. The terms as saved, in the order sent. - `id`: string (uuid), required - `term`: string, required - `definition`: string, required - `hints`: array of string, required ### Example ```json { "data": { "terms": [ { "id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "term": "tener (yo)", "definition": "tuve", "hints": [ "irregular stem tuv-" ] } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or `details.reason` `unknown-term` with the ids that aren't the set's in `details.termIds`. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study set with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get the dashboard layout Source: https://workroad.app/docs/api/get-dashboard-layout `GET https://workroad.app/api/v1/settings/dashboard` The dashboard cards shown, in order, and their sizes. - MCP tool: `getDashboardLayout` on `https://workroad.app/api/mcp` - Required scopes: settings:view - Test mode: `GET https://workroad.app/api/test/v1/settings/dashboard` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: object, required - `widgets`: array of objects, required. The cards shown, in order. A card left out is hidden. - `id`: one of `"reviews"`, `"schedule"`, `"tasks"`, `"exams"`, `"copilot"`, `"continue-studying"`, `"streaks"`, `"contribution"`, `"generation-activity"`, required - `size`: one of `"single"`, `"wide"`, required - `updatedAt`: string (date-time) or null, required. When the layout was last saved, or null while the default is shown. ### Example ```json { "data": { "widgets": [ { "id": "reviews", "size": "single" }, { "id": "schedule", "size": "wide" }, { "id": "tasks", "size": "single" } ], "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get notification settings Source: https://workroad.app/docs/api/get-notification-settings `GET https://workroad.app/api/v1/settings/notifications` Quiet hours, the email digest, push content, calendar alarms, and each notification kind's channels and timing. - MCP tool: `getNotificationSettings` on `https://workroad.app/api/mcp` - Required scopes: settings:view - Test mode: `GET https://workroad.app/api/test/v1/settings/notifications` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: object, required - `quietHoursEnabled`: boolean, required. Push and email wait until quiet hours end. - `quietStart`: string, required. Quiet hours start, as local HH:MM in the account's zone. - `quietEnd`: string, required. Quiet hours end, as local HH:MM in the account's zone. - `pushShowContent`: boolean, required. Push messages name the task, exam or subject. Off sends a generic line instead. - `calendarAlarms`: boolean, required. The all-schedules calendar feed adds alarms to task and exam events. - `digestFrequency`: one of `"OFF"`, `"DAILY"`, `"WEEKLY"`, required - `digestTime`: string, required. When the digest is sent, as local HH:MM in the account's zone. - `digestWeekday`: integer, required. The weekly digest's day, 0 for Sunday to 6 for Saturday. - `emailSuppressedAt`: string (date-time) or null, required. Set after an email bounced or was reported as spam. Notification email stays off until `resumeNotificationEmail`. - `emailSuppressedReason`: one of `"BOUNCE"`, `"COMPLAINT"` or null, required. Why email was suppressed, or null. - `emailVerified`: boolean, required. No notification email is sent until the account's address is verified. - `kinds`: array of objects, required. Every notification kind's channels and timing, defaults included. - `kind`: one of `"TASK_DUE"`, `"EXAM_SOON"`, `"STREAK_AT_RISK"`, `"LESSON_STARTING"`, `"SCHEDULE_SHARE_CHANGED"`, `"GENERATION_FINISHED"`, `"RECORDING_READY"`, `"DIGEST"`, required. `TASK_DUE` before a task is due, `EXAM_SOON` before an exam, `STREAK_AT_RISK` when today's goal isn't met, `LESSON_STARTING` before a lesson, `SCHEDULE_SHARE_CHANGED` when someone shares a timetable or stops, `GENERATION_FINISHED` and `RECORDING_READY` when AI work finishes, `DIGEST` for the email digest. - `inApp`: boolean, required. Shown in the bell and the notifications page. - `email`: boolean, required. Sent by email once the address is verified. - `push`: boolean, required. Sent as a push to every browser the user enabled. - `leadMinutes`: integer or null, required. Minutes before a timed task is due (`TASK_DUE`) or a lesson starts (`LESSON_STARTING`). Null for a kind without it. - `leadDays`: array of integer or null, required. Days before an all-day task is due (`TASK_DUE`) or an exam (`EXAM_SOON`). Null for a kind without it. - `localTime`: string or null, required. When all-day task, exam and streak reminders go out, as local HH:MM. Null for a kind without it. ### Example ```json { "data": { "quietHoursEnabled": true, "quietStart": "22:00", "quietEnd": "07:00", "pushShowContent": false, "calendarAlarms": false, "digestFrequency": "WEEKLY", "digestTime": "07:30", "digestWeekday": 1, "emailSuppressedAt": null, "emailSuppressedReason": null, "emailVerified": true, "kinds": [ { "kind": "TASK_DUE", "inApp": true, "email": false, "push": true, "leadMinutes": 60, "leadDays": [ 1 ], "localTime": "18:00" }, { "kind": "EXAM_SOON", "inApp": true, "email": true, "push": true, "leadMinutes": null, "leadDays": [ 3, 1 ], "localTime": "18:00" } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get language, time zone and privacy Source: https://workroad.app/docs/api/get-preferences `GET https://workroad.app/api/v1/settings/preferences` The app language, the account's time zone and leaderboard visibility. - MCP tool: `getPreferences` on `https://workroad.app/api/mcp` - Required scopes: settings:view - Test mode: `GET https://workroad.app/api/test/v1/settings/preferences` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: object, required - `locale`: one of `"en"`, `"de"`, `"es"`, `"fr"`, `"it"` or null, required. The language of the app, emails, notifications and calendar feeds. Null until the user picks one; the app then follows the browser. - `timeZone`: string or null, required. The IANA time zone the account's days and due dates use, such as `Europe/London`. Null until the app has captured one; the API then uses UTC. - `showOnLeaderboards`: boolean, required. Whether the user appears on the global task and streak leaderboards and shows study stats on their profile. ### Example ```json { "data": { "locale": "en", "timeZone": "Europe/London", "showOnLeaderboards": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get the profile Source: https://workroad.app/docs/api/get-profile `GET https://workroad.app/api/v1/settings/profile` The user's name, username, bio and what their public profile shows. - MCP tool: `getProfile` on `https://workroad.app/api/mcp` - Required scopes: settings:view - Test mode: `GET https://workroad.app/api/test/v1/settings/profile` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: object, required - `name`: string, required. The display name. - `username`: string, required. Lower case letters, digits, `_` and `.`. The public profile lives at `/{username}`. - `description`: string, required. The profile bio. - `status`: string, required. A short status line shown on the profile. - `location`: string or null, required. Free text, or null. - `showLastLogin`: boolean, required. The profile shows when they were last on. - `showCreated`: boolean, required. The profile shows when they joined. - `showTimezone`: boolean, required. The profile shows their time zone. - `showMembership`: boolean, required. The profile shows their plan. ### Example ```json { "data": { "name": "Ada Lovelace", "username": "ada", "description": "Maths and computing, mostly.", "status": "Revising for finals", "location": "London", "showLastLogin": true, "showCreated": true, "showTimezone": false, "showMembership": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get review settings Source: https://workroad.app/docs/api/get-review-settings `GET https://workroad.app/api/v1/settings/review` Daily new-card and review limits, FSRS desired retention, the daily goal, leech handling and per-subject overrides. - MCP tool: `getReviewSettings` on `https://workroad.app/api/mcp` - Required scopes: settings:view - Test mode: `GET https://workroad.app/api/test/v1/settings/review` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: object, required - `newPerDay`: integer, required. New cards introduced a day. - `reviewsPerDay`: integer or null, required. Reviews a day. Null: no limit. - `retention`: number, required. FSRS desired retention, the chance of recalling a card when it comes due, from 0.7 to 0.99. - `goalKind`: one of `"cards"`, `"minutes"`, required. Whether the daily goal counts cards reviewed or minutes studied. - `goalTarget`: integer, required. The daily goal, in `goalKind` units. - `leechThreshold`: integer, required. Lapses after which a card is marked a leech. - `suspendLeeches`: boolean, required. Leeches are suspended automatically. - `subjectOverrides`: array of objects, required. Limits and retention that differ for one subject. - `subjectId`: string (uuid), required - `newPerDay`: integer or null, required. New cards a day for this subject, within the global limit. Null: no limit. - `reviewsPerDay`: integer or null, required. Reviews a day for this subject. Null: no limit. - `retention`: number or null, required. Desired retention for this subject. Null: the global value. - `optimizedAt`: string (date-time) or null, required. When the FSRS optimizer last fitted this user's weights, or null. ### Example ```json { "data": { "newPerDay": 20, "reviewsPerDay": null, "retention": 0.9, "goalKind": "cards", "goalTarget": 30, "leechThreshold": 8, "suspendLeeches": true, "subjectOverrides": [ { "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "newPerDay": 10, "reviewsPerDay": null, "retention": 0.95 } ], "optimizedAt": null } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get task preferences Source: https://workroad.app/docs/api/get-task-settings `GET https://workroad.app/api/v1/settings/tasks` The task types the app offers and the colours of overdue and due tasks. - MCP tool: `getTaskSettings` on `https://workroad.app/api/mcp` - Required scopes: settings:view - Test mode: `GET https://workroad.app/api/test/v1/settings/tasks` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: object, required - `types`: array of string, required. The task types, in the order the app offers them. - `overdueColor`: string, required. The colour of an overdue task. - `dueTodayColor`: string, required. The colour of a task due today. - `nearlyDueColor`: string, required. The colour of a task due soon. ### Example ```json { "data": { "types": [ "Homework", "Test", "Study", "Project" ], "overdueColor": "#ef4444", "dueTodayColor": "#f97316", "nearlyDueColor": "#eab308" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Replace the dashboard layout Source: https://workroad.app/docs/api/replace-dashboard-layout `PUT https://workroad.app/api/v1/settings/dashboard` Sets which cards the dashboard shows, in order, and their sizes. A card listed twice is shown once. - MCP tool: `replaceDashboardLayout` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `PUT https://workroad.app/api/test/v1/settings/dashboard` checks the request and returns the example response without changing anything. ## Request body JSON. - `widgets`: array of objects, required, up to 9 items. Every card to show, in order. Leave a card out to hide it. - `id`: one of `"reviews"`, `"schedule"`, `"tasks"`, `"exams"`, `"copilot"`, `"continue-studying"`, `"streaks"`, `"contribution"`, `"generation-activity"`, required - `size`: one of `"single"`, `"wide"`, required. `wide` spans two columns. - `baseUpdatedAt`: string (date-time) or null. The `updatedAt` you read. When sent, a layout saved elsewhere since then fails with 409; null means no layout was saved yet. Leave it out to replace whatever is saved. ## Response `200` OK - `data`: object, required - `widgets`: array of objects, required. The cards shown, in order. A card left out is hidden. - `id`: one of `"reviews"`, `"schedule"`, `"tasks"`, `"exams"`, `"copilot"`, `"continue-studying"`, `"streaks"`, `"contribution"`, `"generation-activity"`, required - `size`: one of `"single"`, `"wide"`, required - `updatedAt`: string (date-time) or null, required. When the layout was last saved, or null while the default is shown. ### Example ```json { "data": { "widgets": [ { "id": "reviews", "size": "single" }, { "id": "schedule", "size": "wide" }, { "id": "tasks", "size": "single" } ], "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The layout was saved elsewhere after `baseUpdatedAt`. Read it again and resend. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Resume notification email Source: https://workroad.app/docs/api/resume-notification-email `POST https://workroad.app/api/v1/settings/notifications/resume-email` Turns notification email back on after it was suppressed because a message bounced or was reported as spam. Fix the address first, or the next email suppresses it again. - MCP tool: `resumeNotificationEmail` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `POST https://workroad.app/api/test/v1/settings/notifications/resume-email` checks the request and returns the example response without changing anything. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `quietHoursEnabled`: boolean, required. Push and email wait until quiet hours end. - `quietStart`: string, required. Quiet hours start, as local HH:MM in the account's zone. - `quietEnd`: string, required. Quiet hours end, as local HH:MM in the account's zone. - `pushShowContent`: boolean, required. Push messages name the task, exam or subject. Off sends a generic line instead. - `calendarAlarms`: boolean, required. The all-schedules calendar feed adds alarms to task and exam events. - `digestFrequency`: one of `"OFF"`, `"DAILY"`, `"WEEKLY"`, required - `digestTime`: string, required. When the digest is sent, as local HH:MM in the account's zone. - `digestWeekday`: integer, required. The weekly digest's day, 0 for Sunday to 6 for Saturday. - `emailSuppressedAt`: string (date-time) or null, required. Set after an email bounced or was reported as spam. Notification email stays off until `resumeNotificationEmail`. - `emailSuppressedReason`: one of `"BOUNCE"`, `"COMPLAINT"` or null, required. Why email was suppressed, or null. - `emailVerified`: boolean, required. No notification email is sent until the account's address is verified. - `kinds`: array of objects, required. Every notification kind's channels and timing, defaults included. - `kind`: one of `"TASK_DUE"`, `"EXAM_SOON"`, `"STREAK_AT_RISK"`, `"LESSON_STARTING"`, `"SCHEDULE_SHARE_CHANGED"`, `"GENERATION_FINISHED"`, `"RECORDING_READY"`, `"DIGEST"`, required. `TASK_DUE` before a task is due, `EXAM_SOON` before an exam, `STREAK_AT_RISK` when today's goal isn't met, `LESSON_STARTING` before a lesson, `SCHEDULE_SHARE_CHANGED` when someone shares a timetable or stops, `GENERATION_FINISHED` and `RECORDING_READY` when AI work finishes, `DIGEST` for the email digest. - `inApp`: boolean, required. Shown in the bell and the notifications page. - `email`: boolean, required. Sent by email once the address is verified. - `push`: boolean, required. Sent as a push to every browser the user enabled. - `leadMinutes`: integer or null, required. Minutes before a timed task is due (`TASK_DUE`) or a lesson starts (`LESSON_STARTING`). Null for a kind without it. - `leadDays`: array of integer or null, required. Days before an all-day task is due (`TASK_DUE`) or an exam (`EXAM_SOON`). Null for a kind without it. - `localTime`: string or null, required. When all-day task, exam and streak reminders go out, as local HH:MM. Null for a kind without it. ### Example ```json { "data": { "quietHoursEnabled": true, "quietStart": "22:00", "quietEnd": "07:00", "pushShowContent": false, "calendarAlarms": false, "digestFrequency": "WEEKLY", "digestTime": "07:30", "digestWeekday": 1, "emailSuppressedAt": null, "emailSuppressedReason": null, "emailVerified": true, "kinds": [ { "kind": "TASK_DUE", "inApp": true, "email": false, "push": true, "leadMinutes": 60, "leadDays": [ 1 ], "localTime": "18:00" }, { "kind": "EXAM_SOON", "inApp": true, "email": true, "push": true, "leadMinutes": null, "leadDays": [ 3, 1 ], "localTime": "18:00" } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update one notification kind Source: https://workroad.app/docs/api/update-notification-kind `PATCH https://workroad.app/api/v1/settings/notifications/{kind}` Turns a kind's bell, email or push on or off, or changes when it is sent. Returns the kind as it now resolves. - MCP tool: `updateNotificationKind` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `PATCH https://workroad.app/api/test/v1/settings/notifications/{kind}` checks the request and returns the example response without changing anything. ## Path parameters - `kind`: one of `"TASK_DUE"`, `"EXAM_SOON"`, `"STREAK_AT_RISK"`, `"LESSON_STARTING"`, `"SCHEDULE_SHARE_CHANGED"`, `"GENERATION_FINISHED"`, `"RECORDING_READY"`, `"DIGEST"`, required. `TASK_DUE` before a task is due, `EXAM_SOON` before an exam, `STREAK_AT_RISK` when today's goal isn't met, `LESSON_STARTING` before a lesson, `SCHEDULE_SHARE_CHANGED` when someone shares a timetable or stops, `GENERATION_FINISHED` and `RECORDING_READY` when AI work finishes, `DIGEST` for the email digest. ## Request body JSON. - `inApp`: boolean - `email`: boolean - `push`: boolean - `leadMinutes`: integer or null. `TASK_DUE`: 15, 60, 180, 1440. `LESSON_STARTING`: 5, 10, 15, 30. Null restores the default. - `leadDays`: integer or null. `TASK_DUE` (all-day tasks): 0, 1, 2. `EXAM_SOON`: 1, 2, 3, 5, 7, 14; one value replaces the default of 3 days and 1 day before. Null restores the default. - `localTime`: string or null. HH:MM for `TASK_DUE` (all-day tasks), `EXAM_SOON` and `STREAK_AT_RISK`. Null restores the default. ## Response `200` OK - `data`: object, required - `kind`: one of `"TASK_DUE"`, `"EXAM_SOON"`, `"STREAK_AT_RISK"`, `"LESSON_STARTING"`, `"SCHEDULE_SHARE_CHANGED"`, `"GENERATION_FINISHED"`, `"RECORDING_READY"`, `"DIGEST"`, required. `TASK_DUE` before a task is due, `EXAM_SOON` before an exam, `STREAK_AT_RISK` when today's goal isn't met, `LESSON_STARTING` before a lesson, `SCHEDULE_SHARE_CHANGED` when someone shares a timetable or stops, `GENERATION_FINISHED` and `RECORDING_READY` when AI work finishes, `DIGEST` for the email digest. - `inApp`: boolean, required. Shown in the bell and the notifications page. - `email`: boolean, required. Sent by email once the address is verified. - `push`: boolean, required. Sent as a push to every browser the user enabled. - `leadMinutes`: integer or null, required. Minutes before a timed task is due (`TASK_DUE`) or a lesson starts (`LESSON_STARTING`). Null for a kind without it. - `leadDays`: array of integer or null, required. Days before an all-day task is due (`TASK_DUE`) or an exam (`EXAM_SOON`). Null for a kind without it. - `localTime`: string or null, required. When all-day task, exam and streak reminders go out, as local HH:MM. Null for a kind without it. ### Example ```json { "data": { "kind": "TASK_DUE", "inApp": true, "email": false, "push": true, "leadMinutes": 60, "leadDays": [ 1 ], "localTime": "18:00" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, no field to update, or a timing the kind doesn't use or allow. `details.field` names it and `details.reason` lists the allowed values. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No resource with that id exists for this key's user - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update notification settings Source: https://workroad.app/docs/api/update-notification-settings `PATCH https://workroad.app/api/v1/settings/notifications` Changes quiet hours, the digest, push content or calendar alarms. Change one notification kind with `updateNotificationKind`. - MCP tool: `updateNotificationSettings` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `PATCH https://workroad.app/api/test/v1/settings/notifications` checks the request and returns the example response without changing anything. ## Request body JSON. - `quietHoursEnabled`: boolean - `quietStart`: string. HH:MM, such as 22:00. - `quietEnd`: string. HH:MM, such as 07:00. - `pushShowContent`: boolean - `calendarAlarms`: boolean - `digestFrequency`: one of `"OFF"`, `"DAILY"`, `"WEEKLY"` - `digestTime`: string. HH:MM, such as 07:30. - `digestWeekday`: integer, 0 to 6. 0 for Sunday to 6 for Saturday. ## Response `200` OK - `data`: object, required - `quietHoursEnabled`: boolean, required. Push and email wait until quiet hours end. - `quietStart`: string, required. Quiet hours start, as local HH:MM in the account's zone. - `quietEnd`: string, required. Quiet hours end, as local HH:MM in the account's zone. - `pushShowContent`: boolean, required. Push messages name the task, exam or subject. Off sends a generic line instead. - `calendarAlarms`: boolean, required. The all-schedules calendar feed adds alarms to task and exam events. - `digestFrequency`: one of `"OFF"`, `"DAILY"`, `"WEEKLY"`, required - `digestTime`: string, required. When the digest is sent, as local HH:MM in the account's zone. - `digestWeekday`: integer, required. The weekly digest's day, 0 for Sunday to 6 for Saturday. - `emailSuppressedAt`: string (date-time) or null, required. Set after an email bounced or was reported as spam. Notification email stays off until `resumeNotificationEmail`. - `emailSuppressedReason`: one of `"BOUNCE"`, `"COMPLAINT"` or null, required. Why email was suppressed, or null. - `emailVerified`: boolean, required. No notification email is sent until the account's address is verified. - `kinds`: array of objects, required. Every notification kind's channels and timing, defaults included. - `kind`: one of `"TASK_DUE"`, `"EXAM_SOON"`, `"STREAK_AT_RISK"`, `"LESSON_STARTING"`, `"SCHEDULE_SHARE_CHANGED"`, `"GENERATION_FINISHED"`, `"RECORDING_READY"`, `"DIGEST"`, required. `TASK_DUE` before a task is due, `EXAM_SOON` before an exam, `STREAK_AT_RISK` when today's goal isn't met, `LESSON_STARTING` before a lesson, `SCHEDULE_SHARE_CHANGED` when someone shares a timetable or stops, `GENERATION_FINISHED` and `RECORDING_READY` when AI work finishes, `DIGEST` for the email digest. - `inApp`: boolean, required. Shown in the bell and the notifications page. - `email`: boolean, required. Sent by email once the address is verified. - `push`: boolean, required. Sent as a push to every browser the user enabled. - `leadMinutes`: integer or null, required. Minutes before a timed task is due (`TASK_DUE`) or a lesson starts (`LESSON_STARTING`). Null for a kind without it. - `leadDays`: array of integer or null, required. Days before an all-day task is due (`TASK_DUE`) or an exam (`EXAM_SOON`). Null for a kind without it. - `localTime`: string or null, required. When all-day task, exam and streak reminders go out, as local HH:MM. Null for a kind without it. ### Example ```json { "data": { "quietHoursEnabled": true, "quietStart": "22:00", "quietEnd": "07:00", "pushShowContent": false, "calendarAlarms": false, "digestFrequency": "WEEKLY", "digestTime": "07:30", "digestWeekday": 1, "emailSuppressedAt": null, "emailSuppressedReason": null, "emailVerified": true, "kinds": [ { "kind": "TASK_DUE", "inApp": true, "email": false, "push": true, "leadMinutes": 60, "leadDays": [ 1 ], "localTime": "18:00" }, { "kind": "EXAM_SOON", "inApp": true, "email": true, "push": true, "leadMinutes": null, "leadDays": [ 3, 1 ], "localTime": "18:00" } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body or no field to update. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update language, time zone or privacy Source: https://workroad.app/docs/api/update-preferences `PATCH https://workroad.app/api/v1/settings/preferences` Changing the time zone moves every task's due date so it keeps its date and time on the clock, the same as changing it in Settings → Account. - MCP tool: `updatePreferences` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `PATCH https://workroad.app/api/test/v1/settings/preferences` checks the request and returns the example response without changing anything. ## Request body JSON. - `locale`: one of `"en"`, `"de"`, `"es"`, `"fr"`, `"it"`. One of the five app languages. - `timeZone`: string, 1-64 characters. An IANA zone name. Aliases such as `Asia/Calcutta` are stored by their canonical name. Every task keeps its due date and time on the clock, so a task due at 09:00 is still due at 09:00 in the new zone. - `showOnLeaderboards`: boolean ## Response `200` OK - `data`: object, required - `locale`: one of `"en"`, `"de"`, `"es"`, `"fr"`, `"it"` or null, required. The language of the app, emails, notifications and calendar feeds. Null until the user picks one; the app then follows the browser. - `timeZone`: string or null, required. The IANA time zone the account's days and due dates use, such as `Europe/London`. Null until the app has captured one; the API then uses UTC. - `showOnLeaderboards`: boolean, required. Whether the user appears on the global task and streak leaderboards and shows study stats on their profile. ### Example ```json { "data": { "locale": "en", "timeZone": "Europe/London", "showOnLeaderboards": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, no field to update, or an unknown zone. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The time zone changed elsewhere during the request. Read it again. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update the profile Source: https://workroad.app/docs/api/update-profile `PATCH https://workroad.app/api/v1/settings/profile` Changes the name, username, bio, status, location or what the public profile shows. The sign-in email address can't be changed here. - MCP tool: `updateProfile` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `PATCH https://workroad.app/api/test/v1/settings/profile` checks the request and returns the example response without changing anything. ## Request body JSON. - `name`: string, 2-100 characters. 2 to 100 characters. - `username`: string, 3-30 characters, matches `^[a-zA-Z0-9_.]+$`. 3 to 30 letters, digits, `_` or `.`, stored in lower case. Fails with 409 when another account has it. - `description`: string, 0-512 characters. Up to 512 characters. Send an empty string to clear it. - `status`: string, 0-256 characters. Up to 256 characters. Send an empty string to clear it. - `location`: string, 0-256 characters. Up to 256 characters. Send an empty string to clear it. - `showLastLogin`: boolean - `showCreated`: boolean - `showTimezone`: boolean - `showMembership`: boolean ## Response `200` OK - `data`: object, required - `name`: string, required. The display name. - `username`: string, required. Lower case letters, digits, `_` and `.`. The public profile lives at `/{username}`. - `description`: string, required. The profile bio. - `status`: string, required. A short status line shown on the profile. - `location`: string or null, required. Free text, or null. - `showLastLogin`: boolean, required. The profile shows when they were last on. - `showCreated`: boolean, required. The profile shows when they joined. - `showTimezone`: boolean, required. The profile shows their time zone. - `showMembership`: boolean, required. The profile shows their plan. ### Example ```json { "data": { "name": "Ada Lovelace", "username": "ada", "description": "Maths and computing, mostly.", "status": "Revising for finals", "location": "London", "showLastLogin": true, "showCreated": true, "showTimezone": false, "showMembership": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body or no field to update. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: Another account already has that username. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update review settings Source: https://workroad.app/docs/api/update-review-settings `PATCH https://workroad.app/api/v1/settings/review` Changes the limits, retention, daily goal or leech handling the review queue and practice sessions use. - MCP tool: `updateReviewSettings` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `PATCH https://workroad.app/api/test/v1/settings/review` checks the request and returns the example response without changing anything. ## Request body JSON. - `newPerDay`: integer, 0 to 2000. 0 to 2000. - `reviewsPerDay`: integer or null, 0 to 10000. 0 to 10000, or null for no limit. - `retention`: number, 0.7 to 0.99. 0.7 to 0.99. Higher means more reviews and fewer forgotten cards. - `goalKind`: one of `"cards"`, `"minutes"` - `goalTarget`: integer, 1 to 2000. 1 to 2000. - `leechThreshold`: integer, 2 to 100. 2 to 100. - `suspendLeeches`: boolean - `subjectOverrides`: array of objects, up to 200 items. Replaces every override when sent. Send an empty list to remove them all. - `subjectId`: string (uuid), required. One of the user's subjects. - `newPerDay`: integer, 0 to 2000 - `reviewsPerDay`: integer or null, 0 to 10000 - `retention`: number, 0.7 to 0.99 ## Response `200` OK - `data`: object, required - `newPerDay`: integer, required. New cards introduced a day. - `reviewsPerDay`: integer or null, required. Reviews a day. Null: no limit. - `retention`: number, required. FSRS desired retention, the chance of recalling a card when it comes due, from 0.7 to 0.99. - `goalKind`: one of `"cards"`, `"minutes"`, required. Whether the daily goal counts cards reviewed or minutes studied. - `goalTarget`: integer, required. The daily goal, in `goalKind` units. - `leechThreshold`: integer, required. Lapses after which a card is marked a leech. - `suspendLeeches`: boolean, required. Leeches are suspended automatically. - `subjectOverrides`: array of objects, required. Limits and retention that differ for one subject. - `subjectId`: string (uuid), required - `newPerDay`: integer or null, required. New cards a day for this subject, within the global limit. Null: no limit. - `reviewsPerDay`: integer or null, required. Reviews a day for this subject. Null: no limit. - `retention`: number or null, required. Desired retention for this subject. Null: the global value. - `optimizedAt`: string (date-time) or null, required. When the FSRS optimizer last fitted this user's weights, or null. ### Example ```json { "data": { "newPerDay": 20, "reviewsPerDay": null, "retention": 0.9, "goalKind": "cards", "goalTarget": 30, "leechThreshold": 8, "suspendLeeches": true, "subjectOverrides": [ { "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "newPerDay": 10, "reviewsPerDay": null, "retention": 0.95 } ], "optimizedAt": null } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, no field to update, or an override names a subject the user doesn't own. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update task preferences Source: https://workroad.app/docs/api/update-task-settings `PATCH https://workroad.app/api/v1/settings/tasks` Replaces the task types or changes the due-date colours. - MCP tool: `updateTaskSettings` on `https://workroad.app/api/mcp` - Required scopes: settings:update - Test mode: `PATCH https://workroad.app/api/test/v1/settings/tasks` checks the request and returns the example response without changing anything. ## Request body JSON. - `types`: array of string, 1-50 items. Replaces the whole list, in order. Tasks keep their type when it is removed here. - `overdueColor`: string, matches `^#[0-9A-F]{6}$`. Overdue tasks, as #RRGGBB. - `dueTodayColor`: string, matches `^#[0-9A-F]{6}$`. Tasks due today, as #RRGGBB. - `nearlyDueColor`: string, matches `^#[0-9A-F]{6}$`. Tasks due soon, as #RRGGBB. ## Response `200` OK - `data`: object, required - `types`: array of string, required. The task types, in the order the app offers them. - `overdueColor`: string, required. The colour of an overdue task. - `dueTodayColor`: string, required. The colour of a task due today. - `nearlyDueColor`: string, required. The colour of a task due soon. ### Example ```json { "data": { "types": [ "Homework", "Test", "Study", "Project" ], "overdueColor": "#ef4444", "dueTodayColor": "#f97316", "nearlyDueColor": "#eab308" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body or no field to update. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Plan a study block Source: https://workroad.app/docs/api/create-study-block `POST https://workroad.app/api/v1/study-blocks` Puts study time on the user's calendar, the way the planning copilot does: a title, a future start and end in the user's time zone, and optionally the subject, task or study set it is for. The block can't overlap a lesson (including external calendars, read live) or another block, and can't end after a linked task's due day. Find free time first with `getToday` or `getScheduleWeek`. - MCP tool: `createStudyBlock` on `https://workroad.app/api/mcp` - Required scopes: schedule:create - Test mode: `POST https://workroad.app/api/test/v1/study-blocks` checks the request and returns the example response without changing anything. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `title`: string, required, 1-120 characters. What the user studies, 1-120 characters. - `startsAt`: string (date-time), required. When the block starts, in the future. ISO timestamp with `Z` or an offset, such as `2026-10-21T16:00:00+01:00`. - `endsAt`: string (date-time), required. When it ends: 5 minutes to 8 hours after `startsAt`, on the same day in the user's time zone. ISO timestamp with `Z` or an offset, such as `2026-10-21T16:00:00+01:00`. - `subjectId`: string (uuid) or null. The subject studied. Null clears it. - `taskId`: string (uuid) or null. The task this block works on. The block must end by the task's due day. Null clears it. - `setId`: string (uuid) or null. The study set to practise in the block. Null clears it. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `title`: string, required - `startsAt`: string (date-time), required. Start, in UTC. - `endsAt`: string (date-time), required. End, in UTC. - `date`: string, required. The day (YYYY-MM-DD) in the user's time zone. - `timeZone`: string, required. The time zone the block was planned in. - `subjectId`: string or null, required. The subject studied, when set. - `taskId`: string or null, required. The task worked on, when set. - `setId`: string or null, required. The study set practised, when set. - `planned`: boolean, required - `activity`: one of `"FLASHCARDS"`, `"MEMORY"`, `"MATCH"`, `"GUESS_THE_TERM"`, `"SENTENCE_SCRAMBLE"` or null, required - `assessmentId`: string or null, required - `doneAt`: string (date-time) or null, required - `planKind`: one of `"due"`, `"new"`, `"refresh"` or null, required - `planCardIds`: array of string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. ### Example ```json { "data": { "id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "title": "Spanish verbs", "planned": false, "activity": null, "assessmentId": null, "doneAt": null, "planKind": null, "planCardIds": [], "startsAt": "2026-02-02T17:00:00.000Z", "endsAt": "2026-02-02T17:45:00.000Z", "date": "2026-02-02", "timeZone": "Europe/London", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "taskId": null, "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request` with `details.reason`: `planning-date-invalid` (in the past, shorter than 5 minutes, longer than 8 hours, or across midnight), `planning-clock-change`, or `planning-link-invalid` (a subject, task or set that isn't the user's). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason`: `planning-conflict` (overlaps a lesson or another block; read `getToday` or `getScheduleWeek` for free times), `planning-after-deadline` (ends after the linked task's due day), `planning-calendar-incomplete` (an external calendar couldn't be read, or the block is beyond next week while one is connected). The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Delete a study block Source: https://workroad.app/docs/api/delete-study-block `DELETE https://workroad.app/api/v1/study-blocks/{studyBlockId}` Removes a planned block from the user's calendar. - MCP tool: `deleteStudyBlock` on `https://workroad.app/api/mcp` - Required scopes: schedule:delete - Test mode: `DELETE https://workroad.app/api/test/v1/study-blocks/{studyBlockId}` checks the request and returns the example response without changing anything. ## Path parameters - `studyBlockId`: string (uuid), required. Study block id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study block with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List study blocks Source: https://workroad.app/docs/api/list-study-blocks `GET https://workroad.app/api/v1/study-blocks` Study time the user planned on their calendar, by start time, for a range of days in their time zone (today and the next 6 by default, at most 62 days). - MCP tool: `listStudyBlocks` on `https://workroad.app/api/mcp` - Required scopes: schedule:view - Test mode: `GET https://workroad.app/api/test/v1/study-blocks` checks the request and returns the example response without changing anything. ## Query parameters - `from`: string (date). First day (YYYY-MM-DD) in the user's time zone. Defaults to today. At most 62 days before `to`. - `to`: string (date). Last day (YYYY-MM-DD), inclusive. Defaults to 6 days after `from`. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `title`: string, required - `startsAt`: string (date-time), required. Start, in UTC. - `endsAt`: string (date-time), required. End, in UTC. - `date`: string, required. The day (YYYY-MM-DD) in the user's time zone. - `timeZone`: string, required. The time zone the block was planned in. - `subjectId`: string or null, required. The subject studied, when set. - `taskId`: string or null, required. The task worked on, when set. - `setId`: string or null, required. The study set practised, when set. - `planned`: boolean, required - `activity`: one of `"FLASHCARDS"`, `"MEMORY"`, `"MATCH"`, `"GUESS_THE_TERM"`, `"SENTENCE_SCRAMBLE"` or null, required - `assessmentId`: string or null, required - `doneAt`: string (date-time) or null, required - `planKind`: one of `"due"`, `"new"`, `"refresh"` or null, required - `planCardIds`: array of string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. ### Example ```json { "data": [ { "id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "title": "Spanish verbs", "planned": false, "activity": null, "assessmentId": null, "doneAt": null, "planKind": null, "planCardIds": [], "startsAt": "2026-02-02T17:00:00.000Z", "endsAt": "2026-02-02T17:45:00.000Z", "date": "2026-02-02", "timeZone": "Europe/London", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "taskId": null, "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_query`: `to` is before `from`, or the range is longer than 62 days. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update a study block Source: https://workroad.app/docs/api/update-study-block `PATCH https://workroad.app/api/v1/study-blocks/{studyBlockId}` Moves, renames or relinks a planned block. Fields you leave out keep their value, and the whole block is checked again as if it were new, so a block already in the past can't be edited. - MCP tool: `updateStudyBlock` on `https://workroad.app/api/mcp` - Required scopes: schedule:update - Test mode: `PATCH https://workroad.app/api/test/v1/study-blocks/{studyBlockId}` checks the request and returns the example response without changing anything. ## Path parameters - `studyBlockId`: string (uuid), required. Study block id, scoped to the key's user. ## Request body JSON. - `title`: string, 1-120 characters. What the user studies, 1-120 characters. - `startsAt`: string (date-time). New start, in the future. ISO timestamp with `Z` or an offset, such as `2026-10-21T16:00:00+01:00`. - `endsAt`: string (date-time). New end. ISO timestamp with `Z` or an offset, such as `2026-10-21T16:00:00+01:00`. - `subjectId`: string (uuid) or null. The subject studied. Null clears it. - `taskId`: string (uuid) or null. The task this block works on. The block must end by the task's due day. Null clears it. - `setId`: string (uuid) or null. The study set to practise in the block. Null clears it. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `title`: string, required - `startsAt`: string (date-time), required. Start, in UTC. - `endsAt`: string (date-time), required. End, in UTC. - `date`: string, required. The day (YYYY-MM-DD) in the user's time zone. - `timeZone`: string, required. The time zone the block was planned in. - `subjectId`: string or null, required. The subject studied, when set. - `taskId`: string or null, required. The task worked on, when set. - `setId`: string or null, required. The study set practised, when set. - `planned`: boolean, required - `activity`: one of `"FLASHCARDS"`, `"MEMORY"`, `"MATCH"`, `"GUESS_THE_TERM"`, `"SENTENCE_SCRAMBLE"` or null, required - `assessmentId`: string or null, required - `doneAt`: string (date-time) or null, required - `planKind`: one of `"due"`, `"new"`, `"refresh"` or null, required - `planCardIds`: array of string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. ### Example ```json { "data": { "id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "title": "Spanish verbs", "planned": false, "activity": null, "assessmentId": null, "doneAt": null, "planKind": null, "planCardIds": [], "startsAt": "2026-02-02T17:00:00.000Z", "endsAt": "2026-02-02T17:45:00.000Z", "date": "2026-02-02", "timeZone": "Europe/London", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "taskId": null, "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request` with `details.reason`: `planning-date-invalid` (in the past, shorter than 5 minutes, longer than 8 hours, or across midnight), `planning-clock-change`, or `planning-link-invalid` (a subject, task or set that isn't the user's). - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No study block with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `conflict` with `details.reason`: `planning-conflict` (overlaps a lesson or another block; read `getToday` or `getScheduleWeek` for free times), `planning-after-deadline` (ends after the linked task's due day), `planning-calendar-incomplete` (an external calendar couldn't be read, or the block is beyond next week while one is connected). - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a subject Source: https://workroad.app/docs/api/create-subject `POST https://workroad.app/api/v1/subjects` Fails on a name already in active use. - MCP tool: `createSubject` on `https://workroad.app/api/mcp` - Required scopes: subjects:create - Test mode: `POST https://workroad.app/api/test/v1/subjects` checks the request and returns the example response without changing anything. - Next: file tasks, study sets, note folders and lessons under it by passing its `id` as `subjectId`. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 1-64 characters - `teachers`: array of string, up to 20 items, default `[]`. Teacher names, in no particular order. - `period`: string or null, up to 60 characters. Free-text term or year label, e.g. `Year 12`. `null` clears it. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `teachers`: array of string, required - `period`: string or null, required. Free-text term or year label, or null. - `archived`: boolean, required. An archived subject keeps everything filed under it but is not offered for new items. ### Example ```json { "data": { "id": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "name": "Chemistry", "teachers": [ "Dr. Curie" ], "period": "Year 12", "archived": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or the name is already in active use. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Delete a subject Source: https://workroad.app/docs/api/delete-subject `DELETE https://workroad.app/api/v1/subjects/{subjectId}` Unfiles the subject from every task, note folder, study set and schedule element the key's user owns, then deletes it. Never touches another user's timetable. - MCP tool: `deleteSubject` on `https://workroad.app/api/mcp` - Required scopes: subjects:delete - Test mode: `DELETE https://workroad.app/api/test/v1/subjects/{subjectId}` checks the request and returns the example response without changing anything. ## Path parameters - `subjectId`: string (uuid), required. Subject id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No subject with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a subject Source: https://workroad.app/docs/api/get-subject `GET https://workroad.app/api/v1/subjects/{subjectId}` Returns one subject the key's user owns. - MCP tool: `getSubject` on `https://workroad.app/api/mcp` - Required scopes: subjects:view or tasks:view or schedule:view or notes:view or sets:view - Test mode: `GET https://workroad.app/api/test/v1/subjects/{subjectId}` checks the request and returns the example response without changing anything. ## Path parameters - `subjectId`: string (uuid), required. Subject id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `teachers`: array of string, required - `period`: string or null, required. Free-text term or year label, or null. - `archived`: boolean, required. An archived subject keeps everything filed under it but is not offered for new items. ### Example ```json { "data": { "id": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "name": "Chemistry", "teachers": [ "Dr. Curie" ], "period": "Year 12", "archived": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No subject with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List subjects Source: https://workroad.app/docs/api/list-subjects `GET https://workroad.app/api/v1/subjects` Returns every subject the user owns. Subjects are few and carry no creation timestamp, so this endpoint is not paginated. - MCP tool: `listSubjects` on `https://workroad.app/api/mcp` - Required scopes: subjects:view or tasks:view or schedule:view or notes:view or sets:view - Test mode: `GET https://workroad.app/api/test/v1/subjects` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `name`: string, required - `teachers`: array of string, required - `period`: string or null, required. Free-text term or year label, or null. - `archived`: boolean, required. An archived subject keeps everything filed under it but is not offered for new items. ### Example ```json { "data": [ { "id": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "name": "Chemistry", "teachers": [ "Dr. Curie" ], "period": "Year 12", "archived": false } ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update a subject Source: https://workroad.app/docs/api/update-subject `PATCH https://workroad.app/api/v1/subjects/{subjectId}` Changes only the fields you send. `archived` and any name, teachers or period change are checked and written together, against the subject's final state. - MCP tool: `updateSubject` on `https://workroad.app/api/mcp` - Required scopes: subjects:update - Test mode: `PATCH https://workroad.app/api/test/v1/subjects/{subjectId}` checks the request and returns the example response without changing anything. ## Path parameters - `subjectId`: string (uuid), required. Subject id, scoped to the key's user. ## Request body JSON. - `name`: string, 1-64 characters - `teachers`: array of string, up to 20 items. Replaces the full list when sent. - `period`: string or null, up to 60 characters. Free-text term or year label, e.g. `Year 12`. `null` clears it. - `archived`: boolean. Archives or restores the subject. Checked and written together with the other fields, against the subject's final state. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `teachers`: array of string, required - `period`: string or null, required. Free-text term or year label, or null. - `archived`: boolean, required. An archived subject keeps everything filed under it but is not offered for new items. ### Example ```json { "data": { "id": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "name": "Chemistry", "teachers": [ "Dr. Curie" ], "period": "Year 12", "archived": false } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, no field to update, or the name is already in active use. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No subject with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Add a subtask Source: https://workroad.app/docs/api/create-subtask `POST https://workroad.app/api/v1/tasks/{taskId}/subtasks` Adds a checklist item to a top-level task. The subtask takes the parent's type and subject. A task can have at most 50 subtasks, and subtasks cannot have their own. - MCP tool: `createSubtask` on `https://workroad.app/api/mcp` - Required scopes: tasks:create - Test mode: `POST https://workroad.app/api/test/v1/tasks/{taskId}/subtasks` checks the request and returns the example response without changing anything. ## Path parameters - `taskId`: string (uuid), required. Task id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 1-64 characters ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `type`: string, required. Free-form task type from the user's task settings. - `subjectId`: string or null, required. Subject id, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `completed`: boolean, required. Public name for the `finished` column. - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`, required. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. `none` when unset. - `parentId`: string or null, required. The parent task's id for a subtask, `null` for a top-level task. - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. ### Example ```json { "data": { "id": "8b7a6f5e-4d3c-4b2a-9f1e-0d9c8b7a6f5e", "name": "Plot the titration curve", "description": null, "type": "Generic", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "dueDate": "2026-02-10T17:00:00.000Z", "completed": false, "priority": "none", "parentId": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, or the parent already has 50 subtasks. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No top-level task with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a task Source: https://workroad.app/docs/api/create-task `POST https://workroad.app/api/v1/tasks` Creates a task for the key's user. `type` is the kind of work and must be one of the user's task types (`GET /task-types`); the course or module is the subject, sent as `subjectId` or `subjectName`. List the subjects and types first, and create a missing subject (or ask the user) rather than putting its name in another field. An exam or in-class test is type `Test`, with its day as `dueDate` and the topics and start time in the description. To add several tasks, send them in one `POST /tasks/batch`. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `createTask` on `https://workroad.app/api/mcp` - Required scopes: tasks:create - Test mode: `POST https://workroad.app/api/test/v1/tasks` checks the request and returns the example response without changing anything. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `name`: string, required, 2-200 characters - `description`: string, up to 512 characters. Plain text, up to 512 characters: the topics an exam covers, the time it starts, what to hand in. - `dueDate`: string (date) or string (date-time) or null. The day the task is due. `null` or omitted for no due date. Send the day as `YYYY-MM-DD`: it is read in the user's time zone (`timeZone` on `GET /me`) and stored as the start of that day, as the app's task form does. A timestamp with `Z` or an offset is accepted too, and the task is due on the day it falls on in that time zone. The app shows a due date as a day, never a time, so put a time such as an exam's start in the description. - Or string (date) - Or string (date-time) - `type`: string, 2-32 characters. The kind of work, one of the user's task types from `GET /task-types` (such as `Homework`, `Test`, `Project`, `Study` or `Read`), matched without regard to case. Never a course or module name: that is the subject, so send it as `subjectId` or `subjectName`. An exam or in-class test is `Test`. A type the user doesn't have is refused with 400 `unknown-task-type`, whose `details.taskTypes` lists theirs, unless `createType` is true. Defaults to `Generic` when omitted. - `createType`: boolean. Adds `type` to the user's task types when they don't have it, as adding one in the app's task settings does. Only send it when the user asked for a new kind of task, never for a course name. - `subjectId`: string (uuid). Subject id owned by the user. - `subjectName`: string, 1-64 characters. The subject (the course or module, such as `Software Engineering`) by name instead of by id. It must match one of the user's active subjects, ignoring case and accents. A name that matches none is refused with 400 `unknown-subject`, whose `details.subjects` lists the user's subjects, unless `createSubjectIfMissing` is true. Send `subjectId` or `subjectName`, not both. - `createSubjectIfMissing`: boolean. With `subjectName`, creates the subject when the user has none by that name. Needs the `subjects:create` scope. Only send it when the user named a course they haven't added yet. - `recurrence`: object. Repeats the task. Completing it creates the next occurrence `interval` units after the due date. Needs a due date. - `unit`: one of `"day"`, `"week"`, `"month"`, required - `interval`: integer, required, 1 to 30 - `endsAt`: string (date) or string (date-time) or null - Or string (date) - Or string (date-time) - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. Defaults to `none`. Only send it when the user said how urgent it is. ## Response `201` Created - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `type`: string, required. Free-form task type from the user's task settings. - `subjectId`: string or null, required. Subject id, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `completed`: boolean, required. Public name for the `finished` column. - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`, required. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. `none` when unset. - `parentId`: string or null, required. The parent task's id for a subtask, `null` for a top-level task. - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "name": "Finish lab report", "description": "Include the error analysis.", "type": "Generic", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "dueDate": "2026-02-10T17:00:00.000Z", "completed": false, "priority": "high", "parentId": null, "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: `invalid_request` for an invalid body or a recurrence without a due date. `details.reason` `unknown-task-type` lists the user's types in `details.taskTypes`, and `unknown-subject` lists their subjects in `details.subjects`; pick one, or send `createType` or `createSubjectIfMissing`. `too-many-task-types` when `createType` would pass the cap. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: `createSubjectIfMissing` was sent and the key lacks `subjects:create` (`details.missingScopes`). - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: An identical task already exists. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create several tasks Source: https://workroad.app/docs/api/create-tasks `POST https://workroad.app/api/v1/tasks/batch` Creates up to 50 tasks in one call, each with the fields `POST /tasks` takes, and answers 200 with a result per task in the order sent: `created` with the task, or `rejected` with the error `POST /tasks` would have returned for it. Each task is created or refused on its own, so fix the rejected ones and send only those again. A subject or task type the first task creates is there for the ones after it. `type` is the kind of work and must be one of the user's task types (`GET /task-types`); the course or module is the subject, sent as `subjectId` or `subjectName`. List the subjects and types first, and create a missing subject (or ask the user) rather than putting its name in another field. An exam or in-class test is type `Test`, with its day as `dueDate` and the topics and start time in the description. Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses. - MCP tool: `createTasks` on `https://workroad.app/api/mcp` - Required scopes: tasks:create - Test mode: `POST https://workroad.app/api/test/v1/tasks/batch` checks the request and returns the example response without changing anything. - Next: send the `rejected` tasks again after fixing what each `error` says. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `tasks`: array of objects, required, 1-50 items. 1-50 tasks, each with the fields `POST /tasks` takes. Each is created or refused on its own. - `name`: string, required, 2-200 characters - `description`: string, up to 512 characters. Plain text, up to 512 characters: the topics an exam covers, the time it starts, what to hand in. - `dueDate`: string (date) or string (date-time) or null. The day the task is due. `null` or omitted for no due date. Send the day as `YYYY-MM-DD`: it is read in the user's time zone (`timeZone` on `GET /me`) and stored as the start of that day, as the app's task form does. A timestamp with `Z` or an offset is accepted too, and the task is due on the day it falls on in that time zone. The app shows a due date as a day, never a time, so put a time such as an exam's start in the description. - Or string (date) - Or string (date-time) - `type`: string, 2-32 characters. The kind of work, one of the user's task types from `GET /task-types` (such as `Homework`, `Test`, `Project`, `Study` or `Read`), matched without regard to case. Never a course or module name: that is the subject, so send it as `subjectId` or `subjectName`. An exam or in-class test is `Test`. A type the user doesn't have is refused with 400 `unknown-task-type`, whose `details.taskTypes` lists theirs, unless `createType` is true. Defaults to `Generic` when omitted. - `createType`: boolean. Adds `type` to the user's task types when they don't have it, as adding one in the app's task settings does. Only send it when the user asked for a new kind of task, never for a course name. - `subjectId`: string (uuid). Subject id owned by the user. - `subjectName`: string, 1-64 characters. The subject (the course or module, such as `Software Engineering`) by name instead of by id. It must match one of the user's active subjects, ignoring case and accents. A name that matches none is refused with 400 `unknown-subject`, whose `details.subjects` lists the user's subjects, unless `createSubjectIfMissing` is true. Send `subjectId` or `subjectName`, not both. - `createSubjectIfMissing`: boolean. With `subjectName`, creates the subject when the user has none by that name. Needs the `subjects:create` scope. Only send it when the user named a course they haven't added yet. - `recurrence`: object. Repeats the task. Completing it creates the next occurrence `interval` units after the due date. Needs a due date. - `unit`: one of `"day"`, `"week"`, `"month"`, required - `interval`: integer, required, 1 to 30 - `endsAt`: string (date) or string (date-time) or null - Or string (date) - Or string (date-time) - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. Defaults to `none`. Only send it when the user said how urgent it is. ## Response `200` OK - `data`: object, required - `createdCount`: integer, required. How many tasks were created. - `rejectedCount`: integer, required. How many tasks were refused. - `results`: array of these objects, required. One result per task, in the order sent. - One of `status`: `"created"` - `index`: integer, required. The task's position in `tasks`. - `status`: `"created"`, required - `task`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `type`: string, required. Free-form task type from the user's task settings. - `subjectId`: string or null, required. Subject id, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `completed`: boolean, required. Public name for the `finished` column. - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`, required. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. `none` when unset. - `parentId`: string or null, required. The parent task's id for a subtask, `null` for a top-level task. - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. - Or `status`: `"rejected"` - `index`: integer, required. The task's position in `tasks`. - `status`: `"rejected"`, required - `error`: object, required - `status`: integer, required. The HTTP status the single-item call answers. - `code`: string, required. The error code, as in the error envelope. - `message`: string, required - `details`: any value. What to fix, such as `reason` and the user's `taskTypes` or `subjects`. ### Example ```json { "data": { "createdCount": 1, "rejectedCount": 1, "results": [ { "index": 0, "status": "created", "task": { "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "name": "Finish lab report", "description": "Include the error analysis.", "type": "Generic", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "dueDate": "2026-02-10T17:00:00.000Z", "completed": false, "priority": "high", "parentId": null, "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } }, { "index": 1, "status": "rejected", "error": { "status": 400, "code": "invalid_request", "message": "\"Software Engineering\" is not one of the user's task types: \"Generic\", \"Homework\", \"Test\". A task type is the kind of work; a course or module is the subject, so send it as subjectId or subjectName. An exam or test is \"Test\". To add a new type, send createType: true.", "details": { "field": "type", "reason": "unknown-task-type", "taskTypes": [ "Generic", "Homework", "Test" ] } } } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: The body is invalid as a whole. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Move a task to the trash Source: https://workroad.app/docs/api/delete-task `DELETE https://workroad.app/api/v1/tasks/{taskId}` Moves the task and its subtasks to the trash. A later occurrence of a recurring task is kept. `POST /tasks/{taskId}/restore` brings the task back with its subtasks. The trash is emptied after 30 days. - MCP tool: `deleteTask` on `https://workroad.app/api/mcp` - Required scopes: tasks:delete - Test mode: `DELETE https://workroad.app/api/test/v1/tasks/{taskId}` checks the request and returns the example response without changing anything. - Next: `restoreTask` brings it back from the trash. ## Path parameters - `taskId`: string (uuid), required. Task id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No task with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a task Source: https://workroad.app/docs/api/get-task `GET https://workroad.app/api/v1/tasks/{taskId}` Returns one task or subtask with its subtask count and recurrence rule. List a task's subtasks with `GET /tasks?parentId=`. - MCP tool: `getTask` on `https://workroad.app/api/mcp` - Required scopes: tasks:view - Test mode: `GET https://workroad.app/api/test/v1/tasks/{taskId}` checks the request and returns the example response without changing anything. ## Path parameters - `taskId`: string (uuid), required. Task id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `type`: string, required. Free-form task type from the user's task settings. - `subjectId`: string or null, required. Subject id, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `completed`: boolean, required. Public name for the `finished` column. - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`, required. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. `none` when unset. - `parentId`: string or null, required. The parent task's id for a subtask, `null` for a top-level task. - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. - `subtaskCount`: integer, required - `recurrence`: object or null, required. How the task repeats, or `null` when it does not. - `unit`: one of `"day"`, `"week"`, `"month"`, required - `interval`: integer, required - `endsAt`: string (date-time) or null, required. No occurrence is created after this time, when set. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "name": "Finish lab report", "description": "Include the error analysis.", "type": "Generic", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "dueDate": "2026-02-10T17:00:00.000Z", "completed": false, "priority": "high", "parentId": null, "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z", "subtaskCount": 2, "recurrence": { "unit": "week", "interval": 1, "endsAt": null } } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No task with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List task types Source: https://workroad.app/docs/api/list-task-types `GET https://workroad.app/api/v1/task-types` The task types from the user's task settings, in their configured order. Use one as a task's `type`. - MCP tool: `listTaskTypes` on `https://workroad.app/api/mcp` - Required scopes: tasks:view - Test mode: `GET https://workroad.app/api/test/v1/task-types` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: array of string, required ### Example ```json { "data": [ "Generic", "Homework", "Test" ] } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List tasks Source: https://workroad.app/docs/api/list-tasks `GET https://workroad.app/api/v1/tasks` Newest first, subtasks included. Filter with `completed`, `type`, `subjectId`, `parentId` and the due range `dueFrom` (inclusive) to `dueTo` (exclusive). A due range leaves out tasks without a due date. - MCP tool: `listTasks` on `https://workroad.app/api/mcp` - Required scopes: tasks:view - Test mode: `GET https://workroad.app/api/test/v1/tasks` checks the request and returns the example response without changing anything. ## Query parameters - `limit`: integer, 1 to 100, default `25`. Page size, 1-100. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. - `completed`: one of `"true"`, `"false"`. Filter by completion state. Omit for both. - `dueFrom`: string (date-time). Only tasks due at or after this ISO-8601 time. - `dueTo`: string (date-time). Only tasks due before this ISO-8601 time. - `type`: string, 1-32 characters. Only tasks of this type. - `subjectId`: string (uuid). Only tasks filed under this subject. - `parentId`: `"none"` or string (uuid). `none` for top-level tasks only, or a task id for that task's subtasks. - Or `"none"` - Or string (uuid) ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `type`: string, required. Free-form task type from the user's task settings. - `subjectId`: string or null, required. Subject id, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `completed`: boolean, required. Public name for the `finished` column. - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`, required. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. `none` when unset. - `parentId`: string or null, required. The parent task's id for a subtask, `null` for a top-level task. - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "name": "Finish lab report", "description": "Include the error analysis.", "type": "Generic", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "dueDate": "2026-02-10T17:00:00.000Z", "completed": false, "priority": "high", "parentId": null, "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Restore a task from the trash Source: https://workroad.app/docs/api/restore-task `POST https://workroad.app/api/v1/tasks/{taskId}/restore` Brings a trashed task back with the subtasks that went to the trash with it, and returns it. Restoring a subtask whose task is in the trash restores that task too. Needs `tasks:delete`, the scope that trashed it. - MCP tool: `restoreTask` on `https://workroad.app/api/mcp` - Required scopes: tasks:delete - Test mode: `POST https://workroad.app/api/test/v1/tasks/{taskId}/restore` checks the request and returns the example response without changing anything. ## Path parameters - `taskId`: string (uuid), required. Task id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `type`: string, required. Free-form task type from the user's task settings. - `subjectId`: string or null, required. Subject id, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `completed`: boolean, required. Public name for the `finished` column. - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`, required. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. `none` when unset. - `parentId`: string or null, required. The parent task's id for a subtask, `null` for a top-level task. - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "name": "Finish lab report", "description": "Include the error analysis.", "type": "Generic", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "dueDate": "2026-02-10T17:00:00.000Z", "completed": false, "priority": "high", "parentId": null, "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No task with that id is in the trash for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Update a task Source: https://workroad.app/docs/api/update-task `PATCH https://workroad.app/api/v1/tasks/{taskId}` Changes only the fields you send; `null` clears a nullable field. `completed` is applied first, and completing a recurring task creates its next occurrence from the task as it was before the other changes. Everything in one call succeeds or fails together. - MCP tool: `updateTask` on `https://workroad.app/api/mcp` - Required scopes: tasks:update - Test mode: `PATCH https://workroad.app/api/test/v1/tasks/{taskId}` checks the request and returns the example response without changing anything. ## Path parameters - `taskId`: string (uuid), required. Task id, scoped to the key's user. ## Request body JSON. - `completed`: boolean. Completes or reopens the task. Completing a recurring task creates its next occurrence. - `name`: string, 2-200 characters - `description`: string or null, up to 512 characters. Plain text. `null` or an empty string clears it. - `dueDate`: string (date) or string (date-time) or null. The day the task is due. `null` clears it and stops any recurrence. Send the day as `YYYY-MM-DD`: it is read in the user's time zone (`timeZone` on `GET /me`) and stored as the start of that day, as the app's task form does. A timestamp with `Z` or an offset is accepted too, and the task is due on the day it falls on in that time zone. The app shows a due date as a day, never a time, so put a time such as an exam's start in the description. - Or string (date) - Or string (date-time) - `type`: string, 2-32 characters. The kind of work, one of the user's task types from `GET /task-types` (such as `Homework`, `Test`, `Project`, `Study` or `Read`), matched without regard to case. Never a course or module name: that is the subject, so send it as `subjectId` or `subjectName`. An exam or in-class test is `Test`. A type the user doesn't have is refused with 400 `unknown-task-type`, whose `details.taskTypes` lists theirs, unless `createType` is true. - `createType`: boolean. Adds `type` to the user's task types when they don't have it, as adding one in the app's task settings does. Only send it when the user asked for a new kind of task, never for a course name. - `recurrence`: object or null. Repeats the task. `null` stops it. Needs a due date and only works on top-level tasks. - `unit`: one of `"day"`, `"week"`, `"month"`, required - `interval`: integer, required, 1 to 30 - `endsAt`: string (date) or string (date-time) or null - Or string (date) - Or string (date-time) - `subjectId`: string (uuid) or null. Subject id owned by the user. `null` unfiles the task. - `subjectName`: string, 1-64 characters. The subject (the course or module, such as `Software Engineering`) by name instead of by id. It must match one of the user's active subjects, ignoring case and accents. A name that matches none is refused with 400 `unknown-subject`, whose `details.subjects` lists the user's subjects, unless `createSubjectIfMissing` is true. Send `subjectId` or `subjectName`, not both. - `createSubjectIfMissing`: boolean. With `subjectName`, creates the subject when the user has none by that name. Needs the `subjects:create` scope. Only send it when the user named a course they haven't added yet. - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. `none` clears it. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `name`: string, required - `description`: string or null, required - `type`: string, required. Free-form task type from the user's task settings. - `subjectId`: string or null, required. Subject id, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `completed`: boolean, required. Public name for the `finished` column. - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`, required. How urgent the task is: `none`, `low`, `medium` or `high`, from least to most urgent. `none` when unset. - `parentId`: string or null, required. The parent task's id for a subtask, `null` for a top-level task. - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. ### Example ```json { "data": { "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "name": "Finish lab report", "description": "Include the error analysis.", "type": "Generic", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "dueDate": "2026-02-10T17:00:00.000Z", "completed": false, "priority": "high", "parentId": null, "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-01T12:00:00.000Z" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body, no field to update, a recurrence was set on a task with no due date, or a subtask was given a recurrence. `details.reason` `unknown-task-type` and `unknown-subject` list the user's types and subjects, as on `POST /tasks`. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No task with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get today's study overview Source: https://workroad.app/docs/api/get-today `GET https://workroad.app/api/v1/today` Answers "what should I study now?" in one call. Returns the flashcards due now by subject and set, today's goal, streak and new-card limit, overdue and upcoming tasks, today's lessons and planned study blocks, and `suggestions`: what to do next, most useful first, each naming the tool and arguments that act on it. Each section needs its own scope (`activity:view`, `tasks:view`, `schedule:view`) and is null without it. Lessons from external calendars are read live and can take a few seconds. - MCP tool: `getToday` on `https://workroad.app/api/mcp` - Required scopes: activity:view or tasks:view or schedule:view - Test mode: `GET https://workroad.app/api/test/v1/today` checks the request and returns the example response without changing anything. - Next: act on the first suggestion, for example `startPracticeSession` with its arguments. ## Response `200` OK - `data`: object, required - `date`: string, required. Today (YYYY-MM-DD) in the user's time zone. - `timeZone`: string, required. The user's time zone, or UTC when none is set. - `serverTime`: string (date-time), required. The server clock the view was computed at. - `study`: object or null, required. Flashcards, goal and streak. Null without `activity:view`. - `dueNow`: integer, required. Cards due now across every set. - `dueLaterToday`: integer, required. Cards that become due later in today's study day. - `newCards`: integer, required. Cards never studied. - `goal`: object, required - `kind`: one of `"cards"`, `"minutes"`, required. What the daily goal counts. - `target`: integer, required - `progress`: number, required. Cards answered or minutes studied. - `reached`: boolean, required - `streakDays`: integer, required. Days in a row the goal was reached. - `longestStreakDays`: integer, required - `limits`: object, required - `newRemaining`: integer, required. New cards the user may still start today. - `reviewsRemaining`: integer or null, required. Reviews left under the daily cap. Null when uncapped. - `studyDayEndsAt`: string (date-time), required. When today's study day ends (4am in the user's time zone) and the limits reset. - `subjects`: array of objects, required. Card counts per subject. - `subjectId`: string or null, required. Null for sets without a subject. - `name`: string, required - `dueNow`: integer, required - `overdue`: integer, required. Due before today's study day began. - `dueLaterToday`: integer, required - `newCards`: integer, required - `sets`: array of objects or null, required. The sets with the most cards due now, up to 10. Null without `sets:view`. - `setId`: string (uuid), required - `name`: string, required - `subjectId`: string or null, required. Subject id, when set. - `dueNow`: integer, required - `tasks`: object or null, required. Null without `tasks:view`. - `overdue`: array of objects, required. Unfinished tasks past due, oldest first, up to 5. - `id`: string (uuid), required - `name`: string, required - `type`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectName`: string or null, required. The subject's name, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `dueDay`: string or null, required. The due day (YYYY-MM-DD) in the user's time zone. - `exam`: boolean, required. True when the type or name reads like an exam, test or quiz. - `overdueTotal`: integer, required - `dueSoon`: array of objects, required. Unfinished tasks due today or in the next 6 days, up to 10. - `id`: string (uuid), required - `name`: string, required - `type`: string, required - `subjectId`: string or null, required. Subject id, when set. - `subjectName`: string or null, required. The subject's name, when set. - `dueDate`: string (date-time) or null, required. Due date, when set. - `dueDay`: string or null, required. The due day (YYYY-MM-DD) in the user's time zone. - `exam`: boolean, required. True when the type or name reads like an exam, test or quiz. - `dueSoonTotal`: integer, required - `lessons`: object or null, required. Today's lessons across active schedules. Null without `schedule:view`. - `occurrences`: array of objects, required - `scheduleId`: string (uuid), required - `date`: string, required. The day (YYYY-MM-DD). - `endDate`: string or null, required. The last day of an event that spans several days, or null. - `time`: one of the objects below, required. When the lesson runs, in the week's time zone. - One of `kind`: `"allDay"` - `kind`: `"allDay"`, required - Or `kind`: `"timed"` - `kind`: `"timed"`, required - `startMinute`: integer, required. Minutes after midnight. - `endMinute`: integer, required. Minutes after midnight. - Or `kind`: `"period"` - `kind`: `"period"`, required - `period`: integer, required. The period index. The period has no times. - `name`: string, required - `timeZone`: string or null, required. The event's own time zone, when an integration reports one. - `title`: string, required - `subtitle`: string or null, required. Room and teacher, when known. - `issues`: array of objects, required. External schedules that could not be read. - `scheduleId`: string (uuid), required - `issue`: one of `"unavailable"`, `"failed"`, `"timeout"`, required - `studyBlocks`: array of objects or null, required. Study blocks planned for today. Null without `schedule:view`. - `id`: string (uuid), required - `title`: string, required - `startsAt`: string (date-time), required. Start, in UTC. - `endsAt`: string (date-time), required. End, in UTC. - `date`: string, required. The day (YYYY-MM-DD) in the user's time zone. - `timeZone`: string, required. The time zone the block was planned in. - `subjectId`: string or null, required. The subject studied, when set. - `taskId`: string or null, required. The task worked on, when set. - `setId`: string or null, required. The study set practised, when set. - `planned`: boolean, required - `activity`: one of `"FLASHCARDS"`, `"MEMORY"`, `"MATCH"`, `"GUESS_THE_TERM"`, `"SENTENCE_SCRAMBLE"` or null, required - `assessmentId`: string or null, required - `doneAt`: string (date-time) or null, required - `planKind`: one of `"due"`, `"new"`, `"refresh"` or null, required - `planCardIds`: array of string, required - `createdAt`: string (date-time), required. Creation timestamp. - `updatedAt`: string (date-time), required. Last update timestamp. - `suggestions`: array of objects, required. What to do next, most useful first, built only from the sections this key may read. - `kind`: one of `"practice"`, `"learn"`, `"exam"`, `"overdue_task"`, `"lesson"`, `"plan"`, required. `practice`: flashcards are due. `learn`: nothing is due but new cards are left today. `exam`: an exam-like task is due within 7 days. `overdue_task`: an unfinished task is past due. `lesson`: the next lesson today. `plan`: nothing is planned today and tasks are due soon. - `message`: string, required. One sentence to show or say to the user. - `tool`: string or null, required. The operation (MCP tool) that acts on it, such as `startPracticeSession`. Only tools this key may call. Null when there is nothing to call. - `arguments`: any value, required. Arguments to pass to `tool`, as a JSON object. `{}` when it needs none. ### Example ```json { "data": { "date": "2026-02-02", "timeZone": "Europe/London", "serverTime": "2026-02-02T08:15:00.000Z", "study": { "dueNow": 12, "dueLaterToday": 3, "newCards": 40, "goal": { "kind": "cards", "target": 20, "progress": 4, "reached": false, "streakDays": 6, "longestStreakDays": 14 }, "limits": { "newRemaining": 46, "reviewsRemaining": null }, "studyDayEndsAt": "2026-02-03T04:00:00.000Z", "subjects": [ { "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "name": "Chemistry", "dueNow": 12, "overdue": 2, "dueLaterToday": 3, "newCards": 40 } ], "sets": [ { "setId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b", "name": "Spanish verbs", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "dueNow": 12 } ] }, "tasks": { "overdue": [], "overdueTotal": 0, "dueSoon": [ { "id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a", "name": "Finish lab report", "type": "Generic", "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "subjectName": "Chemistry", "dueDate": "2026-02-10T17:00:00.000Z", "dueDay": "2026-02-10", "exam": false } ], "dueSoonTotal": 1 }, "lessons": { "occurrences": [ { "scheduleId": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a", "date": "2026-02-02", "endDate": null, "time": { "kind": "timed", "startMinute": 540, "endMinute": 600 }, "timeZone": null, "title": "Biology", "subtitle": "S2 · Dr Patel" } ], "issues": [] }, "studyBlocks": [], "suggestions": [ { "kind": "practice", "message": "12 flashcards are due now, most of them in Chemistry.", "tool": "startPracticeSession", "arguments": { "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f" } }, { "kind": "lesson", "message": "Next lesson: Biology at 09:00.", "tool": null, "arguments": {} } ] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Create a webhook Source: https://workroad.app/docs/api/create-webhook `POST https://workroad.app/api/v1/webhooks` Registers an https URL to receive the events you list. The response carries the signing secret, and no other response does: store it now. Webhooks belong to the user, not the key, so they fire for jobs started anywhere, including the app. - Not available over MCP: call it over REST. - Required scopes: webhooks:manage - Test mode: `POST https://workroad.app/api/test/v1/webhooks` checks the request and returns the example response without changing anything. ## Request body JSON. - `url`: string, required, 1-2048 characters. An https URL on a public host, up to 2048 characters, with no username or password. `localhost`, `.local`, `.internal` and private IP addresses are refused. - `events`: array of one of `"job.succeeded"`, `"job.failed"`, `"job.cancelled"`, `"task.due_soon"`, required, 1-4 items. The events to send. Duplicates are ignored. ## Response `201` Created - `data`: object, required - `pausedAt`: string (date-time) or null, required. When manually paused. Null while accepting events. - `failureStreak`: integer, required, at least 0 - `id`: string (uuid), required - `url`: string, required - `events`: array of one of `"job.succeeded"`, `"job.failed"`, `"job.cancelled"`, `"task.due_soon"`, required - `createdAt`: string (date-time), required. When the webhook was created. - `status`: one of `"active"`, `"failing"`, `"disabled"`, required. `failing` once an event delivery has failed and none has succeeded since; failed tests never count. `disabled` once an event delivery fails and none has succeeded since a failure at least 72 hours earlier; a disabled webhook gets no new events. - `failingSince`: string (date-time) or null, required. When the first failed event-delivery attempt after the last success happened. - `disabledAt`: string (date-time) or null, required. When failures disabled the webhook. - `lastDeliveryAt`: string (date-time) or null, required. When the last successful delivery reached the URL. - `lastStatus`: integer or null, required. The HTTP status of the last successful delivery. Failed attempts leave it unchanged; each delivery's own `lastStatus` shows those. - `secret`: string, required. The signing secret, `whsec_` followed by base64. Shown only in this response. Verify the `webhook-signature` header with it, using any Standard Webhooks library. ### Example ```json { "data": { "pausedAt": null, "failureStreak": 0, "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b", "url": "https://hooks.example.com/workroad", "events": [ "job.succeeded", "job.failed" ], "createdAt": "2026-01-15T09:30:00.000Z", "status": "active", "failingSince": null, "disabledAt": null, "lastDeliveryAt": null, "lastStatus": null, "secret": "whsec_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid body; the URL is not https, carries credentials or points at a private host; or the request sent an Idempotency-Key. This operation refuses Idempotency-Key because a stored replay would keep the signing secret for 24 hours. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `details.reason` is `webhook-quota`: the user already has 5 webhooks. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Delete a webhook Source: https://workroad.app/docs/api/delete-webhook `DELETE https://workroad.app/api/v1/webhooks/{webhookId}` Deletes the webhook and its deliveries. Deliveries still queued are not sent. - MCP tool: `deleteWebhook` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `DELETE https://workroad.app/api/test/v1/webhooks/{webhookId}` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `deleted`: `true`, required ### Example ```json { "data": { "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b", "deleted": true } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No webhook with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Re-enable a webhook Source: https://workroad.app/docs/api/enable-webhook `POST https://workroad.app/api/v1/webhooks/{webhookId}/enable` Clears the auto-disable and the failing run, so the webhook receives new events again with the same signing secret. Events that arrived while it was disabled were never recorded and are not sent, and its `endpoint-disabled` deliveries stay failed. Calling it on a webhook that is not disabled changes nothing. Returns the webhook. - MCP tool: `enableWebhook` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `POST https://workroad.app/api/test/v1/webhooks/{webhookId}/enable` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `200` OK - `data`: object, required - `pausedAt`: string (date-time) or null, required. When manually paused. Null while accepting events. - `failureStreak`: integer, required, at least 0 - `id`: string (uuid), required - `url`: string, required - `events`: array of one of `"job.succeeded"`, `"job.failed"`, `"job.cancelled"`, `"task.due_soon"`, required - `createdAt`: string (date-time), required. When the webhook was created. - `status`: one of `"active"`, `"failing"`, `"disabled"`, required. `failing` once an event delivery has failed and none has succeeded since; failed tests never count. `disabled` once an event delivery fails and none has succeeded since a failure at least 72 hours earlier; a disabled webhook gets no new events. - `failingSince`: string (date-time) or null, required. When the first failed event-delivery attempt after the last success happened. - `disabledAt`: string (date-time) or null, required. When failures disabled the webhook. - `lastDeliveryAt`: string (date-time) or null, required. When the last successful delivery reached the URL. - `lastStatus`: integer or null, required. The HTTP status of the last successful delivery. Failed attempts leave it unchanged; each delivery's own `lastStatus` shows those. ### Example ```json { "data": { "pausedAt": null, "failureStreak": 0, "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b", "url": "https://hooks.example.com/workroad", "events": [ "job.succeeded", "job.failed" ], "createdAt": "2026-01-15T09:30:00.000Z", "status": "active", "failingSince": null, "disabledAt": null, "lastDeliveryAt": "2026-02-01T12:00:00.000Z", "lastStatus": 200 } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No webhook with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Get a webhook Source: https://workroad.app/docs/api/get-webhook `GET https://workroad.app/api/v1/webhooks/{webhookId}` Returns endpoint settings and health, without its signing secret. - MCP tool: `getWebhook` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `GET https://workroad.app/api/test/v1/webhooks/{webhookId}` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. ## Response `200` OK - `data`: object, required - `pausedAt`: string (date-time) or null, required. When manually paused. Null while accepting events. - `failureStreak`: integer, required, at least 0 - `id`: string (uuid), required - `url`: string, required - `events`: array of one of `"job.succeeded"`, `"job.failed"`, `"job.cancelled"`, `"task.due_soon"`, required - `createdAt`: string (date-time), required. When the webhook was created. - `status`: one of `"active"`, `"failing"`, `"disabled"`, required. `failing` once an event delivery has failed and none has succeeded since; failed tests never count. `disabled` once an event delivery fails and none has succeeded since a failure at least 72 hours earlier; a disabled webhook gets no new events. - `failingSince`: string (date-time) or null, required. When the first failed event-delivery attempt after the last success happened. - `disabledAt`: string (date-time) or null, required. When failures disabled the webhook. - `lastDeliveryAt`: string (date-time) or null, required. When the last successful delivery reached the URL. - `lastStatus`: integer or null, required. The HTTP status of the last successful delivery. Failed attempts leave it unchanged; each delivery's own `lastStatus` shows those. ### Example ```json { "data": { "pausedAt": null, "failureStreak": 0, "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b", "url": "https://hooks.example.com/workroad", "events": [ "job.succeeded", "job.failed" ], "createdAt": "2026-01-15T09:30:00.000Z", "status": "active", "failingSince": null, "disabledAt": null, "lastDeliveryAt": "2026-02-01T12:00:00.000Z", "lastStatus": 200 } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No resource with that id exists for this key's user - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Inspect a delivery Source: https://workroad.app/docs/api/get-webhook-delivery `GET https://workroad.app/api/v1/webhooks/{webhookId}/deliveries/{deliveryId}` Returns the event payload and up to 30 days of attempt diagnostics. Response bodies are withheld until relay egress blocks private addresses; responseBody is always null and responseBodyTruncated is always false. Requests include receiver headers only, never relay credentials. - MCP tool: `getWebhookDelivery` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `GET https://workroad.app/api/test/v1/webhooks/{webhookId}/deliveries/{deliveryId}` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. - `deliveryId`: string (uuid), required ## Response `200` OK - `data`: object, required - `id`: string (uuid), required. The queue delivery id. Resends retain the original event id and webhook-id header. - `eventType`: string, required. An event from the webhook's list, or `webhook.test`. - `status`: one of `"pending"`, `"succeeded"`, `"failed"`, required. `failed` after the last of 8 attempts, after a `webhook.test` delivery's only attempt, or when the webhook is disabled. - `attempts`: integer, required. Attempts made so far. - `lastStatus`: integer or null, required. The HTTP status of the last attempt. Null before the first, or when no response came. - `lastError`: string or null, required. Why the last attempt failed, such as `timeout` or `endpoint-disabled`. - `createdAt`: string (date-time), required. When the event was queued. - `nextAttemptAt`: string (date-time) or null, required. When the next attempt is due. Null unless pending. - `deliveredAt`: string (date-time) or null, required. When the URL accepted the delivery. - `messageId`: string, required - `resendOf`: string or null, required - `payload`: any value, required. The original event payload. - `diagnostics`: array of objects, required - `id`: string, required - `attempt`: integer, required - `relayRetry`: boolean, required - `url`: string, required - `requestHeaders`: any value, required. Receiver request headers, without relay authorization. - `requestBody`: string, required - `startedAt`: string, required - `finishedAt`: string or null, required - `responseStatus`: number or null, required - `responseBody`: string or null, required - `responseBodyTruncated`: boolean, required - `durationMs`: number or null, required - `error`: string or null, required ### Example ```json { "data": { "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "eventType": "job.succeeded", "status": "succeeded", "attempts": 1, "lastStatus": 200, "lastError": null, "createdAt": "2026-02-01T12:00:00.000Z", "nextAttemptAt": null, "deliveredAt": "2026-02-01T12:00:00.000Z", "messageId": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "resendOf": null, "payload": {}, "diagnostics": [] } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No resource with that id exists for this key's user - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List a webhook's deliveries Source: https://workroad.app/docs/api/list-webhook-deliveries `GET https://workroad.app/api/v1/webhooks/{webhookId}/deliveries` Newest first, with each delivery's status and last attempt. Deliveries are kept until the webhook is deleted. - MCP tool: `listWebhookDeliveries` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `GET https://workroad.app/api/test/v1/webhooks/{webhookId}/deliveries` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. ## Query parameters - `limit`: integer, 1 to 100, default `25`. Page size, 1-100. - `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged. ## Response `200` OK - `data`: array of objects, required - `id`: string (uuid), required. The queue delivery id. Resends retain the original event id and webhook-id header. - `eventType`: string, required. An event from the webhook's list, or `webhook.test`. - `status`: one of `"pending"`, `"succeeded"`, `"failed"`, required. `failed` after the last of 8 attempts, after a `webhook.test` delivery's only attempt, or when the webhook is disabled. - `attempts`: integer, required. Attempts made so far. - `lastStatus`: integer or null, required. The HTTP status of the last attempt. Null before the first, or when no response came. - `lastError`: string or null, required. Why the last attempt failed, such as `timeout` or `endpoint-disabled`. - `createdAt`: string (date-time), required. When the event was queued. - `nextAttemptAt`: string (date-time) or null, required. When the next attempt is due. Null unless pending. - `deliveredAt`: string (date-time) or null, required. When the URL accepted the delivery. - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "eventType": "job.succeeded", "status": "succeeded", "attempts": 1, "lastStatus": 200, "lastError": null, "createdAt": "2026-02-01T12:00:00.000Z", "nextAttemptAt": null, "deliveredAt": "2026-02-01T12:00:00.000Z" } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No webhook with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # List webhooks Source: https://workroad.app/docs/api/list-webhooks `GET https://workroad.app/api/v1/webhooks` Returns the user's webhooks, oldest first. A user has at most 5. Secrets are never listed. The list is never paged, so nextCursor is always null. - MCP tool: `listWebhooks` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `GET https://workroad.app/api/test/v1/webhooks` checks the request and returns the example response without changing anything. ## Response `200` OK - `data`: array of objects, required - `pausedAt`: string (date-time) or null, required. When manually paused. Null while accepting events. - `failureStreak`: integer, required, at least 0 - `id`: string (uuid), required - `url`: string, required - `events`: array of one of `"job.succeeded"`, `"job.failed"`, `"job.cancelled"`, `"task.due_soon"`, required - `createdAt`: string (date-time), required. When the webhook was created. - `status`: one of `"active"`, `"failing"`, `"disabled"`, required. `failing` once an event delivery has failed and none has succeeded since; failed tests never count. `disabled` once an event delivery fails and none has succeeded since a failure at least 72 hours earlier; a disabled webhook gets no new events. - `failingSince`: string (date-time) or null, required. When the first failed event-delivery attempt after the last success happened. - `disabledAt`: string (date-time) or null, required. When failures disabled the webhook. - `lastDeliveryAt`: string (date-time) or null, required. When the last successful delivery reached the URL. - `lastStatus`: integer or null, required. The HTTP status of the last successful delivery. Failed attempts leave it unchanged; each delivery's own `lastStatus` shows those. - `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page. ### Example ```json { "data": [ { "pausedAt": null, "failureStreak": 0, "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b", "url": "https://hooks.example.com/workroad", "events": [ "job.succeeded", "job.failed" ], "createdAt": "2026-01-15T09:30:00.000Z", "status": "active", "failingSince": null, "disabledAt": null, "lastDeliveryAt": "2026-02-01T12:00:00.000Z", "lastStatus": 200 } ], "nextCursor": null } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No endpoint exists at this path. - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Pause or resume a webhook Source: https://workroad.app/docs/api/pause-webhook `POST https://workroad.app/api/v1/webhooks/{webhookId}/pause` Set paused true to pause or false to resume. Paused endpoints collect no new events. Queued deliveries wait until resume; manual tests remain available. A request already accepted by the relay may finish. This does not clear an automatic disable. - MCP tool: `pauseWebhook` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `POST https://workroad.app/api/test/v1/webhooks/{webhookId}/pause` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Request body JSON. - `paused`: boolean, required ## Response `200` OK - `data`: object, required - `pausedAt`: string (date-time) or null, required. When manually paused. Null while accepting events. - `failureStreak`: integer, required, at least 0 - `id`: string (uuid), required - `url`: string, required - `events`: array of one of `"job.succeeded"`, `"job.failed"`, `"job.cancelled"`, `"task.due_soon"`, required - `createdAt`: string (date-time), required. When the webhook was created. - `status`: one of `"active"`, `"failing"`, `"disabled"`, required. `failing` once an event delivery has failed and none has succeeded since; failed tests never count. `disabled` once an event delivery fails and none has succeeded since a failure at least 72 hours earlier; a disabled webhook gets no new events. - `failingSince`: string (date-time) or null, required. When the first failed event-delivery attempt after the last success happened. - `disabledAt`: string (date-time) or null, required. When failures disabled the webhook. - `lastDeliveryAt`: string (date-time) or null, required. When the last successful delivery reached the URL. - `lastStatus`: integer or null, required. The HTTP status of the last successful delivery. Failed attempts leave it unchanged; each delivery's own `lastStatus` shows those. ### Example ```json { "data": { "pausedAt": "2026-10-01T12:00:00.000Z", "failureStreak": 0, "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b", "url": "https://hooks.example.com/workroad", "events": [ "job.succeeded", "job.failed" ], "createdAt": "2026-01-15T09:30:00.000Z", "status": "active", "failingSince": null, "disabledAt": null, "lastDeliveryAt": "2026-02-01T12:00:00.000Z", "lastStatus": 200 } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No resource with that id exists for this key's user - `405`: Method not allowed. Use a method from the Allow header. - `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Resend a delivery Source: https://workroad.app/docs/api/resend-webhook `POST https://workroad.app/api/v1/webhooks/{webhookId}/deliveries/{deliveryId}/resend` Queues a new delivery with the original event payload and webhook-id, using the current URL and signing secret. Receivers may deduplicate an event they already processed. Refuses pending deliveries and paused or auto-disabled endpoints. An endpoint may have at most five pending resends. Resend requests share a per-user limit of ten per minute across the web app and REST API. - MCP tool: `resendWebhook` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `POST https://workroad.app/api/test/v1/webhooks/{webhookId}/deliveries/{deliveryId}/resend` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. - `deliveryId`: string (uuid), required ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `202` Accepted - `data`: object, required - `id`: string (uuid), required. The queue delivery id. Resends retain the original event id and webhook-id header. - `eventType`: string, required. An event from the webhook's list, or `webhook.test`. - `status`: one of `"pending"`, `"succeeded"`, `"failed"`, required. `failed` after the last of 8 attempts, after a `webhook.test` delivery's only attempt, or when the webhook is disabled. - `attempts`: integer, required. Attempts made so far. - `lastStatus`: integer or null, required. The HTTP status of the last attempt. Null before the first, or when no response came. - `lastError`: string or null, required. Why the last attempt failed, such as `timeout` or `endpoint-disabled`. - `createdAt`: string (date-time), required. When the event was queued. - `nextAttemptAt`: string (date-time) or null, required. When the next attempt is due. Null unless pending. - `deliveredAt`: string (date-time) or null, required. When the URL accepted the delivery. ### Example ```json { "data": { "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "eventType": "webhook.test", "status": "pending", "attempts": 0, "lastStatus": null, "lastError": null, "createdAt": "2026-02-01T12:00:00.000Z", "nextAttemptAt": "2026-02-01T12:00:00.000Z", "deliveredAt": null } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No resource with that id exists for this key's user - `405`: Method not allowed. Use a method from the Allow header. - `409`: The event is already pending, the endpoint is inactive, or details.reason is webhook-resend-pending because five resends are pending. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: The per-user resend rate limit has been reached. Retry after the returned delay. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Rotate a webhook's secret Source: https://workroad.app/docs/api/rotate-webhook-secret `POST https://workroad.app/api/v1/webhooks/{webhookId}/rotate` Replaces the signing secret and returns the new one, once. For 24 hours the old secret keeps working: each delivery then carries two signatures in `webhook-signature`, and a Standard Webhooks library accepts either secret. Send `previousSecretExpiresInSeconds` (0 to 86400) to shorten that, or 0 to revoke the old secret at once. Rotating again inside the period ends the earlier old secret immediately, and only the newest previous secret is kept. Retries of earlier events are signed the same way. - Not available over MCP: call it over REST. - Required scopes: webhooks:manage - Test mode: `POST https://workroad.app/api/test/v1/webhooks/{webhookId}/rotate` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. ## Request body JSON. - `previousSecretExpiresInSeconds`: integer, 0 to 86400. How long the old secret keeps signing deliveries after the rotation, from 0 to 86400 seconds (24 hours). Defaults to 86400. Send 0 to revoke the old secret at once, for example when it leaked. ## Response `200` OK - `data`: object, required - `id`: string (uuid), required - `secret`: string, required. The signing secret, `whsec_` followed by base64. Shown only in this response. Verify the `webhook-signature` header with it, using any Standard Webhooks library. ### Example ```json { "data": { "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b", "secret": "whsec_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: The request sent an Idempotency-Key. This operation refuses Idempotency-Key because a stored replay would keep the signing secret for 24 hours. - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No webhook with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `details.reason` is `concurrent-rotation`: other rotations kept winning. Retry. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Send a test event Source: https://workroad.app/docs/api/test-webhook `POST https://workroad.app/api/v1/webhooks/{webhookId}/test` Queues a `webhook.test` event for this webhook, whatever events it subscribes to, and returns the delivery. It goes out within about a minute, signed like any other event, and is attempted once: a test that fails is not retried and does not count toward disabling the webhook. Read its outcome with `GET /webhooks/{webhookId}/deliveries`. A webhook has at most one pending test; send the next once that one's attempt finishes. - MCP tool: `testWebhook` on `https://workroad.app/api/mcp` - Required scopes: webhooks:manage - Test mode: `POST https://workroad.app/api/test/v1/webhooks/{webhookId}/test` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. ## Headers - `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument. ## Response `202` Accepted - `data`: object, required - `id`: string (uuid), required. The queue delivery id. Resends retain the original event id and webhook-id header. - `eventType`: string, required. An event from the webhook's list, or `webhook.test`. - `status`: one of `"pending"`, `"succeeded"`, `"failed"`, required. `failed` after the last of 8 attempts, after a `webhook.test` delivery's only attempt, or when the webhook is disabled. - `attempts`: integer, required. Attempts made so far. - `lastStatus`: integer or null, required. The HTTP status of the last attempt. Null before the first, or when no response came. - `lastError`: string or null, required. Why the last attempt failed, such as `timeout` or `endpoint-disabled`. - `createdAt`: string (date-time), required. When the event was queued. - `nextAttemptAt`: string (date-time) or null, required. When the next attempt is due. Null unless pending. - `deliveredAt`: string (date-time) or null, required. When the URL accepted the delivery. ### Example ```json { "data": { "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "eventType": "webhook.test", "status": "pending", "attempts": 0, "lastStatus": null, "lastError": null, "createdAt": "2026-02-01T12:00:00.000Z", "nextAttemptAt": "2026-02-01T12:00:00.000Z", "deliveredAt": null } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No webhook with that id exists for this key's user. - `405`: Method not allowed. Use a method from the Allow header. - `409`: `details.reason` is `webhook-test-pending`: an earlier test delivery to this webhook has not finished its attempt. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error --- # Edit a webhook Source: https://workroad.app/docs/api/update-webhook `PATCH https://workroad.app/api/v1/webhooks/{webhookId}` Updates the URL and subscribed events without changing its signing secret. The URL must resolve to a public HTTPS host. Changes apply to subsequent attempts. - Not available over MCP: call it over REST. - Required scopes: webhooks:manage - Test mode: `PATCH https://workroad.app/api/test/v1/webhooks/{webhookId}` checks the request and returns the example response without changing anything. ## Path parameters - `webhookId`: string (uuid), required. Webhook id, scoped to the key's user. ## Request body JSON. - `url`: string, required, 1-2048 characters. An https URL on a public host, up to 2048 characters, with no username or password. `localhost`, `.local`, `.internal` and private IP addresses are refused. - `events`: array of one of `"job.succeeded"`, `"job.failed"`, `"job.cancelled"`, `"task.due_soon"`, required, 1-4 items. The events to send. Duplicates are ignored. ## Response `200` OK - `data`: object, required - `pausedAt`: string (date-time) or null, required. When manually paused. Null while accepting events. - `failureStreak`: integer, required, at least 0 - `id`: string (uuid), required - `url`: string, required - `events`: array of one of `"job.succeeded"`, `"job.failed"`, `"job.cancelled"`, `"task.due_soon"`, required - `createdAt`: string (date-time), required. When the webhook was created. - `status`: one of `"active"`, `"failing"`, `"disabled"`, required. `failing` once an event delivery has failed and none has succeeded since; failed tests never count. `disabled` once an event delivery fails and none has succeeded since a failure at least 72 hours earlier; a disabled webhook gets no new events. - `failingSince`: string (date-time) or null, required. When the first failed event-delivery attempt after the last success happened. - `disabledAt`: string (date-time) or null, required. When failures disabled the webhook. - `lastDeliveryAt`: string (date-time) or null, required. When the last successful delivery reached the URL. - `lastStatus`: integer or null, required. The HTTP status of the last successful delivery. Failed attempts leave it unchanged; each delivery's own `lastStatus` shows those. ### Example ```json { "data": { "pausedAt": null, "failureStreak": 0, "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b", "url": "https://hooks.example.com/workroad", "events": [ "job.succeeded", "job.failed" ], "createdAt": "2026-01-15T09:30:00.000Z", "status": "active", "failingSince": null, "disabledAt": null, "lastDeliveryAt": "2026-02-01T12:00:00.000Z", "lastStatus": 200 } } ``` ## Errors Every error has the body `{ "error": { "code", "message", "details" } }`. - `400`: Invalid request body or query - `401`: Missing, invalid, disabled or expired API key - `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit. - `403`: The API key lacks the required scope - `404`: No resource with that id exists for this key's user - `405`: Method not allowed. Use a method from the Allow header. - `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran. - `500`: Internal server error