DevPlace Quizzes API
Quiz endpoints for creation, import and export, question authoring and reordering, publishing, and the attempt lifecycle - start, answer, finish, results - plus the leaderboard and scoreboard reads, with a scripted end-to-end walkthrough.
Endpoint reference
A quiz is user-generated content like a gist or a project: it has an owner, a slug, comments, votes, bookmarks and reactions. Any signed-in member authors quizzes, every member plays them, and guests read published ones.
Publishing is terminal. A draft is fully editable; the moment its owner publishes it, the quiz, its questions and its options are frozen forever. There is no unpublish and no post-publish edit, which is what makes two members' scores on the same quiz comparable. Every write endpoint on a published quiz returns 400; only delete still works. Publish validates the whole quiz first and refuses with the exact list of problems.
Playing a quiz creates an attempt. There is at most one in-progress attempt per member per quiz - starting again returns the existing one. Each question can be answered exactly once; a second submit returns 400 and credits nothing. A time limit is stored on the attempt and evaluated lazily on read, so an expired attempt reads as expired with no background process involved.
Seven question kinds are graded deterministically. The eighth, freetext, is graded by the internal AI gateway against the author's criteria and billed to the answering member's own API key. When the gateway is unavailable the answer is still graded, by a deterministic token-overlap fallback, and the answer carries gradedby: "fallback" so the degradation is visible rather than silent. graded_by is one of auto, ai, fallback.
Correct answers are never served to a player mid-attempt. iscorrect on the options and correctboolean / expectedanswer / numericvalue / matchvalue on the question are omitted unless the viewer owns the quiz, or the question has already been answered in this attempt and the quiz has revealanswers on. A public export of a published quiz omits them too; the owner's export includes them.
The scoreboard at /quizzes/scoreboard sums each member's best completed attempt per quiz, never the sum of all attempts, so replaying a quiz can raise a member's contribution to their personal best and never beyond it. Quizzes a member wrote themselves count like any other.
All endpoints negotiate HTML or JSON. POST bodies are form encoded (application/x-www-form-urlencoded). Action POSTs answer {"ok": true, "redirect": "...", "data": {...}}; an invalid domain operation answers 400 as {"error": {"status": 400, "message": "..."}}.
GET /quizzes - Quiz hub
Published quizzes with the viewer's per-quiz state, the filter counts and the cross-quiz scoreboard.
Minimal role: Public
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
search | query | string | no | Match the title, description or author username. |
filter | query | enum | no | Which quizzes to list. Allowed: all, todo, done, mine, drafts. |
page | query | integer | no | 1-based page number. |
Sample response
{
"quizzes": [
{
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals",
"url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"title": "SQLite fundamentals",
"status": "published",
"question_count": 10,
"total_points": 14,
"attempt_count": 23,
"time_limit_seconds": 900,
"pass_percent": 70,
"viewer_owns": false,
"viewer_can_edit": false,
"viewer_can_play": true,
"viewer_state": "todo",
"validation_errors": [],
"viewer_best_percent": 0.0,
"comment_count": 3,
"stars": 5
}
],
"filter": "all",
"counts": {
"all": 12,
"todo": 9,
"done": 3,
"mine": 2,
"drafts": 1
},
"pagination": {
"page": 1,
"total": 12,
"total_pages": 1
},
"scoreboard": [
{
"rank": 1,
"user": {
"username": "alice"
},
"total_points": 84.0,
"quizzes_completed": 7,
"avg_percent": 88.4,
"perfect_count": 2
}
],
"viewer_can_create": true
}
GET /quizzes/scoreboard - Quiz scoreboard
Score per user across every published quiz, counting each member's best attempt per quiz. Cached about 15 seconds.
Minimal role: Public
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
limit | query | integer | no | How many entries to return, up to 100. |
Sample response
{
"scoreboard": [
{
"rank": 1,
"user": {
"username": "alice"
},
"total_points": 84.0,
"quizzes_completed": 7,
"avg_percent": 88.4,
"perfect_count": 2
}
],
"viewer_standing": null,
"limit": 20
}
GET /quizzes/new - New quiz form
The create form behind the New quiz button.
Minimal role: Member
Sample response
{
"viewer_can_create": true
}
POST /quizzes/create - Create a quiz
Create a draft quiz. Add its questions afterwards, then publish it.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
title | form | string | yes | 3 to 200 characters. |
description | form | string | no | Markdown, up to 5000 characters. |
shuffle_questions | form | boolean | no | Shuffle the question order per attempt. |
shuffle_options | form | boolean | no | Shuffle the answer options. |
reveal_answers | form | boolean | no | Reveal the correct answer after each question. |
allow_review | form | boolean | no | Allow reviewing every answer on the results screen. |
timelimitseconds | form | integer | no | 0 for no limit, up to 86400. |
pass_percent | form | integer | no | 0 to 100, 0 for no pass or fail verdict. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
"data": {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals"
}
}
POST /quizzes/import - Import a quiz document
Create a complete quiz - metadata, settings, every question and every option - from one JSON document. Capped at 100 questions and 12 options per question.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
document | form | string | yes | The complete quiz as a JSON string. See the export endpoint for the exact shape. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
"data": {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals",
"question_count": 10
}
}
GET /quizzes/{slug} - Quiz detail
One quiz with its stats, its leaderboard, its comments and the viewer's own state. A draft is visible only to its owner and to administrators.
Minimal role: Public
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
Sample response
{
"quiz": {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals",
"url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"title": "SQLite fundamentals",
"status": "published",
"question_count": 10,
"total_points": 14,
"attempt_count": 23,
"time_limit_seconds": 900,
"pass_percent": 70,
"viewer_owns": false,
"viewer_can_edit": false,
"viewer_can_play": true,
"viewer_state": "todo",
"validation_errors": []
},
"leaderboard": [],
"comments": [],
"viewer_state": "todo",
"star_count": 5
}
GET /quizzes/{slug}/export - Export a quiz
The full quiz document, the exact inverse of the import endpoint. The owner gets every correct answer; everyone else gets the questions without the key.
Minimal role: Public
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
Sample response
{
"title": "SQLite fundamentals",
"description": "Ten questions on WAL, indexing and transactions.",
"settings": {
"shuffle_questions": true,
"reveal_answers": true,
"pass_percent": 70,
"time_limit_seconds": 900
},
"questions": [
{
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers and one writer?",
"points": 1,
"options": [
{
"label": "DELETE"
},
{
"label": "WAL",
"is_correct": true
}
]
}
]
}
GET /quizzes/{slug}/leaderboard - Quiz leaderboard
Top completed attempts on one quiz, best percentage first.
Minimal role: Public
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
limit | query | integer | no | How many entries to return, up to 100. |
Sample response
{
"quiz_uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"entries": [
{
"rank": 1,
"user": {
"username": "bob"
},
"score_points": 13.0,
"score_percent": 92.86,
"passed": true,
"completed_at": "2026-07-25T10:00:00+00:00"
}
]
}
GET /quizzes/{slug}/edit - Quiz builder
The owner's builder page: the quiz, every question with its answer key, the question-kind catalogue and the live pre-publish checklist.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
Sample response
{
"quiz": {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals",
"url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"title": "SQLite fundamentals",
"status": "published",
"question_count": 10,
"total_points": 14,
"attempt_count": 23,
"time_limit_seconds": 900,
"pass_percent": 70,
"viewer_owns": false,
"viewer_can_edit": false,
"viewer_can_play": true,
"viewer_state": "todo",
"validation_errors": []
},
"questions": [],
"kinds": [
{
"key": "single_choice",
"label": "Single choice",
"has_options": true
}
],
"validation_errors": [
"Add at least one question before publishing."
]
}
POST /quizzes/edit/{slug} - Edit a quiz
Change the title, description and settings of a DRAFT quiz. 400 once published.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
title | form | string | yes | 3 to 200 characters. |
description | form | string | no | Markdown, up to 5000 characters. |
shuffle_questions | form | boolean | no | Shuffle the question order per attempt. |
shuffle_options | form | boolean | no | Shuffle the answer options. |
reveal_answers | form | boolean | no | Reveal the correct answer after each question. |
allow_review | form | boolean | no | Allow reviewing every answer on the results screen. |
timelimitseconds | form | integer | no | 0 for no limit, up to 86400. |
pass_percent | form | integer | no | 0 to 100, 0 for no pass or fail verdict. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals"
}
POST /quizzes/{slug}/publish - Publish a quiz
IRREVERSIBLE. Freezes the quiz, its questions and its options forever. Refuses with the validation problems when the quiz is incomplete.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"data": {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"status": "published"
}
}
POST /quizzes/delete/{slug} - Delete a quiz
Owner or administrator. Removes the quiz with its questions, options, attempts and answers. The only operation left on a published quiz.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
Sample response
{
"ok": true,
"redirect": "/quizzes"
}
POST /quizzes/{slug}/questions - Add a question
Append one question with its options to a DRAFT quiz. 400 once published.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
kind | form | enum | yes | The question kind. Allowed: singlechoice, multiplechoice, truefalse, freetext, fill_blank, numeric, ordering, matching. |
prompt | form | string | yes | Markdown, up to 2000 characters. |
points | form | integer | no | 1 to 100. |
explanation | form | string | no | Shown after answering. |
options | form | string | no | Option labels, one per line or comma separated. |
match_values | form | string | no | Accepted answers aligned with the options, for fill_blank and matching. |
correct_indexes | form | string | yes | 0-based indexes of the correct options, comma separated. Required for choice questions. |
correct_boolean | form | boolean | no | true_false only: the statement is true. |
expected_answer | form | string | no | free_text only: the reference answer. |
grading_criteria | form | string | no | free_text only: criteria for the AI reviewer. |
numeric_value | form | number | no | numeric only: the correct value. |
numeric_tolerance | form | number | no | numeric only: accepted absolute tolerance. |
case_sensitive | form | boolean | no | fill_blank only: compare case sensitively. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
"data": {
"uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
"position": 0
}
}
POST /quizzes/{slug}/questions/{question_uid} - Edit a question
Replace one question and its options on a DRAFT quiz. 400 once published.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
question_uid | path | string | yes | The question uid. |
kind | form | enum | yes | The question kind. Allowed: singlechoice, multiplechoice, truefalse, freetext, fill_blank, numeric, ordering, matching. |
prompt | form | string | yes | Markdown, up to 2000 characters. |
points | form | integer | no | 1 to 100. |
explanation | form | string | no | Shown after answering. |
options | form | string | no | Option labels, one per line or comma separated. |
match_values | form | string | no | Accepted answers aligned with the options. |
correct_indexes | form | string | yes | 0-based indexes of the correct options. |
correct_boolean | form | boolean | no | true_false only. |
expected_answer | form | string | no | free_text only. |
grading_criteria | form | string | no | free_text only. |
numeric_value | form | number | no | numeric only. |
numeric_tolerance | form | number | no | numeric only. |
case_sensitive | form | boolean | no | fill_blank only. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"
}
POST /quizzes/{slug}/questions/{question_uid}/delete - Delete a question
Remove one question and its options from a DRAFT quiz, then renumber.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
question_uid | path | string | yes | The question uid. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"
}
POST /quizzes/{slug}/questions/reorder - Reorder the questions
Set a new question order on a DRAFT quiz. Every uid must be listed exactly once.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
order | form | string | yes | Every question uid in the wanted order, comma separated. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"
}
POST /quizzes/{slug}/attempts - Start or resume an attempt
Returns the member's single in-progress attempt, creating it when there is none. The question order and one blank answer row per question are materialized at start.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
Sample response
{
"ok": true,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/attempts/0198f2c0-3333-7aaa-8bbb-000000000003",
"data": {
"uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
"status": "in_progress"
}
}
GET /quizzes/{slug}/attempts/{attempt_uid} - Read an attempt
The attempt with its questions in play order. Correct answers are withheld until a question is answered and the quiz reveals answers.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
attempt_uid | path | string | yes | The attempt uid. |
Sample response
{
"quiz": {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals",
"url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"title": "SQLite fundamentals",
"status": "published",
"question_count": 10,
"total_points": 14,
"attempt_count": 23,
"time_limit_seconds": 900,
"pass_percent": 70,
"viewer_owns": false,
"viewer_can_edit": false,
"viewer_can_play": true,
"viewer_state": "todo",
"validation_errors": []
},
"attempt": {
"uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
"status": "in_progress",
"remaining_seconds": 812,
"answered_count": 2,
"question_count": 10,
"score_points": 2.0,
"max_points": 14,
"score_percent": 14.29,
"questions": [
{
"uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers?",
"points": 1,
"options": [
{
"uid": "opt-a",
"label": "DELETE"
},
{
"uid": "opt-b",
"label": "WAL"
}
]
}
]
}
}
POST /quizzes/{slug}/attempts/{attempt_uid}/answer - Answer a question
Grade and record one answer. Each question can be answered exactly once; a second submit answers 400 and credits nothing.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
attempt_uid | path | string | yes | The attempt uid. |
question_uid | form | string | yes | The question being answered. |
answer_text | form | string | no | Free text, the numeric value, or true/false. |
option_uids | form | string | no | Chosen option uids, comma separated and in order for ordering. |
blanks | form | string | no | fill_blank only: one answer per blank, comma separated. |
matches | form | string | no | matching only: the chosen right-hand value per option_uid, in order. |
Sample response
{
"ok": true,
"answer": {
"question_uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
"answered": true,
"is_correct": true,
"awarded_points": 1.0,
"feedback": "Correct.",
"graded_by": "auto",
"confidence": 1.0
},
"attempt": {
"answered_count": 3,
"score_points": 3.0,
"max_points": 14
}
}
POST /quizzes/{slug}/attempts/{attempt_uid}/finish - Finish an attempt
Close the attempt and compute the final score from its answer rows. A second finish returns the same result and awards nothing again.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
attempt_uid | path | string | yes | The attempt uid. |
Sample response
{
"quiz": {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals",
"url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"title": "SQLite fundamentals",
"status": "published",
"question_count": 10,
"total_points": 14,
"attempt_count": 23,
"time_limit_seconds": 900,
"pass_percent": 70,
"viewer_owns": false,
"viewer_can_edit": false,
"viewer_can_play": true,
"viewer_state": "todo",
"validation_errors": []
},
"attempt": {
"status": "completed",
"score_points": 13.0,
"max_points": 14,
"score_percent": 92.86,
"passed": true
},
"review": [],
"fallback_count": 0
}
GET /quizzes/{slug}/attempts/{attempt_uid}/results - Attempt results
The result of one attempt: score, percentage, pass verdict, and the per-question review when the author allowed it. Attempt owner or admin.
Minimal role: Member
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | Quiz slug or uid. |
attempt_uid | path | string | yes | The attempt uid. |
Sample response
{
"quiz": {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals",
"url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"title": "SQLite fundamentals",
"status": "published",
"question_count": 10,
"total_points": 14,
"attempt_count": 23,
"time_limit_seconds": 900,
"pass_percent": 70,
"viewer_owns": false,
"viewer_can_edit": false,
"viewer_can_play": true,
"viewer_state": "todo",
"validation_errors": []
},
"attempt": {
"status": "completed",
"score_percent": 92.86,
"passed": true
},
"review": [],
"fallback_count": 0
}
Driving a quiz from a script
Everything above is available over the JSON API. This client imports a quiz, publishes it, plays it end to end and prints the result, using only the standard library.
import json
import urllib.request
BASE = "https://devplace.net"
API_KEY = "63995001-2291-45bf-9fe3-45dae21cfcee"
def call(method, path, fields=None):
data = urllib.parse.urlencode(fields, doseq=True).encode() if fields else None
request = urllib.request.Request(f"{BASE}{path}", data=data, method=method)
request.add_header("Authorization", f"Bearer {API_KEY}")
request.add_header("Accept", "application/json")
if data:
request.add_header("Content-Type", "application/x-www-form-urlencoded")
with urllib.request.urlopen(request) as response:
return json.loads(response.read())
document = {
"title": "SQLite fundamentals",
"description": "Three questions on WAL and indexing.",
"settings": {"reveal_answers": True, "pass_percent": 60},
"questions": [
{
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers and one writer?",
"points": 1,
"options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}],
},
{
"kind": "true_false",
"prompt": "A partial index can carry a WHERE clause.",
"points": 1,
"correct_boolean": True,
},
{
"kind": "numeric",
"prompt": "How many bytes are in a kibibyte?",
"points": 1,
"numeric_value": 1024,
},
],
}
created = call("POST", "/quizzes/import", {"document": json.dumps(document)})
slug = created["data"]["slug"]
call("POST", f"/quizzes/{slug}/publish", {"confirm": "true"})
started = call("POST", f"/quizzes/{slug}/attempts")
attempt_uid = started["data"]["uid"]
attempt = call("GET", f"/quizzes/{slug}/attempts/{attempt_uid}")
for question in attempt["attempt"]["questions"]:
fields = {"question_uid": question["uid"]}
if question["kind"] == "single_choice":
fields["option_uids"] = question["options"][1]["uid"]
elif question["kind"] == "true_false":
fields["answer_text"] = "true"
else:
fields["answer_text"] = "1024"
call("POST", f"/quizzes/{slug}/attempts/{attempt_uid}/answer", fields)
result = call("POST", f"/quizzes/{slug}/attempts/{attempt_uid}/finish")
print(result["attempt"]["score_percent"], "percent")
The complete request and response reference is the Quizzes API group.