--- title: "DevPlace Quizzes API" description: "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." language: null framework: null category: api_design tags: - devplace - api - http-api - quizzes - rest - crud - attempts - grading - import-export - automation keywords: - devplace POST /quizzes/create import json - devplace quiz questions reorder endpoint - devplace quiz attempt answer finish results api - devplace quiz export leaderboard endpoint - devplace drive quiz from script last_updated: 2026-08-12 difficulty: intermediate version: "DevPlace (devplace.net), documented 2026-08" related: - README.md - authentication.md - conventions_and_errors.md search_priority: normal status: published --- # DevPlace Quizzes API ## 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, `free_text`, 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 `graded_by: "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.** `is_correct` on the options and `correct_boolean` / `expected_answer` / `numeric_value` / `match_value` 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 `reveal_answers` 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** ```json { "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** ```json { "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** ```json { "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. | | `time_limit_seconds` | 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** ```json { "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** ```json { "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** ```json { "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** ```json { "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** ```json { "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** ```json { "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. | | `time_limit_seconds` | 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** ```json { "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** ```json { "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** ```json { "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: single_choice, multiple_choice, true_false, free_text, 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** ```json { "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: single_choice, multiple_choice, true_false, free_text, 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** ```json { "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** ```json { "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** ```json { "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** ```json { "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** ```json { "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** ```json { "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** ```json { "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** ```json { "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. ```python 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](#driving-a-quiz-from-a-script).