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.ai

Endpoints

MethodPathPurpose
POST/generate-questionsQuestion turns — fresh, or continued with response_id.
POST/follow-upText turns — inline-explanation and chat.
GET/configActive limits and runtime configuration. No API key required.
GET/healthService 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:

ShapeBehavior
Key omittedAuto 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 0 values, type_counts must still contain at least one entry — an empty type_counts, or a type_counts whose values are all 0, returns 422.
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.

KindSupported mime_typeMax per request
Imageimage/jpeg, image/png, image/gif, image/webp1,500
Documentapplication/pdf, application/msword (.doc), application/vnd.openxmlformats-officedocument.wordprocessingml.document (.docx), text/plain50

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_research response 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 reasoning flag, so reasoning: false keeps its zero-reasoning-token contract.
  • If research fails, generation proceeds without the brief and web_research.status is "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 returns 422. Cap client-side and report the remainder in omitted_count.
  • type and question are required per item. status is one of "active" | "archived" | "alternative" and defaults to "active" (legacy "pending" normalizes to "active"); any other value is a 422.
  • sequence must be >= 1, id must be <= 128 characters, and each item rejects unknown keys — all three are 422s, not silent drops.
  • Silently truncated server-side: question to 300 chars, answer to 150 chars, tags to 20 × 64 chars, source_ids to 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 (and done.question_history when streaming).
  • /follow-up does not accept this field — sending it there is a 422.

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_id is the continuation handle for this model session — pass it back as response_id on the next POST /generate-questions (for more questions) or POST /follow-up (for an explanation or chat turn).
  • Each question is type-scoped: only the keys meaningful to its type are present (no null placeholders). See Response Schema.
  • id (UUID) and created_at (ISO 8601, UTC offset form with microseconds) are added server-side on every question.
  • reasoning_summary appears whenever reasoning is on — which is the default (send reasoning: false to suppress it).
  • For multiple_choice questions, server-side enrichment shuffles choices and re-keys them to c1, c2, … in the emitted order, then remaps answer.value to the new key. Other types' choices are 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.)

KeyWhen presentShape
generation_planEvery 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.
validationEvery 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_metadataBatch: 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_researchweb_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_summaryreasoning: 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

EventDataNotes
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.

EventDataNotes
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)

EventData
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_startedweb_research_progress / web_research_deltaweb_researchplanreasoning_summary_part / reasoning_summaryquestion (× N) → done. The web-research and reasoning events appear only when the corresponding flag is on.

/follow-up inline-explanation stream

EventData
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)

EventData
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-output codeBlock.script is 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

FieldDescription
typeOne 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.
questionThe question text (fill-in-the-blank uses {{key}} placeholders)
idUUID — added server-side before emit
created_atISO 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.

TypeEmitted fields
multiple_choicechoices: [{ 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_falsechoices with keys pinned to "true" / "false", answer: { "value": "true" | "false" }.
short_answeranswer: { "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_answeranswer: { "value": string }. No choices.
code-outputcodeBlock: { language, script }, answer: { "value": "<stdout>" }. No choices.
reorderchoices: [{ key, label }] in canonical (correct) order. No answer field.
matchingmatchPairs: { left: [{key,label}], right: [{key,label}] }; canonical pairs are positional (left[i]right[i]). No answer field.
fill-in-the-blankchoices 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

StatusDescription
200Success
400Bad request (e.g. the upstream model rejected the request, or an attachment URL could not be fetched)
401Missing or invalid API key (detail is a string)
413Request body exceeds the max request size, or the model provider rejected an attachment as too large
422Validation error — unknown/missing/invalid fields (detail is an array)
429Rate limited
500Server 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.

LimitValue
Requests1,000 per minute (per API key)
Max context200,000 characters on production (applies to context and to input independently). Deployment-tunable — GET /config reports the live value.
Max request size1 MB
Max image inputs1,500 total across attachments + image_urls
Max document attachments50
Max file URL length8,192 chars
Max question_history items200

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.