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

# Custom Flow

> Build your own question-by-question UX — Duolingo-style flows, drills, practice modes — with the SDK's rendering helpers and the /check endpoint.

Custom Flow gives you full control over pacing, navigation, and scoring UX. The SDK renders questions and collects answers; **you own everything else** — progress bars, hearts/lives, animations, "next" buttons.

**The shape of a Custom Flow:**

<Steps>
  <Step title="Fetch the assessment (server)">
    Your server calls `client.getAssessment()` — the API key never reaches the browser.
  </Step>

  <Step title="Flatten into steps">
    `flattenAssessment()` turns the nested assessment into a flat array of questions.
  </Step>

  <Step title="Render each question">
    `<EdpireQuestion>` (React) or `renderQuestion()` (vanilla) renders one step at a time.
  </Step>

  <Step title="Check answers per question">
    Your server proxies `POST /check` for immediate per-question feedback. Stateless — no record created.
  </Step>

  <Step title="Submit the full attempt">
    When done, your server calls `client.submit()` once to record + grade the whole attempt.
  </Step>
</Steps>

***

## 1. Fetch (server)

Keep the API key server-side — never call Edpire directly from the browser. Expose a thin proxy your browser can call:

```typescript theme={null}
// GET /api/edpire/assessment/[id]/route.ts
import { EdpireClient } from "@edpire/sdk/client"

const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! }) // module level

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const assessment = await client.getAssessment(id)
  return Response.json({ data: assessment })
}
```

## 2. Flatten — `flattenAssessment()`

```typescript theme={null}
import { flattenAssessment } from "@edpire/sdk"

const steps = flattenAssessment(assessment)
```

Returns an ordered `FlatStep[]` — one entry per question, in the order a learner would see them (exercise order, then question order).

```typescript theme={null}
interface FlatStep {
  /** The exercise this question belongs to — required for POST /check. */
  exerciseId: string
  /** The question ID — required for POST /check. */
  questionId: string
  /** ContentAST — pass directly to EdpireQuestion's `content` prop. */
  content: unknown
  /** Point value of this question. */
  points: number
  /** Original sequence number within its exercise. */
  sequenceNumber: number
  /** 0-based index in the flattened sequence — use for a progress bar. */
  index: number
}
```

<Warning>
  `exerciseId` and `questionId` are **required** for the `/check` call — that's why `flattenAssessment` preserves them on every step. Use `index` for progress (`index + 1` of `steps.length`).
</Warning>

## 3. Render — `<EdpireQuestion>` or `renderQuestion()`

<Note>
  **Generate your `session_id` now, before rendering the first question.** You'll need it for every `/check` call and it must be the same across all questions in one attempt. Create it once at attempt start:

  ```typescript theme={null}
  const sessionId = crypto.randomUUID()  // once per attempt, before step 3
  ```

  If you wait until step 4 and generate one per question, rate limiting accumulates across retakes and causes surprise `429`s.
</Note>

