Webhooks let Examplary tell your application when something has happened, like a test that finished generating or a
session that finished grading, instead of you polling the API for it. You register a URL, pick the events you care
about, and Examplary sends a signed POST request to that URL every time one of them happens.
Creating a webhook
Create a webhook with the Create webhook endpoint. The URL must use
HTTPS and be reachable from the internet, so localhost and private network addresses are rejected. To receive
webhooks on your own machine during development, use a tunnelling tool such as ngrok.
{
"url": "https://example.com/webhooks/examplary",
"events": [
"exam.generation.completed",
"exam.session.completed"
]
}The response includes a secret starting with whsec_. You need it to verify that requests
really come from Examplary, so store it somewhere safe.
A webhook has one of two scopes:
personal(the default) receives events for your own work: tests you generated or imported, and sessions of tests you created.orgreceives events for everyone in the workspace.
Webhooks can't be changed after they are created. To point a webhook at a different URL or change its events, create a new one and delete the old one.
Events
| Event | Sent when |
|---|---|
exam.generation.completed | AI finished generating a test's questions, after Generate exam. |
exam.import.completed | A background import finished, after Import exam, or Import questions when it responds with "status": "started". |
exam.questions.generation.completed | A single question was generated, after Generate question. |
exam.session.completed | A student handed in a test, or a scanned paper test was processed. |
exam.session.grading.completed | AI grading finished for all answers in a session. |
Failed generations and imports are sent as well, with a status of failed, so you're never left waiting.
Cancelled generations are not sent.
Sessions you create yourself through Create session
don't send exam.session.completed, since you already know about them. They do send
exam.session.grading.completed when you import them with autoGrade set to true. The
grading guide shows that flow from start to finish.
Payloads
Every request has a JSON body with the event type, the timestamp of the event, and the event's data:
{
"type": "exam.session.grading.completed",
"timestamp": "2026-10-08T12:00:00.000Z",
"data": {
"examId": "exam_...",
"session": { "id": "...", "status": "completed", "answers": { ... } }
}
}What's in data depends on the event:
| Event | data |
|---|---|
exam.generation.completed | examId, status (completed or failed) and questions |
exam.import.completed | examId, status (imported or failed) and questions |
exam.questions.generation.completed | examId, jobId (the job from the generate response), status (completed or failed) and, when it succeeded, the question |
exam.session.completed | examId and the session |
exam.session.grading.completed | examId and the session |
Questions are Question objects, the same as
Get exam returns. The session has the same shape as
Get exam session, without its events.
After exam.session.grading.completed, every answer has a gradingStatus of auto-graded (an AI suggestion that a
teacher can still review), auto-grading-failed, or no AI grade at all. The event can be sent more than once for the
same session, for example when answers are graded again later.
Verifying requests
Requests follow the Standard Webhooks spec and carry three headers:
webhook-id: whdel_...
webhook-timestamp: 1759924800
webhook-signature: v1,<signature>Always check the signature before trusting a request. The easiest way is one of the Standard Webhooks libraries,
which take the whsec_ secret as is. Pass them the raw request body, before any JSON parsing:
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const webhook = new Webhook(process.env.EXAMPLARY_WEBHOOK_SECRET);
app.post(
"/webhooks/examplary",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = webhook.verify(req.body.toString("utf8"), req.headers);
} catch {
return res.status(400).send("Invalid signature");
}
// Handle event.type and event.data here
res.sendStatus(204);
},
);To verify a request without a library, base64-decode the part of the secret after whsec_ and use it as the key for
an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}. The base64-encoded result must match the signature after
v1,. Also reject requests whose timestamp is more than a few minutes old, so a captured request can't be replayed.
You can get the secret again with Get webhook:
Responding and retries
Respond with any 2xx status within 10 seconds. If processing an event takes longer, store it and handle it in the
background.
Any other status, a network error or a timeout counts as a failed delivery. Examplary then tries again with an increasing delay, starting at 30 seconds and growing to 15 minutes, for 8 attempts over about 45 minutes in total.
This means you can receive the same event more than once. The webhook-id header stays the same across retries, so
use it to skip events you already handled. Events can also arrive out of order, so don't rely on the order in which
they come in.
Checking deliveries
List webhook deliveries returns a webhook's 50 most recent
deliveries from the last 30 days, newest first. Each one shows its status (pending, succeeded or failed), the
number of attempts, and the responseStatus and error of the last attempt, which helps when your endpoint isn't
receiving what you expect:
Managing webhooks
List webhooks returns the webhooks you can manage: your own, plus the workspace's org webhooks if you're an admin or owner. The list leaves out the secrets.
Delete webhooks you no longer need with Delete webhook. Deliveries that are still waiting for a retry are not sent after that.
Managing webhooks needs the webhooks:read and webhooks:write OAuth scopes, and a
teacher, admin or owner role.