> ## Documentation Index
> Fetch the complete documentation index at: https://doc.quippy-lab.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Event catalog

> Every webhook event Quippy emits, with sample payloads.

Each delivery is a JSON object with a common envelope. The event-specific
fields live under `data`.

## Common envelope

| Field           | Type               | Description                                                                                                          |
| --------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `id`            | string             | Unique delivery ID, e.g. `dlv_01abc…`. Same value as the `X-Quippy-Delivery-Id` header. Use it to dedupe on retries. |
| `type`          | string             | The event type — e.g. `exam.completed`. Same as the `X-Quippy-Event` header.                                         |
| `created`       | ISO 8601 string    | Timestamp of this delivery attempt.                                                                                  |
| `institutionId` | string             | Your institution's ID. Handy if you run one receiver for many tenants.                                               |
| `test`          | boolean (optional) | Present and `true` only for synthetic test deliveries.                                                               |
| `data`          | object             | Event-specific payload — see below.                                                                                  |

<Note>
  **IDs are opaque strings.** The sample IDs below (`usr_…`, `exm_…`, `ses_…`)
  are illustrative — treat every ID as an opaque token and match it exactly.
  Do not parse, validate, or depend on any prefix or internal structure; the
  format can change without notice.
</Note>

<Note>
  **Partner API correlation.** When an exam session was created through the
  [Partner API](/partner-api/overview) — i.e. via `POST /partner/v1/assignments`,
  or by a student who signed in through your SSO — the `exam.started`,
  `exam.completed`, `violation.detected`, and `grading.done` events carry three
  extra `data` fields so you can correlate without a lookup:

  | Field                 | Meaning                                                                                            |
  | --------------------- | -------------------------------------------------------------------------------------------------- |
  | `externalUserId`      | Your own stable user id (the IdP `sub`) for this student. `null` if unknown.                       |
  | `partnerAssignmentId` | The `assignmentId` returned by `POST /partner/v1/assignments`. `null` for non-assignment sessions. |
  | `metadata`            | The opaque object you passed at assignment time, echoed back. Absent when you sent none.           |

  These fields are omitted (or `null`) for exam activity that didn't originate
  from the Partner API.
</Note>

## Events

