MakeQuestions API by Karson AI
API Reference
Generate educational questions from text, images, and documents with the Karson AI API for question generation. The API supports 8 question types, automatic question planning, reasoning mode, optional live web research, follow-up turns, and real-time streaming.
Supported question types
- multiple_choice
- true_false
- short_answer
- exact_answer
- reorder
- code-output
- matching
- fill-in-the-blank
Base URL
https://api.makequestions.aiEndpoints
| Method | Path | Purpose |
|---|---|---|
POST | /generate-questions | Question turns — fresh, or continued with response_id. |
POST | /follow-up | Text turns — inline-explanation and chat. |
GET | /config | Active limits and runtime configuration. No API key required. |
GET | /health | Service and dependency health. No API key required. |
GET | / | Service banner. No API key required. |
Authentication
Authenticate requests by including your API key in the X-API-Key header.
To get an API key, email support@karson.ai.
curl https://api.makequestions.ai/generate-questions \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"context": "Your text here...",
"question_config": {
"type_counts": {
"true_false": 3,
"multiple_choice": 2,
"short_answer": 1,
"fill-in-the-blank": 1,
"reorder": 1
},
"difficulty": "medium"
}
}'Generate Questions (POST /generate-questions)
The canonical question endpoint. Omit response_id to start a fresh question chain; send it to continue an existing one.
Unknown top-level keys are rejected with 422 (extra="forbid"), so a typo fails loudly rather than being ignored.
Source material
Every origin turn must carry at least one of context, attachments, or image_urls. A request with none of them — and no response_id — returns 422:
Provide 'response_id', 'context' text, at least one attachment, or at least one image_url.
Continuation turns (those with response_id) may omit source material entirely; the chain carries the prior context. input is steering only and never counts as source material — neither does question_history.
Parameters
response_idstring | null
Continuation handle returned by a previous turn. Omit it for an origin turn; provide it to continue that question chain (this replaces the removed more-questions follow-up intent).
contextstring | nullconditionally required
The input text to generate questions on (e.g. an article, a book chapter, content instructions, or other learning material). Max 200,000 characters on the production API (max_context_length is deployment-tunable — read the live value from GET /config). Required unless response_id, attachments, or image_urls is provided. Whitespace-only values are treated as omitted, so they do not satisfy the source-material rule.
question_configobjectoptional
Controls which question types to generate and (optionally) the difficulty band. Omit it to let the server plan the question set for you — that is the default behavior.
There are three accepted shapes:
| Shape | Behavior |
|---|---|
| Key omitted | Auto planning. The server infers types and counts from your source material. |
{ "mode": "auto", "difficulty"?: … } | Explicit auto planning. Sending type_counts alongside mode: "auto" returns 422. |
{ "type_counts": { … }, "difficulty"?: … } | Manual control (see below). |
Sending an explicit "question_config": null returns 422 — omit the key instead.
Whichever shape you use, the resolved plan comes back as generation_plan on the response (and as the plan SSE event when streaming).
mode"auto"optional
Set to "auto" to have the server infer question types and counts from your source material. Forbidden together with type_counts — sending both returns 422. Omitting question_config entirely has the same effect as mode: "auto".
type_countsmaprequired unless mode: "auto"
- Each key must be one of the 8 concrete question types. If a type is not present in the map, the response will not include questions of that type.
- Each value must be one of:
- A positive integer — fixed number of questions for that type.
null— the model chooses the count; you always receive at least one question of that type (the server's agentic filler tops up any requested type that would otherwise return zero).0— exclude that type (same as omitting the key). Silently dropped during validation.
- After dropping
0values,type_countsmust still contain at least one entry — an emptytype_counts, or atype_countswhose values are all0, returns422.
difficulty"easy" | "medium" | "hard" | nulloptional
Omit the field or set null for mixed difficulty; otherwise use "easy", "medium", or "hard". Applies to both auto and manual planning.
question_config examples
1. Auto planning (omit the key entirely)
{
"context": "Photosynthesis is the process by which plants convert sunlight into energy."
}2. Explicit auto planning with a difficulty band
{
"mode": "auto",
"difficulty": "medium"
}3. Selected types with exact counts
{
"type_counts": {
"multiple_choice": 3,
"true_false": 2,
"short_answer": 1
},
"difficulty": "medium"
}4. Selected types, model-chosen counts (null per type)
{
"type_counts": {
"multiple_choice": null,
"true_false": null
},
"difficulty": null
}5. Mix of exact counts and null
{
"type_counts": {
"multiple_choice": 3,
"true_false": null,
"short_answer": 1
}
}How counts are enforced. With a manual type_counts, the server validates what the model returned and then corrects it: types you did not request are dropped, a shortfall triggers an agentic fill loop that requests the missing questions on the same chain, and any excess beyond an exact integer target is trimmed. What happened is reported in validation (requested vs actual) and agent_metadata (iterations, corrections, and fill_attempts_exhausted if the filler gave up). A null count is only guaranteed to yield at least one of that type.
allowed_question_typesstring[] | null
Optional allowlist constraining which question types the planner (or a manual type_counts) may use. Must be non-empty when provided. A manual type_counts containing a type outside the allowlist returns 422.
{ "allowed_question_types": ["multiple_choice", "true_false"] }max_questionsinteger | null
Optional positive cap on the number of generated questions. Must be > 0 when provided. With a manual type_counts, the minimum required questions (each positive count contributes its value; each null count contributes 1) must not exceed max_questions, otherwise the request returns 422.
inputstring | null
Optional steering text, separate from context. Does not replace the source material; it nudges tone, difficulty of wording, or what to emphasize. Bound by the same max_context_length as context (200,000 on production), enforced independently. Whitespace-only values are treated as omitted.
Examples:
Focus on exam-style wording; use plausible distractors.Keep language at a high-school reading level; avoid undefined jargon.Prioritize conceptual understanding over rote memorization.Multiple choice with 3 options or 5 options per question.
attachmentsobject[] | null
Optional HTTPS file attachments for this turn — the preferred way to send files. Each item is { "url", "mime_type", "name"? }, and unknown keys inside an attachment are rejected with 422:
url(required): public HTTPS URL. The model fetches it directly.mime_type(required): must be in the supported allow-list below.name(optional): original filename, used as a prompt hint.
Images are sent as vision input; documents are read directly as source material — no need to pre-extract text.
| Kind | Supported mime_type | Max per request |
|---|---|---|
| Image | image/jpeg, image/png, image/gif, image/webp | 1,500 |
| Document | application/pdf, application/msword (.doc), application/vnd.openxmlformats-officedocument.wordprocessingml.document (.docx), text/plain | 50 |
All file URLs must be HTTPS and ≤ 8,192 characters; duplicate URLs are dropped server-side. Prefer attachments over legacy image_urls.
The 1,500 image cap is shared. It applies to the union of image attachments and image_urls after de-duplication — not 1,500 of each. Exceeding the combined total returns 422. Per-file and per-request byte limits are enforced upstream by the model provider, not by this API.
image_urlsstring[] | null
Legacy HTTPS image URLs (max 1,500 shared with image attachments, deduplicated, each URL ≤ 8,192 chars). New clients should prefer attachments.
reasoningboolean
Reasoning model path — the default. Adds reasoning_summary to the response (and reasoning_summary_part / reasoning_summary SSE events when streaming), and bills reasoning tokens. Set to false for instant mode (no reasoning tokens, no summary). Default: true.
web_searchboolean
When true, the server runs an internal web-research phase before generation: it issues live web searches, assembles a cited markdown source brief, and uses that brief as primary source material for the questions. Default: false (strictly opt-in).
- The outcome is reported in the
web_researchresponse object. Streaming emits dedicated web-research events before the questions. - Questions generated this way may carry
source_ids— the[n]markers from the brief they drew on. These are best-effort hints, not a contract. - Research runs on its own model and is independent of the
reasoningflag, soreasoning: falsekeeps its zero-reasoning-token contract. - If research fails, generation proceeds without the brief and
web_research.statusis"failed"— the request does not error. - Expect roughly +4–10 s of latency and +$0.02–0.05 per request when enabled.
question_historyobject | null
Optional bounded metadata about questions your app already holds, used for duplicate avoidance and coverage steering. It is not source material and does not satisfy the source-material requirement.
{
"items": [
{
"type": "multiple_choice",
"question": "What pigment makes plants green?",
"answer": "Chlorophyll",
"status": "active",
"sequence": 1,
"id": "q-1",
"tags": ["biology"],
"source_ids": [2]
}
],
"omitted_count": 0
}items: at most 200 entries — more returns422. Cap client-side and report the remainder inomitted_count.typeandquestionare required per item.statusis one of"active" | "archived" | "alternative"and defaults to"active"(legacy"pending"normalizes to"active"); any other value is a422.sequencemust be>= 1,idmust be<= 128characters, and each item rejects unknown keys — all three are422s, not silent drops.- Silently truncated server-side:
questionto 300 chars,answerto 150 chars,tagsto 20 × 64 chars,source_idsto 50 entries. - Listed questions become an avoid-list in the generation prompt, and near-duplicate candidates are dropped. Drops are reported in
agent_metadata.history_duplicate_drops(anddone.question_historywhen streaming). /follow-updoes not accept this field — sending it there is a422.
ui_language"en" | "vi"
App-UI locale at generation time. Default "en". It sets the output language only when the input is language-ambiguous (e.g. a topic-only prompt) — a clear content language in the source material always wins, so a Vietnamese passage yields Vietnamese questions regardless of this value. Unknown or invalid values (including null) are silently treated as "en"; they never produce a 4xx.
streamingboolean
When true, streams questions via SSE from the same endpoint. Default: false (full JSON response).
Request example
{
"context": "Photosynthesis is the process by which plants convert sunlight into energy.",
"question_config": {
"type_counts": {
"multiple_choice": 2,
"true_false": 1
},
"difficulty": "medium"
},
"input": "Use concise, exam-style wording.",
"attachments": [
{
"url": "https://cdn.example.com/photosynthesis-notes.pdf",
"mime_type": "application/pdf",
"name": "photosynthesis-notes.pdf"
}
],
"reasoning": true,
"streaming": false
}Response example
{
"success": true,
"response_id": "resp_abc123",
"total_questions": 3,
"questions": [
{
"type": "multiple_choice",
"difficulty": "medium",
"question": "What do plants convert into energy?",
"choices": [
{ "key": "c1", "label": "Sunlight" },
{ "key": "c2", "label": "Water" },
{ "key": "c3", "label": "Soil" },
{ "key": "c4", "label": "Air" }
],
"answer": { "value": "c1" },
"id": "8b2f0a5c-6e5e-4a1b-9b5e-9a9f1c4e6a7d",
"created_at": "2026-04-22T17:00:00.481920+00:00"
},
{
"type": "multiple_choice",
"difficulty": "medium",
"question": "Which organelle is the main site of photosynthesis?",
"choices": [
{ "key": "c1", "label": "Chloroplast" },
{ "key": "c2", "label": "Mitochondrion" },
{ "key": "c3", "label": "Nucleus" },
{ "key": "c4", "label": "Ribosome" }
],
"answer": { "value": "c1" },
"id": "c4d8e1f2-3a4b-5c6d-7e8f-901234abcdef",
"created_at": "2026-04-22T17:00:01.481920+00:00"
},
{
"type": "true_false",
"difficulty": "medium",
"question": "Photosynthesis releases oxygen as a byproduct.",
"choices": [
{ "key": "true", "label": "True" },
{ "key": "false", "label": "False" }
],
"answer": { "value": "true" },
"id": "d5e9f0a3-4b5c-6d7e-8f90-123456789abc",
"created_at": "2026-04-22T17:00:02.481920+00:00"
}
],
"generation_plan": {
"source": "client_exact",
"type_counts": { "multiple_choice": 2, "true_false": 1 },
"difficulty": "medium"
},
"validation": {
"requested_counts": { "multiple_choice": 2, "true_false": 1 },
"actual_counts": { "multiple_choice": 2, "true_false": 1 },
"is_exact_match": true
}
}response_idis the continuation handle for this model session — pass it back asresponse_idon the nextPOST /generate-questions(for more questions) orPOST /follow-up(for an explanation or chat turn).- Each question is type-scoped: only the keys meaningful to its
typeare present (nonullplaceholders). See Response Schema. id(UUID) andcreated_at(ISO 8601, UTC offset form with microseconds) are added server-side on every question.reasoning_summaryappears wheneverreasoningis on — which is the default (sendreasoning: falseto suppress it).- For
multiple_choicequestions, server-side enrichment shuffleschoicesand re-keys them toc1, c2, …in the emitted order, then remapsanswer.valueto the new key. Other types'choicesare emitted in canonical order.
Optional response keys
On the batch JSON response these keys are always present and carry null when they do not apply — test for a truthy value, not for key presence. On the SSE done event they are genuinely omitted when they do not apply. (The strip-unused-keys rule applies to the question objects themselves, not to this envelope.)
| Key | When present | Shape |
|---|---|---|
generation_plan | Every generation turn | { source, type_counts, difficulty, reasoning_summary? } — the effective plan. source is client_exact / client_soft for manual type_counts, auto_default (config omitted) / auto_explicit (mode: "auto") for planner runs, or an auto_fallback_* variant when the planner failed and defaults were used. |
validation | Every generation turn (the resolved plan always supplies requested counts) | { requested_counts, actual_counts, is_exact_match }, plus errors / warnings (string arrays) when the counts did not line up. |
agent_metadata | Batch: every turn (iterations: 0 when no fill was needed). Streaming done: only when fill actually ran | { iterations, response_chain, corrections, total_latency_ms, fill_attempts_exhausted, success?, final_counts?, history_duplicate_drops? }. |
web_research | web_search: true | { status: "completed" | "failed", queries: string[], sources: [{ n, url, title, published_date }], brief, brief_chars }. brief is the full source brief generation saw; n matches the [n] markers used inside it and in each question's source_ids. |
reasoning_summary | reasoning: true | [{ "text": "…", "type": "summary_text" }]. |
Follow-up (POST /follow-up)
Continue a session by sending its response_id in the request body. The server keeps prior context — you usually only add new instructions. Unknown JSON keys are rejected with 422.
Generating more questions? That moved to POST /generate-questions — send the prior turn's response_id there to continue the question chain. /follow-up no longer accepts request_type: "more-questions" (it returns 422).
Request types
There are two ways to continue a conversation on this endpoint. Pick one by setting request_type:
inline-explanation
Produce a short explanation for one question: put the question wording (and optional identifiers) in input. The model uses the thread's prior context and answers. Response is an explanation string in batch mode, or an explanation_text delta stream followed by a final explanation event when streaming: true.
chat
Free-form tutoring chat about the material in the thread: put the user's message in input. There is no batch JSON mode — the response is always SSE (text deltas, then done with the full message).
Parameters
request_typestringrequired
"inline-explanation" or "chat" — see Request types above.
response_idstring | null
The response_id returned by the previous turn (from /generate-questions or a prior /follow-up). Required for both inline-explanation and chat — it references the thread's conversation context.
contextstring | null
Optional extra learning material for this turn; does not replace the stored conversation context. Bound by the same max_context_length as on /generate-questions (200,000 on production).
inputstring | null
Required non-empty string for both intents — the question text to explain (inline-explanation), or the user's chat message (chat).
attachmentsobject[] | null
New files for this turn only, same shape and limits as on /generate-questions. Do not re-send attachments from earlier turns — the model retains them through the response chain. Send only what is newly added.
image_urlsstring[] | null
Legacy HTTPS image URLs (max 1,500 shared with image attachments, deduplicated, each URL ≤ 8,192 chars) for this turn. As with attachments, images from earlier turns in the chain do not need to be re-sent. New clients should prefer attachments.
reasoningboolean
Reasoning model path — the default. Adds reasoning_summary to the response (and reasoning_summary_part / reasoning_summary SSE events when streaming), and bills reasoning tokens. Set to false for instant mode (no reasoning tokens, no summary). Default: true.
web_searchboolean
Runs a web-research phase before this follow-up, exactly as on /generate-questions. When response_id is present the research is chained from that prior response, and the resulting brief is injected into the follow-up prompt. Default: false. The outcome appears as web_research on the JSON response, or as the dedicated web-research SSE events when streaming.
ui_language"en" | "vi"
Accepted for symmetry with /generate-questions (default "en"; unknown values are treated as "en"). Text follow-ups normally keep the language already established in the conversation.
streamingboolean
When true, inline-explanation returns SSE like /generate-questions. chat is always SSE; this flag does not apply. Default: false.
/follow-up does not accept question_config, allowed_question_types, max_questions, or question_history — those belong on /generate-questions, and sending them here returns 422.
Request example — inline-explanation
{
"response_id": "resp_abc123",
"request_type": "inline-explanation",
"input": "Why is the answer 'True' for the photosynthesis question?",
"reasoning": true,
"streaming": false
}Response example — inline-explanation
{
"success": true,
"response_id": "resp_def456",
"explanation": "The correct answer is …"
}chat has no non-streaming JSON body — responses are always SSE (event: text deltas, then event: done with message and response_id). See Streaming.
Streaming
Stream responses in real-time using Server-Sent Events (SSE) by setting streaming: true in your request. chat is always streamed regardless of the flag. The exact event mix depends on the request type.
Clients should ignore event names they do not recognize — new event types are added additively.
Common events
| Event | Data | Notes |
|---|---|---|
reasoning_summary_part | { "index": <0-based> } | Marks the start of a new reasoning summary part. Only when reasoning: true. |
reasoning_summary | { "text": "<delta>", "index": <0-based> } | Incremental text delta for the current reasoning part. Only when reasoning: true. |
error | { "error": "<message>" } | Failure event; connection closes after. |
Web-research events (web_search: true only)
Emitted on both endpoints, before the intent's own stream begins.
| Event | Data | Notes |
|---|---|---|
web_search_started | { "status": "searching" } | The research phase has begun. |
web_research_progress | { "stage": "searching", "query": "..." } or { "stage": "source", "url": "...", "title": "..." } | One event per search issued and per source cited. |
web_research_delta | { "text": "<delta>" } | The source brief being written. |
web_research | { "web_research": { "status": "completed" | "failed", "queries": [...], "sources": [{ "n", "url", "title", "published_date" }], "brief": "...", "brief_chars": <int> } } | The authoritative research payload, nested under a web_research key (unlike the batch response, where those fields are the value of the top-level web_research). done does not repeat it — capture it here. |
Question streams (/generate-questions)
| Event | Data |
|---|---|
plan | { "generation_plan": { "source": "...", "type_counts": { ... }, "difficulty": ... } } — the resolved type/count plan. Emitted before any question event, so an auto-planned client learns what the server decided up front rather than waiting for done (which repeats it as generation_plan). |
question | { "index": <1-based>, "question": { ... } } — a fully enriched question. |
done | { "success": true, "response_id": "...", "total_questions": <int>, "generation_plan"?: { ... }, "validation"?: { ... }, "agent_metadata"?: { ... }, "question_history"?: { ... } } |
Event order on a question stream: web_search_started → web_research_progress / web_research_delta → web_research → plan → reasoning_summary_part / reasoning_summary → question (× N) → done. The web-research and reasoning events appear only when the corresponding flag is on.
/follow-up inline-explanation stream
| Event | Data |
|---|---|
explanation_text | { "text": "<delta>" } — incremental text delta. |
explanation | { "explanation": "<full text>" } — emitted once after streaming completes. |
done | { "success": true, "response_id": "...", "explanation": "...", "reasoning_summary"?: [...] } |
/follow-up chat stream (always streamed)
| Event | Data |
|---|---|
text | { "text": "<delta>" } — incremental message delta. |
done | { "success": true, "response_id": "...", "message": "<full text>", "reasoning_summary"?: [...] } |
Example SSE Stream
POST /generate-questions with streaming: true:
event: plan
data: {"generation_plan": {"source": "auto_default", "type_counts": {"multiple_choice": 1, "true_false": 1}, "difficulty": null}}
event: question
data: {"index": 1, "question": {"type": "multiple_choice", ...}}
event: question
data: {"index": 2, "question": {"type": "true_false", ...}}
event: done
data: {"success": true, "response_id": "resp_abc123", "total_questions": 2}See Response Schema for the per-type field layout of streamed question objects.
Response Schema
Each question is a strict, type-scoped object: only the keys meaningful to its type are emitted. There are no null placeholders for unused fields (a multiple_choice response has no matchPairs or codeBlock key at all). Type your client's question model with optional keys, not nullable ones.
Text and math
Every human-readable string (question, choices[].label, matchPairs.*[].label, answer.value, answer.rubric[]) is Markdown, and may contain KaTeX math delimited by $…$. Render with a Markdown renderer plus a KaTeX/MathJax pass; rendering these fields as plain text will show raw $ and ** markers to your users.
Two constraints on that content are enforced server-side:
- Inline math only. Generation is instructed to use
$…$and never$$…$$,\(…\), or\[…\], so display math should not appear. Render defensively anyway. - No currency symbols. Every field except a
code-outputcodeBlock.scriptis validated against currency signs (€ £ ¥ ₹ ¢ …) and LaTeX currency commands (\$,\euro, …); amounts are written with codes or words instead (5 USD,1.25 USD per EUR).$is reserved as the math delimiter.
Always present
| Field | Description |
|---|---|
type | One of the 8 supported question types |
difficulty | "easy" | "medium" | "hard" — always one of the three. "Mixed" difficulty means a mix across questions; an individual question never carries null. |
question | The question text (fill-in-the-blank uses {{key}} placeholders) |
id | UUID — added server-side before emit |
created_at | ISO 8601 timestamp — added server-side, UTC offset form with microseconds (e.g. "2026-04-22T17:00:00.481920+00:00") |
Type-specific fields
Only the fields listed for a given type are emitted; everything else is absent.
| Type | Emitted fields |
|---|---|
multiple_choice | choices: [{ key, label }] (always ≥ 2), answer: { "value": "<choices[].key>" }. Choices are shuffled server-side and re-keyed c1, c2, … in the emitted order; answer.value is remapped to the new key. Choice keys are not stable across turns. |
true_false | choices with keys pinned to "true" / "false", answer: { "value": "true" | "false" }. |
short_answer | answer: { "value": string, "rubric"?: string[] }. rubric is either absent or an array — it may be an empty array, and is never null. Treat absent and [] the same. No choices. |
exact_answer | answer: { "value": string }. No choices. |
code-output | codeBlock: { language, script }, answer: { "value": "<stdout>" }. No choices. |
reorder | choices: [{ key, label }] in canonical (correct) order. No answer field. |
matching | matchPairs: { left: [{key,label}], right: [{key,label}] }; canonical pairs are positional (left[i] ↔ right[i]). No answer field. |
fill-in-the-blank | choices hold the canonical answer per blank (choices[].label, keyed to {{key}} placeholders in question). No answer field. |
source_ids (web-search generations only)
When a request set web_search: true, a question may additionally carry source_ids: number[] — the [n] markers from the research brief that the question drew on. Look each n up in web_research.sources to show provenance.
These are best-effort hints, not a contract: they are never validated for presence, unresolvable ids are dropped server-side, and the key is absent entirely on non-web requests.
{
"type": "exact_answer",
"difficulty": "medium",
"question": "In what year was the James Webb Space Telescope launched?",
"answer": { "value": "2021" },
"source_ids": [1, 3],
"id": "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
"created_at": "2026-04-22T17:00:08.114233+00:00"
}Per-type examples
multiple_choice
{
"type": "multiple_choice",
"difficulty": "easy",
"question": "What is the capital of France?",
"choices": [
{ "key": "c1", "label": "Paris" },
{ "key": "c2", "label": "London" },
{ "key": "c3", "label": "Berlin" },
{ "key": "c4", "label": "Madrid" }
],
"answer": { "value": "c1" },
"id": "8b2f0a5c-6e5e-4a1b-9b5e-9a9f1c4e6a7d",
"created_at": "2026-04-22T17:00:00.481920+00:00"
}true_false
{
"type": "true_false",
"difficulty": "easy",
"question": "Water boils at 100°C at sea level.",
"choices": [
{ "key": "true", "label": "True" },
{ "key": "false", "label": "False" }
],
"answer": { "value": "true" },
"id": "d5e9f0a3-4b5c-6d7e-8f90-123456789abc",
"created_at": "2026-04-22T17:00:01.481920+00:00"
}short_answer
{
"type": "short_answer",
"difficulty": "medium",
"question": "Explain the process of photosynthesis.",
"answer": {
"value": "Plants convert sunlight, water, and CO2 into glucose and oxygen.",
"rubric": [
"Mentions sunlight conversion",
"Mentions glucose/oxygen outputs"
]
},
"id": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
"created_at": "2026-04-22T17:00:02.481920+00:00"
}exact_answer
{
"type": "exact_answer",
"difficulty": "hard",
"question": "In what year did World War II end?",
"answer": { "value": "1945" },
"id": "b2c3d4e5-f6a7-8901-bcde-f01234567890",
"created_at": "2026-04-22T17:00:03.481920+00:00"
}reorder
{
"type": "reorder",
"difficulty": "medium",
"question": "Arrange the software development phases in order:",
"choices": [
{ "key": "r1", "label": "Requirements" },
{ "key": "r2", "label": "Design" },
{ "key": "r3", "label": "Implementation" },
{ "key": "r4", "label": "Testing" },
{ "key": "r5", "label": "Deployment" }
],
"id": "c3d4e5f6-a7b8-9012-cdef-012345678901",
"created_at": "2026-04-22T17:00:04.481920+00:00"
}code-output
{
"type": "code-output",
"difficulty": "medium",
"question": "What is the output of the following code?",
"codeBlock": {
"language": "python",
"script": "my_list = [1, 2, 3]\nmy_list.append(4)\nprint(my_list)"
},
"answer": { "value": "[1, 2, 3, 4]\n" },
"id": "d4e5f6a7-b8c9-0123-def0-123456789012",
"created_at": "2026-04-22T17:00:05.481920+00:00"
}matching
{
"type": "matching",
"difficulty": "medium",
"question": "Match each programming language with its primary use:",
"matchPairs": {
"left": [
{ "key": "l1", "label": "Python" },
{ "key": "l2", "label": "JavaScript" },
{ "key": "l3", "label": "SQL" }
],
"right": [
{ "key": "r1", "label": "Data Science" },
{ "key": "r2", "label": "Web Development" },
{ "key": "r3", "label": "Databases" }
]
},
"id": "e5f6a7b8-c9d0-1234-ef01-234567890123",
"created_at": "2026-04-22T17:00:06.481920+00:00"
}fill-in-the-blank
{
"type": "fill-in-the-blank",
"difficulty": "medium",
"question": "The process of {{b1}} converts sunlight into {{b2}}.",
"choices": [
{ "key": "b1", "label": "photosynthesis" },
{ "key": "b2", "label": "chemical energy" }
],
"id": "f6a7b8c9-d0e1-2345-f012-345678901234",
"created_at": "2026-04-22T17:00:07.481920+00:00"
}Streaming Examples
Minimal client snippets for consuming the SSE response from /generate-questions with streaming: true. Both send a manual question_config; drop that key to have the server plan the set instead.
JavaScript
const response = await fetch("https://api.makequestions.ai/generate-questions", {
method: "POST",
headers: {
"X-API-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
context: "Your text...",
question_config: {
type_counts: { multiple_choice: 3, true_false: 2 },
difficulty: null
},
streaming: true
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
// Parse SSE events and handle questions
console.log(text);
}Python
import requests
response = requests.post(
"https://api.makequestions.ai/generate-questions",
headers={"X-API-Key": "your-api-key"},
json={
"context": "Your text...",
"question_config": {
"type_counts": {"multiple_choice": 3, "true_false": 2},
"difficulty": None
},
"streaming": True
},
stream=True
)
for line in response.iter_lines():
if line:
print(line.decode('utf-8'))Frontend Integration
Drop-in helpers for React and Next.js apps.
API Helper (lib/api.ts)
// Call this from the server only (a Route Handler or Server Action). Your API
// key must never reach the browser — do NOT put it in a NEXT_PUBLIC_ variable.
const API_URL = "https://api.makequestions.ai";
type ConcreteQuestionType =
| "multiple_choice" | "true_false" | "short_answer" | "exact_answer"
| "reorder" | "code-output" | "matching" | "fill-in-the-blank";
type QuestionConfig =
// Omit the whole object for auto planning, or:
| { mode: "auto"; difficulty?: "easy" | "medium" | "hard" | null }
| {
// Non-empty. Positive int = exact count; null = model decides
// quantity with a guaranteed minimum of 1 of that type.
type_counts: Partial<Record<ConcreteQuestionType, number | null>>;
difficulty?: "easy" | "medium" | "hard" | null;
};
type Attachment = {
url: string; // public HTTPS URL
mime_type: string; // e.g. "application/pdf", "image/png"
name?: string; // optional original filename
};
export async function generateQuestions(
context: string,
options?: {
questionConfig?: QuestionConfig; // omit entirely for server-side auto planning
responseId?: string; // continue an existing question chain
input?: string | null;
attachments?: Attachment[] | null; // preferred for files (images + documents)
imageUrls?: string[] | null; // legacy; prefer attachments
reasoning?: boolean;
webSearch?: boolean;
maxQuestions?: number;
allowedQuestionTypes?: ConcreteQuestionType[];
uiLanguage?: "en" | "vi";
}
) {
// The API rejects unknown keys AND an explicit `question_config: null`,
// so build the body by omission rather than by sending nulls.
const body: Record<string, unknown> = {
context,
reasoning: options?.reasoning ?? true, // default is reasoning mode; pass false for instant
};
if (options?.questionConfig) body.question_config = options.questionConfig;
if (options?.responseId) body.response_id = options.responseId;
if (options?.input) body.input = options.input;
if (options?.attachments) body.attachments = options.attachments;
if (options?.imageUrls) body.image_urls = options.imageUrls;
if (options?.webSearch) body.web_search = true;
if (options?.maxQuestions) body.max_questions = options.maxQuestions;
if (options?.allowedQuestionTypes) body.allowed_question_types = options.allowedQuestionTypes;
if (options?.uiLanguage) body.ui_language = options.uiLanguage;
const response = await fetch(`${API_URL}/generate-questions`, {
method: "POST",
headers: {
"X-API-Key": process.env.MAKEQUESTIONS_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
// 422 returns `detail` as an array of validation objects — see Errors.
const { detail, request_id } = await response.json().catch(() => ({}));
const message = Array.isArray(detail)
? detail.map((d) => `${(d.loc ?? []).slice(1).join(".")}: ${d.msg}`).join("; ")
: detail ?? "Failed to generate questions";
throw new Error(`${message}${request_id ? ` (request ${request_id})` : ""}`);
}
return response.json();
}React Component (components/QuestionGenerator.tsx)
"use client";
import { useState } from "react";
import { generateQuestions } from "@/lib/api";
export function QuestionGenerator() {
const [questions, setQuestions] = useState([]);
const [loading, setLoading] = useState(false);
async function handleGenerate(context: string) {
setLoading(true);
try {
// No question_config → the server plans the types and counts.
const data = await generateQuestions(context, { maxQuestions: 6 });
setQuestions(data.questions);
} finally {
setLoading(false);
}
}
return (
// Your UI here
);
}Errors
Every error response — including validation failures — uses the same envelope:
{
"detail": "API key required. Please provide X-API-Key header.",
"request_id": "b6f0c2de-1f8a-4a3e-9c11-0d4a2f6e7b83"
}The same id is returned in the X-Request-ID response header on every request, successful or not. Quote it when reporting a problem. You may also send your own X-Request-ID request header to correlate calls with your logs.
detail is not always a string
On a 422, detail is an array of validation objects, one per offending field:
{
"detail": [
{
"type": "value_error",
"loc": ["body"],
"msg": "Value error, Provide 'response_id', 'context' text, at least one attachment, or at least one image_url.",
"input": {},
"ctx": { "error": {} }
},
{
"type": "value_error",
"loc": ["body", "question_config"],
"msg": "Value error, question_config.mode='auto' forbids question_config.type_counts"
}
],
"request_id": "b6f0c2de-1f8a-4a3e-9c11-0d4a2f6e7b83"
}Read msg for the human-readable reason. Items may also carry input (the value that was rejected) and ctx (validator context) — treat both as diagnostic extras, not a stable contract.
Note loc is often just ["body"]: whole-request rules like the source-material check are raised against the model, not a single field. Handle both shapes — rendering detail directly will print [object Object] for every validation error, and assuming loc[1] exists will drop the field name on model-level errors.
Status codes
| Status | Description |
|---|---|
| 200 | Success |
| 400 | Bad request (e.g. the upstream model rejected the request, or an attachment URL could not be fetched) |
| 401 | Missing or invalid API key (detail is a string) |
| 413 | Request body exceeds the max request size, or the model provider rejected an attachment as too large |
| 422 | Validation error — unknown/missing/invalid fields (detail is an array) |
| 429 | Rate limited |
| 500 | Server error. An unhandled exception returns the opaque string "Internal server error"; a deliberately raised server error returns a specific message. Either way, quote request_id when reporting it. |
Streaming requests that fail after the SSE connection opens deliver the failure as an error event with HTTP 200, not as an error status.
Rate Limits
The API implements rate limiting to ensure fair usage.
| Limit | Value |
|---|---|
| Requests | 1,000 per minute (per API key) |
| Max context | 200,000 characters on production (applies to context and to input independently). Deployment-tunable — GET /config reports the live value. |
| Max request size | 1 MB |
| Max image inputs | 1,500 total across attachments + image_urls |
| Max document attachments | 50 |
| Max file URL length | 8,192 chars |
Max question_history items | 200 |
Implement exponential backoff when receiving 429 errors. GET /config reports the live values for these limits.
Rate limiting is backed by a Redis sliding window and fails open: if the limit store is unreachable the API admits requests rather than rejecting them, and GET /health reports "degraded". Do not treat the absence of 429s as confirmation that you are within the limit — pace your own client.
Utility Endpoints
None of these require an API key.
GET /config
Reports the active configuration and limits, so a client can adapt instead of hard-coding numbers.
{
"api_key_required": true,
"api_mode": "prod",
"rate_limit_enabled": true,
"rate_limit_requests": 1000,
"rate_limit_window_seconds": 60,
"cors_origins": "all",
"models": ["non-reasoning", "reasoning"],
"max_context_length": 200000,
"max_request_size_bytes": 1048576,
"vision": { "max_images": 1500, "max_url_length": 8192 },
"attachments": {
"max_image_attachments": 1500,
"max_document_attachments": 50,
"max_url_length": 8192,
"document_size_limits_enforced_by": "frontend_proxy_or_openai"
}
}rate_limit_requests and rate_limit_window_seconds are null when rate limiting is disabled.
GET /health
{
"status": "healthy",
"service": "question-generation-api",
"dependencies": {
"supabase": { "status": "healthy" },
"redis": { "healthy": true }
}
}This endpoint returns 200 even when the service is impaired — read the status field, which is "healthy" or "degraded". Degraded means a dependency (analytics storage or the rate-limit store) is unavailable; question generation still works.
GET /
Returns a { "message": … } service banner with a link to these docs.