<Tabs>
  <Tab title="React">
    ```tsx theme={null}
    import { EdpireQuestion } from "@edpire/sdk/react"
    import type { RuntimeAnswer } from "@edpire/sdk"

    const [i, setI] = useState(0)
    const [answers, setAnswers] = useState<RuntimeAnswer[]>([])
    const [feedback, setFeedback] = useState<Record<string, unknown> | null>(null)
    const step = steps[i]

    <EdpireQuestion
      content={step.content}
      onAnswersChange={setAnswers}
      feedback={feedback}   // null until the learner checks; pass /check result to reveal
      dir="ltr"
    />
    ```

    See [`EdpireQuestionProps`](/developer/sdk/react-components#edpirequestionprops).
  </Tab>

  <Tab title="Vanilla JS">
    ```typescript theme={null}
    import { flattenAssessment, renderQuestion } from "@edpire/sdk"

    const steps = flattenAssessment(assessment)
    let i = 0

    const q = renderQuestion({
      container: "#question-root",      // selector or HTMLElement
      content: steps[i].content,
      onAnswersChange: (answers) => { current = answers },
    })

    // After POST /check:
    q.setFeedback(checkResult.feedback)

    // Move to the next question (also clears feedback):
    q.setContent(steps[++i].content)

    // Cleanup:
    q.unmount()
    ```

    `renderQuestion()` returns a `QuestionInstance` for non-React environments.
  </Tab>
</Tabs>

### `renderQuestion()` reference

```typescript theme={null}
function renderQuestion(options: RenderQuestionOptions): QuestionInstance
```

**`RenderQuestionOptions`** — same fields as `EdpireQuestionProps`: `container`, `content`, `onAnswersChange?`, `feedback?`, `initialAnswers?`, `dir?`.

**`QuestionInstance`:**

| Method                          | Description                                                               |
| ------------------------------- | ------------------------------------------------------------------------- |
| `setContent(content)`           | Replace the question. **Also clears feedback** — ready for the next step. |
| `setFeedback(feedback \| null)` | Update feedback (e.g. after `/check`). `null` clears it.                  |
| `setInitialAnswers(answers)`    | Pre-fill answers (e.g. navigating back).                                  |
| `unmount()`                     | Tear down the React root.                                                 |

## 4. Check per question — `POST /check`

Grade a single question for immediate feedback. **Stateless** — no submission is recorded. Proxy it through your server so the API key and `include_correct_answers` flag stay trusted:

```typescript theme={null}
// POST /api/edpire/check/[id]/route.ts
import { EdpireClient } from "@edpire/sdk/client"

const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params              // assessmentId in the URL path
  const body = await req.json()
  // Always set include_correct_answers server-side — never trust the client.
  const result = await client.checkQuestion(id, { ...body, include_correct_answers: true })
  return Response.json(result)             // { correct, score, max_score, feedback }
}
```

The browser then passes `result.feedback` straight into `EdpireQuestion`'s `feedback` prop (or `q.setFeedback()`).

<details>
  <summary>Raw fetch (without EdpireClient)</summary>

  The assessment ID goes in the **URL path**, not the body:

  ```typescript theme={null}
  const res = await fetch(`https://edpire.com/api/v1/assessments/${assessmentId}/check`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.EDPIRE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      exercise_id: step.exerciseId,
      question_id: step.questionId,
      answers,                  // RuntimeAnswer[] from onAnswersChange
      learner_ref: userId,
      session_id: sessionId,    // UUID generated once per attempt
      include_correct_answers: true,
    }),
  })
  // Response envelope: { data: CheckResult, error: null, meta: null }
  const { data } = await res.json()
  ```
</details>

<Warning>
  **Rate limiting:** **3 checks per question, per session, per rolling hour** (configurable per org), keyed on `(api key, assessment, question, session_id)` and falling back to `learner_ref` if `session_id` is omitted. Use the `session_id` you generated in step 3 — one per attempt, not per question.
</Warning>

### How feedback maps onto the question

The `/check` response's `feedback` is keyed by **node ID** → feedback state (correct/partial/incorrect, score, message, and — when `include_correct_answers` is true — the correct answer fields). `EdpireQuestion` consumes that object directly and renders the right per-node visual state. Set `feedback` back to `null` (or call `setContent`) before showing the next question to reset it.

When `include_correct_answers` is `true`, feedback also includes: `correctChoiceIds` (choice/drag-drop), `correctPairings` (matching), and `displayAnswer` (typed blanks).

## 5. Submit the full attempt — `client.submit()`

`/check` records nothing. After the last question, submit the whole attempt once to create the graded submission record (and fire the `submission.graded` [webhook](/developer/webhooks)).

Collect answers as a flat `StoredAnswer[]` during the session:

```typescript theme={null}
import { EdpireClient } from "@edpire/sdk/client"
import type { StoredAnswer } from "@edpire/sdk"

const stored: StoredAnswer[] = []
// after each step:
stored.push({ exerciseId: step.exerciseId, questionId: step.questionId, answers })

// at the end (client is module-level — see step 1):
const result = await client.submit(assessmentId, {
  learner_ref: userId,
  answers: stored,   // client.submit() accepts the flat array directly
})
console.log(`Final: ${result.score}/${result.max_score}`)
```

<Note>
  `client.submit()` accepts **either** a flat `StoredAnswer[]` **or** the nested `{ exerciseAnswers: [...] }` format. If you need the nested form explicitly (e.g. constructing it elsewhere), convert with [`buildSubmitPayload(stored)`](/developer/sdk/types#custom-flow-helpers).
</Note>

**Required API key scopes:** `read:assessments` (fetch + check) and `write:submissions` (the final submit).

<Note>
  **If the learner abandons mid-flow:** `/check` is stateless, so nothing is recorded. If they close the tab before step 5, their attempt is lost. Consider saving answers to `sessionStorage` after each question so you can restore them on reload, and warn users before they navigate away during an active attempt.
</Note>

***

## Full example

The steps above map directly to a complete implementation — see the [Custom Flow example](https://docs.edpire.com/developer/sdk/custom-flow) for a reference build that wires all five steps together end-to-end.
