# QuizBase — full documentation QuizBase is a multilingual trivia API: 1.4M+ quiz-ready questions blended from 11 open-licensed sources, English and Polish at launch, transparent licensing on every record. ## Key URLs - Base URL: https://quizbase.runriva.com - API root: /api/v1 - OpenAPI 3.1 spec: /openapi.json - Interactive API reference: /docs/api-reference - Health: /api/health - Public data dump (CC-licensed): https://github.com/maciejdzierzek/quizbase-dumps-byasa - Status: /status (UptimeRobot) - Public contact: maciej.dzierzek@gmail.com ## Public endpoints (no API key required) GET /api/v1/categories, /api/v1/stats, /api/v1/topics, /api/v1/topics/{slug}, /api/v1/tags, /api/v1/subcategories, /api/v1/languages, POST /api/v1/report ## Authenticated endpoints (require X-API-Key or Authorization: Bearer) GET /api/v1/me, /api/v1/usage, /api/v1/questions, /api/v1/questions/{id}, /api/v1/questions/random ## Auth tl;dr Key prefixes (Plan 105, single key model): qb_pk_* (publishable, CORS-safe — can ship in browser/mobile bundles) and qb_sk_* (secret, backend-only — no CORS headers). Both share the same per-user quota. Header: `X-API-Key: ` or `Authorization: Bearer `. Never put keys in URLs. Cap 20 active keys per user. ## CORS tl;dr Public endpoints: `Access-Control-Allow-Origin: *` always. Authenticated endpoints with qb_*_pk_* keys: CORS header set; with qb_*_sk_* keys: no CORS header (browsers block, by design — secret keys are server-only). ## Errors tl;dr RFC 9457 Problem Details on every non-2xx. Headers: IETF RateLimit-Limit/Remaining/Reset on every response, Retry-After on 429. Body 429 lists tier, current usage, retry-after, upgrade URL. ## Performance tl;dr (verified 2026-05-07) Public discovery endpoints (categories, stats, topics, tags, subcategories, languages): p95 ~31ms at 50 RPS sustained. Listing /questions: p95 ~112ms. Random: p95 ~158ms broad / ~180ms narrow filter. Single id lookup /questions/{id}: p95 ~102ms. Holds at 200 RPS burst with no degradation. SLO: p95 < 500ms, error rate < 1%. Full numbers + methodology + raw k6 output: https://quizbase.runriva.com/docs/performance. Machine-readable per-operation: x-performance extension in /openapi.json. ## TypeScript SDK Official typed client: `@quizbase/client` on npm. Install: `pnpm add @quizbase/client` (or npm/yarn/bun). Requires Node ≥20 or any modern browser. Zero runtime dependencies. ESM + CJS + d.ts. ```ts import { createClient, QuizbaseError } from '@quizbase/client'; const client = createClient({ apiKey: process.env.QUIZBASE_API_KEY! }); const random = await client.questions.random({ amount: 5, lang: 'pl' }); ``` Features: typed methods for all 13 endpoints (questions/categories/languages/topics/tags/subcategories/stats/me/usage/report), automatic retry on 429/5xx with Retry-After honored and exponential backoff, RFC 9457 `QuizbaseError` with `.requestId`, `.retryAfter`, `.problem`, X-Request-Id auto-generated per call, performance-aware per-endpoint timeouts (defaults 10–15s, 30s ceiling, overridable via `timeouts: { 'questions.random': 5000 }`), `onRequest` telemetry hook for PostHog/Datadog/Sentry breadcrumbs (fires on every attempt with `{ method, endpoint, duration, status, requestId, retryCount, final }`). Source: https://github.com/maciejdzierzek/quizbase-sdk-ts. Releases: release-please-driven from conventional commits. Versioning: 0.x — API may change; 1.0 with stability commitment after launch feedback. Docs: https://quizbase.runriva.com/docs/sdks/typescript. --- ## About ### About QuizBase I built QuizBase because I needed it myself. **TL;DR:** - QuizBase is a multilingual trivia API with an open CC BY-SA dataset. - It is run by Maciej Dzierżek — a one-person studio in Poland. - The main value is the API: fast, multilingual, easy to plug into anything. - The dataset stays open. The API is the product. ### Story I have loved playing Buzz and similar trivia games for years. I no longer have those consoles, but along the way I discovered that I enjoy hosting quizzes even more than playing them — for friends, at family events, and during corporate training sessions I run. Every time I needed a fresh batch of questions I hit the same wall: there is only so much you can make up on the spot, and the public trivia datasets are scattered, English-only, or hard to use programmatically. So I built QuizBase: a single API that returns clean, categorised, multilingual questions sourced from open datasets and refined with care. RunRiva — a product still in the making — will be the first thing built on top of QuizBase. But I never wanted this to be a private moat. The sources I draw from are open, so I keep my work open too. The dataset is published under CC BY-SA. The API is the product; the data is the commons. In 2026 anyone can be a developer, and anyone can build a quiz game in many languages without inventing the questions themselves. That is the world I want to make slightly easier. ### Who is behind QuizBase Since 1996 I have been working in and around the internet and digital products. I have been a product manager, worked on large portals, small fan sites, and corporate transformations. Change has been the one constant in my professional life — and this project is part of the change happening around us. I live and work in Poland. More about me and what I write about is on maciejdzierzek.com. ### What QuizBase stands for - **Transparent service** — Clear pricing, clear limits, clear licensing. No dark patterns, no surprise charges, no vendor lock-in. - **Open data, useful API** — The dataset is CC BY-SA and freely downloadable. The value you pay for is the API: speed, multilingual coverage, and easy integration. - **Built for developers — human and AI** — REST, MCP, TypeScript SDK. A developer (or an AI coding agent) should get exactly what they need with the least possible friction. - **A long game** — The goal is for QuizBase to become the largest stable, affordable, multilingual trivia base on the planet. Built to last. ### Operated by Maciej Dzierżek (JDG). NIP: PL7411885009. VAT EU: PL7411885009. ### Get in touch Best by email. Happy to chat on LinkedIn as well. Email: maciej.dzierzek@gmail.com LinkedIn — Maciej Dzierżek: https://linkedin.com/in/maciej-dzierzek-63031712 Full page: https://quizbase.runriva.com/about --- ## Use cases QuizBase fits four primary audiences. Each has a dedicated page with a worked example, demo recording, and copy-paste code. ### Vibe Coders Weekend trivia app in 20 lines — AI-assisted, no backend. Build a trivia quiz app in React + Vite + QuizBase API. Six-step tutorial, Cursor and Claude Code prompts, Next.js variant, free tier with no credit card. Detail page: https://quizbase.runriva.com/use-cases/vibe-coders ### AI Agent Builders MCP server, ChatGPT actions, Claude tools — trivia as native capability. Trivia API for AI agents — MCP server with 10 tools, Bearer auth, free tier with no credit card. Drop-in Claude Desktop / Cursor / ChatGPT Custom GPT setup. Detail page: https://quizbase.runriva.com/use-cases/ai-agent-builders ### Game Developers Quiz mechanic for Unity, Roblox, React Native. Multilingual out of the box. Drop-in quiz mechanic for your game — React Native + Expo primary, Unity / Godot / Roblox variants. 50k+ questions, 20+ languages, free tier with no credit card. Detail page: https://quizbase.runriva.com/use-cases/game-developers ### Educators Flashcards, lesson plans, BY-SA dump for classroom materials. Classroom-ready trivia and flashcards — HTML embed, Anki / Quizlet / Google Forms paths. 50k+ questions, 20+ languages, free tier with no credit card. Detail page: https://quizbase.runriva.com/use-cases/educators Hub: https://quizbase.runriva.com/use-cases --- # QuizBase API QuizBase is a multilingual trivia API for developers building quiz apps, learning tools, and interactive experiences. - **1.4M+ quiz-ready questions** blended from 11 open-licensed sources (OpenTDB, OpenTriviaQA, MKQA, Mintaka, NQ-Open, KQA Pro, EntityQuestions, …). - **English and Polish at launch** — Polish as a first-class language with reviewed machine translation, not an afterthought. More languages on the roadmap. - **Transparent licensing** — every question carries `license`, `licenseUrl`, `author`, `source`, and `modifications`. - **Real free tier** — Free gives you the full API (every endpoint, all 50k+ questions) with 500 requests/day. No card. Upgrade only when traffic outgrows it. - **Public data dump** — CC-licensed questions only, regenerated around major content updates. See [/data](/data). - **Three-layer taxonomy** — 24 categories, 2,184 curated topics with aliases, 87k+ raw tags. - **Fast under load** — verified p95 < 100ms at 50 sustained RPS, holds at 200 RPS burst. Numbers, methodology, raw k6 output: [/docs/performance](/docs/performance). - **OpenAPI 3.1 spec at [/openapi.json](/openapi.json)** + interactive [API reference](/docs/api-reference). - **MCP server** — on the post-launch roadmap. ## Start here > **TIP: New to QuizBase?** > > Go to Quickstart — first successful request in under 5 minutes. Or jump straight to the multi-round quiz tutorial for an end-to-end walkthrough. ## What's in this documentation - [Quickstart](/docs/quickstart) — signup, API key, first request - [Authentication](/docs/authentication) — keys (`qb_pk_*` / `qb_sk_*`), `X-API-Key` header, rotation - [Errors and retries](/docs/errors-and-retries) — RFC 9457 Problem Details, rate-limit headers, exponential backoff - **API reference** — [me](/docs/api/me) · [usage](/docs/api/usage) · [random](/docs/api/questions-random) · [browse](/docs/api/questions) · [by id](/docs/api/questions-by-id) · [categories](/docs/api/categories) · [topics](/docs/api/topics) · [tags](/docs/api/tags) · [subcategories](/docs/api/subcategories) · [languages](/docs/api/languages) · [stats](/docs/api/stats) · [report](/docs/api/report) - **Guides** — [languages & translations](/docs/guides/languages-and-translations) · [migrating from OpenTDB](/docs/guides/migrating-from-opentdb) · [building a Polish quiz app](/docs/guides/polish-quiz-app) · [multi-round quiz](/docs/guides/multi-round-quiz) · [MCP for Claude](/docs/guides/mcp-for-claude) · [rate limits in practice](/docs/guides/rate-limits-and-retries) - **SDKs** — [TypeScript](/docs/sdks/typescript) · [MCP server](/docs/sdks/mcp-server) · [other languages](/docs/sdks/other) - [FAQ](/docs/faq) and [support](/docs/support) ## One-line preview ```bash curl -H "X-API-Key: {{API_KEY}}" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=1&lang=pl" ``` Every response includes attribution — we don't hide where the data came from. --- # Quickstart From signup to your first response in five steps. Total time: about 5 minutes. ## 1. Sign up [Create a free account](/signup) — no credit card required. The Free tier gives you the full API: every endpoint, all 50k+ questions, all 20+ languages, 500 requests/day, 10 burst/10s. ## 2. Generate your API key In the dashboard, open [**API keys**](/dashboard/keys) and click **Create key**. Pick `qb_pk_*` for browser/mobile code (CORS-enabled) or `qb_sk_*` for server-side. Both share the same per-user quota.
## 3. Make your first request Pick your language. The key placeholder below is auto-filled once you sign in. ```bash curl -H "X-API-Key: {{API_KEY}}" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=1&lang=pl" ``` ```bash curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=1&lang=pl" ``` ```typescript const res = await fetch( 'https://quizbase.runriva.com/api/v1/questions/random?amount=1&lang=pl', { headers: { 'X-API-Key': 'qb_pk_YOUR_KEY' } } ); const { data } = await res.json(); console.log(data[0]); ``` ```python import requests r = requests.get( "https://quizbase.runriva.com/api/v1/questions/random", params={"amount": 1, "lang": "pl"}, headers={"X-API-Key": "qb_pk_YOUR_KEY"}, ) print(r.json()["data"][0]) ```
## 4. Inspect the response **200 — Success** ```json { "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": { "count": 1, "language": "pl", "requestId": "req_01HZABC..." } } ``` Every question ships with `attribution` — `license`, `author`, `source`. Keep these in your cache layer; the CC-BY-SA chain requires them. ## Use the TypeScript SDK If you're on Node, Deno, Bun, or in a browser — skip the raw `fetch` and install the official typed client: ```bash pnpm add @quizbase/client ``` ```ts import { createClient } from '@quizbase/client'; const client = createClient({ apiKey: process.env.QUIZBASE_API_KEY! }); const random = await client.questions.random({ amount: 5, lang: 'pl' }); console.log(random.data); ``` Typed responses, automatic retry on 429 / 5xx, RFC 9457 typed errors, and an `onRequest` telemetry hook out of the box. Full reference: [/docs/sdks/typescript](/docs/sdks/typescript). ## What to expect — performance The catalog is 1.4M+ approved questions, served from Postgres + Redis behind Cloudflare. Discovery endpoints respond in ~30ms (warm); `/v1/questions` and `/v1/questions/:id` around 100ms; `/v1/questions/random` around 160ms. Verified at 50 sustained RPS and 200 RPS burst with no degradation. SLO: p95 < 500ms, error rate < 1%. Full numbers, methodology, and quarterly drift mechanism: [/docs/performance](/docs/performance). Each `/docs/api/*` page also lists a per-endpoint Performance section. ## 5. Next steps - [Authentication](/docs/authentication) — how keys work, `qb_pk_*` (browser-safe) vs `qb_sk_*` (backend-only), rotation, CORS - [Errors and retries](/docs/errors-and-retries) — RFC 9457, `Retry-After`, backoff strategy - [GET /v1/questions/random](/docs/api/questions-random) — all parameters for this endpoint - [Languages & translations](/docs/guides/languages-and-translations) — English and Polish at launch, `translationOf` field, strict-language mode - [Migrating from OpenTDB](/docs/guides/migrating-from-opentdb) — field-by-field mapping --- # Authentication Every `/api/v1/*` request — except `/api/v1/report` (anonymous bug submission) — requires an API key. Quota is **per user account**, shared across all your keys (up to 20 active). ## Key prefixes | Prefix | Purpose | Browser-safe (CORS) | | --------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------ | | `qb_pk_*` | Publishable — safe to embed in client bundles (browsers, mobile apps). | **Yes** — sends `Access-Control-Allow-Origin: *` | | `qb_sk_*` | Secret — backend only. Don't ship in client bundles. | **No** — browsers will block cross-origin use | > **INFO: Same quota, different scope** > > Both prefixes count against the same per-user quota. The split is purely about CORS — `pk` for browser code, `sk` for server code. Create as many keys as you need (cap: 20 active) to organize traffic by app or environment. ## Passing the key Send your key in the `X-API-Key` header. `Authorization: Bearer ` also works. ```bash curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=1" ``` ```typescript const res = await fetch(url, { headers: { 'X-API-Key': process.env.QUIZBASE_KEY! } }); ``` ```python r = requests.get(url, headers={"X-API-Key": os.environ["QUIZBASE_KEY"]}) ``` > **WARNING: Never put keys in the URL** > > Query-string keys (`?api_key=...`) end up in server logs, referrer headers, and CDN cache keys. Always use headers. ## Rotation Rotate keys from [the dashboard](/dashboard/keys). When you rotate: 1. Create the new key first. 2. Deploy the new key alongside the old one in your environment. 3. Delete the old key after your traffic has drained to the new key (check dashboard usage). The rotated old key keeps working for 24h as a grace period — no downtime, no broken requests. ## Storage - **Server:** environment variables (`QUIZBASE_KEY`), never committed files. - **Client (browser/mobile):** only `qb_pk_*`. Consider a server-side proxy if you want to hide the key entirely — it costs you a hop but stops key extraction. - **CI:** encrypted secrets (GitHub Actions, GitLab CI, Railway variables). ## Public endpoint (no key needed) One endpoint is public — anonymous-by-design with per-IP rate limit: - [`POST /v1/report`](/docs/api/report) — translation / factual / attribution / inappropriate problem reports. Rate-limited 5/min per IP. Every other endpoint requires an API key. ## CORS — when browser fetch works - **`/v1/report`**: open CORS — fetch from any origin works without a key. - **Authenticated endpoints** with `qb_pk_*`: CORS header set. Browser fetch from any origin works — that's what publishable keys are for. - **Authenticated endpoints** with `qb_sk_*`: **no CORS header**. Browsers block these requests by design. Secret keys aren't meant to ship in browser bundles. `OPTIONS` preflight returns `204` with `Access-Control-Allow-Methods: GET, POST, OPTIONS` and `Allow-Headers: Authorization, Content-Type, X-API-Key, X-Request-Id`. Per-key origin allowlists (Stripe-style `allowed_domains`) are on the post-launch roadmap — currently any origin can use a `pk_*` key. ## Next - [Errors and retries](/docs/errors-and-retries) — what a 401 looks like and how to handle it - [GET /v1/me](/docs/api/me) — first authenticated endpoint to smoke-test your key - [GET /v1/questions/random](/docs/api/questions-random) — fetch a quiz round --- # Errors and retries QuizBase returns errors in **[RFC 9457 Problem Details](https://datatracker.ietf.org/doc/html/rfc9457)** format — one shape for every non-2xx response. Rate limits follow the **[IETF RateLimit draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)**. ## Error body — always the same shape ```json { "type": "https://quizbase.runriva.com/errors/rate-limit-exceeded", "title": "Too Many Requests", "status": 429, "detail": "Rate limit exceeded on requests per day. Limit: 100/day on Free tier. Current: 101/day. Retry after: 3600s.", "instance": "/api/v1/questions?amount=10&lang=pl", "code": "rate_limit_exceeded" } ``` - `type` — absolute URL documenting this error class (stable, machine-readable) - `title` — short human summary (stable per `type`) - `status` — HTTP status, same as response code - `detail` — context-specific message with values (rate, limit, retry-after) - `instance` — the URL you called (with query string) - `code` — kebab/snake version of `type` slug, for switch statements - Optional per-error fields: `retryAfter` (seconds), `upgradeUrl`, `errors[]` for validation ## Status codes you'll see | Status | When | Retry? | | --- | --- | --- | | `400` | Invalid parameter (`lang=xyz`, `amount=999`, malformed cursor) | No — fix the request | | `401` | Missing or invalid `X-API-Key` | No — fix the key | | `403` | Key valid but lacks permission (admin-only endpoint) | No | | `404` | Question deleted, soft-hidden by moderation, or id unknown | No | | `429` | Rate limit exceeded | Yes — wait `Retry-After` seconds | | `500` | Our bug | Yes — with backoff | | `503` | Brief outage (deploy, database failover) | Yes — with backoff | ## Rate-limit headers (IETF) Every response — not just 429 — includes: ```http RateLimit-Limit: 100 RateLimit-Remaining: 87 RateLimit-Reset: 3487 RateLimit-Policy: 100;w=86400 ``` - `RateLimit-Limit` — your quota for the current window - `RateLimit-Remaining` — requests left in this window - `RateLimit-Reset` — seconds until the window resets - `RateLimit-Policy` — `limit;w=` — for clients that want the window explicitly On `429` we also set `Retry-After: `. ## 429 body **429 — Rate limit** ```json { "type": "https://quizbase.runriva.com/errors/rate-limit-exceeded", "title": "Too Many Requests", "status": 429, "detail": "Rate limit exceeded on requests per day. Limit: 100/day on Free tier. Current: 101/day. Retry after: 3600s.", "instance": "/api/v1/questions?amount=10&lang=pl", "code": "rate_limit_exceeded", "retryAfter": 3600, "upgradeUrl": "https://quizbase.runriva.com/pricing" } ``` `upgradeUrl` is a hint — you can route users to the pricing page when they hit the free-tier ceiling. ## Backoff strategy Retry 429 and 5xx. **Never retry** 4xx (except 429 — which is technically 4xx but rate-limit not permanent). ### The rule of thumb ```ts async function fetchWithRetry(url: string, key: string, maxAttempts = 5) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { const res = await fetch(url, { headers: { 'X-API-Key': key } }); if (res.ok) return res.json(); // 4xx (except 429) — fix the request, don't retry if (res.status >= 400 && res.status < 500 && res.status !== 429) { throw new Error(`Client error ${res.status}: ${await res.text()}`); } // 429: respect Retry-After; 5xx: exponential backoff with jitter const retryAfter = res.status === 429 ? parseInt(res.headers.get('Retry-After') ?? '60', 10) * 1000 : Math.min(60_000, 1000 * 2 ** attempt) + Math.random() * 1000; await new Promise((r) => setTimeout(r, retryAfter)); } throw new Error(`Exhausted ${maxAttempts} retries`); } ``` ### Checklist - ✅ Respect `Retry-After` on 429 to the second — we mean it - ✅ Add jitter (`+ Math.random() * 1000`) on 5xx backoff to avoid thundering herd - ✅ Cap total retries (3-5) and total wall-clock time - ❌ Don't retry 400/401/403/404 - ❌ Don't ignore `Retry-After` with your own shorter delay ## Request ID Every response has `X-Request-Id`. Include it when you contact support — it lets us find your request in logs. ```http X-Request-Id: req_01HZABC123DEF ``` ## Next - [Rate limits in practice](/docs/guides/rate-limits-and-retries) — full worked examples with idempotency and queue patterns - [Performance](/docs/performance) — SLOs and p95 numbers so you know what timeouts to set - [Support](/docs/support) — what to send us when an error needs investigation --- # Performance QuizBase is built and load-tested as a developer-grade API. This page lists the **measured** numbers, the methodology behind them, and how we keep them honest. > All figures below come from k6 sustained load tests against production (`quizbase.runriva.com`) on 2026-05-07. We re-run baseline + spike scenarios quarterly. Latest raw output: [k6-baseline-2026-05-07.json](/performance/k6-baseline-2026-05-07.json). ## Service Level Objectives | Metric | Target | Measured (2026-05-07) | | -------------------------------------------------- | ------: | ---------------------------------------------------: | | p95 response time (any endpoint, sustained 50 RPS) | < 500ms | **< 100ms** for 8 of 9 endpoints, < 140ms for narrow random | | Error rate (sustained 50 RPS) | < 1% | **0.00%** | | Sustained throughput | 50 RPS | **200 RPS verified** | | Uptime | 99.9% | [Live status](https://quizbase.runriva.com/api/health) | `x-performance` extension on every operation in the [OpenAPI spec](/openapi.json) reports the same numbers per-endpoint — machine-readable for your monitoring or SDK telemetry. ## Per-endpoint performance Sustained 50 RPS for 5 minutes (60% public discovery / 30% browse / 10% random + PK lookup). All thresholds met with 5-8× margin. | Endpoint | p50 | p95 | p99 | Sustained RPS | | ------------------------------------- | ----: | ----: | ----: | ------------: | | GET /api/v1/categories | 24ms | 31ms | 45ms | 50 | | GET /api/v1/stats | 24ms | 31ms | 45ms | 50 | | GET /api/v1/topics | 24ms | 31ms | 45ms | 50 | | GET /api/v1/tags | 22ms | 31ms | 45ms | 50 | | GET /api/v1/subcategories | 24ms | 30ms | 45ms | 50 | | GET /api/v1/questions | 97ms | 112ms | 200ms | 50 | | GET /api/v1/questions/random (broad) | 138ms | 158ms | 250ms | 50 | | GET /api/v1/questions/random (narrow) | 110ms | 180ms | 280ms | 30 | | GET /api/v1/questions/:id | 90ms | 102ms | 180ms | 50 | `/v1/me`, `/v1/usage`, `/v1/languages`, `/v1/topics/:slug`, `/v1/report` are not part of the load mix (small payloads, low-traffic endpoints). Single-request warm latency is < 100ms for all of them; SLO p95 < 500ms applies. ## Burst capacity The same mix at **200 RPS for 30 seconds** (4× the sustained baseline) showed **zero degradation** — p95 numbers identical to the baseline (categories 30ms, questions 88ms, random 98ms). 0.00% error rate, 8,499 total requests in the burst window. The ceiling is somewhere above 200 RPS — testing was capped by client-side k6 VUs, not the server. What this means for you: a Show-HN spike or a viral integration doesn't need a coordination call with us first. Hit the API hard. ## Methodology - **Tool:** [k6](https://k6.io) v1.7.1 (Grafana), `constant-arrival-rate` and `ramping-arrival-rate` executors. - **Scenarios:** baseline (50 RPS / 5 min), spike (50→100→50 RPS / 1m45s), burst (50→200→50 RPS / 50s), narrow filter (30 RPS / 3 min). - **Total prod requests in this round:** 38,502 across all four scenarios. - **Smoke key:** an internal benchmarking key with rate-limit bypass — system under test was production, but the limiter was bypassed so the numbers measure app + database + cache behavior, not the limiter itself. ## Quarterly drift mechanism We re-run `pnpm load:baseline` + `pnpm load:spike` quarterly. Each run produces a fresh `k6-baseline-DATE-summary.json` and updates the per-endpoint `x-performance` block in [openapi.json](/openapi.json). If p95 regresses by more than 1.5× vs the previous run we investigate before marking the new run as the baseline (deployment, dependency upgrade, data growth, query plan change). The `lastMeasured` field on every `x-performance` block is the date of the last successful baseline. ## Raw data For skeptics and benchmarkers: [k6-baseline-2026-05-07.json](/performance/k6-baseline-2026-05-07.json) — full k6 summary metrics (p50/p90/p95/p99 per endpoint, throughput, request counts). Sanitized — sample IDs and any internal headers stripped, only aggregates retained. ## See also - [API Reference](/docs/api-reference) — interactive Scalar render of the spec. - [/openapi.json](/openapi.json) — OpenAPI 3.1, `x-performance` per operation. - [Errors and retries](/docs/errors-and-retries) — what `429` looks like and how to back off. - [Rate limits in practice](/docs/guides/rate-limits-and-retries) — pacing strategies that keep you under 429. --- # GET /v1/me **`GET /api/v1/me`** — API key required Returns information about the authenticated API key — **the first endpoint to hit when wiring up a new client**. It confirms your key, tier, and current rate-limit window in one call, with no side effects. Use it as a smoke test in CI, after rotating keys, or whenever a request unexpectedly fails with `401` / `429` and you need to inspect what the server thinks of your key. ## Parameters None. The key is read from the `X-API-Key` header (or `Authorization: Bearer `). ## Examples ```bash curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/me" ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/me', { headers: { 'X-API-Key': process.env.QUIZBASE_API_KEY! } }); const me = await res.json(); console.log(`tier=${me.tier} remaining=${me.rateLimit.remaining}/${me.rateLimit.limit}`); ``` ```python r = requests.get( 'https://quizbase.runriva.com/api/v1/me', headers={'X-API-Key': os.environ['QUIZBASE_API_KEY']}, ) me = r.json() print(f"tier={me['tier']} remaining={me['rateLimit']['remaining']}/{me['rateLimit']['limit']}") ``` ## Response **200 — Success** ```json { "key": { "id": "01J3KXY1ZAB7CQNR8TP4Z9EXMQ", "name": "Default key", "prefix": "qb_pk_••••1234", "scope": "pk" }, "tier": "free", "rateLimit": { "limit": 10, "remaining": 8, "resetInSeconds": 9, "burstPer10s": 10, "perDay": 500 }, "monthlyQuota": { "limit": null, "used": 0, "resetsAt": null }, "meta": { "requestId": "req_01HZABC123DEF" } } ``` **401 — Missing key** ```json { "type": "https://quizbase.runriva.com/errors/missing_api_key", "title": "API key required", "status": 401, "detail": "Include your API key in the X-API-Key header (or Authorization: Bearer ). Get a key at https://quizbase.runriva.com/dashboard/keys.", "instance": "/api/v1/me", "code": "missing_api_key" } ``` **401 — Invalid key** ```json { "type": "https://quizbase.runriva.com/errors/invalid_api_key", "title": "Invalid API key", "status": 401, "detail": "The API key you sent does not match any active key.", "instance": "/api/v1/me", "code": "invalid_api_key" } ``` ### Response fields - **`key.id`** — internal identifier of the API key (UUID v7). Stable across rotations of the secret. - **`key.name`** — the label you set when creating the key (e.g. `"CI runner"`, `"Mobile app — staging"`). - **`key.prefix`** — masked display value (e.g. `qb_pk_••••1234`). Safe to log; the full secret is only shown once at creation. - **`key.scope`** — `"pk"` (publishable, safe for client-side / mobile apps, CORS-enabled) or `"sk"` (secret, server-side only). - **`tier`** — current subscription tier: `"free"`, `"indie"`, `"pro"`, or `"enterprise"`. Drives rate limits and feature gates. - **`rateLimit.limit`** / **`rateLimit.remaining`** / **`rateLimit.resetInSeconds`** — current 10s burst window. Mirrors `RateLimit-*` headers. Quota is **per user account** — shared across all your keys. - **`rateLimit.burstPer10s`** / **`rateLimit.perDay`** — your tier's full limits, useful for client-side throttling logic. - **`monthlyQuota.limit`** / **`used`** / **`resetsAt`** — monthly quota window. All `null` / `0` pre-launch (subscription billing arrives in a later phase). Reserved shape; safe to read now. - **`meta.requestId`** — request identifier (also in `X-Request-Id` response header). Include in support requests so we can locate the request in logs. ## Performance - p50 (warm): ~40ms - p95: <100ms (auth lookup + locals serialization, no DB read on hit path) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% > **TIP: Time-to-first-curl** > > A default `qb_pk_*` key is created automatically when you sign up — visit [/dashboard/keys](/dashboard/keys) to copy it. Then this `/v1/me` call is the fastest way to confirm everything works end-to-end before wiring up a real handler. ## See also - [Authentication](/docs/authentication) — header formats, scopes, rotation - [Rate limits and retries](/docs/guides/rate-limits-and-retries) — backoff strategies - [Errors and retries](/docs/errors-and-retries) — RFC 9457 error format reference --- # GET /v1/usage **`GET /api/v1/usage`** — API key required Daily series of API request counts for your account, plus today/week/month summary. Same data the [dashboard usage page](/dashboard/usage) reads — exposed programmatically so SDKs, billing automation, and monitoring tools don't have to scrape HTML. The endpoint aggregates across **all your API keys** (test + live) under your account. Use [`GET /v1/me`](/docs/api/me) to inspect a specific key's recent rate-limit state. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `days` | `int` | `30` | — | Number of trailing days for the daily series. 1–90. | ## Examples ```bash curl https://quizbase.runriva.com/api/v1/usage?days=7 \ -H "X-API-Key: qb_sk_..." ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/usage?days=7', { headers: { 'X-API-Key': process.env.QUIZBASE_API_KEY! } }); const { summary, series } = await res.json(); console.log(`This month: ${summary.month.count} requests`); for (const point of series) { console.log(`${point.day}: ${point.requests}`); } ``` ```python headers = {'X-API-Key': os.environ['QUIZBASE_API_KEY']} r = requests.get('https://quizbase.runriva.com/api/v1/usage', params={'days': 7}, headers=headers) data = r.json() print(f"This month: {data['summary']['month']['count']} requests") ``` ## Response **200 — Success** ```json { "summary": { "tier": "free", "today": { "count": 142, "prev": 87, "delta": 63, "limit": 500, "windowStart": "2026-05-04", "windowEnd": "2026-05-04" }, "week": { "count": 891, "prev": 612, "delta": 45, "limit": null, "windowStart": "2026-04-28", "windowEnd": "2026-05-04" }, "month": { "count": 3422, "prev": 2891, "delta": 18, "limit": null, "windowStart": "2026-05-01", "windowEnd": "2026-05-04" } }, "series": [ { "day": "2026-04-28", "requests": 87, "errors": 0, "clientErrors": 2 }, { "day": "2026-04-29", "requests": 124, "errors": 0, "clientErrors": 0 } ], "meta": { "from": "2026-04-28", "to": "2026-05-04", "days": 7, "requestId": "req_..." } } ``` `series[].errors` is 5xx (our problem). `series[].clientErrors` is 4xx (your side — 401/429/400/422). Split deliberately so you can tell whose fault a spike is. `summary.*.delta` is the percentage change from the previous equivalent window (today vs yesterday, this week vs last week, this month vs last month). `null` when the previous window had zero requests. ## Performance - p50 (warm): ~50ms - p95: <150ms for a 30-day window - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% ## See also - [GET /v1/me](/docs/api/me) — current key's rate-limit state and tier - [Pricing](/pricing) — what limits and caps apply to each tier --- # GET /v1/questions/random **`GET /api/v1/questions/random`** — API key required Returns up to 50 random questions in the requested language. This is the **killer endpoint** for quiz apps — one request, one JSON array, ready to render. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `amount` | `integer` | `10` | — | How many questions to return (1–50). | | `lang` | `enum` | `en` | — | Supported: `en`, `pl`. Strict — any other value returns `400`. If we have no questions matching your filters in that language, you get an empty array, not English. | | `category` | `integer or slug` | `—` | — | Filter by category. Accepts internal id (1-24), OpenTDB ids (9-32), or canonical slugs like `geography`. | | `difficulty` | `enum` | `—` | — | One of: `easy`, `medium`, `hard`. | | `type` | `enum` | `—` | — | Default response includes `multiple` and `boolean` only. Pass `?type=text_input` to opt into open-ended questions explicitly. | | `tags` | `CSV kebab-case` | `—` | — | All-of match (AND). Example: `tags=capitals,europe`. | | `tags_any` | `CSV kebab-case` | `—` | — | Any-of match (OR), max 10. | | `topic` | `kebab-case` | `—` | — | Curated topic slug — see [GET /v1/topics](/docs/api/topics). | | `topics_any` | `CSV kebab-case` | `—` | — | Any-of match (OR) on curated topics, max 10. | | `subcategory` | `kebab-case` | `—` | — | Raw subcategory slug. | | `quality` | ``high`` | `—` | — | Pass `quality=high` to exclude questions flagged by distractor validation. | | `regions` | `CSV ISO 3166-1` | `—` | — | All-of match (AND), lowercase 2-letter codes. Example: `regions=pl,de`. | | `source` | `enum` | `—` | — | Restrict to one import source: `opentdb`, `opentriviaqa`, `mkqa`, `mintaka`, `nq-open`, `kqa-pro`, `entityq`, `quizbase`. | | `license` | `SPDX` | `—` | — | e.g. `CC-BY-SA-4.0`, `MIT`. | | `exclude` | `CSV UUIDs` | `—` | — | Up to 250 ids to skip. Useful for de-duplicating within a game session. | ## Examples ```bash curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=5&lang=pl&difficulty=medium&category=geography" ``` ```typescript const params = new URLSearchParams({ amount: '5', lang: 'pl', difficulty: 'medium', category: 'geography' }); const res = await fetch( `https://quizbase.runriva.com/api/v1/questions/random?${params}`, { headers: { 'X-API-Key': process.env.QUIZBASE_KEY! } } ); const { data, meta } = await res.json(); ``` ```python r = requests.get( "https://quizbase.runriva.com/api/v1/questions/random", params={ "amount": 5, "lang": "pl", "difficulty": "medium", "category": "geography", }, headers={"X-API-Key": os.environ["QUIZBASE_KEY"]}, ) data = r.json()["data"] ``` ## Response **200 — Success** ```json { "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": { "count": 5, "language": "pl", "requestId": "req_01HZABC123DEF" } } ``` **401 — Missing key** ```json { "type": "https://quizbase.runriva.com/errors/missing_api_key", "title": "API key required", "status": 401, "detail": "Include your API key in the X-API-Key header (or Authorization: Bearer ). Get a key at https://quizbase.runriva.com/dashboard/keys.", "instance": "/api/v1/questions/random?amount=5&lang=pl", "code": "missing_api_key" } ``` **429 — Rate limit** ```json { "type": "https://quizbase.runriva.com/errors/rate-limit-exceeded", "title": "Too Many Requests", "status": 429, "detail": "Rate limit exceeded on requests per day. Limit: 100/day on Free tier. Current: 101/day. Retry after: 3600s.", "instance": "/api/v1/questions/random", "code": "rate_limit_exceeded", "retryAfter": 3600, "upgradeUrl": "https://quizbase.runriva.com/pricing" } ``` **400 — Validation** ```json { "type": "https://quizbase.runriva.com/errors/invalid_query_param", "title": "Invalid query parameters", "status": 400, "detail": "amount: amount must be between 1 and 50", "instance": "/api/v1/questions/random?amount=999", "code": "invalid_query_param", "errors": [ { "path": "amount", "message": "amount must be between 1 and 50" } ] } ``` > **INFO: Empty array is a valid response** > > If your filters match no rows (e.g. `lang=pl&category=obscure-niche`), you get `data: []` with `meta.count: 0` — not a 404. > **INFO: Sampling strategy** > > For broad filters (e.g. `lang=en` only) we use a fast statistical sample — randomness is uniform across the catalog. For narrow filters (`tags`, `topic`, `subcategory`) we draw uniformly from the matched subset. Both feel random to the caller; distribution is uniform within practical use cases. ## Rate limits Every response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`. On 429 also `Retry-After`. See [Errors and retries](/docs/errors-and-retries) for the full picture. ## Performance - Broad filter (no `tags`/`topic`/`subcategory`): **p50 138ms, p95 158ms** (sustained 50 RPS). - Narrow filter (any of `tags`, `tags_any`, `topic`, `topics_any`, `subcategory`): **p50 ~110ms, p95 ~180ms** (sustained 30 RPS). - p99 ≤ ~280ms across both paths. - Last measured: 2026-05-07. Burst-tested at 200 RPS without degradation. - SLO: p95 < 800ms (broad) / < 1500ms (narrow), error rate < 1%. ## See also - [GET /v1/questions](/docs/api/questions) — browse with cursor pagination and `updated_since` - [Languages & translations](/docs/guides/languages-and-translations) — language coverage and `is_translation` flag - [Migrating from OpenTDB](/docs/guides/migrating-from-opentdb) — how to port existing code --- # GET /v1/questions **`GET /api/v1/questions`** — API key required Paginated browse of the full question catalog. Use this when you need **all** matching questions (e.g. building a local cache, a study app, or a data exporter) — not random samples. Cursor-based pagination using UUID v7 ordering — stable across inserts, no page-number drift. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `cursor` | `UUID v7` | `—` | — | Last `id` from the previous page. Omit for the first page. | | `limit` | `integer` | `20` | — | Page size, max 100. | | `lang` | `enum` | `en` | — | Supported: `en`, `pl`. Strict — any other value returns `400`. | | `updated_since` | `ISO 8601` | `—` | — | Only questions with `updatedAt >= this`. Delta-sync pattern. | | `category` | `integer or slug` | `—` | — | Category id (1-24 internal, or 9-32 opentdbId) or slug. | | `difficulty` | `enum` | `—` | — | `easy` | `medium` | `hard` | | `type` | `enum` | `—` | — | Default response includes `multiple` and `boolean` only. Pass `?type=text_input` to opt into open-ended questions explicitly. | | `tags` | `CSV kebab-case` | `—` | — | All-of match (AND). Each returned question echoes tags as `{slug, label}` objects. | | `tags_any` | `CSV kebab-case` | `—` | — | Any-of match (OR), max 10. Use when AND logic is too restrictive. | | `topic` | `kebab-case` | `—` | — | Curated topic slug (resolves through aliases). 2,184 curated topics — see [GET /v1/topics](/docs/api/topics). | | `topics_any` | `CSV kebab-case` | `—` | — | Any-of match (OR) on curated topics, max 10. | | `subcategory` | `kebab-case` | `—` | — | Raw subcategory slug. Single-value filter; prefer `topic`/`topics_any` for OR semantics across many. | | `quality` | ``high`` | `—` | — | Pass `quality=high` to exclude questions our internal review flagged for distractor problems. | | `regions` | `CSV ISO 3166-1` | `—` | — | All-of match (AND), lowercase 2-letter codes. | | `source` | `enum` | `—` | — | `opentdb` | `opentriviaqa` | `mkqa` | `mintaka` | `nq-open` | `kqa-pro` | `entityq` | `quizbase` | … | | `license` | `SPDX` | `—` | — | e.g. `CC-BY-SA-4.0`, `MIT`. Filter by license string. | | `count` | `enum` | `estimate` | — | How to compute total: `estimate` (cheap, ±5-50% via planner stats) → `meta.totalEstimate`; `exact` (slow, 5-9s on full dataset) → `meta.total`; `none` (skip). | > **INFO: Why default is estimate, not exact** > > `COUNT(*)` on the 1.4M+ row catalog with a JOIN takes 5-9 seconds. For browse / pagination use cases (the typical caller), an estimate is fine — UI shows "~545k matches" anyway. Pass `?count=exact` if you genuinely need the precise total (e.g. exporting and verifying completeness), or `?count=none` to skip the count entirely (fastest). ## Examples ### First page ```bash curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions?lang=pl&limit=50" ``` ```typescript async function* fetchAll(key: string, lang = 'pl') { let cursor: string | undefined; while (true) { const url = new URL('https://quizbase.runriva.com/api/v1/questions'); url.searchParams.set('lang', lang); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { 'X-API-Key': key } }); const { data, _links } = await res.json(); yield* data; if (!_links?.next) return; cursor = new URL(_links.next, url).searchParams.get('cursor')!; } } ``` ```python def fetch_all(key, lang='pl'): cursor = None while True: params = {'lang': lang, 'limit': 100} if cursor: params['cursor'] = cursor r = requests.get( 'https://quizbase.runriva.com/api/v1/questions', params=params, headers={'X-API-Key': key}, ) body = r.json() yield from body['data'] next_link = body.get('_links', {}).get('next') if not next_link: return cursor = parse_qs(urlparse(next_link).query)['cursor'][0] ``` ### Delta sync Fetch only questions updated since your last sync: ```bash curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions?lang=pl&updated_since=2026-04-01T00:00:00Z&limit=100" ``` Persist the highest `updatedAt` you've seen and use it as the next `updated_since`. ## Response **200 — Success** ```json { "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" }, { "slug": "central-europe", "label": "Europa Środkowa" } ], "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", "central-europe"] }, "createdAt": "2026-04-24T10:00:00Z", "updatedAt": "2026-04-24T10:00:00Z" } ], "meta": { "count": 50, "countMode": "estimate", "totalEstimate": 545379, "language": "pl", "requestId": "req_..." }, "_links": { "next": "/api/v1/questions?lang=pl&cursor=019db5a1...&limit=50" } } ``` **400 — Validation** ```json { "type": "https://quizbase.runriva.com/errors/invalid_query_param", "title": "Invalid query parameters", "status": 400, "detail": "cursor: Invalid uuid", "instance": "/api/v1/questions?cursor=not-a-uuid", "code": "invalid_query_param", "errors": [ { "path": "cursor", "message": "Invalid uuid" } ] } ``` ### Response fields - **`tags` / `subcategories`** — both arrays of `{ slug, label }`. The `slug` is the stable identifier you filter by; the `label` is a human-readable string in the requested `?lang=`. Tags are short identifiers (≤4 words, proper nouns and concepts), subcategories are broader topic groupings (≤4 words, organic). A question typically has 3-5 tags and 3-6 subcategories. - **`category`** — the top-level category (24 in total). `id` is the legacy OpenTDB id (9-32). `name` is localized to `?lang=` with English fallback if a translation is missing. - **`translationOf` / `rootQuestionId`** — non-null when this row is a translation. `translationOf` is the direct parent (usually English source), `rootQuestionId` is the canonical source across translation chains. - **`translator`** — coarse provenance: `"machine"` (machine-translated from the source language), `"human"` (manually translated/edited), `"native"` (imported as-is from a multilingual upstream source), or `null` (original-language record, no translation involved). The specific upstream system that produced a machine translation is an implementation detail and not exposed. - **`extensions`** — forward-compatible container for stable per-source extras. Today exposes `subcategories: string[]` (also surfaced as the typed top-level `subcategories: [{slug, label}]`). Future stable fields land here additively. Internal pipeline observability (model versions, prompt versions, run IDs) is **not** exposed here — that's audit data we keep server-side. ### License metadata in `attribution` The 9-field `attribution` object gives you everything needed to comply with upstream licenses without consulting external docs: - **`license`** — the effective applied license string (e.g. `CC-BY-SA-4.0` — with `3.0 → 4.0` upgrade for sources where applicable). - **`licenseVersion`** — the original upstream license version (`"3.0"`, `"4.0"`, or `null` for MIT). Use this when you need the exact version the upstream record came under. - **`licenseUrl`** — the canonical license URL (creativecommons.org or opensource.org). `null` for proprietary records. - **`modifications`** — array of changes we made on top of the upstream record. Possible values: `translated_` (per-language), `refined_text` (we improved phrasing), `quizified` (we converted a Q&A pair into a quiz item with distractors). An empty array means we kept the upstream record verbatim. - **`lastModified`** — ISO timestamp of our last modification (`updatedAt`). Use this for cache invalidation and `updated_since` syncs. If you redistribute records covered by share-alike licenses, build your attribution string using `author` + `licenseUrl` + the `modifications` list. ## Stop condition When `_links.next` is absent, you've reached the end. Some pages may return fewer than `limit` rows even mid-stream — **do not** use "fewer rows than limit" as a stop condition. ## Rate limits Pagination lets you burn through your quota fast. Consider: - Requesting `limit=100` (max) to halve request count - Using `updated_since` for incremental syncs after initial fetch - Caching on your side — questions don't change often ## Performance - p50 (warm): ~97ms - p95: ~112ms (sustained 50 RPS, baseline) - p99: ~200ms - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% - Cursor pagination scales to any catalog size — performance stays flat as you walk forward. - `?count=estimate` (default) returns in tens of milliseconds. `?count=exact` walks the full set (5-9s on the current dataset) — use sparingly, only when you genuinely need an exact total. ## See also - [GET /v1/questions/random](/docs/api/questions-random) — random sample for game sessions - [GET /v1/questions/:id](/docs/api/questions-by-id) — deep link a single question - [Rate limits in practice](/docs/guides/rate-limits-and-retries) — scheduling patterns for large syncs --- # GET /v1/questions/:id **`GET /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 | Required | Description | | --- | --- | --- | --- | --- | | `id` | `UUID v7` | `—` | yes | 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 ```bash curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions/0193f8b5-7e5c-7c24-9f7a-3d1e1c2a5f10" ``` ```typescript const id = '0193f8b5-7e5c-7c24-9f7a-3d1e1c2a5f10'; const res = await fetch( `https://quizbase.runriva.com/api/v1/questions/${id}`, { headers: { 'X-API-Key': process.env.QUIZBASE_KEY! } } ); if (res.status === 404) return null; const { data } = await res.json(); ``` ```python r = requests.get( f"https://quizbase.runriva.com/api/v1/questions/{qid}", headers={"X-API-Key": os.environ["QUIZBASE_KEY"]}, ) if r.status_code == 404: return None data = r.json()["data"] ``` ## Response **200 — Success** ```json { "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_..." } } ``` **404 — Not found** ```json { "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. ```ts // 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 ``` ```python # 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](/docs/api/questions-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. ```ts 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. ```ts const seen = new Set(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` 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. ```ts 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 { 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. ```ts // 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: ```ts // 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". ```py 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](/docs/api/questions) — list endpoint that returned this id - [Languages & translations](/docs/guides/languages-and-translations) — `translationOf` and `rootQuestionId` semantics --- # GET /v1/categories **`GET /api/v1/categories`** — Public (no key) Returns the full list of categories with localized `name`. **No API key required** — rate-limited per IP, cached aggressively (`Cache-Control: public, s-maxage=3600, stale-while-revalidate=300`). Use this for dropdowns, filter UIs, and landing page counts. Categories change rarely — fetch once, cache for hours. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `lang` | `enum` | `en` | — | Supported: `en`, `pl`. Sets the language of `name`. Falls back to English where a category translation is missing. Any other value returns `400`. | ## Examples ```bash curl "https://quizbase.runriva.com/api/v1/categories?lang=pl" ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/categories?lang=pl'); const { data } = await res.json(); // data: Array<{ id: number; slug: string; name: string; opentdbId: number | null; parentId: number | null }> ``` ```python r = requests.get('https://quizbase.runriva.com/api/v1/categories', params={'lang': 'pl'}) categories = r.json()['data'] ``` ## Response **200 — Success** ```json { "data": [ { "id": 1, "slug": "general-knowledge", "name": "Wiedza ogólna", "opentdbId": 9, "parentId": null }, { "id": 3, "slug": "film", "name": "Rozrywka: Film", "opentdbId": 11, "parentId": null }, { "id": 14, "slug": "geography", "name": "Geografia", "opentdbId": 22, "parentId": null }, { "id": 15, "slug": "history", "name": "Historia", "opentdbId": 23, "parentId": null } ], "meta": { "count": 24, "language": "pl", "requestId": "req_..." } } ``` **400 — Validation** ```json { "type": "https://quizbase.runriva.com/errors/invalid_query_param", "title": "Invalid query parameters", "status": 400, "detail": "lang: lang \"xyz\" is not supported. Supported: en, pl", "instance": "/api/v1/categories?lang=xyz", "code": "invalid_query_param", "errors": [ { "path": "lang", "message": "lang \"xyz\" is not supported. Supported: en, pl" } ] } ``` > **TIP: OpenTDB compatibility** > > `opentdbId` links our categories to the original OpenTDB numeric ids (9–32). If you're porting from OpenTDB, you can keep using those ids as the `category` parameter everywhere — both `category=22` and `category=geography` resolve to the same set. ## Performance - p50 (warm): ~25ms - p95: ~30ms (sustained 50 RPS, baseline) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% - CDN cache: `s-maxage=3600, stale-while-revalidate=300` — most requests served by CDN edge. ## See also - [GET /v1/stats](/docs/api/stats) — per-category counts - [GET /v1/languages](/docs/api/languages) — language whitelist accepted by `?lang=` - [Migrating from OpenTDB](/docs/guides/migrating-from-opentdb) — id mapping in practice --- # GET /v1/topics **`GET /api/v1/topics`** — Public (no key) List of curated topics — stable conceptual identifiers (e.g. `star-wars`, `world-war-ii`) with all aliases that map to them in the dataset, plus question counts. **No API key required.** Cached for 1h server-side, with `Cache-Control: public, s-maxage=3600`. > **Counts freshness:** question counts are pre-aggregated and refreshed when the dataset changes. Refresh cadence is irregular at launch — focus is on dataset quality and new languages, not a fixed import schedule. Once that pipeline lands we'll publish the schedule. Use this for typeahead search, dropdown filters, or as the first step of any "give me questions about X" flow. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `lang` | `enum` | `en` | — | Display language for `label`. Supported: `en`, `pl`. Other values return `400`. | | `q` | `string` | `—` | — | Substring search across label, slug, and aliases (case-insensitive, 1–64 chars). | | `kind` | `enum` | `—` | — | `tag` or `subcategory`. Limits results to one taxonomy layer. | | `cursor` | `string` | `—` | — | Slug of the last item from the previous page. Use `_links.next` to navigate. | | `limit` | `int` | `100` | — | Page size, 1–500. | ## Examples ```bash curl "https://quizbase.runriva.com/api/v1/topics?q=star&lang=en" ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/topics?q=star&lang=en'); const { data } = await res.json(); const starWars = data.find(t => t.slug === 'star-wars'); console.log(`${starWars.label}: ${starWars.count} questions`); ``` ```python r = requests.get('https://quizbase.runriva.com/api/v1/topics', params={'q': 'star', 'lang': 'en'}) for topic in r.json()['data']: print(f"{topic['label']}: {topic['count']} questions") ``` ## Response **200 — Success** ```json { "data": [ { "slug": "biography", "kind": "subcategory", "label": "Biography", "aliases": [ "biography", "biographical-facts", "biographies", "biographical-data", "biographical-details", "celebrity-biography" ], "count": 64977 }, { "slug": "american-history", "kind": "subcategory", "label": "American History", "aliases": [ "american-history", "united-states-history", "us-history" ], "count": 19449 } ], "meta": { "count": 2, "total": 2184, "language": "en", "requestId": "req_..." }, "_links": { "next": "/api/v1/topics?lang=en&limit=2&cursor=..." } } ``` > **TIP: Topic vs tag vs subcategory** > > **Topics** (this endpoint) are the curated identifiers — stable across our content updates. **Tags** and **subcategories** are the raw slugs from upstream sources, often messy. When you filter (`?topic=star-wars` on `/random` or `/questions`), we automatically resolve aliases for you. ## Performance - p50 (warm): ~23ms - p95: ~28ms (sustained 50 RPS, baseline) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% - Backed by pre-aggregated topic counts + 1h response cache. ## See also - [GET /v1/categories](/docs/api/categories) — the 24 OpenTDB-aligned top-level categories - [GET /v1/tags](/docs/api/tags) — raw tags (10k+) when curated topics aren't enough - [GET /v1/subcategories](/docs/api/subcategories) — middle taxonomy layer - [GET /v1/questions/random](/docs/api/questions-random) — fetch questions filtered by `?topic=` - [Multi-round quiz tutorial](/docs/guides/multi-round-quiz) — end-to-end example using topics + filters --- # GET /v1/topics/:slug **`GET /api/v1/topics/:slug`** — Public (no key) Detail view of a single curated topic. Returns the topic record (label, aliases, total count) plus **facets** broken down by category, difficulty, and language, the top **co-occurring tags and subcategories**, and **3 sample questions** so you can preview the topic before composing a quiz round. This is the "what's actually in this topic" endpoint — the answer to "we have 1,599 Star Wars questions, but how do they break down? are they mostly easy / medium / hard? mostly films, or also books and games?". **Public, no API key required.** Cached for 1h server-side. ## Path parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `slug` | `kebab-case` | `—` | yes | Topic slug from [GET /v1/topics](/docs/api/topics). Aliases also resolve — `/v1/topics/star-wars-saga` redirects to the canonical `star-wars` topic. | ## Query parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `lang` | `enum` | `en` | — | Display language for `label` (topic and all facet labels). Supported: `en`, `pl`. Other values return `400`. | ## When you'll get 404 - The slug doesn't match any curated topic or alias - The topic was retired (very rare; we keep aliases pointing to surviving topics where possible) ## Examples ```bash curl "https://quizbase.runriva.com/api/v1/topics/star-wars?lang=en" ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/topics/star-wars?lang=en'); if (res.status === 404) return null; const { topic, facets, samples } = await res.json(); console.log(`${topic.label}: ${topic.count} questions across ${facets.byCategory.length} categories`); for (const sample of samples) console.log(' •', sample.text); ``` ```python r = requests.get('https://quizbase.runriva.com/api/v1/topics/star-wars', params={'lang': 'en'}) if r.status_code == 404: raise Exception('topic not found') body = r.json() print(f"{body['topic']['label']}: {body['topic']['count']} questions") ``` ## Response **200 — Success** ```json { "topic": { "slug": "star-wars", "kind": "tag", "label": "Star Wars", "aliases": ["star-wars"], "count": 1599 }, "facets": { "byCategory": [ { "slug": "film", "name": "Entertainment: Film", "count": 1191 }, { "slug": "books", "name": "Entertainment: Books", "count": 202 }, { "slug": "celebrities", "name": "Celebrities", "count": 99 } ], "byDifficulty": { "easy": 5, "medium": 1247, "hard": 347 }, "byLanguage": { "en": 1599, "pl": 1575 }, "coOccurringTags": [ { "slug": "george-lucas", "label": "George Lucas", "count": 813 }, { "slug": "lucasfilm", "label": "Lucasfilm", "count": 476 }, { "slug": "a-new-hope", "label": "A New Hope", "count": 250 } ], "coOccurringSubcategories": [ { "slug": "science-fiction", "label": "Science Fiction", "count": 594 }, { "slug": "space-opera", "label": "Space Opera", "count": 523 }, { "slug": "star-wars-franchise", "label": "Star Wars Universe", "count": 385 } ] }, "samples": [ { "id": "019dc5f7-02a5-774f-b627-3b4c25fb7f61", "text": "Is Voldemort a character in the Star Wars book series?", "type": "boolean", "difficulty": "medium", "language": "en" } ], "meta": { "language": "en", "requestId": "req_..." } } ``` **404 — Not found** ```json { "type": "https://quizbase.runriva.com/errors/topic_not_found", "title": "Topic not found", "status": 404, "detail": "No topic with slug=non-existent-topic.", "instance": "/api/v1/topics/non-existent-topic", "code": "topic_not_found" } ``` ### Response fields - **`topic`** — same shape as a single entry from [`/v1/topics`](/docs/api/topics): `{slug, kind, label, aliases, count}`. `kind` is `tag` or `subcategory` depending on which discovery layer the topic was promoted from. - **`facets.byCategory`** — top-level category breakdown for this topic. `name` is localized to your `?lang=`. Sorted by count desc. - **`facets.byDifficulty`** — fixed shape with `easy`, `medium`, `hard` keys (only those present). `unrated` does not appear here even when present in the catalog. - **`facets.byLanguage`** — counts per supported language (`en`, `pl` at launch). Tells you whether a topic has parity across languages. - **`facets.coOccurringTags`** / **`coOccurringSubcategories`** — top tags and subcategories that appear alongside this topic in questions, sorted by frequency. Use this to suggest related topics in a quiz UI ("if you liked Star Wars, try…") or to compose multi-tag rounds. - **`samples`** — 3 example questions (id, text, type, difficulty, language). Just a preview — fetch full content via [`/v1/questions`](/docs/api/questions) or [`/v1/questions/random?topic=…`](/docs/api/questions-random). > **TIP: Star Wars use case** > > The single best signal for "do I have enough material for a Star Wars quiz round?" Show the user 1,599 questions, spread across 12 of the 24 top-level categories, mostly medium difficulty, parity across en/pl, plus George Lucas + Lucasfilm + A New Hope as adjacent tags. They can compose a 10-question round on the spot. ## Performance - p50 (warm): ~50ms - p95: ~60ms (sustained) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% - Cached for 1h after first request per `(slug, lang)` pair. ## See also - [GET /v1/topics](/docs/api/topics) — discover topics by substring or kind - [GET /v1/questions/random?topic=:slug](/docs/api/questions-random) — fetch random questions for a topic - [GET /v1/questions?topic=:slug&lang=…](/docs/api/questions) — paginated browse of all questions for a topic - [Multi-round quiz tutorial](/docs/guides/multi-round-quiz) — end-to-end round composition using topics + facets --- # GET /v1/tags **`GET /api/v1/tags`** — Public (no key) List of **raw tags** as they appear in the question dataset (e.g. `darth-vader`, `world-war-ii`, `19th-century-poetry`). Each entry has a display label (translated to your `?lang=`) and a count of approved questions carrying it. This is the lowest layer of the taxonomy. For most quiz apps **prefer [`/v1/topics`](/docs/api/topics)**: it's a curated layer with aliases that group equivalent raw tags. Use `/tags` only when you specifically need raw access (research, dataset analysis, building your own taxonomy). **Public, no API key required.** Cached for 1h server-side (`Cache-Control: public, s-maxage=3600`). > **Listing scope:** the response includes only tags appearing in **5 or more questions** to filter out one-off artifacts. Filtering questions by any tag (e.g. `/v1/questions?tag=foo`) works for **all** tags regardless — the threshold applies only to this discovery listing. > > **Counts freshness:** counts are pre-aggregated and refreshed when the dataset changes. Refresh cadence is irregular at launch — focus is on dataset quality and new languages, not a fixed import schedule. New tags appear once they cross the 5-question threshold and the next refresh runs. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `lang` | `enum` | `en` | — | Display language for `label`. Supported: `en`, `pl`. Other values return `400`. | | `q` | `string` | `—` | — | Substring search across slug and label (case-insensitive, 1–64 chars). | | `cursor` | `string` | `—` | — | Slug of the last item from the previous page. Use `_links.next` to navigate. | | `limit` | `int` | `100` | — | Page size, 1–500. | ## Examples ```bash curl "https://quizbase.runriva.com/api/v1/tags?q=darth&lang=en" ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/tags?q=darth&lang=en'); const { data } = await res.json(); for (const tag of data) { console.log(`${tag.label}: ${tag.count} questions`); } ``` ```python r = requests.get('https://quizbase.runriva.com/api/v1/tags', params={'q': 'darth', 'lang': 'en'}) for tag in r.json()['data']: print(f"{tag['label']}: {tag['count']} questions") ``` ## Response **200 — Success** ```json { "data": [ { "slug": "united-states", "label": "United States", "count": 7673 }, { "slug": "nfl", "label": "NFL", "count": 6240 }, { "slug": "us-presidency", "label": "US Presidency", "count": 5894 } ], "meta": { "count": 3, "total": 87559, "language": "en", "requestId": "req_..." }, "_links": { "next": "/api/v1/tags?lang=en&limit=3&cursor=..." } } ``` Sort: `count DESC, slug ASC`. Pagination via `_links.next` when available. `meta.total` is the global count of tags meeting the ≥5 threshold (currently ~87k); `meta.count` is how many fit on this page. ## Filtering questions by tag ```bash # AND logic — both must match curl "https://quizbase.runriva.com/api/v1/questions/random?tags=darth-vader,skywalker&amount=10" \ -H "X-API-Key: qb_pk_..." # OR logic — either matches curl "https://quizbase.runriva.com/api/v1/questions/random?tags_any=darth-vader,skywalker&amount=10" \ -H "X-API-Key: qb_pk_..." ``` ## Performance - p50 (warm): ~22ms - p95: ~29ms (sustained 50 RPS, baseline) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% - Backed by a pre-aggregated counts table (filtered to ≥5 questions per tag) + 1h response cache. ## See also - [GET /v1/topics](/docs/api/topics) — curated topic layer with aliases (preferred for most apps) - [GET /v1/subcategories](/docs/api/subcategories) — middle taxonomy layer - [Multi-round quiz tutorial](/docs/guides/multi-round-quiz) — using tags for round filtering --- # GET /v1/subcategories **`GET /api/v1/subcategories`** — Public (no key) The **middle layer** of QuizBase's three-tier taxonomy: `category` (24 broad buckets like "Geography", "Film") → **`subcategory`** (1k+ faceted slugs like `science-fiction-films`, `polish-geography`, `19th-century-poetry`) → `tags` (10k+ proper nouns and specifics). Each subcategory entry has a display label and question count. Use this to build narrower filter dropdowns than `/categories` allows, especially for quiz round generators ("films and franchises", "European geography", etc.). **Public, no API key required.** Cached for 1h server-side. > **Listing scope:** the response includes only subcategories appearing in **5 or more questions** to filter out one-off artifacts. Filtering questions by any subcategory works for **all** values regardless — the threshold applies only to this discovery listing. > > **Counts freshness:** counts are pre-aggregated and refreshed when the dataset changes. Refresh cadence is irregular at launch — focus is on dataset quality and new languages, not a fixed import schedule. New subcategories appear once they cross the 5-question threshold and the next refresh runs. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `lang` | `enum` | `en` | — | Display language for `label`. Supported: `en`, `pl`. | | `q` | `string` | `—` | — | Substring search across slug and label (1–64 chars). | | `cursor` | `string` | `—` | — | Pagination cursor from `_links.next`. | | `limit` | `int` | `100` | — | Page size, 1–500. | ## Examples ```bash curl "https://quizbase.runriva.com/api/v1/subcategories?q=film&lang=en" ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/subcategories?q=film&lang=en'); const { data } = await res.json(); const films = data.find(s => s.slug === 'science-fiction-films'); console.log(`${films.label}: ${films.count} questions`); ``` ## Response **200 — Success** ```json { "data": [ { "slug": "biography", "label": "Biography", "count": 47124 }, { "slug": "american-history", "label": "American History", "count": 13611 }, { "slug": "world-history", "label": "World History", "count": 9520 } ], "meta": { "count": 3, "total": 36200, "language": "en", "requestId": "req_..." }, "_links": { "next": "/api/v1/subcategories?lang=en&limit=3&cursor=..." } } ``` `meta.total` is the global count of subcategories meeting the ≥5 threshold (currently ~36k); `meta.count` is how many fit on this page. ## Filtering questions by subcategory ```bash curl "https://quizbase.runriva.com/api/v1/questions/random?subcategory=science-fiction-films&amount=10" \ -H "X-API-Key: qb_pk_..." ``` Single-value filter only. For OR logic across multiple subcategories, prefer `?topic=` with curated topics — it resolves aliases and matches across both layers in one shot. ## Performance - p50 (warm): ~23ms - p95: ~29ms (sustained 50 RPS, baseline) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% - Backed by a pre-aggregated counts table (filtered to ≥5 questions per subcategory) + 1h response cache. ## See also - [GET /v1/topics](/docs/api/topics) — curated topic layer (preferred for filtering) - [GET /v1/tags](/docs/api/tags) — finer taxonomy layer - [GET /v1/categories](/docs/api/categories) — top-level 24-bucket layer --- # GET /v1/languages **`GET /api/v1/languages`** — Public (no key) List of languages QuizBase serves through the public API, with native names and quiz-ready question counts per language. Use this to populate a language picker in your UI or to verify a `?lang=` value before sending it. **Public, no API key required.** > **INFO: Day 1 supported: en, pl** > > The dataset includes 1.4M+ records across 30+ languages internally, but only **English** and **Polish** are exposed through the public API at launch. Native translations for German, French, Spanish, Italian, Japanese, Portuguese, Hindi, Arabic, and 21 more (MKQA + Mintaka backbones) are queued for post-launch publication based on demand. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `lang` | `enum` | `en` | — | Display language for `name`. Native names (`nativeName`) are always in their own script. | ## Examples ```bash curl https://quizbase.runriva.com/api/v1/languages ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/languages'); const { data } = await res.json(); for (const lang of data) { console.log(`${lang.code} — ${lang.nativeName}: ${lang.count} questions`); } ``` ```python r = requests.get('https://quizbase.runriva.com/api/v1/languages') for lang in r.json()['data']: print(f"{lang['code']} - {lang['nativeName']}: {lang['count']} questions") ``` ## Response **200 — Success** ```json { "data": [ { "code": "en", "name": "English", "nativeName": "English", "count": 645923 }, { "code": "pl", "name": "Polish", "nativeName": "Polski", "count": 647599 } ], "meta": { "count": 2, "language": "en", "requestId": "req_..." } } ``` `count` is the number of approved, public-dump-eligible questions for that language. Updated nightly from `public_stats_snapshots`. ## Performance - p50 (warm): ~30ms - p95 (warm): <50ms (small static dataset, no heavy aggregation) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% ## See also - [Languages and translations](/docs/guides/languages-and-translations) — what each language code means in practice - [GET /v1/stats](/docs/api/stats) — full breakdown including `byLanguage` - [GET /v1/categories](/docs/api/categories) — pair with `?lang=` for fully localized UI --- # GET /v1/stats **`GET /api/v1/stats`** — Public (no key) Aggregate counters — total questions, per-language, per-source, per-category. **No API key required**. Cached for 5 minutes server-side (Redis) with `Cache-Control: public, s-maxage=300, stale-while-revalidate=60`. Use this for landing pages, dashboards, and status boards. ## Parameters | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `lang` | `enum` | `en` | — | Supported: `en`, `pl`. Sets the language of `byCategory[].name`. Counts themselves are not per-language — `byLanguage` always returns all languages present in the catalog. Any other value returns `400`. | ## Examples ```bash curl "https://quizbase.runriva.com/api/v1/stats?lang=pl" ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/stats?lang=pl'); const stats = await res.json(); console.log(`${stats.total} questions across ${Object.keys(stats.byLanguage).length} languages`); ``` ```python stats = requests.get('https://quizbase.runriva.com/api/v1/stats').json() print(f"{stats['total']} questions across {len(stats['byLanguage'])} languages") ``` ## Response **200 — Success** ```json { "total": 1294009, "byLanguage": { "en": 646194, "pl": 647815 }, "bySource": {}, "byCategory": [ { "slug": "geography", "name": "geography", "count": 146384 }, { "slug": "history", "name": "history", "count": 146171 }, { "slug": "music", "name": "music", "count": 139247 } ], "byDifficulty": { "easy": 0, "medium": 0, "hard": 0, "unrated": 0 }, "byTopic": [ { "slug": "biography", "label": "Biografie", "count": 245103 }, { "slug": "american-history", "label": "Historia Stanów Zjednoczonych", "count": 71860 }, { "slug": "us-presidents", "label": "Prezydenci USA", "count": 60348 } ], "byTag": [ { "slug": "united-states", "label": "united-states", "count": 15281 }, { "slug": "nfl", "label": "nfl", "count": 14630 } ], "meta": { "generatedAt": "2026-05-05T16:32:09.001Z", "language": "pl", "requestId": "req_..." } } ``` **400 — Validation** ```json { "type": "https://quizbase.runriva.com/errors/invalid_query_param", "title": "Invalid query parameters", "status": 400, "detail": "lang: lang \"xyz\" is not supported. Supported: en, pl", "instance": "/api/v1/stats?lang=xyz", "code": "invalid_query_param", "errors": [ { "path": "lang", "message": "lang \"xyz\" is not supported. Supported: en, pl" } ] } ``` > **INFO: generatedAt — snapshot time, not request time** > > `meta.generatedAt` reflects when the cached aggregate was computed, not when you called. At 5-minute TTL, two adjacent requests can see the same `generatedAt`. If you need fresher data, wait at least 60 seconds and retry — `stale-while-revalidate` triggers a background refresh. > **INFO: Pre-launch shape gaps** > > Pre-launch the snapshot intentionally returns an empty bySource object and zero-filled byDifficulty. byCategory[].name may show a slug instead of the localized name, and rare byTag[].label may fall back to the slug too. These widen as snapshots rebuild; the response shape itself is stable. ## Performance - p50 (warm): ~25ms - p95: ~30ms (sustained 50 RPS, baseline) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% - Read path: `public_stats_snapshots` table, then a 5 min Redis cache. No live aggregation on the request path. ## See also - [GET /v1/categories](/docs/api/categories) — category list with `opentdbId` mapping - [GET /v1/languages](/docs/api/languages) — language whitelist behind `byLanguage` - [Languages & translations](/docs/guides/languages-and-translations) — what each `byLanguage` key means --- # POST /v1/report **`POST /api/v1/report`** — Public (no key) Submit a report about a specific question. **No API key required** — this is intentionally accessible to any consumer of the API or the data dump. > **Non-developer alternative:** [/legal/report](/legal/report) — a human-friendly form that calls this same endpoint internally, with three ways to identify the question (ID, text, or URL). Rate-limited to **5 requests per minute per IP** to prevent spam. Reports are reviewed by our team; if you provide `reporterEmail`, we'll follow up after triage. This endpoint is also the documented channel for translation correction requests under Polish copyright law (Pr.Aut. Art. 16(3), moral rights). ## Body | Parameter | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `questionId` | `UUID` | `—` | — | The `id` of the question you are reporting. Optional — provide one of `questionId`, `questionText`, or `questionUrl`. | | `questionText` | `string` | `—` | — | Text of the question if you do not have an ID (10-2000 chars). Optional — provide one of the three identifiers. | | `questionUrl` | `URL` | `—` | — | Public URL where you saw the question (max 500 chars). Optional — provide one of the three identifiers. | | `type` | `enum` | `—` | — | One of: `translation` (mistranslation, distortion), `factual` (wrong answer or premise), `inappropriate` (offensive content), `attribution` (incorrect or missing attribution), `other`. Required. | | `comment` | `string` | `—` | — | What is wrong, in your own words. Max 2000 characters. | | `reporterEmail` | `string` | `—` | — | Your email if you want a follow-up. Optional. We do not subscribe you to anything. | ## Examples ```bash curl -X POST https://quizbase.runriva.com/api/v1/report \ -H "Content-Type: application/json" \ -d '{ "questionId": "019db549-b3ff-7228-9174-3dfc7bf30415", "type": "translation", "comment": "Polish translation reverses the meaning of the original", "reporterEmail": "you@example.com" }' ``` ```typescript const res = await fetch('https://quizbase.runriva.com/api/v1/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ questionId: '019db549-b3ff-7228-9174-3dfc7bf30415', type: 'translation', comment: 'Polish translation reverses the meaning' }) }); const { reportId } = await res.json(); console.log('Report received:', reportId); ``` ```python r = requests.post('https://quizbase.runriva.com/api/v1/report', json={ 'questionId': '019db549-b3ff-7228-9174-3dfc7bf30415', 'type': 'factual', 'comment': 'The capital is wrong' }) print(r.json()) ``` ## Response **202 — Accepted** ```json { "received": true, "reportId": "019df4c5-1234-7abc-9def-0123456789ab", "meta": { "requestId": "req_..." } } ``` **404 — Question not found** ```json { "type": "https://quizbase.runriva.com/errors/question_not_found", "title": "Question not found", "status": 404, "detail": "No question with id=...", "instance": "/api/v1/report", "code": "question_not_found" } ``` **429 — Rate limited** ```json { "type": "https://quizbase.runriva.com/errors/rate_limit_exceeded", "title": "Too Many Reports", "status": 429, "detail": "Report rate limit exceeded. Limit: 5/minute per IP. Retry after: 47s.", "instance": "/api/v1/report", "code": "rate_limit_exceeded", "retryAfter": 47 } ``` ## Performance - p50 (warm): ~50ms - p95: <200ms (single insert + Redis rate-limit bucket) - Last measured: 2026-05-07 - SLO: p95 < 500ms, error rate < 1% ## See also - [GET /v1/questions/:id](/docs/api/questions-by-id) — look up the question whose id you'll report - [Support](/docs/support) — other support channels and what to include --- # Languages and translations QuizBase treats languages as first-class — not as an afterthought. This guide explains what's available, how translations are sourced, and how `?lang=` behaves at the API surface. ## Supported languages (at launch) | Code | Language | Source | Quality | | ---- | -------- | ------------------------------------------------------------ | ------------ | | `en` | English | native (OpenTDB, OpenTriviaQA, MKQA, QuizBase original) | ground truth | | `pl` | Polish | machine translation from English source, prompt-reviewed | production | Any other value of `?lang=` returns `400 invalid_query_param` (RFC 9457). Additional languages are on the roadmap — when they ship, they appear in this table and pass validation. Run [`GET /v1/stats`](/docs/api/stats) to see exact per-language counts in the live database. ## How translations work Every question has a `language` field (ISO 639-1). Translations link back to the source English question via `translationOf` and share `rootQuestionId`: ```json { "id": "0193f8b5-7e5c-7c24-9f7a-3d1e1c2a5f10", "language": "pl", "text": "Jaka jest stolica Polski?", "translationOf": "0193f8b5-7e5c-7c24-9f7a-000000000001", "rootQuestionId": "0193f8b5-7e5c-7c24-9f7a-000000000001", "translator": "machine" } ``` - `translationOf` — the direct parent id (usually English source) - `rootQuestionId` — the canonical source across translation chains - `translator` — coarse provenance: `"machine"`, `"human"`, `"native"`, or `null`. The specific upstream system that produced a machine translation is an implementation detail and may change without notice. > **TIP: Picking canonicalized translations** > > For apps that want the original question regardless of user language, fetch by `rootQuestionId`. For user-facing rendering, filter by `lang=` and accept that you'll get a translation if one exists. ## Fallback rules `GET /v1/questions/random?lang=pl` is **strict** — if we have no matching Polish question for your filters, you get `data: []`, not an English fallback. This is intentional: mixing languages mid-session breaks the UX of language-specific apps. If you **want** fallback behavior, implement it client-side: ```typescript async function randomWithFallback(key: string, lang: string, amount = 5) { const res = await fetch( `/api/v1/questions/random?lang=${lang}&amount=${amount}`, { headers: { 'X-API-Key': key } } ); const body = await res.json(); if (body.data.length >= amount) return body.data; // Top up with English const shortfall = amount - body.data.length; const enRes = await fetch( `/api/v1/questions/random?lang=en&amount=${shortfall}`, { headers: { 'X-API-Key': key } } ); const enBody = await enRes.json(); return [...body.data, ...enBody.data]; } ``` ```python def random_with_fallback(key, lang, amount=5): r = requests.get( '/api/v1/questions/random', params={'lang': lang, 'amount': amount}, headers={'X-API-Key': key}, ) data = r.json()['data'] if len(data) >= amount: return data shortfall = amount - len(data) r2 = requests.get( '/api/v1/questions/random', params={'lang': 'en', 'amount': shortfall}, headers={'X-API-Key': key}, ) return data + r2.json()['data'] ``` ## Category names — fallback to English Unlike questions, `category.name` **does** fall back to English when a localization is missing for a supported language. This keeps filter UIs functional even before every category is fully translated: ```bash curl "https://quizbase.runriva.com/api/v1/categories?lang=pl" # → categories with PL names where translated, English name where not ``` This is per-field fallback within a supported language — not a fallback for unsupported `?lang=` values. Passing `?lang=de` still returns `400`. ## Quality expectations - **English** — ground truth, human-reviewed where curated - **Polish** — machine-translated from the English source with calibrated trivia-tone prompt and reviewed-on-sample edge cases (quoted film titles preserved, Cyrillic/Ukrainian names transliteration). Watch `attribution.source` and `translator` per question for provenance. Always check `attribution.source` and `translator` to judge provenance per question. ## See also - [GET /v1/questions/random](/docs/api/questions-random) — `lang` parameter - [GET /v1/stats](/docs/api/stats) — per-language counts --- # Migrating from OpenTDB QuizBase is designed for drop-in migration from [Open Trivia DB](https://opentdb.com). The endpoints, fields, and category ids map cleanly — most existing clients need only two changes. ## The two changes 1. **Base URL**: `https://opentdb.com/api.php` → `https://quizbase.runriva.com/api/v1/questions/random` 2. **Add auth**: `X-API-Key: qb_pk_*` header That's it for the happy path. The response shape is different — see below — but the semantics are identical. ## Field mapping (question) | OpenTDB field | QuizBase field | Notes | | --- | --- | --- | | `category` (string) | `category.name` + `category.slug` + `category.id` | QuizBase returns a nested object. `category.id` equals `opentdbId` when applicable. | | `type` | `type` | Same enum: `multiple`, `boolean`. QuizBase adds `text_input`. | | `difficulty` | `difficulty` | Same enum: `easy`, `medium`, `hard`. | | `question` | `text` | **Renamed** — `question` → `text`. | | `correct_answer` | `correctAnswer` | camelCase. | | `incorrect_answers` | `incorrectAnswers` | camelCase. | | — | `language` | New — ISO 639-1. OpenTDB is English-only. | | — | `id` | New — UUID v7. Use for caching/dedup. | | — | `attribution` | New — `{ author, source, license, sourceId, url }`. Mandatory to preserve (CC-BY-SA chain). | | — | `tags`, `subcategories` | New — filter-friendly metadata. Both arrays of `{ slug, label }`: `slug` is the stable filter key (kebab-case), `label` is the human-readable string in the requested `?lang=`. Tags are short concept identifiers, subcategories are broader topic groupings. | | — | `regions` | New — array of ISO 3166-1 codes. Optional, present where geographically relevant. | ## Envelope mapping | OpenTDB | QuizBase | | --- | --- | | `response_code` | ❌ removed — use HTTP status codes + RFC 9457 error body | | `results[]` | `data[]` | | — | `meta: { count, requestId, language }` | OpenTDB's `response_code: 1/2/3/4/5` (no results / invalid param / token not found / token empty / rate limit) maps to: | OpenTDB code | QuizBase response | | --- | --- | | `0` (success) | `200` + `data` populated | | `1` (no results) | `200` + `data: []` | | `2` (invalid param) | `400` + RFC 9457 with `errors[]` | | `3/4` (session token) | ❌ not applicable — use `exclude=` for de-dup | | `5` (rate limit) | `429` + `Retry-After` header | ## Category ids OpenTDB uses numeric ids 9–32. QuizBase preserves them on our category table as `opentdbId`. You can pass either the numeric id **or** our slug — both work: ```bash # OpenTDB-style numeric id curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=5&category=22" # QuizBase slug curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=5&category=geography" ``` Call [`GET /v1/categories`](/docs/api/categories) once and build your id↔slug map. ## Side-by-side diff ```bash # BEFORE (OpenTDB) curl "https://opentdb.com/api.php?amount=5&category=22&difficulty=medium&type=multiple" # → { response_code: 0, results: [{ category, type, difficulty, question, correct_answer, incorrect_answers }] } # AFTER (QuizBase) curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=5&category=22&difficulty=medium&type=multiple" # → { data: [{ id, text, correctAnswer, incorrectAnswers, type, difficulty, language, category, attribution, ... }], meta } ``` ```typescript // BEFORE const r = await fetch('https://opentdb.com/api.php?amount=5&category=22'); const { results } = await r.json(); results.forEach(q => render(q.question, q.correct_answer, q.incorrect_answers)); // AFTER const r = await fetch( 'https://quizbase.runriva.com/api/v1/questions/random?amount=5&category=22&lang=en', { headers: { 'X-API-Key': process.env.QUIZBASE_KEY! } } ); const { data } = await r.json(); data.forEach(q => { render(q.text, q.correctAnswer, q.incorrectAnswers); // New: remember attribution per CC-BY-SA chain footer.push({ license: q.attribution.license, url: q.attribution.url }); }); ``` ## Session tokens (OpenTDB) → `exclude` (QuizBase) OpenTDB's session-token system (codes 3/4) prevents repeats in a session. QuizBase replaces it with explicit `exclude`: ```bash # Fetch 5 questions, excluding those you've already shown curl -H "X-API-Key: qb_pk_YOUR_KEY" \ "https://quizbase.runriva.com/api/v1/questions/random?amount=5&exclude=id1,id2,id3" ``` Max 100 ids per request. Track them client-side. ## Rate limits OpenTDB is "don't abuse" without headers. QuizBase uses IETF `RateLimit-*` headers on every response — measure yourself and respect `Retry-After` on 429. See [Errors and retries](/docs/errors-and-retries). ## See also - [GET /v1/questions/random](/docs/api/questions-random) — full parameter list - [Languages and translations](/docs/guides/languages-and-translations) — something OpenTDB never had --- # Build a Polish quiz app A complete walkthrough. By the end you have a SvelteKit app that serves Polish trivia with score tracking, a per-question timer, and visible attribution. Total code: ~120 lines. Works with any framework — the QuizBase calls are framework-agnostic. ## 1. Fetch from your server Never put `qb_sk_*` in client bundles. Hide the call behind your own endpoint. ```ts // src/routes/api/round/+server.ts import { json } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; export async function GET() { const url = new URL('https://quizbase.runriva.com/api/v1/questions/random'); url.searchParams.set('amount', '10'); url.searchParams.set('lang', 'pl'); url.searchParams.set('type', 'multiple'); url.searchParams.set('difficulty', 'medium'); const res = await fetch(url, { headers: { 'X-API-Key': env.QUIZBASE_KEY } }); if (!res.ok) { return json({ error: 'upstream' }, { status: 502 }); } const { data, meta } = await res.json(); return json({ questions: data, requestId: meta.requestId }); } ``` ## 2. Shuffle answers in the component QuizBase returns `correctAnswer` + `incorrectAnswers` separately. Shuffle once per render to avoid the correct answer always being in the same slot. ```ts // src/lib/quiz.ts export type Question = { id: string; text: string; correctAnswer: string; incorrectAnswers: string[]; attribution: { author: string; source: string; license: string; url: string }; }; export function prepareChoices(q: Question): Array<{ text: string; correct: boolean }> { const choices = [ { text: q.correctAnswer, correct: true }, ...q.incorrectAnswers.map((t) => ({ text: t, correct: false })) ]; // Fisher-Yates in place for (let i = choices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [choices[i], choices[j]] = [choices[j]!, choices[i]!]; } return choices; } ``` ## 3. UI — one question at a time ```svelte {#if current}

Pytanie {index + 1} / {questions.length} · {secondsLeft}s · wynik {score}

{current.text}

    {#each choices as choice (choice.text)}
  • {/each}
{#if picked !== null} {/if}
{/if} ``` > **WARNING: Attribution is not optional** > > CC-BY-SA-4.0 requires you to preserve `attribution.author`, `attribution.source`, `attribution.license`, and link back. A small `