Study sets and practice
Manage cards, record reviews, run practice sessions and take practice tests.
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:
- Fetch
GET /sets/{setId}/review-queue.cardsholds what is due now: learning and relearning cards, then reviews, then new cards. - Show a card, then send
POST /reviewswith a freshreviewId(a UUID you generate before the first attempt), thecardId, therating(again,hard,goodoreasy) anddurationMs. - 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.
- When your copy runs out, fetch the queue again. The server refills it.
- When
finishedis true, nothing is due. IfnextDueAtis 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.
POST /practice-sessionsstarts one. SendsubjectIdorsetIdsto narrow it, or nothing for every set. The response already holds the firstcards, up to 30: learning and relearning first, then reviews, then new cards, taking turns between sets.- Show a card, then send
POST /practice-sessions/{sessionId}/answerswith itsitemId, arating(again,hard,goodoreasy) anddurationMs.remainingin the response counts the cards from the last read still waiting. - When
remainingis 0,GET /practice-sessions/{sessionId}refills the session. Reading it again before then returns the same cards, so it is safe to repeat. finished: truemeans nothing is due and today's new cards are spent. The session stays open for 7 days; read it again atnextDueAt.
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). GET /sets/{setId}/review-queue and POST /reviews still review one set at a time, as described in 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.
POST /testswith a uuidrequestId, aname,sourceSetIdsorsourceNoteIds, the questionkinds(MCQ,TRUE_FALSE,SHORTorFILL_BLANK), alengthof 1-50 and an optionaltimerSecs. It answers 202 with thetestIdand the generation'sjobId. Mint therequestIdonce 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 usedrequestIdwith different sources,kinds,length,timerSecsormodelTierreturns 409idempotency_conflict.nameis 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 notestId.- Poll
GET /jobs/{jobId}orGET /tests/{testId}until the test'sstatusisready, waiting the seconds inRetry-Afterbetween job polls (3 for a test). A test that failed has the reason inerror. POST /tests/{testId}/attemptsstarts an attempt and returns itsattemptId. A test has one open attempt at a time. If the user already has one open, you get it back with status 200 andresumed: true. A test that isn't ready returns 409 withdetails.reasontest-not-ready.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".PUT /attempts/{attemptId}/answerssaves the answers so far, replacing what was saved before. Past the time limit it stores nothing and returnsexpired: true. A key must be a question index without leading zeros, and a key past the last question returns 400 withdetails.reasonunknown-question.POST /attempts/{attemptId}/submitends the attempt, optionally with finalanswers. Submitting twice changes nothing and returnsalreadySubmitted: true.
{
"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.