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):
| Mode | Behavior |
|---|
off | Answers are only recorded at final submit. Do not call /save. |
crash_recovery | Save every 30–60 s. Lets learners recover from a closed tab within the current session. |
resumable | Save 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_type | When to fire | Extra fields |
|---|
answer_change | Learner modifies an answer | question_id (required) |
node_view | Learner views an interactive node | node_id |
paused | Learner navigates away / minimizes | — |
resumed | Learner returns to the assessment | — |
navigated | Learner moves between questions | payload: { from, to } |
flagged | Learner flags a question for review | question_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.