# Webhooks and events

Use webhooks for live evidence and completion summaries. Use the REST read endpoints as a pull fallback.

## Outbound events

| Event | When it sends | Payload summary |
|---|---|---|
| `violation.detected` | After a violation is saved | One violation and the Endpoint session UUID. |
| `recording.uploaded` | After a recording uploads to your S3 bucket | One `partner_s3` recording. |
| `exam.completed` | After the session reaches `completed` and no recording upload is pending | Session summary, all violations, deliverable recordings, and upload errors if any. |

To receive events, configure a webhook URL and webhook secret on your tenant. Event filtering is tenant-level; an empty event list means all events are enabled.

## Transport

```
POST <your webhook URL>
Content-Type: application/json
X-Webhook-Signature: <64 hex chars>
X-Webhook-Timestamp: <unix seconds>
User-Agent: EndpointProctoring/1.0
```

Any `2xx` response is success. Current delivery does not guarantee redelivery for all failure types. Return `2xx` quickly, store the signed payload or `event_id`, and process asynchronously.

Every payload has a top-level `event_id`. Use it as your deduplication key.

## Webhook signatures

Endpoint signs the exact JSON body it sends with:

```
HMAC-SHA256(webhook_secret, json_body)
```

The header value is lower-case hex with no `sha256=` or `v1=` prefix. Verify against the raw request body bytes before parsing JSON. The timestamp header is not part of the signed value; use it only for your own replay window.

Ruby example:

```ruby
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
valid = ActiveSupport::SecurityUtils.secure_compare(expected, request.headers["X-Webhook-Signature"].to_s)
```

Node example:

```js
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const valid = Buffer.byteLength(expected) === Buffer.byteLength(signature || '') &&
  crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || ''));
```

## Payloads

### `violation.detected`

```json
{
  "event": "violation.detected",
  "event_id": "5c3e1f8a-2b6d-57e2-9f1a-6d0c2e7b4a91",
  "timestamp": "2026-09-10T14:41:03Z",
  "session_id": "9d2b6c31-1f45-4a80-b7de-5c1e0a3f9b22",
  "violation": {
    "id": 4412,
    "type": "tab_switch",
    "severity": "medium",
    "description": "Switched away from the exam tab",
    "occurred_at": "2026-09-10T14:41:03Z",
    "auto_detected": true
  }
}
```

### `recording.uploaded`

```json
{
  "event": "recording.uploaded",
  "event_id": "9b1deb4d-3b7d-5bad-9bdd-2b0d7b3dcb6d",
  "timestamp": "2026-09-10T15:30:44Z",
  "session_id": "9d2b6c31-1f45-4a80-b7de-5c1e0a3f9b22",
  "recording": {
    "id": 771,
    "type": "camera",
    "url": "s3://your-bucket/proctoring/9d2b6c31/camera.webm",
    "storage_provider": "partner_s3",
    "duration_seconds": 5189,
    "file_size_bytes": 104857600,
    "uploaded_at": "2026-09-10T15:30:44Z"
  }
}
```

### `exam.completed`

```json
{
  "event": "exam.completed",
  "event_id": "0a128f02-a18b-5558-9df7-058104ed5272",
  "timestamp": "2026-09-10T15:31:12Z",
  "session": {
    "id": "9d2b6c31-1f45-4a80-b7de-5c1e0a3f9b22",
    "exam_id": "exam-204",
    "reservation_uuid": "3f6a1d92-7c40-4b1e-9a55-0e2f8b4c1d77",
    "candidate_email": "ada@example.com",
    "candidate_name": "Ada Lovelace",
    "exam_name": "Network Security Final",
    "course_name": "Network Security",
    "started_at": "2026-09-10T14:02:11Z",
    "ended_at": "2026-09-10T15:28:40Z",
    "duration_minutes": 86,
    "compliance_score": 90
  },
  "violations": [
    {
      "id": 4412,
      "type": "tab_switch",
      "severity": "medium",
      "description": "Switched away from the exam tab",
      "occurred_at": "2026-09-10T14:41:03Z"
    }
  ],
  "recordings": [
    {
      "type": "camera",
      "url": "s3://your-bucket/proctoring/9d2b6c31/camera.webm",
      "storage_provider": "partner_s3",
      "duration_seconds": 5189,
      "file_size_bytes": 104857600
    }
  ],
  "recording_errors": [
    {
      "type": "screen",
      "error": "AccessDenied",
      "failed_at": "2026-09-10T15:30:02Z"
    }
  ]
}
```

`duration_minutes` is elapsed exam time, not scheduled length. `recording_errors` is omitted when there are no upload errors.

## Deliverable recordings

`exam.completed` includes only recordings with `storage_provider` `partner_s3` or `local_only`.

| Provider | Meaning |
|---|---|
| `partner_s3` | Uploaded to your bucket. The URL is an `s3://` URI; fetch it with your AWS credentials. |
| `local_only` | Endpoint is holding the file because no partner S3 bucket was configured. The URL is a signed HTTPS URL valid for 7 days. |
| `failed` | Not listed in `recordings`; represented in `recording_errors` when an upload error was recorded. |
| `skipped` | Not listed in `recordings`. Screen upload is skipped unless explicitly enabled. |

Current attempt recordings use `camera` and `screen`; room-scan video may appear as `room_scan_video`. Do not reject a payload solely because a future recording `type` is unfamiliar. Check-in still images are not delivered by webhook in the current flow.

## Violation catalogue

Severity is always `low`, `medium`, `high`, or `critical`. Treat `type` as an open catalogue: handle unknown future types generically by severity.

| Type | Typical severity |
|---|---|
| `tab_switch`, `window_blur`, `alt_tab_attempt` | `medium` |
| `copy_attempt`, `cut_attempt`, `paste_attempt`, `excessive_selection` | `low` to `high` |
| `right_click`, `drag_attempt`, `form_submission_blocked` | `low` |
| `print_attempt`, `print_shortcut`, `screenshot_attempt` | `medium` to `high` |
| `screen_capture_attempt`, `screen_share_stopped`, `devtools_detected` | `critical` |
| `devtools_shortcut`, `console_accessed`, `camera_access_denied`, `screen_share_denied` | `high` |
| `screen_share_muted` | `medium` |
| `navigation_attempt`, `navigation_blocked`, `navigation_violation` | `low` to `high` |
| `multiple_displays_detected` | `medium` |
| `iframe_injection`, `suspicious_dom_injection`, `spoofed_end_signal` | `medium` to `high` |
| `no_person_visible`, `multiple_people` | `critical` |
| `phone_visible`, `ai_detected_violation` | `high` |
| `looking_away`, `suspicious_object` | `medium` |
| `improper_lighting`, `background_activity` | `low` |

`test_violation` is used during staging and is deleted before the real exam starts.

## Inbound lifecycle events

| Integration | Stop signal |
|---|---|
| LTI Proctoring Services | `POST /lti/end_assessment` with `LtiEndAssessment` |
| REST | `POST /proctoring/api/v1/webhooks/exam/status` with `event_type: "assessment_ended"` |

`assessment_finalized` marks the session `finalized` after completion. It is a partner/platform lifecycle marker, not proof that every recording was delivered.

## Related

- [Overview](/docs/proctoring/overview)
- [LTI 1.3 Proctoring Services](/docs/proctoring/lti-1-3)
- [REST integration](/docs/proctoring/rest-integration)
- [Proctoring settings](/docs/proctoring/proctoring-settings)

_Last verified: 2026-09-03 against main._