<AccordionGroup>
  <Accordion title="exam.started — fires when a student begins a proctored exam session">
    ```json theme={null}
    {
      "id": "dlv_01abc...",
      "type": "exam.started",
      "created": "2026-04-21T09:05:12.456Z",
      "institutionId": "inst_acme",
      "data": {
        "examId": "exm_01...",
        "sessionId": "ses_01...",
        "studentId": "usr_01...",
        "studentEmail": "jane@acme.com",
        "attempt": 1,
        "startedAt": "2026-04-21T09:05:12.012Z",
        "externalUserId": "your-sub-123",
        "partnerAssignmentId": "pas_01...",
        "metadata": { "cohort": "spring-2026" }
      }
    }
    ```

    Fires once per new proctoring session, immediately after the session
    document is persisted. `studentId` may be `null` for guest / link-based
    exam modes. `attempt` is 1-indexed — the student's nth attempt at this
    exam. Great for driving real-time attendance dashboards. The
    `externalUserId` / `partnerAssignmentId` / `metadata` fields appear only
    for [Partner API](/partner-api/overview) sessions.
  </Accordion>

  <Accordion title="exam.completed — fires when a student submits an exam">
    ```json theme={null}
    {
      "id": "dlv_01abc...",
      "type": "exam.completed",
      "created": "2026-04-20T10:15:30.123Z",
      "institutionId": "inst_acme",
      "data": {
        "examId": "exm_01...",
        "sessionId": "ses_01...",
        "studentId": "usr_01...",
        "studentEmail": "jane@acme.com",
        "score": {
          "percentage": 87,
          "totalScore": 87,
          "maxScore": 100,
          "correct": 26,
          "total": 30
        },
        "submittedAt": "2026-04-20T10:15:29.998Z",
        "externalUserId": "your-sub-123",
        "partnerAssignmentId": "pas_01...",
        "metadata": { "cohort": "spring-2026" }
      }
    }
    ```

    Fires once per submission, after the session is scored. `studentId` may
    be `null` for guest / link-based exam modes. The `score` here is the
    auto-marked portion; for exams that require manual or AI marking the final
    result arrives later via `grading.done`. The `externalUserId` /
    `partnerAssignmentId` / `metadata` fields appear only for
    [Partner API](/partner-api/overview) sessions.
  </Accordion>

  <Accordion title="violation.detected — fires when a critical proctoring violation is recorded">
    ```json theme={null}
    {
      "id": "dlv_01abc...",
      "type": "violation.detected",
      "created": "2026-04-21T09:12:05.789Z",
      "institutionId": "inst_acme",
      "data": {
        "examId": "exm_01...",
        "sessionId": "ses_01...",
        "studentId": "usr_01...",
        "violationId": "vio_01...",
        "type": "multiple_faces",
        "severity": "critical",
        "message": "Multiple faces detected in frame",
        "riskScore": 30,
        "totalViolations": 2,
        "recordingTimestamp": 342,
        "recordingTimeFormatted": "00:05:42",
        "detectedAt": "2026-04-21T09:12:05.701Z",
        "externalUserId": "your-sub-123",
        "partnerAssignmentId": "pas_01...",
        "metadata": { "cohort": "spring-2026" }
      }
    }
    ```

    <Note>
      **Only critical violations fire this event.** Warnings (e.g. one
      quick head turn) are too noisy to push to customer systems by default
      — they're visible inside Quippy's own dashboard but don't emit.
    </Note>

    Fields:

    | Field                    | Meaning                                                                                           |
    | ------------------------ | ------------------------------------------------------------------------------------------------- |
    | `type`                   | The violation classifier — `multiple_faces`, `face_not_visible`, `tab_switch`, `copy_paste`, etc. |
    | `severity`               | Always `"critical"` for webhook deliveries.                                                       |
    | `riskScore`              | Cumulative session risk score (0–100), recalculated on each violation.                            |
    | `totalViolations`        | Count of all violations on this session so far, critical + warning combined.                      |
    | `recordingTimestamp`     | Seconds from recording start — useful to link directly into the playback. `null` if no recording. |
    | `recordingTimeFormatted` | Same timestamp as `HH:MM:SS`.                                                                     |
  </Accordion>

  <Accordion title="grading.done — fires when an AI-graded assessment is persisted">
    ```json theme={null}
    {
      "id": "dlv_01abc...",
      "type": "grading.done",
      "created": "2026-04-21T09:20:44.321Z",
      "institutionId": "inst_acme",
      "data": {
        "assessmentId": "asm_01...",
        "userId": "usr_01...",
        "assessmentType": "cefr-speaking",
        "sessionId": "ses_01...",
        "assignmentId": null,
        "score": {
          "overall": 82,
          "pronunciation": 78,
          "fluency": 85,
          "accuracy": 80,
          "content": 84,
          "cefrLevel": "B2"
        },
        "language": "en-US",
        "completedAt": "2026-04-21T09:20:44.111Z",
        "externalUserId": "your-sub-123",
        "partnerAssignmentId": "pas_01...",
        "metadata": { "cohort": "spring-2026" }
      }
    }
    ```

    Fires once per new graded assessment across **all** grading entry points
    — \`POST /api/grading/grade-open-ended\`, \`grade-pronunciation\`,
    \`grade-writing\`, \`cefr/speaking\`, \`ielts/speaking\`, \`ielts/writing\`,
    \`hskk/grade-speaking\`, \`dels-speaking/grade\`, \`dels-writing/grade\`.

    `score` fields you'll see depend on `assessmentType`:

    | Assessment type                                                | Score fields populated                            |
    | -------------------------------------------------------------- | ------------------------------------------------- |
    | `speaking`, `open-ended`, `mixed`                              | `overall`, `pronunciation`, `fluency`, `accuracy` |
    | `cefr-speaking`, `cefr-writing`                                | `overall`, `cefrLevel` (A1–C2)                    |
    | `ielts-speaking`, `ielts-writing-task1`, `ielts-writing-task2` | `overall`, IELTS band in `overall` (0–9 scale)    |
    | `hskk_speaking`                                                | `overall`, `pronunciation`, `fluency`             |
    | written                                                        | `overall`, `content`, `grammar` (under `score.*`) |

    Unpopulated score fields are `null` — don't key off their presence.

    <Note>
      Teacher edits to an existing assessment (reviewing, overriding a
      score) do **not** re-fire `grading.done`. The event is strictly
      "first grade was persisted."
    </Note>
  </Accordion>

  <Accordion title="user.provisioned — fires when a new institution user is created">
    ```json theme={null}
    {
      "id": "dlv_01abc...",
      "type": "user.provisioned",
      "created": "2026-04-20T10:15:30.123Z",
      "institutionId": "inst_acme",
      "data": {
        "userId": "usr_01...",
        "email": "jane@acme.com",
        "displayName": "Jane Doe",
        "role": "member",
        "accessLevel": 2,
        "provisionedAt": "2026-04-20T10:15:29.998Z",
        "via": "sso"
      }
    }
    ```

    Fires when a new user is added to the institution. The `via` field tells
    you how it happened:

    | `via`                   | Fires on                                                                                                             |
    | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
    | `"invite"` (or omitted) | Admin invites a user via `POST /api/v2/users/invite` or the Members page                                             |
    | `"sso"`                 | A user authenticates via SSO for the first time (JIT provisioning) or a soft-deleted user is reactivated through SSO |
    | `"scim"`                | The customer's IdP pushes a new user via `POST /api/scim/v2/Users`                                                   |

    `role` is one of `admin`, `manager`, `editor`, `member`, `viewer`.
    `accessLevel` is the numeric equivalent (5 / 4 / 3 / 2 / 1).
  </Accordion>

  <Accordion title="user.deprovisioned — fires when an institution user is removed">
    ```json theme={null}
    {
      "id": "dlv_01abc...",
      "type": "user.deprovisioned",
      "created": "2026-04-20T10:15:30.123Z",
      "institutionId": "inst_acme",
      "data": {
        "userId": "usr_01...",
        "email": "jane@acme.com",
        "displayName": "Jane Doe",
        "deactivatedAt": "2026-04-20T10:15:29.998Z",
        "via": "scim"
      }
    }
    ```

    Fires on soft-delete. `via` tells you the trigger:

    | `via`                  | Fires on                                                                                           |
    | ---------------------- | -------------------------------------------------------------------------------------------------- |
    | `"admin"` (or omitted) | An admin removes the user from the Members page                                                    |
    | `"scim"`               | The customer's IdP deactivates the user (`active: false` PATCH or `DELETE /api/scim/v2/Users/:id`) |

    Pair with `user.provisioned` to drive user lifecycle syncs in external
    HRIS, CRM, or SIEM systems.
  </Accordion>

  <Accordion title="subscription.updated — fires when the Stripe subscription status changes">
    ```json theme={null}
    {
      "id": "dlv_01abc...",
      "type": "subscription.updated",
      "created": "2026-04-20T10:15:30.123Z",
      "institutionId": "inst_acme",
      "data": {
        "userId": "usr_01...",
        "stripeSubscriptionId": "sub_1Nx...",
        "status": "active",
        "cancelAtPeriodEnd": false,
        "currentPeriodEnd": "2026-05-20T10:15:29.000Z",
        "planId": "plan_pro_monthly"
      }
    }
    ```

    Fires when Stripe tells Quippy a subscription has changed — `active`,
    `past_due`, `canceled`, etc. `planId` may be `null` if the plan isn't
    recorded on our side.
  </Accordion>

  <Accordion title="webhook.test — synthetic event from the 'Send test' button">
    ```json theme={null}
    {
      "id": "dlv_01abc...",
      "type": "webhook.test",
      "created": "2026-04-20T10:15:30.123Z",
      "test": true,
      "institutionId": "inst_acme",
      "data": {
        "message": "This is a synthetic test delivery from Quippy.",
        "at": "2026-04-20T10:15:30.000Z"
      }
    }
    ```

    Fires exactly once when you click **Send test** on an endpoint's detail
    page. Test deliveries are **not retried** on failure — they're a
    one-shot check.
  </Accordion>
</AccordionGroup>

## Delivering to multiple endpoints

If you subscribe two endpoints to the same event, each gets its own
delivery row with a distinct `id` — they don't share attempts or retry
state.
