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.
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.
Related Articles
Deploying QuizAPI on DigitalOcean: A Production Setup Guide
Deploy a production-ready quiz API on DigitalOcean with PM2 process management, Nginx reverse proxy, SSL, and PostgreSQL.
How to Create Engaging Quiz Questions: A Content Creator's Guide
Write quiz questions that actually test understanding. Practical tips for difficulty calibration, distractor design, and explanations that teach.
Quiz Accessibility: Making Your Quizzes WCAG Compliant
Make your quiz UI usable by everyone. Practical WCAG compliance for screen readers, keyboard navigation, color contrast, and focus management.
Enjoyed this article?
Share it with your team or try our quiz platform.