Back to Blog
Guide

Using QuizAPI Webhooks to Track Student Progress

Set up QuizAPI webhooks to receive real-time quiz completion events and build a student progress dashboard backed by a database.

Bobby Iliev2026-04-087 min read

Real-Time Progress Without Polling

Polling an API every few seconds to check whether a student finished a quiz is wasteful and slow. Webhooks flip the model: QuizAPI pushes events to your server the moment something happens. A student completes a quiz, you get a POST request with the results, and you update your dashboard instantly.

This guide covers setting up a webhook endpoint, validating payloads, storing progress data, and displaying it in a real-time dashboard.

Prerequisites

  • Node.js 20+ with Express or any HTTP framework
  • PostgreSQL for storing progress data
  • A QuizAPI account with webhook access

Webhook Payload Structure

When a student completes a quiz, QuizAPI sends a POST request to your configured URL with this shape:

{
  "event": "quiz.completed",
  "timestamp": "2026-04-08T14:30:00Z",
  "data": {
    "userId": "user_abc123",
    "quizId": "quiz_js_basics",
    "quizTitle": "JavaScript Basics",
    "score": 8,
    "totalQuestions": 10,
    "percentage": 80,
    "timeSpentSeconds": 245,
    "answers": [
      {
        "questionId": "q1",
        "selectedAnswerId": "a3",
        "correct": true
      }
    ]
  },
  "signature": "sha256=abcdef1234567890..."
}

Other events you may receive include quiz.started, quiz.abandoned, and question.answered.

Building the Webhook Endpoint

Create a webhook handler that validates the signature and processes events:

import express from "express";
import crypto from "crypto";
import { Pool } from "pg";

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// Use raw body for signature verification
app.use(
  "/webhooks/quizapi",
  express.raw({ type: "application/json" })
);

function verifySignature(payload: Buffer, signature: string): boolean {
  const secret = process.env.QUIZAPI_WEBHOOK_SECRET!;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(payload)
    .digest("hex");

  const received = signature.replace("sha256=", "");

  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(received, "hex")
  );
}

app.post("/webhooks/quizapi", async (req, res) => {
  const signature = req.headers["x-quizapi-signature"] as string;

  if (!signature || !verifySignature(req.body, signature)) {
    console.warn("Invalid webhook signature");
    return res.status(401).json({ error: "Invalid signature" });
  }

  const event = JSON.parse(req.body.toString());

  // Respond immediately to avoid timeouts
  res.status(200).json({ received: true });

  // Process asynchronously
  try {
    await processWebhookEvent(event);
  } catch (err) {
    console.error("Failed to process webhook event:", err);
  }
});

The key detail here is responding with 200 before processing. QuizAPI has a timeout window, and if your handler takes too long, it will retry the delivery. Always acknowledge first, then process.

Storing Progress Data

Create the database schema:

CREATE TABLE student_progress (
    id SERIAL PRIMARY KEY,
    user_id VARCHAR(255) NOT NULL,
    quiz_id VARCHAR(255) NOT NULL,
    quiz_title VARCHAR(500),
    score INTEGER NOT NULL,
    total_questions INTEGER NOT NULL,
    percentage NUMERIC(5, 2) NOT NULL,
    time_spent_seconds INTEGER,
    completed_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(user_id, quiz_id, completed_at)
);

CREATE INDEX idx_progress_user ON student_progress(user_id);
CREATE INDEX idx_progress_quiz ON student_progress(quiz_id);
CREATE INDEX idx_progress_completed ON student_progress(completed_at);

CREATE TABLE question_responses (
    id SERIAL PRIMARY KEY,
    progress_id INTEGER REFERENCES student_progress(id) ON DELETE CASCADE,
    question_id VARCHAR(255) NOT NULL,
    selected_answer_id VARCHAR(255) NOT NULL,
    correct BOOLEAN NOT NULL
);

Implement the event processor:

interface WebhookEvent {
  event: string;
  timestamp: string;
  data: {
    userId: string;
    quizId: string;
    quizTitle: string;
    score: number;
    totalQuestions: number;
    percentage: number;
    timeSpentSeconds: number;
    answers: Array<{
      questionId: string;
      selectedAnswerId: string;
      correct: boolean;
    }>;
  };
}

async function processWebhookEvent(event: WebhookEvent): Promise<void> {
  switch (event.event) {
    case "quiz.completed":
      await handleQuizCompleted(event);
      break;
    case "quiz.started":
      console.log(`Quiz started: ${event.data.quizId} by ${event.data.userId}`);
      break;
    default:
      console.log(`Unhandled event type: ${event.event}`);
  }
}

