Examplary
  • Start for free
    Developer docs

    Webhooks

    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.

    POST/webhooks
    {
      "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.
    • org receives 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

    EventSent when
    exam.generation.completedAI finished generating a test's questions, after Generate exam.
    exam.import.completedA background import finished, after Import exam, or Import questions when it responds with "status": "started".
    exam.questions.generation.completedA single question was generated, after Generate question.
    exam.session.completedA student handed in a test, or a scanned paper test was processed.
    exam.session.grading.completedAI 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:

    Eventdata
    exam.generation.completedexamId, status (completed or failed) and questions
    exam.import.completedexamId, status (imported or failed) and questions
    exam.questions.generation.completedexamId, jobId (the job from the generate response), status (completed or failed) and, when it succeeded, the question
    exam.session.completedexamId and the session
    exam.session.grading.completedexamId 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:

    GET/webhooks/{id}

    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:

    GET/webhooks/{id}/deliveries

    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.

    GET/webhooks

    Delete webhooks you no longer need with Delete webhook. Deliveries that are still waiting for a retry are not sent after that.

    DELETE/webhooks/{id}

    Managing webhooks needs the webhooks:read and webhooks:write OAuth scopes, and a teacher, admin or owner role.