Question bank
The question bank ("My Questions" in the teacher console) holds reusable questions on their own, separate from any quiz. Storing a question creates no quiz and uses no AI credits.
| Operation | Endpoint | Scope |
|---|---|---|
| List questions | GET /api/v1/question-bank/questions | api:quiz:read |
| Add questions | POST /api/v1/question-bank/questions | api:quiz:write |
| Get a question | GET /api/v1/question-bank/questions/{questionId} | api:quiz:read |
| Update a question | PATCH /api/v1/question-bank/questions/{questionId} | api:quiz:write |
| Archive a question | DELETE /api/v1/question-bank/questions/{questionId} | api:quiz:write |
The bank uses the quiz scopes: a key that can read or write quizzes can read or write the bank.
Where bank questions come from
Besides the questions you add here, the bank fills itself: whenever a quiz is saved — through /api/v1/quizzes, in the console, or through the Claude connector — each question of a storable type is added to the bank (a question identical to one already there is not added twice). Those rows are what status: "needs_review" is for — a question that was read from a quiz but did not pass the bank's validation.
So after creating or updating a quiz you do not need to post its questions here; doing so stores them a second time, because POST on this page does not deduplicate.
A quiz holds its own copy of its questions, so editing or archiving a bank question never changes a quiz. See Questions, Quizzes, and Collections.
The question object
A question is written and returned in one shape:
| Field | Type | Notes |
|---|---|---|
question_type | string | One of vocab_mcq, cloze_mcq, reading_mcq, subject_mcq, true_false, ordering. |
prompt | string | The question text. |
options | string[] | Every type except ordering: 2–6 unique choices. true_false takes exactly 2 and defaults to ["True", "False"]. |
answer_index | number | 0-based index of the correct option. |
ordered_items | string[] | ordering only: 2–8 items in their correct order. |
passage | string | Required for reading_mcq. |
explanation, hint, instruction | string | Optional. |
image_url | string | Optional absolute http(s) URL. |
matching, short_answer, and fill_in_blank questions cannot be stored in the bank yet; the API answers 400 and names the supported types. Put those in a quiz with POST /api/v1/quizzes.
List questions
GET /api/v1/question-bank/questions?search=chai&limit=20| Query | Notes |
|---|---|
search | Text to find in prompts, options, items, explanations, and passages. |
question_type | One of the types above. |
target_language | Language code, e.g. vi. |
tag | An exact tag. |
status | verified or needs_review (see below). |
set_id | Only questions in this question set. |
limit | 1–100, default 20. |
cursor | next_cursor from the previous page. |
Questions come newest first as summaries (no options or answers). Paging is by cursor because the bank reports no total: pass next_cursor back as cursor until it is null. Archived questions are never listed.
{
"data": [
{ "id": "…", "revision": 1, "question_type": "vocab_mcq", "target_language": "vi",
"status": "verified", "tags": ["food"], "prompt": "What is 'Chai'?",
"created_at": "…", "updated_at": "…" }
],
"next_cursor": null,
"limit": 20
}status is needs_review for a question that was copied from one of your quizzes but did not pass the bank's validation. Saving valid content with PATCH makes it verified.
Add questions
POST /api/v1/question-bank/questions| Field | Type | Required | Notes |
|---|---|---|---|
target_language | string | Yes | Language code shared by every question in the call. |
questions | array | Yes | 1–50 question objects. |
tags | string[] | No | Up to 50 tags, applied to every question in the call. |
Every question is validated before any is saved, so a rejected call stores nothing. The bank does not look for duplicates: sending the same question twice stores it twice.
{
"target_language": "vi",
"tags": ["restaurant"],
"questions": [
{ "question_type": "vocab_mcq", "prompt": "What is 'Chai'?",
"options": ["Bottle", "Can", "Plate"], "answer_index": 0 },
{ "question_type": "ordering", "prompt": "Put the words in order.",
"ordered_items": ["Cho", "anh", "một", "tô", "phở"] }
]
}Returns 201 with { "data": [ …summaries… ] }.
Get a question
GET /api/v1/question-bank/questions/{questionId}Returns the summary fields plus question, the full question object, and revision, which PATCH and DELETE use as a guard.
Update a question
PATCH /api/v1/question-bank/questions/{questionId}| Field | Type | Notes |
|---|---|---|
question | object | The complete replacement question. Saved as a new revision. |
expected_revision | number | The revision you read. Required with question; optional with tags alone. Whenever it is given and no longer matches, the call answers 409. |
target_language | string | Optional; defaults to the stored language. |
tags | string[] | Replaces the tag list. Send [] to clear it. |
Send question, tags, or both. Earlier revisions are kept, and quizzes that were already built from the question keep the revision they used. If the question changed after you read it, the call answers 409; read it again and retry.
Archive a question
DELETE /api/v1/question-bank/questions/{questionId}?expected_revision=3Archiving is the bank's only way to remove a question, and it cannot be undone. Quizzes already built from the question are not affected. expected_revision is optional; when given, the call answers 409 if the question changed after you read it. Returns { "data": { "id": "…", "archived": true } }. Archiving the same id again answers 404.
Errors
| Status | code | Meaning |
|---|---|---|
| 400 | — | The body is not question-shaped (error: "Invalid input" with details). |
| 400 | INVALID_INPUT | A question failed validation; error names the question and the field. |
| 403 | FEATURE_DISABLED | The question bank is not enabled for this account. |
| 404 | NOT_FOUND | No such question in your bank, or it was archived. |
| 409 | REVISION_CONFLICT | expected_revision no longer matches. |