Webhooks
Webhook events, payloads, HMAC signatures, idempotency, retries, and replay handling.
Webhooks push session lifecycle events from the proctor server into a customer platform. Configure a destination URL in Settings -> Webhooks and pick the events the platform should handle — the endpoint receives a signed JSON POST whenever one fires.
Each delivery is signed with HMAC-SHA256 over {timestamp}.{body}. The verifier ships in the SDK, so there is no need to roll your own.
Webhooks are scoped to the organization, not to an app key. One configured webhook receives events from every app key in the workspace unless event selection narrows delivery.
Event payloads
The deliverable event types are listed below. Every payload shares the envelope below; the data field carries the event-specific fields.
{
"type": "session.ended",
"emittedAt": "2026-05-26T10:32:18.014Z",
"data": {
"sessionId": "sess_abc123",
"reason": "stopped"
}
}| Event | Fires when |
|---|---|
session.started | First batch lands for a previously-unseen session id. |
session.ended | The SDK emits a terminal session event after a clean media drain or a pagehide fallback. |
session.abandoned | A session ends with an abandoned reason, or a stale active attempt is closed before creating the next internal attempt. Always followed by a session.ended for the same session — treat it as a qualifier on the ending, not an alternative to it. |
preflight.completed | The wizard reports a final preflight verdict with passed in the payload. |
preflight.passed | Compatibility event fired when the wizard reports a passing verdict. |
preflight.failed | Compatibility event fired when the wizard reports a failing verdict. |
analysis.completed is intentionally not emitted in V1. Webcam, screen, and snapshot analysis currently finish as separate stream statuses; the aggregate customer webhook should wait until the pipeline has one customer-ready completion gate.
Verifying signatures
Every POST carries three identification headers:
X-Proctoring-Signature—t=<unix>,v1=<hex>wherehexisHMAC-SHA256(secret, "{t}.{body}").X-Proctoring-Event— the event type, mirroring the body'stypefield.Idempotency-Key— stable across retries. Use it to dedupe in your handler.
Use the SDK helper to verify — it is constant-time and rejects stale timestamps (default tolerance 5 minutes).
import { verifyWebhookSignature } from "@a4anthony/proctorkit-sdk";
const ok = await verifyWebhookSignature({
body: rawBodyString,
header: req.headers["x-proctoring-signature"] ?? "",
secret: process.env.PROCTOR_WEBHOOK_SECRET ?? "",
});
if (!ok) {
return res.status(401).end();
}Verify against the raw request body — once you JSON.parse and re-stringify, even a key-order change breaks the signature.
Retries and replay
Non-2xx responses (or network errors) re-enqueue the delivery at the next backoff step. The schedule:
| Attempt | Wait before next try |
|---|---|
| 1 (initial) | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |
| 6 (final) | Marked failed permanently. |
Replay window: the signed timestamp must be within 5 minutes of your server's clock. Reject older signatures even if they verify — they may be captured replays. Use the Idempotency-Key to dedupe customer-side handlers.
Delivery guarantees
Not every ending produces a webhook. session.ended fires only when the SDK's terminal event arrives through ingest — a session whose SDK goes dark (browser crash, closed laptop) is finalized server-side by an expiry-based finalizer without emitting customer webhooks. Likewise, POST /v1/attempts/:correlationId/retake with force: true supersedes the live attempt directly and emits no session.ended for it. If your integration must catch every ending, reconcile by polling GET /v1/sessions?status= on the server API rather than relying on webhooks alone.
Sample handlers
Express:
import express from "express";
import { verifyWebhookSignature } from "@a4anthony/proctorkit-sdk";
const app = express();
app.post(
"/webhooks/proctor",
express.raw({ type: "application/json" }),
async (req, res) => {
const body = req.body.toString(
ok
body
header req
secret processenv
ok res
event body
res
Next.js (App Router):
// app/api/webhooks/proctor/route.ts
import { verifyWebhookSignature } from "@a4anthony/proctorkit-sdk";
export async function POST(req: Request) {
const body = await req.text();
const ok = await verifyWebhookSignature({
body,
header: req.headers.get("x-proctoring-signature") ?? "",
secret processenv
ok status
event body
status