async function handleQuizCompleted(event: WebhookEvent): Promise<void> {
  const { data } = event;

  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    const progressResult = await client.query(
      `INSERT INTO student_progress
        (user_id, quiz_id, quiz_title, score, total_questions, percentage, time_spent_seconds, completed_at)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
       ON CONFLICT (user_id, quiz_id, completed_at) DO NOTHING
       RETURNING id`,
      [
        data.userId,
        data.quizId,
        data.quizTitle,
        data.score,
        data.totalQuestions,
        data.percentage,
        data.timeSpentSeconds,
        event.timestamp,
      ]
    );

    if (progressResult.rows.length > 0) {
      const progressId = progressResult.rows[0].id;

      for (const answer of data.answers) {
        await client.query(
          `INSERT INTO question_responses (progress_id, question_id, selected_answer_id, correct)
           VALUES ($1, $2, $3, $4)`,
          [progressId, answer.questionId, answer.selectedAnswerId, answer.correct]
        );
      }
    }

    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

The ON CONFLICT DO NOTHING clause handles duplicate deliveries. Webhooks can be retried, so your handler must be idempotent.

Building the Progress Dashboard

Create an API endpoint that serves aggregated student data:

app.get("/api/students/:userId/progress", async (req, res) => {
  const { userId } = req.params;

  const summary = await pool.query(
    `SELECT
       COUNT(*) as quizzes_completed,
       ROUND(AVG(percentage), 1) as average_score,
       SUM(time_spent_seconds) as total_time_seconds,
       MAX(completed_at) as last_activity
     FROM student_progress
     WHERE user_id = $1`,
    [userId]
  );

  const recentQuizzes = await pool.query(
    `SELECT quiz_id, quiz_title, score, total_questions, percentage,
            time_spent_seconds, completed_at
     FROM student_progress
     WHERE user_id = $1
     ORDER BY completed_at DESC
     LIMIT 10`,
    [userId]
  );

  const weakAreas = await pool.query(
    `SELECT sp.quiz_title,
            COUNT(*) FILTER (WHERE qr.correct = false) as incorrect_count,
            COUNT(*) as total_answers
     FROM student_progress sp
     JOIN question_responses qr ON qr.progress_id = sp.id
     WHERE sp.user_id = $1
     GROUP BY sp.quiz_title
     HAVING COUNT(*) FILTER (WHERE qr.correct = false) > 0
     ORDER BY (COUNT(*) FILTER (WHERE qr.correct = false))::float / COUNT(*) DESC
     LIMIT 5`,
    [userId]
  );

  res.json({
    summary: summary.rows[0],
    recentQuizzes: recentQuizzes.rows,
    weakAreas: weakAreas.rows,
  });
});

Real-Time Updates with SSE

For a live dashboard, use Server-Sent Events to push updates to the browser:

const progressSubscribers = new Map<string, Set<express.Response>>();

app.get("/api/students/:userId/progress/stream", (req, res) => {
  const { userId } = req.params;

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  if (!progressSubscribers.has(userId)) {
    progressSubscribers.set(userId, new Set());
  }
  progressSubscribers.get(userId)!.add(res);

  req.on("close", () => {
    progressSubscribers.get(userId)?.delete(res);
  });
});

// Call this from handleQuizCompleted after inserting the record
function notifyProgressUpdate(userId: string, data: object) {
  const subscribers = progressSubscribers.get(userId);
  if (!subscribers) return;

  const payload = `data: ${JSON.stringify(data)}\n\n`;
  for (const res of subscribers) {
    res.write(payload);
  }
}

On the client side, connect with the EventSource API:

const events = new EventSource(`/api/students/${userId}/progress/stream`);

events.onmessage = (event) => {
  const progress = JSON.parse(event.data);
  updateDashboard(progress);
};

Summary

Webhooks give you real-time quiz completion data without the overhead of polling. The pattern is straightforward: validate the signature, acknowledge immediately, process asynchronously, and handle duplicate deliveries with idempotent inserts.

The progress dashboard layered on top turns raw completion events into actionable insights - average scores, weak areas, and activity timelines. Add SSE for live updates and your instructors can watch student progress as it happens.

Stay Updated

Get the latest tutorials and API tips delivered to your inbox.

No spam, unsubscribe anytime.

Enjoyed this article?

Share it with your team or try our quiz platform.