WikiForms API
Complete REST API reference for building forms and quizzes on the Wikimedia ecosystem. Open-source, free, and Wikimedia-native.
OverviewIntroduction
WikiForms is a free, open-source form and quiz builder hosted on Wikimedia Toolforge. It allows Wikipedia editors, researchers, and community members to create structured forms and AI-proctored quizzes without leaving the Wikimedia ecosystem.
All API endpoints are prefixed with /api/, accept and return JSON, and always include a status field in the response ("success" or "error").
Getting StartedQuick Start
Here's everything you need to create a quiz and collect responses in under 5 minutes.
Step 1 — Create a quiz
// Requires Wikipedia login — send X-WF-Token header
const res = await fetch('/api/save-form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WF-Token': 'YOUR_AUTH_TOKEN'
},
body: JSON.stringify({
slug: 'my-first-quiz',
contentType: 'quiz',
title: 'My First Quiz',
questions: [{
id: 'q1', type: 'radio',
text: 'Capital of Bangladesh?',
options: ['Dhaka', 'Chittagong', 'Sylhet'],
correctAnswer: 'Dhaka', points: 10, required: true
}],
result_timing: 'instant'
})
});
// → { status: 'success' }
Step 2 — Submit an answer
const res = await fetch('/api/save-response', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
form_slug: 'my-first-quiz',
title: 'My First Quiz',
type: 'quiz',
answers: { q1: 'Dhaka' }
})
});
const data = await res.json();
// Server grades server-side — correct answers never sent to client:
// { status: 'success', score: { earned: 10, total: 10, results: [...] } }
Getting StartedAuthentication
WikiForms uses MediaWiki OAuth 2.0 via meta.wikimedia.org. Public read endpoints require no auth. Write operations (save form, add collaborator, publish translation) require a valid X-WF-Token header.
Starts the OAuth 2.0 login flow. Redirects to MediaWiki authorization. Open in a popup — on success, the popup posts a WIKI_AUTH_SUCCESS message to the opener window.
const popup = window.open(
'https://wikiforms.toolforge.org/api/auth/mediawiki',
'WikiForms Login',
'width=600,height=600,popup=1'
);
window.addEventListener('message', (e) => {
if (e.data.type === 'WIKI_AUTH_SUCCESS') {
const { username, auth_token } = e.data.user;
// Store token — send as X-WF-Token on all protected requests
localStorage.setItem('wf_user', JSON.stringify(e.data.user));
}
});
After login, include the token on protected requests:
headers: {
'Content-Type': 'application/json',
'X-WF-Token': user.auth_token
}
Returns the currently authenticated user. Returns { status: 'guest' } if not logged in.
const data = await fetch('/api/auth/me').then(r => r.json());
// Logged in: { status: 'success', username: 'Anaf' }
// Logged out: { status: 'guest' }
Getting StartedSecurity
WikiForms implements multiple layers of security at both the server and application level.
🔒 AES-256-CBC Encryption
All form questions encrypted at rest via Laravel's Crypt facade. Correct answers never reach the client.
🛡️ Origin Enforcement
Browser requests from unauthorized origins are blocked (error code 677). Checked at both lighttpd and Laravel levels.
🤖 Bot Blocking
Known scanner UAs (sqlmap, nikto, nmap, masscan, python-requests, wget, curl, etc.) are blocked at the API gate.
📦 Request Size Limit
Requests over 1MB are rejected with HTTP 413 to prevent payload flooding attacks.
⚡ Rate Limiting
Per-IP rate limiting via Laravel throttle middleware. Tighter limits on grading and submission endpoints.
🧹 Token Cleanup
Expired auth tokens and old quiz sessions are automatically cleaned up (1% chance per request).
🎯 Anti-Cheat System
Server-side heartbeat sessions with 5-second gap detection. Tab switches, DevTools, and keyboard shortcuts blocked client-side.
✅ Input Validation
All endpoints validate: slug regex, contentType enum, cover_image URL format, result_timing enum, and array max sizes.
Getting StartedRate Limits
All endpoints are rate-limited per IP address. Exceeding any limit returns HTTP 429 with a Retry-After header.
| Endpoint | Limit | Reason |
|---|---|---|
| All endpoints (default) | 20 req / min | General abuse prevention |
/api/save-response | 10 req / min | Spam submission prevention |
/api/grade-response | 5 req / min | AI API cost protection |
/api/quiz/start | 10 req / min | Session abuse prevention |
/api/quiz/validate-session | 10 req / min | Session abuse prevention |
/api/usr-lang/{lang} | Cached 10 min (server) + 5 min (CDN) | DB load reduction |
Getting StartedError Codes
| HTTP Code | Meaning | Common Cause |
|---|---|---|
200 | Success | Request completed normally |
400 | Bad Request | Validation failed — check request body fields |
403 | Forbidden | Missing/expired token, unauthorized origin, or insufficient permissions |
404 | Not Found | Form/resource with that slug doesn't exist |
413 | Request Too Large | Body exceeds 1MB limit |
429 | Rate Limited | Too many requests — wait 1 minute |
500 | Server Error | Unexpected backend error — check logs |
502 | Bad Gateway | OpenRouter AI service returned an invalid response |
503 | Service Unavailable | AI grading failed after all retries — try again shortly |
677 | Unauthorized Origin Custom | Cross-origin request from unauthorized domain blocked |
ReferenceQuestion Types
Every question object in the questions array has a type field. Grading method depends on type — exact-match types are graded locally, open-ended types use OpenRouter AI.
Question Object Schema
| Field | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Unique identifier within the form (e.g. q1, uuid) |
type | string | ✅ | One of the question types above |
text | string | ✅ | Question text — supports HTML (from RichTextEditor) |
required | boolean | — | Whether an answer is mandatory before submission |
options | string[] | — | Choices for radio/checkbox/select types |
correctAnswer | string | — | Expected answer for quiz grading (encrypted at rest) |
points | integer | — | Points awarded for a correct answer |
successMsg | string | — | Feedback shown when answer is correct |
failMsg | string | — | Feedback shown when answer is incorrect |
starMax | integer | — | Max star rating value (default: 5) |
description | string | — | Sub-text shown below question (section type) |
FormsSave Form
Creates or updates a form/quiz. If the slug already exists, ownership is verified before updating. Questions are AES-256-CBC encrypted before storage — correct answers never stored in plaintext.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | ✅ | URL identifier — alphanumeric, hyphens, underscores only (max 100) |
contentType | form | quiz | ✅ | Type of content |
title | string | ✅ | Display title (max 255 chars) |
questions | array | ✅ | Array of question objects (max 200) |
description | string | — | Optional description (max 2000 chars) |
cover_image | URL string | — | Valid URL to a cover image (Wikimedia Commons supported) |
timer_type | none | static | scheduled | — | Timer mode (default: none) |
timer_duration | integer | — | Duration in minutes for static mode (1–1440) |
timer_start | ISO datetime | — | Start time for scheduled mode |
timer_end | ISO datetime | — | End time for scheduled mode |
result_timing | instant | delayed | — | When to show quiz results (default: instant) |
const res = await fetch('/api/save-form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WF-Token': token
},
body: JSON.stringify({
slug: 'bangladesh-history-quiz',
contentType: 'quiz',
title: 'Bangladesh History Quiz',
description: 'Test your knowledge of Bangladesh history.',
questions: [
{
id: 'q1', type: 'radio', required: true,
text: 'When did Bangladesh gain independence?',
options: ['1971', '1947', '1952', '1965'],
correctAnswer: '1971', points: 10,
successMsg: 'Correct! March 26, 1971.',
failMsg: 'Bangladesh declared independence on March 26, 1971.'
},
{
id: 'q2', type: 'text', required: true,
text: 'Who is known as the Father of the Nation of Bangladesh?',
correctAnswer: 'Sheikh Mujibur Rahman', points: 10
}
],
timer_type: 'static', timer_duration: 30,
result_timing: 'instant'
})
});
// → { status: 'success' }
FormsGet Form Metadata
Returns form metadata only. Questions are intentionally excluded — they are fetched separately via /api/get-form-questions/{slug} only when the user clicks Start, to prevent answer pre-loading.
const form = await fetch('/api/get-form/bangladesh-history-quiz')
.then(r => r.json());
/*
{
id: 'bangladesh-history-quiz',
contentType: 'quiz',
title: 'Bangladesh History Quiz',
description: 'Test your knowledge...',
cover_image: null,
owner_username: 'Anaf',
collaborators: [],
timer_type: 'static',
timer_duration: 30,
timer_start: null,
timer_end: null,
timer_before_msg: {},
timer_after_msg: {},
result_timing: 'instant'
}
*/
FormsGet Questions
Returns decrypted questions. For non-owners, correctAnswer, successMsg, and failMsg are stripped from the response — grading happens server-side in /api/save-response.
const data = await fetch(`/api/get-form-questions/${slug}`, {
method: 'POST'
}).then(r => r.json());
// { status: 'success', questions: [ ...questions without correctAnswer ] }
FormsMy Forms
Returns all forms owned by or collaborated on by the given Wikipedia username. The authenticated user must match the username in the URL — you cannot view other users' forms.
const data = await fetch('/api/my-forms/Anaf', {
headers: { 'X-WF-Token': token }
}).then(r => r.json());
/*
{
status: 'success',
forms: [{
slug: 'bangladesh-history-quiz',
title: 'Bangladesh History Quiz',
content_type: 'quiz',
owner_username: 'Anaf',
collaborators: ['OtherUser'],
timer_type: 'static',
response_count: 42,
recent_dates: ['2026-07-05T10:00:00', ...], // last 30 responses
created_at: '2026-06-01T00:00:00',
updated_at: '2026-07-05T00:00:00'
}]
}
*/
ResponsesSave Response
Saves a form or quiz submission. For quizzes, grading is performed entirely server-side — the correct answers are decrypted on the server, never sent to the client, and the score is returned in the response.
Request Body
| Field | Type | Description |
|---|---|---|
form_slug | string | Target form slug |
title | string | Form title (stored for display in response list) |
type | form | quiz | Content type — determines whether server grading runs |
answers | object | Map of question_id → answer. Checkbox answers are arrays. |
const res = await fetch('/api/save-response', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
form_slug: 'bangladesh-history-quiz',
title: 'Bangladesh History Quiz',
type: 'quiz',
answers: {
q1: '1971',
q2: 'Sheikh Mujibur Rahman',
'__email__': 'user@example.com' // optional
}
})
});
const data = await res.json();
Quiz Response
{
"status": "success",
"score": {
"earned": 20,
"total": 20,
"results": [
{ "id": "q1", "correct": true },
{ "id": "q2", "correct": true }
]
},
"revealed": {
"q1": {
"correctAnswer": "1971",
"successMsg": "Correct! March 26, 1971.",
"failMsg": "Bangladesh declared independence on March 26, 1971."
},
"q2": { "correctAnswer": "Sheikh Mujibur Rahman", ... }
}
}
The revealed field is only present in quiz responses and is safe to send post-submission — the quiz is already saved at this point.
Form Response
{ "status": "success" }
ResponsesGet Responses
Returns all submissions for a form. Restricted to the form owner and collaborators only.
const data = await fetch('/api/get-responses/bangladesh-history-quiz', {
headers: { 'X-WF-Token': token }
}).then(r => r.json());
/*
{
status: 'success',
responses: [{
id: 1,
answers: { q1: '1971', q2: 'Sheikh Mujibur Rahman' },
timestamp: '2026-07-05 10:00:00'
}]
}
*/
ResponsesGrade Response (AI)
Grades open-ended answers using AI (OpenRouter). Compares meaning not exact wording — "the capital city of Bangladesh" and "Dhaka" would both be marked correct for a correctAnswer of "Dhaka".
const res = await fetch('/api/grade-response', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
questions: [{
id: 'q1',
question: 'What is the capital of Bangladesh?',
correctAnswer: 'Dhaka',
userAnswer: 'the capital city is Dhaka'
}]
})
});
const data = await res.json();
// { status: 'success', results: [{ id: 'q1', correct: true }] }
Anti-CheatStart Quiz Session
WikiForms has a server-side heartbeat anti-cheat system. For quizzes, call /api/quiz/start when the user starts, send heartbeats every 3 seconds, and validate the session before accepting a submission.
Creates a quiz session and returns a server-side deadline. Returns { status: 'skip' } for non-quiz forms.
const data = await fetch('/api/quiz/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ form_slug: 'my-quiz', username: 'Anaf' })
}).then(r => r.json());
// { status: 'success', session_id: '64-char-hex', deadline: '2026-07-05T10:30:00Z' }
Anti-CheatHeartbeat
Send every 3 seconds while the quiz is active. If the server detects a gap > 5 seconds (tab switch or freeze), the session is terminated. Also enforces the server-side deadline.
const interval = setInterval(async () => {
const res = await fetch('/api/quiz/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId })
}).then(r => r.json());
if (res.status === 'terminated') {
clearInterval(interval);
// Lock the quiz UI
}
// { status: 'alive', server_ts: '2026-07-05T10:15:03Z' }
// { status: 'terminated', reason: 'heartbeat_missed' | 'deadline_exceeded' }
}, 3000);
Anti-CheatValidate Session
Validates a session before accepting a submission. Call this immediately before /api/save-response. Marks the session as submitted to prevent double submissions.
const validation = await fetch('/api/quiz/validate-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, form_slug: slug })
}).then(r => r.json());
if (validation.status === 'terminated') {
// reject submission
} else if (validation.status === 'valid') {
// proceed to save-response
}
CollaboratorsAdd Collaborator
Grants a Wikipedia user edit and response-view access to a form. The form must be published before adding collaborators.
await fetch('/api/add-collaborator', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WF-Token': token },
body: JSON.stringify({
slug: 'my-quiz',
new_collaborator: 'AnotherWikipediaUser'
})
});
// → { status: 'success' }
CollaboratorsRemove Collaborator
await fetch('/api/remove-collaborator', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WF-Token': token },
body: JSON.stringify({ slug: 'my-quiz', collaborator: 'AnotherWikipediaUser' })
});
// → { status: 'success' }
i18nGet Translations
Returns all live translations for a language. Falls back to English for any missing keys. Currently supported: en (97 keys), bn (97 keys), es (72 keys), fr (72 keys), de (21 keys). Community members can contribute more via the /contribute page.
// List available languages with coverage percentages
const langs = await fetch('/api/usr-lang').then(r => r.json());
// { languages: [{ code: 'bn', name: 'বাংলা', live_count: 97, coverage: 100 }] }
// Get Bengali translations
const bn = await fetch('/api/usr-lang/bn').then(r => r.json());
/*
{
status: 'success', lang: 'bn',
keys: { app_name: 'উইকিফর্মস', welcome_title: 'উইকিফর্মে আপনাকে স্বাগত!', ... }
}
*/
i18nSave Draft Translation
Saves or updates a translation as a draft. Any Wikipedia user can contribute. English source keys (contributed by system) are read-only.
await fetch('/api/editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WF-Token': token },
body: JSON.stringify({
lang_code: 'de',
lang_name: 'Deutsch',
translation_key: 'welcome_title',
value: 'Willkommen bei WikiForms!'
})
});
// → { status: 'success', message: 'Translation saved as draft.' }
i18nPublish Translation
Publishes a draft translation to live. Only the user who submitted the draft (contributed_by) can publish it. English system keys cannot be republished.
await fetch('/api/publisher', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WF-Token': token },
body: JSON.stringify({
lang_code: 'de',
translation_key: 'welcome_title'
})
});
// → { status: 'success', message: 'Translation published.' }
UtilitiesHealth Check
Simple connectivity check. Use this to verify the API is reachable before making other requests.
const data = await fetch('/api/test-connection').then(r => r.json());
// { status: 'success', message: 'Connected!' }
ReferenceChangelog
July 2026 — Security & Grading Overhaul
- Server-side quiz grading — correct answers never reach the client
- Post-submission answer reveal via
revealedfield in save-response - Exact-match question types (radio, checkbox, true_false, select) graded locally — no AI call
- Translation publisher restricted to the original contributor only
- Bot/scanner UA blocking at API gate (sqlmap, nikto, nmap, wget, curl etc.)
- Request size limit (1MB) enforced
- Per-endpoint rate limits (5/min grading, 10/min submissions)
- Expired token and quiz session auto-cleanup
- Anti-cheat: DevTools detection, F12/Ctrl+Shift+I keyboard blocks, right-click disable
- FormBuilder preview now renders RichTextEditor HTML correctly
- Progress bar replaces section dots in quiz UI
- Codex-style flat question cards (no border radius)
June 2026 — Anti-Cheat & i18n
- Heartbeat anti-cheat system with server-side deadline enforcement
- Tab switch detection (visibilitychange + blur + pagehide)
- Custom warning popover — no browser alert()
- Translation cache clearing endpoint
- Rate limiting tightened on sensitive endpoints
- Error code 677 for unauthorized origin
May 2026 — Security Hardening (GrinningIodize Report)
- Fixed: correctAnswer exposed in get-form-questions for non-owners
- Fixed: /my-forms IDOR — any username was accessible
- Fixed: OAuth state parameter not validated
- Fixed: XSS in renderResult() via $userJson interpolation
- Added: cover_image URL validation
- Added: slug and result_timing enum validation
- Added: session cookies HTTPS-only
- Added: DB-backed token auth replacing HMAC signing
2026 — Initial Release
- Form and quiz builder with drag-and-drop
- MediaWiki OAuth 2.0 login
- AES-256-CBC question encryption
- Multilingual support (EN, BN, ES, FR)
- Collaborator access control
- Scheduled quizzes with auto start/end
- AI answer grading via OpenRouter
WikiForms v2.0.0 — Open Source — GNU General Public License v3.0 — GPL-3.0 · GitHub · Security Hall of Fame