platforms/devplace/quizzes_api.md

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

NameInTypeRequiredDescription
searchquerystringnoMatch the title, description or author username.
filterqueryenumnoWhich quizzes to list. Allowed: all, todo, done, mine, drafts.
pagequeryintegerno1-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

NameInTypeRequiredDescription
limitqueryintegernoHow 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

NameInTypeRequiredDescription
titleformstringyes3 to 200 characters.
descriptionformstringnoMarkdown, up to 5000 characters.
shuffle_questionsformbooleannoShuffle the question order per attempt.
shuffle_optionsformbooleannoShuffle the answer options.
reveal_answersformbooleannoReveal the correct answer after each question.
allow_reviewformbooleannoAllow reviewing every answer on the results screen.
timelimitsecondsformintegerno0 for no limit, up to 86400.
pass_percentformintegerno0 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

NameInTypeRequiredDescription
documentformstringyesThe 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

NameInTypeRequiredDescription
slugpathstringyesQuiz 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

NameInTypeRequiredDescription
slugpathstringyesQuiz 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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
limitqueryintegernoHow 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

NameInTypeRequiredDescription
slugpathstringyesQuiz 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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
titleformstringyes3 to 200 characters.
descriptionformstringnoMarkdown, up to 5000 characters.
shuffle_questionsformbooleannoShuffle the question order per attempt.
shuffle_optionsformbooleannoShuffle the answer options.
reveal_answersformbooleannoReveal the correct answer after each question.
allow_reviewformbooleannoAllow reviewing every answer on the results screen.
timelimitsecondsformintegerno0 for no limit, up to 86400.
pass_percentformintegerno0 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

NameInTypeRequiredDescription
slugpathstringyesQuiz 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

NameInTypeRequiredDescription
slugpathstringyesQuiz 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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
kindformenumyesThe question kind. Allowed: singlechoice, multiplechoice, truefalse, freetext, fill_blank, numeric, ordering, matching.
promptformstringyesMarkdown, up to 2000 characters.
pointsformintegerno1 to 100.
explanationformstringnoShown after answering.
optionsformstringnoOption labels, one per line or comma separated.
match_valuesformstringnoAccepted answers aligned with the options, for fill_blank and matching.
correct_indexesformstringyes0-based indexes of the correct options, comma separated. Required for choice questions.
correct_booleanformbooleannotrue_false only: the statement is true.
expected_answerformstringnofree_text only: the reference answer.
grading_criteriaformstringnofree_text only: criteria for the AI reviewer.
numeric_valueformnumbernonumeric only: the correct value.
numeric_toleranceformnumbernonumeric only: accepted absolute tolerance.
case_sensitiveformbooleannofill_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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
question_uidpathstringyesThe question uid.
kindformenumyesThe question kind. Allowed: singlechoice, multiplechoice, truefalse, freetext, fill_blank, numeric, ordering, matching.
promptformstringyesMarkdown, up to 2000 characters.
pointsformintegerno1 to 100.
explanationformstringnoShown after answering.
optionsformstringnoOption labels, one per line or comma separated.
match_valuesformstringnoAccepted answers aligned with the options.
correct_indexesformstringyes0-based indexes of the correct options.
correct_booleanformbooleannotrue_false only.
expected_answerformstringnofree_text only.
grading_criteriaformstringnofree_text only.
numeric_valueformnumbernonumeric only.
numeric_toleranceformnumbernonumeric only.
case_sensitiveformbooleannofill_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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
question_uidpathstringyesThe 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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
orderformstringyesEvery 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

NameInTypeRequiredDescription
slugpathstringyesQuiz 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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
attempt_uidpathstringyesThe 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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
attempt_uidpathstringyesThe attempt uid.
question_uidformstringyesThe question being answered.
answer_textformstringnoFree text, the numeric value, or true/false.
option_uidsformstringnoChosen option uids, comma separated and in order for ordering.
blanksformstringnofill_blank only: one answer per blank, comma separated.
matchesformstringnomatching 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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
attempt_uidpathstringyesThe 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

NameInTypeRequiredDescription
slugpathstringyesQuiz slug or uid.
attempt_uidpathstringyesThe 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.