> ## Documentation Index
> Fetch the complete documentation index at: https://docs.edpire.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Attempt History: Cards, Retry and Review

> Show each assessment as a card with the learner's score, let them retry, and let them reopen any past attempt as the corrected paper. What to store, what to fetch, and which endpoints to call.

A learner opens your app and sees their assessments as cards. A card they have never opened says **Start**. A card they have finished shows their score, and offers **Retry** and **Review**. Review reopens any past attempt exactly as they saw it on submitting: their answers, locked, each one marked, with the expected answer on anything they missed, the AI's comment on any AI-graded answer, and their teacher's comments.

This page is the recommended way to build that. A complete, runnable version is in the [Angular example](https://github.com/youssefalmia/edpire-angular-example): the cards, the history, the review, retry and the webhook, in about 300 lines of server code.

## The whole design in two sentences

<Info>
  **Store the numbers. Fetch the paper.**

  Keep one small table of attempts (who, which assessment, the score) in your own database, and build every card from it. When a learner opens a past attempt, ask Edpire for that one attempt and hand it to the SDK.
</Info>

Everything below is detail.

## Who keeps what

|                                                                                 | Kept by                            | Why                                                                                      |
| ------------------------------------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------- |
| Your catalogue: which assessments appear, their order, price, lock state, level | **You**                            | It is your product. Each row carries one `edpire_assessment_id`                          |
| Who the learner is                                                              | **You**                            | Sent to Edpire as `learner_ref`, your own user ID                                        |
| One row per attempt: score, date, attempt number                                | **You, copied from Edpire**        | So a page of cards costs one query on your database, not one API call per card           |
| Questions, answer keys, grading                                                 | **Edpire**                         | Never copy these                                                                         |
| The learner's answers and the corrections for each attempt                      | **Edpire**                         | Large, read one attempt at a time, and they change when a teacher marks. Fetch on demand |
| Attempt limits                                                                  | **Edpire** (the teacher sets them) | Edpire enforces the limit. You read it to hide a Retry button that would fail            |

The rule behind the table: copy what you **list**, fetch what you **open**. A catalogue page lists many assessments at once, so it must not depend on another service. A corrected paper is opened one at a time, by a learner who has just clicked, so one call is fine.

## The one table you add

```sql theme={null}
CREATE TABLE edpire_attempts (
  submission_id   UUID PRIMARY KEY,        -- Edpire's ID, and your idempotency key
  user_id         <your user PK> NOT NULL, -- the learner_ref you send
  assessment_id   UUID NOT NULL,
  attempt_number  INT NOT NULL,
  score           NUMERIC NOT NULL,
  max_score       NUMERIC NOT NULL,
  percentage      INT NOT NULL,
  passed          BOOLEAN,
  is_fully_graded BOOLEAN NOT NULL,        -- false = a teacher still has to mark
  submitted_at    TIMESTAMPTZ NOT NULL,
  synced_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON edpire_attempts (user_id, assessment_id, submitted_at DESC);
```

Every write is an upsert on `submission_id`, so writing the same attempt twice is harmless. That matters, because it will be written more than once.

## Keeping it up to date

Write the table from **one function**, and call it from three places.

```typescript theme={null}
// Fetch one attempt from Edpire and store it. Never trust the caller's numbers.
async function recordAttempt(submissionId: string, expectedLearner?: string) {
  const s = await edpire.getSubmission(submissionId)
  if (expectedLearner && s.learner_ref !== expectedLearner) throw new NotFound()
  if (!s.submitted_at) return
  await db.edpireAttempts.upsert({
    submission_id: s.id,
    user_id: s.learner_ref,
    assessment_id: s.assessment_id,
    attempt_number: s.attempt_number,
    score: s.score,
    max_score: s.max_score,
    percentage: s.percentage,
    passed: s.passed,
    is_fully_graded: s.is_fully_graded,
    submitted_at: s.submitted_at,
  })
}
```

| Caller                  | When                                                            | Why you need it                                                                          |
| ----------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Your app, on finish** | The player's `onComplete` sends `submission_id` to your backend | Fast. The card is already correct when the learner clicks back                           |
| **The webhook**         | `submission.graded` and `submission.grading.completed`          | Reliable. Covers a closed tab, and it is the only one that sees a teacher finish marking |
| **A backfill**          | Once per learner, or a one-off script on launch day             | Repair. Picks up attempts made before this code shipped, or a webhook you missed         |

Each caller passes only a submission ID, and `recordAttempt` reads the facts from Edpire with your API key. The browser path also passes the signed-in learner, so nobody can claim someone else's attempt by posting its ID.

```typescript theme={null}
// 1. Your app, on finish (the browser sends only the ID)
app.post("/api/attempts", async (req) => {
  await recordAttempt(req.body.submission_id, session.user.id)
})

// 2. The webhook (verify the signature first: see Webhooks)
app.post("/webhooks/edpire", async (req) => {
  const event = req.headers["x-edpire-event"]
  if (event === "submission.graded" || event === "submission.grading.completed") {
    await recordAttempt(JSON.parse(rawBody).submission_id)
  }
})

// 3. A backfill
const { items } = await edpire.getLearnerResults(userId, { limit: 100 })
for (const s of items) if (s.submitted_at) await upsertFrom(s)
```

<Tip>
  Re-read the attempt in the webhook rather than storing the payload's numbers. Deliveries can arrive late and out of order, and a fresh read is always current.
</Tip>

## The cards

A card's state is plain code over your own rows:

```typescript theme={null}
function cardFor(assessment, attempts /* newest first */) {
  const allowed = assessment.allowedAttempts            // 0 = unlimited
  const attemptsLeft = allowed === 0 ? null : Math.max(0, allowed - attempts.length)
  const latest = attempts[0]

  if (!latest) return { state: "new", canRetry: true }  // Start
  return {
    state: latest.is_fully_graded ? "done" : "grading", // Review (+ score when done)
    latest,
    canRetry: attemptsLeft !== 0,                       // Retry
    attemptsLeft,
  }
}
```

| State     | Show                                  | Buttons                                             |
| --------- | ------------------------------------- | --------------------------------------------------- |
| `new`     | Not started, points, attempts allowed | **Start**                                           |
| `done`    | The score                             | **Review**, **Retry** (unless no attempts are left) |
| `grading` | "Awaiting teacher", no score          | **Review**                                          |

Compute the state on your server and send it to every client, so your web app, your mobile app and a weekly email never disagree.

<Warning>
  **Never show a score while `is_fully_graded` is false.** An assessment with open responses is only partly marked at submit time, and its score covers only the automatic questions. Show it as final and you will be taking it back when the teacher marks.
</Warning>

## Retry

There is no retry endpoint. A retry is a new attempt: mint a token and mount the player exactly as the first time. Edpire numbers the attempt and enforces the assessment's limit, returning `MAX_ATTEMPTS_REACHED` if none are left.

## Review a past attempt

Your server, after checking ownership:

```typescript theme={null}
app.get("/api/attempts/:id/review", async (req) => {
  // Yours first: is this attempt in this learner's rows?
  if (!(await db.edpireAttempts.exists({ submission_id: req.params.id, user_id: session.user.id }))) {
    return notFound()
  }
  // Pass learnerRef too: a bug in the check above then yields a 404, not someone else's paper
  return edpire.getSubmissionReview(req.params.id, { learnerRef: session.user.id })
})
```

Your page:

```javascript theme={null}
const review = await fetch(`/api/attempts/${submissionId}/review`).then((r) => r.json())
EdpireSDK.EdpireAssessment.review({ review, container: "#paper" })
```

Pass the response through unchanged. The paper is read-only, with no submit button. There is no token, and the browser makes no call to Edpire, so the [origin allow-list](/developer/security) does not apply to it.

The history list (Attempt 1, Attempt 2 and so on) comes from your own table. Only the paper being looked at is fetched.

### Or one question at a time

For a flow that shows one question per screen, give the review the same shape. The same response, turned into steps:

```javascript theme={null}
const steps = EdpireSDK.flattenReview(review)
EdpireSDK.renderQuestion({
  container: "#question",
  content: steps[i].content,
  initialAnswers: steps[i].answers,
  feedback: steps[i].feedback,
  readOnly: true,
})
```

Each step also carries its `score`, `maxScore`, a `status` for your verdict line, and the exercise's reading passage. Previous, Next and the progress dots are yours to draw. Both views work for any attempt, whichever way it was taken. Details in [Custom Flow](/developer/sdk/custom-flow#6-review-a-past-attempt-one-question-at-a-time).

<Tip>
  If your flow shuffles questions, keep the order you showed them in. Steps come back in the assessment's order, and Edpire does not record the order an attempt was presented in.
</Tip>

| Endpoint                                                                                                | Scope          | Returns                                                                                                           |
| ------------------------------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------- |
| [`GET /submissions/{id}/review`](/api-reference/submissions/get-submission-review)                      | `read:results` | Content, the learner's answers, per-node marking, teacher comments. For the whole paper or one question at a time |
| [`GET /learners/{id}/results?learner_ref=&assessment_id=`](/api-reference/learners/get-learner-results) | `read:results` | Attempt summaries, for the backfill                                                                               |
| [`GET /submissions/{id}`](/api-reference/submissions/get-submission)                                    | `read:results` | One attempt's score, for `recordAttempt`                                                                          |

Your API key needs `read:results` on top of the scopes you already use.

## Two product decisions that are yours

**Which score goes on the card.** The latest attempt shows where the learner is now. The best attempt rewards effort. The example shows the latest as the headline and the best beside it when it differs. Both are one line in `cardFor`.

**Retrying after seeing the corrections.** Review shows the expected answers. A learner can read them and retry for full marks. That is fine for practice. For anything that counts, cap attempts in Edpire, or show and record the **first** attempt's score.

## Good to know

* **Review needs SDK 0.8.0** or later, for `EdpireAssessment.review()`, `flattenReview()` and `readOnly`.
* **A question worth 0 points** (usually one published with nothing to answer) has status `unscored`. Show it as neutral, not wrong. In a one-question-at-a-time flow, let the learner move past it: a Check button that waits for an answer will never enable.
* **The paper uses the current version of the assessment.** Answers and marks are keyed by question, so a republish keeps them attached. A question added since shows as unanswered.
* **An unpublished assessment cannot be reviewed.** The endpoint returns 409. The scores in your table are unaffected.
* **Attempts from before September 2026 made through the SDK or the REST submit** were stored without per-question detail and return 409. Their scores are intact. Attempts made from edpire.com links are not affected.

## Checklist

<Steps>
  <Step title="Create the table">
    `edpire_attempts`, keyed on `submission_id`.
  </Step>

  <Step title="Write recordAttempt">
    One function. It reads from Edpire and upserts.
  </Step>

  <Step title="Call it three ways">
    From the player's `onComplete` (via your backend), from the webhook, and once as a backfill.
  </Step>

  <Step title="Build the cards from your table">
    `new`, `done`, `grading`. No Edpire call per card.
  </Step>

  <Step title="Add the review route">
    Check ownership, call `getSubmissionReview` with `learnerRef`, and pass the result to `EdpireAssessment.review()` for the whole paper, or `flattenReview()` + `renderQuestion({ readOnly: true })` for one question at a time.
  </Step>

  <Step title="Add read:results to your API key">
    Review and backfill both need it.
  </Step>
</Steps>
