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

# Autosave & Telemetry

> Persist in-progress answers and stream learner interaction events from your custom player.

<Warning>
  **These endpoints require a submission that is already open (`in_progress`).**

  The only flow that creates one is the Edpire-hosted share link, where the learner opens the
  assessment and a submission is started server-side before they begin answering.

  They therefore do **not** work with the headless REST submit
  (`POST /assessments/{id}/submit` creates and grades in a single call) or with the embedded
  SDK player (which does not expose a submission ID until after grading). Calling `/save` or
  `/events` from those integrations returns `404 submission_not_found` every time.

  If you are building a custom player and need server-side autosave, contact us — this needs an
  endpoint to open a submission up front, which does not exist yet.
</Warning>

## Autosave modes

Every assessment has an `autosave` 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 periodically. Lets a learner recover after closing the tab, within a limited recovery window (5 minutes by default, set per assessment). |
| `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`) before starting the session.

## Autosaving answers

```http theme={null}
POST /api/v1/submissions/{id}/save
Authorization: Bearer edp_live_<key>
Content-Type: application/json
```

**Body:**

```json theme={null}
{
  "answers": {
    "exerciseAnswers": [
      {
        "exerciseId": "ex_abc",
        "questionAnswers": [
          { "questionId": "q_xyz", "answers": [{ "value": "Paris" }] }
        ]
      }
    ]
  }
}
```

**Response:**

```json theme={null}
{
  "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

```typescript theme={null}
// 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.

```http theme={null}
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:**

```json theme={null}
{
  "event_type": "answer_change",
  "question_id": "q_xyz",
  "payload": { "answer_preview": "Par" }
}
```

**Response:**

```json theme={null}
{
  "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:

```json theme={null}
{
  "question_id": "q_xyz",
  "sequence_number": 3,
  "points": 2,
  "change_count": 7,
  "result": { "...": "per-node grading detail" }
}
```

It is `0` when your integration does not record `answer_change` events.

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

```typescript theme={null}
// 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 },
    }),
  })
})
```

<Note>
  All event requests should go through your backend proxy (same as `/check` calls) so your API key is never exposed in the browser.
</Note>
