GET /v1/questions/:id#
/api/v1/questions/:id API key required Fetch one question by its UUID v7. Use this for deep links, shareable URLs (e.g. “question of the day”), or moderation review flows.
Path parameters#
| Parameter | Type | Default | Description |
|---|---|---|---|
| id * | UUID v7 | — | Canonical question id. Use the value from `data[].id` in any list endpoint. |
When you’ll get 404#
- The id doesn’t exist (or was never imported)
- The question was soft-hidden by moderation
- The question is a dropped duplicate — a canonical version is served under a different id; the one you have is stale
- The upstream source deleted it
Examples#
Response#
{
"data": {
"id": "0193f8b5-7e5c-7c24-9f7a-3d1e1c2a5f10",
"text": "Jaka jest stolica Polski?",
"correctAnswer": "Warszawa",
"incorrectAnswers": ["Kraków", "Gdańsk", "Wrocław"],
"type": "multiple",
"difficulty": "easy",
"language": "pl",
"category": { "id": 14, "slug": "geography", "name": "Geografia" },
"subcategories": [
{ "slug": "european-capitals", "label": "Stolice europejskie" }
],
"tags": [
{ "slug": "capitals", "label": "Stolice" },
{ "slug": "europe", "label": "Europa" }
],
"regions": ["pl"],
"attribution": {
"author": "community",
"source": "opentriviaqa",
"license": "CC-BY-SA-4.0",
"licenseVersion": "4.0",
"licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0/",
"sourceId": "otq:12345",
"url": "https://opentriviaqa.example/q/12345",
"modifications": ["translated_pl"],
"lastModified": "2026-04-24T10:00:00Z"
},
"translationOf": "0193f8b5-7e5c-7c24-9f7a-000000000001",
"rootQuestionId": "0193f8b5-7e5c-7c24-9f7a-000000000001",
"translator": "machine",
"explanation": null,
"extensions": {
"subcategories": ["european-capitals"]
},
"createdAt": "2026-04-24T10:00:00Z",
"updatedAt": "2026-04-24T10:00:00Z"
},
"meta": { "language": "pl", "requestId": "req_..." }
}{
"type": "https://quizbase.runriva.com/errors/question_not_found",
"title": "Question not found",
"status": 404,
"detail": "No approved question with id=0193f8b5-dead-beef-0000-000000000000. Try /api/v1/questions/random for alternatives, or /api/v1/questions for browsing.",
"instance": "/api/v1/questions/0193f8b5-dead-beef-0000-000000000000",
"code": "question_not_found"
}Performance#
- p50 (warm): ~90ms
- p95: ~102ms (sustained 50 RPS, baseline)
- p99: ~180ms
- Last measured: 2026-05-07
- SLO: p95 < 100ms, error rate < 1%
- Direct id lookup — fastest path in the API.
What you can do with a stable id#
Every question carries a stable UUID v7 id that never changes after import. Save it client-side, pass it around, fetch it back later — same question, same content. That stability is the building block for most quiz mechanics. QuizBase deliberately does not ship convenience parameters like ?seed=, ?daily=true, or ?exclude_seen= — you get primitives (questions, IDs, filters), you build the mechanic. Here are the patterns that come up in real apps:
1. Same question across languages — multi-language quiz#
Pass the same id with a different ?lang= and you get the same question, translated. Critical for “language gauntlet” mechanics where round 1 is English, round 2 is the user’s first language, round 3 is a third. The id makes cross-language linking possible — without it, three separate random fetches would give three unrelated questions.
// Fetch one random question in English, save the id, then re-fetch in two more languages
const KEY = process.env.QUIZBASE_KEY!;
const BASE = 'https://quizbase.runriva.com/api/v1/questions';
const r1 = await fetch(BASE + '/random?lang=en&limit=1', { headers: { 'X-API-Key': KEY } });
const en = (await r1.json()).data[0];
const r2 = await fetch(BASE + '/' + en.id + '?lang=es', { headers: { 'X-API-Key': KEY } });
const es = (await r2.json()).data;
const r3 = await fetch(BASE + '/' + en.id + '?lang=pl', { headers: { 'X-API-Key': KEY } });
const pl = (await r3.json()).data;
// en.text, es.text, pl.text — same trivia content, three translations # Same approach in Python
import os, requests
KEY = os.environ['QUIZBASE_KEY']
BASE = 'https://quizbase.runriva.com/api/v1/questions'
en = requests.get(
BASE + '/random?lang=en&limit=1',
headers={'X-API-Key': KEY}
).json()['data'][0]
es = requests.get(
BASE + '/' + en['id'] + '?lang=es',
headers={'X-API-Key': KEY}
).json()['data']
pl = requests.get(
BASE + '/' + en['id'] + '?lang=pl',
headers={'X-API-Key': KEY}
).json()['data'] 2. Don’t repeat — exclude seen questions#
Two flavors depending on session size:
Power-user shortcut (≤250 seen ids): pass them on the request itself with ?exclude=id1,id2,... — see /random. Server filters with NOT IN on a btree index, ~ms cost. Designed for the pub-quiz pattern (25 rounds × 10 questions = 250). The 250 cap keeps the query string under ~9.3 KB.
const seen = JSON.parse(localStorage.getItem('seen-ids') ?? '[]'); // last 250 ids
const url = new URL('https://quizbase.runriva.com/api/v1/questions/random');
url.searchParams.set('lang', 'en');
url.searchParams.set('limit', '1');
if (seen.length) url.searchParams.set('exclude', seen.join(','));
const r = await fetch(url, { headers: { 'X-API-Key': process.env.QUIZBASE_KEY! } });
const next = (await r.json()).data[0];
// Remember it, keep the list bounded (FIFO drop oldest beyond 250)
seen.push(next.id);
while (seen.length > 250) seen.shift();
localStorage.setItem('seen-ids', JSON.stringify(seen)); Larger sessions / longer history: keep the seen Set client-side and filter after a batch fetch.
const seen = new Set<string>(JSON.parse(localStorage.getItem('seen-ids') ?? '[]'));
const r = await fetch(
'https://quizbase.runriva.com/api/v1/questions/random?lang=en&limit=10',
{ headers: { 'X-API-Key': process.env.QUIZBASE_KEY! } }
);
const fresh = (await r.json()).data.filter((q) => !seen.has(q.id));
const next = fresh[0];
seen.add(next.id);
localStorage.setItem('seen-ids', JSON.stringify([...seen])); For a 50,000-question pool, a Set<string> of seen ids costs < 50KB at 1,000 entries — well within localStorage budget. Refresh strategy: when the user finishes the pool, drop the set and start over.
3. Daily challenge — same question for everyone today, fresh tomorrow#
QuizBase doesn’t expose ?seed= or ?date= — it’s not the API’s job to know what “today” means for your users. Instead: fetch a batch of ~365 questions once, cache them, and pick today’s by hashing the date.
function dateHash(): number {
const iso = new Date().toISOString().slice(0, 10); // "2026-06-15"
let h = 5381;
for (let i = 0; i < iso.length; i++) h = (h * 33) ^ iso.charCodeAt(i);
return h >>> 0; // unsigned 32-bit
}
async function getDailyQuestion(): Promise<Question> {
let batch = JSON.parse(localStorage.getItem('daily-batch') ?? 'null');
if (!batch || Date.now() - batch.fetchedAt > 90 * 24 * 60 * 60 * 1000) {
const r = await fetch(
'https://quizbase.runriva.com/api/v1/questions?lang=en&limit=365',
{ headers: { 'X-API-Key': process.env.QUIZBASE_KEY! } }
);
batch = { data: (await r.json()).data, fetchedAt: Date.now() };
localStorage.setItem('daily-batch', JSON.stringify(batch));
}
return batch.data[dateHash() % batch.data.length];
} One fetch every 90 days. Same id for every user on the same day (because hash is deterministic from the ISO date string). Works offline after the first fetch.
4. Multiplayer sync — both players see the same question#
Player A fetches a random question, broadcasts the id over your realtime channel (Pusher / Ably / WebSockets), Player B fetches that id. Both see the same trivia content, the realtime layer only carries the id.
// Player A
const q = (await fetchRandom()).data[0];
channel.send({ type: 'next-question', id: q.id });
// Player B (or N players)
channel.on('next-question', async ({ id }) => {
const r = await fetch(
`https://quizbase.runriva.com/api/v1/questions/${id}?lang=${myLang}`,
{ headers: { 'X-API-Key': process.env.QUIZBASE_KEY! } }
);
const q = (await r.json()).data;
renderQuestion(q); // same trivia, optionally localized per player
}); Bonus — each player can pass their own ?lang= and see the question translated to their preference while still answering the same quiz.
5. Server-side anti-cheat — never ship correctAnswer to the client#
For a competitive game, you don’t want correctAnswer in the client bundle (DevTools makes it trivial to read). Use the id as a server-validated reference:
// Your frontend: render Q + 4 shuffled choices, hide correctAnswer
// User clicks → POST to your backend with just the id and their pick
await fetch('/api/my-game/answer', {
method: 'POST',
body: JSON.stringify({ questionId: q.id, selectedChoice: 'Jupiter' })
});
// Your backend (Node example) — verifies server-side
import { Hono } from 'hono';
const app = new Hono();
app.post('/api/my-game/answer', async (c) => {
const { questionId, selectedChoice } = await c.req.json();
const r = await fetch(
`https://quizbase.runriva.com/api/v1/questions/${questionId}`,
{ headers: { 'X-API-Key': process.env.QUIZBASE_SECRET_KEY! } } // sk_ key, server-side
);
const q = (await r.json()).data;
return c.json({ correct: q.correctAnswer === selectedChoice });
}); The frontend never sees correctAnswer. The id is the contract between frontend and backend.
6. Stable Anki / Quizlet flashcards across updates#
Anki keys cards by guid. If you re-export your deck weekly (refreshed QuizBase batch), users would lose their spaced-repetition history — unless you reuse the QuizBase id as the Anki guid. Then Anki sees “same card, updated content” instead of “new card”.
import genanki, requests
QUIZBASE_KEY = os.environ['QUIZBASE_KEY']
questions = requests.get(
'https://quizbase.runriva.com/api/v1/questions?lang=en&limit=50&category=history',
headers={'X-API-Key': QUIZBASE_KEY}
).json()['data']
deck = genanki.Deck(1234567890, 'QuizBase — History 50')
model = genanki.Model(...)
for q in questions:
note = genanki.Note(
model=model,
fields=[q['text'], q['correctAnswer']],
guid=q['id'], # ← stable across deck refreshes
)
deck.add_note(note)
genanki.Package(deck).write_to_file('history-50.apkg') Same trick works for Quizlet bulk import — keep an id column in your CSV, re-import preserves study progress.
See also#
- GET /v1/questions — list endpoint that returned this id
- Languages & translations —
translationOfandrootQuestionIdsemantics