Skip to main content
These two endpoints are for Pattern B and C integrations (Custom Flow / SDK) where you control the learner UI. They do not apply to the Hosted Redirect flow.

Autosave modes

Every assessment has an autosave_mode setting (configurable by the teacher, with an org-level default):
ModeBehavior
offAnswers are only recorded at final submit. Do not call /save.
crash_recoverySave every 30–60 s. Lets learners recover from a closed tab within the current session.
resumableSave on every answer change. Learners can close the app and return later — their answers are restored from the server.
Read the mode from the assessment object (assessment.settings.autosave_mode) before starting the session.

Autosaving answers

POST /api/v1/submissions/{id}/save
Authorization: Bearer edp_live_<key>
Content-Type: application/json
Body:
{
  "answers": {
    "exerciseAnswers": [
      {
        "exerciseId": "ex_abc",
        "questionAnswers": [
          { "questionId": "q_xyz", "answers": [{ "value": "Paris" }] }
        ]
      }
    ]
  }
}
Response:
{
  "data": { "ok": true, "saved_at": "2026-05-17T14:30:00.000Z" },
  "error": null
}
The answers shape is the same AssessmentAnswers object used in the submit endpoint — store it in state and pass it directly.

Implementation pattern

// Call this from your player on a timed interval or on answer change
async function autosave(submissionId: string, answers: AssessmentAnswers) {
  await fetch(`/api/edpire/submissions/${submissionId}/save`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ answers }),
  })
}

// crash_recovery: save every 30 seconds
const interval = setInterval(() => autosave(submissionId, currentAnswers), 30_000)
onUnmount(() => clearInterval(interval))

// resumable: save on every answer change (debounced)
const debouncedSave = debounce((answers) => autosave(submissionId, answers), 2_000)
onAnswerChange((answers) => debouncedSave(answers))
Draft rows are silently overwritten by the final submit — you do not need to delete them.

Recording interaction events

Stream learner interaction events to Edpire for analytics, reporting, and future proctoring features.
POST /api/v1/submissions/{id}/events
Authorization: Bearer edp_live_<key>
Content-Type: application/json
Event types:
event_typeWhen to fireExtra fields
answer_changeLearner modifies an answerquestion_id (required)
node_viewLearner views an interactive nodenode_id
pausedLearner navigates away / minimizes
resumedLearner returns to the assessment
navigatedLearner moves between questionspayload: { from, to }
flaggedLearner flags a question for reviewquestion_id
Example — track answer changes:
{
  "event_type": "answer_change",
  "question_id": "q_xyz",
  "payload": { "answer_preview": "Par" }
}
Response:
{
  "data": { "ok": true },
  "error": null
}

change_count in results

When you record answer_change events, Edpire atomically increments a change_count counter on the answer row. This counter is returned in GET /submissions/{id} under each question_results entry:
{
  "question_id": "q_xyz",
  "change_count": 7,
  "score": 1,
  "max_score": 1
}
Use change_count to identify questions where learners second-guess themselves — a signal of low confidence regardless of whether the final answer is correct.

Implementation pattern

// Fire answer_change events — debounced so you don't flood the API
const debouncedEvent = debounce(async (questionId: string) => {
  await fetch(`/api/edpire/submissions/${submissionId}/events`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ event_type: "answer_change", question_id: questionId }),
  })
}, 500)

onAnswerChange((questionId) => debouncedEvent(questionId))

// Fire navigation events immediately
onNavigate((from, to) => {
  fetch(`/api/edpire/submissions/${submissionId}/events`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      event_type: "navigated",
      payload: { from, to },
    }),
  })
})
All event requests should go through your backend proxy (same as /check calls) so your API key is never exposed in the browser.