WikiForms API

Complete REST API reference for building forms and quizzes on the Wikimedia ecosystem. Open-source, free, and Wikimedia-native.

Base: https://wikiforms.toolforge.org/api v2.0.0 JSON only MediaWiki OAuth 2.0 Open Source · GPL-3.0

Introduction

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").

ℹ️
WikiForms is hosted on Wikimedia Toolforge Kubernetes. The API is publicly accessible but rate-limited. All form questions are encrypted at rest with AES-256-CBC.

Quick Start

Here's everything you need to create a quiz and collect responses in under 5 minutes.

Step 1 — Create a quiz

JS
// 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

JS
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: [...] } }

Authentication

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.

🔑
Tokens are 64-character hex strings stored in the auth_tokens table with a 30-day expiry. After APP_KEY rotation, all existing tokens are invalidated and users must re-login.
GET /api/auth/mediawiki
Public

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
}
GET /api/auth/me
Public

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' }

Security

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.

Rate Limits

All endpoints are rate-limited per IP address. Exceeding any limit returns HTTP 429 with a Retry-After header.

EndpointLimitReason
All endpoints (default)20 req / minGeneral abuse prevention
/api/save-response10 req / minSpam submission prevention
/api/grade-response5 req / minAI API cost protection
/api/quiz/start10 req / minSession abuse prevention
/api/quiz/validate-session10 req / minSession abuse prevention
/api/usr-lang/{lang}Cached 10 min (server) + 5 min (CDN)DB load reduction

Error Codes

HTTP CodeMeaningCommon Cause
200SuccessRequest completed normally
400Bad RequestValidation failed — check request body fields
403ForbiddenMissing/expired token, unauthorized origin, or insufficient permissions
404Not FoundForm/resource with that slug doesn't exist
413Request Too LargeBody exceeds 1MB limit
429Rate LimitedToo many requests — wait 1 minute
500Server ErrorUnexpected backend error — check logs
502Bad GatewayOpenRouter AI service returned an invalid response
503Service UnavailableAI grading failed after all retries — try again shortly
677Unauthorized Origin CustomCross-origin request from unauthorized domain blocked
⚠️
Error code 677 is a WikiForms-specific code returned inside a 403 response body. Check for data.code === 677 to distinguish it from regular permission errors.

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

text
Single-line text input
AI graded
textarea
Multi-line text input
AI graded
radio
Single choice from options
Exact match
checkbox
Multiple choice from options
Exact match
select
Dropdown selection
Exact match
true_false
True / False choice
Exact match
email
Email address input
Not graded
number
Numeric input
Not graded
star
Star rating (1–N)
Not graded
section
Section divider/header
Not a question

Question Object Schema

FieldTypeRequiredDescription
idstringUnique identifier within the form (e.g. q1, uuid)
typestringOne of the question types above
textstringQuestion text — supports HTML (from RichTextEditor)
requiredbooleanWhether an answer is mandatory before submission
optionsstring[]Choices for radio/checkbox/select types
correctAnswerstringExpected answer for quiz grading (encrypted at rest)
pointsintegerPoints awarded for a correct answer
successMsgstringFeedback shown when answer is correct
failMsgstringFeedback shown when answer is incorrect
starMaxintegerMax star rating value (default: 5)
descriptionstringSub-text shown below question (section type)

Save Form

POST /api/save-form
Auth required

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

FieldTypeRequiredDescription
slugstringURL identifier — alphanumeric, hyphens, underscores only (max 100)
contentTypeform | quizType of content
titlestringDisplay title (max 255 chars)
questionsarrayArray of question objects (max 200)
descriptionstringOptional description (max 2000 chars)
cover_imageURL stringValid URL to a cover image (Wikimedia Commons supported)
timer_typenone | static | scheduledTimer mode (default: none)
timer_durationintegerDuration in minutes for static mode (1–1440)
timer_startISO datetimeStart time for scheduled mode
timer_endISO datetimeEnd time for scheduled mode
result_timinginstant | delayedWhen 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' }

Get Form Metadata

GET /api/get-form/{slug}
Public

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'
}
*/

Get Questions

POST /api/get-form-questions/{slug}
Public

Returns decrypted questions. For non-owners, correctAnswer, successMsg, and failMsg are stripped from the response — grading happens server-side in /api/save-response.

⚠️
Only call this endpoint when the user actually starts the form. Calling it on page load defeats the security model.
const data = await fetch(`/api/get-form-questions/${slug}`, {
  method: 'POST'
}).then(r => r.json());
// { status: 'success', questions: [ ...questions without correctAnswer ] }

My Forms

GET /api/my-forms/{username}
Auth required

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'
  }]
}
*/

Save Response

POST /api/save-response
PublicGrading v2

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.

Security note: correctAnswer values are decrypted server-side for grading only. They are never included in any API response to non-owners.

Request Body

FieldTypeDescription
form_slugstringTarget form slug
titlestringForm title (stored for display in response list)
typeform | quizContent type — determines whether server grading runs
answersobjectMap 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

Response — Quiz submission
{
  "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

Response — Form submission
{ "status": "success" }

Get Responses

GET /api/get-responses/{slug}
Owner / Collaborator

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'
  }]
}
*/

Grade Response (AI)

POST /api/grade-response
Public5 / min

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

ℹ️
For quiz submissions, use /api/save-response instead. It grades server-side automatically without exposing correct answers. This endpoint is for standalone grading use cases only.
⏱️
AI grading may take 2–10 seconds. The endpoint retries automatically on 429/500/502/503 from OpenRouter. Returns 503 if all retries fail.
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 }] }

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

POST /api/quiz/start
Public10 / min

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' }

Heartbeat

POST /api/quiz/heartbeat
Public

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

Validate Session

POST /api/quiz/validate-session
Public10 / min

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
}

Add Collaborator

POST /api/add-collaborator
Owner only

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' }

Remove Collaborator

POST /api/remove-collaborator
Owner only
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' }

Get Translations

GET /api/usr-lang/{lang}
PublicCached 10 min

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: 'উইকিফর্মে আপনাকে স্বাগত!', ... }
}
*/

Save Draft Translation

POST /api/editor
Wikipedia login

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.' }

Publish Translation

POST /api/publisher
Contributor only

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.' }

Health Check

GET /api/test-connection
Public

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!' }

Changelog

v2.0.0

July 2026 — Security & Grading Overhaul

  • Server-side quiz grading — correct answers never reach the client
  • Post-submission answer reveal via revealed field 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)
v1.3.0

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
v1.2.0

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
v1.0.0

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