# Reviews and practice

> Review one set's due cards, or run a practice session across every set, with the same scheduling as the app.

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

Two ways to study over the API, both scheduled by FSRS exactly as in the app. A [practice session](#practice-sessions) is the app's Review page: one queue across many sets. [Reviewing one set](#reviewing-flashcards) works through a single set's queue.

## 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).

## Reviewing one set

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.

## Get the flashcard 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

## Grade a flashcard

`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

## Start a 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

## Get a practice session's next cards

`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

## Answer a 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

## Undo a 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
