Skip to main content
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:
1

Fetch the assessment (server)

Your server calls client.getAssessment() — the API key never reaches the browser.
2

Flatten into steps

flattenAssessment() turns the nested assessment into a flat array of questions.
3

Render each question

<EdpireQuestion> (React) or renderQuestion() (vanilla) renders one step at a time.
4

Check answers per question

Your server proxies POST /check for immediate per-question feedback. Stateless — no record created.
5

Submit the full attempt

When done, your server calls client.submit() once to record + grade the whole attempt.

1. Fetch (server)

Keep the API key server-side — never call Edpire directly from the browser. Expose a thin proxy your browser can call:
// 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()

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

3. Render — <EdpireQuestion> or renderQuestion()

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:
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 429s.
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.

renderQuestion() reference

function renderQuestion(options: RenderQuestionOptions): QuestionInstance
RenderQuestionOptions — same fields as EdpireQuestionProps: container, content, onAnswersChange?, feedback?, initialAnswers?, dir?. QuestionInstance:
MethodDescription
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:
// 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()).
Rate limiting: each (session_id, question_id) pair allows 3 checks per hour (configurable per org). Use the session_id you generated in step 3 — one per attempt, not per question.

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). Collect answers as a flat StoredAnswer[] during the session:
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}`)
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).
Required API key scopes: read:assessments (fetch + check) and write:submissions (the final submit).
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.

Full example

The steps above map directly to a complete implementation — see the Custom Flow example for a reference build that wires all five steps together end-to-end.