{
  "openapi": "3.1.0",
  "info": {
    "title": "QRvote API",
    "version": "1.0.0",
    "summary": "Read public contest, survey and hunt data; manage your own with its organizer key.",
    "description": "QRvote runs contests, surveys and scavenger hunts at in-person events with printed QR codes. This API lets agents read public results and help an organizer manage their own contest, survey or hunt. Creating one, voting, answering and reporting are for people in a browser: those endpoints require a Cloudflare Turnstile check and refuse automated clients, so they are not described here. There is no account system. Every contest, survey or hunt has one organizer key, which the organizer finds in their organizer link (https://qrvote.org/c/<slug>/admin#key=<key>) and can give to an agent. See https://qrvote.org/auth.md.",
    "contact": { "email": "legal@qrvote.org" },
    "termsOfService": "https://qrvote.org/terms"
  },
  "servers": [{ "url": "https://qrvote.org" }],
  "externalDocs": { "url": "https://qrvote.org/developers" },
  "components": {
    "securitySchemes": {
      "organizerKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "The organizer key from the organizer link (#key=…). Grants full control of that one contest, survey or hunt."
      },
      "signIn": {
        "type": "oauth2",
        "description": "Sign in as the organizer (OAuth 2.1 with PKCE; clients register themselves at https://qrvote.org/oauth/register or use a Client ID Metadata Document). The organizer picks which contests and Read or Manage at https://qrvote.org/connect. Tokens work on the operations documented here, for those contests only. See https://qrvote.org/auth.md",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://qrvote.org/authorize",
            "tokenUrl": "https://qrvote.org/oauth/token",
            "refreshUrl": "https://qrvote.org/oauth/token",
            "scopes": { "read": "Read results, entries, responses and exports", "manage": "Also change settings, end voting, add entries and run a live quiz" }
          }
        }
      }
    },
    "parameters": {
      "slug": { "name": "slug", "in": "path", "required": true, "description": "The id in the public link, e.g. `scarecrow-contest-2026-ab12` from https://qrvote.org/c/scarecrow-contest-2026-ab12", "schema": { "type": "string", "pattern": "^[a-z0-9-]{1,64}$" } }
    },
    "schemas": {
      "Health": { "type": "object", "properties": { "status": { "enum": ["ok", "degraded", "down"] }, "checks": { "type": "object", "properties": { "database": { "type": "boolean" }, "nightlyJobs": { "type": "boolean" }, "errorRate": { "type": "boolean" } } } } },
      "Error": { "type": "object", "properties": { "error": { "type": "string" }, "code": { "type": "string" } }, "required": ["error"] },
      "Campaign": {
        "type": "object",
        "properties": {
          "kind": { "type": "string", "enum": ["contest", "survey", "hunt"] },
          "quiz": { "type": "boolean", "description": "Surveys: a quiz. Questions may have correct answers and each response gets a score. Correct answers are public only once the quiz closes." },
          "leaderboard": { "type": "boolean", "description": "Quizzes: players may add a name and scores are ranked." },
          "hostSecs": { "type": "integer", "minimum": 0, "maximum": 300, "description": "Live quizzes: seconds per question on the auto clock (0 = the organizer moves it on)." },
          "host": { "type": "boolean", "description": "Quizzes: run live by the organizer, one question at a time. The public sees only questions already asked and answers already revealed." },
          "slug": { "type": "string" },
          "title": { "type": "string" },
          "description": { "type": "string" },
          "mode": { "type": "string", "enum": ["single", "each"], "description": "Contests: pick one favorite, or like several." },
          "results": { "type": "string", "enum": ["live", "after", "private"] },
          "requireScan": { "type": "boolean" },
          "requireBallot": { "type": "boolean" },
          "allowChange": { "type": "boolean" },
          "categories": { "type": "array", "description": "Contests: award categories. Each phone gets one vote in each. Empty = one overall vote.", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" } } } },
          "judging": { "type": "boolean", "description": "Contests: judges score the entries too. Their award is public once voting closes (unless results are private)." },
          "opensAt": { "type": ["integer", "null"], "description": "Unix ms" },
          "closesAt": { "type": ["integer", "null"], "description": "Unix ms" },
          "status": { "type": "string", "enum": ["scheduled", "open", "closed"] },
          "resultsVisible": { "type": "boolean" }
        }
      }
    }
  },
  "paths": {
    "/api/health": {
      "get": {
        "operationId": "health",
        "summary": "Service status",
        "responses": { "200": { "description": "Up. status is \"degraded\" when the nightly jobs are late or failing or server errors are spiking.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } } }, "503": { "description": "Down: the database is unreachable.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } } } }
      }
    },
    "/api/c/{slug}": {
      "get": {
        "operationId": "getCampaign",
        "summary": "A contest, survey or hunt as the public sees it",
        "description": "Entries (contests), questions (surveys) or checkpoints (hunts; unfound ones show only their clue). Entry QR codes are never included when scanning is required.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "campaign": { "$ref": "#/components/schemas/Campaign" } }, "additionalProperties": true } } } },
          "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "410": { "description": "Removed by QRvote for breaking the rules", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/c/{slug}/results": {
      "get": {
        "operationId": "getResults",
        "summary": "Results: vote tallies, survey totals, or a hunt leaderboard",
        "description": "Public when the organizer made results visible; otherwise send the organizer key. Contests also return categoryResults (standings per award category) and, once voting has closed, judgeResults (each criterion averaged over the judges, summed). Written survey answers and individual judges' scores are never included here.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{}, { "organizerKey": [] }, { "signIn": ["read"] }],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } },
          "403": { "description": "Results are private", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/admin/{slug}": {
      "get": {
        "operationId": "getDashboard",
        "summary": "Everything on the organizer dashboard",
        "description": "Settings, entries with vote counts, ballot tickets, fairness signals (grouped, hashed), and survey or hunt data.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read"] }],
        "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "401": { "description": "Wrong or missing organizer key" } }
      },
      "patch": {
        "operationId": "updateSettings",
        "summary": "Change settings, or end / reopen voting",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read", "manage"] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": { "type": "string", "maxLength": 120 },
                  "description": { "type": "string", "maxLength": 1000 },
                  "results": { "type": "string", "enum": ["live", "after", "private"] },
                  "allowChange": { "type": "boolean" },
                  "requireBallot": { "type": "boolean" },
                  "ipLimit": { "type": "integer", "minimum": 1, "maximum": 100000 },
                  "opensAt": { "type": ["integer", "null"] },
                  "closesAt": { "type": ["integer", "null"] },
                  "categories": { "type": "array", "maxItems": 10, "description": "Contests: award categories. Names (new) or {id, name} (keeps that category's votes when renaming). A category with votes can't be removed.", "items": { "oneOf": [{ "type": "string", "maxLength": 40 }, { "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string", "maxLength": 40 } } }] } },
                  "criteria": { "type": "array", "maxItems": 5, "description": "Contests: what judges score, 1–10 each. Empty turns judging off.", "items": { "type": "string", "maxLength": 30 } },
                  "theme": { "type": "string", "description": "classic, halloween, harvest, winter, garden, carnival, beach, sports, hearts, stars, lunar, diwali, muertos, hanami, cats, dogs, campfire, art, trivia, classroom, cookoff, science, wedding, carshow, workplace, or custom:<accent hex>-<second hex> (e.g. custom:1a73e8-ff8a5b)", "pattern": "^(classic|halloween|harvest|winter|garden|carnival|beach|sports|hearts|stars|lunar|diwali|muertos|hanami|trivia|classroom|cookoff|science|custom:[0-9a-f]{6}-[0-9a-f]{6})$" },
                  "lang": { "type": "string", "description": "The voters' language: the contest's QR codes and links open in it (anyone can switch). Defaults to en.", "enum": ["en", "es", "de", "zh", "fr", "pt", "ja", "ko", "it", "hi", "id", "tr", "ar"] },
                  "closed": { "type": "boolean", "description": "true ends voting now; false reopens it" }
                }
              }
            }
          }
        },
        "responses": { "200": { "description": "Updated dashboard data" }, "401": { "description": "Wrong or missing organizer key" } }
      }
    },
    "/api/admin/{slug}/judges": {
      "post": {
        "operationId": "addJudge",
        "summary": "Contests: add a judge (judging must be on)",
        "description": "The dashboard (getDashboard → judging.judges) then lists the judge with their private scorecard link key: https://qrvote.org/j/<slug>#<id>.<key>. Give it to that judge only.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read", "manage"] }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string", "maxLength": 60 } } } } } },
        "responses": { "200": { "description": "Added" }, "409": { "description": "Judging is off" } }
      }
    },
    "/api/admin/{slug}/judges/{id}": {
      "delete": {
        "operationId": "removeJudge",
        "summary": "Contests: remove a judge, their link and their scores",
        "parameters": [{ "$ref": "#/components/parameters/slug" }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read", "manage"] }],
        "responses": { "200": { "description": "Removed" } }
      }
    },
    "/api/admin/{slug}/entrants": {
      "post": {
        "operationId": "addEntries",
        "summary": "Add entries (contests) or checkpoints (hunts)",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read", "manage"] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object", "required": ["entrants"], "properties": { "entrants": { "type": "array", "items": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string", "maxLength": 80 }, "description": { "type": "string", "maxLength": 300 } } } } } } } }
        },
        "responses": { "200": { "description": "Updated dashboard data" } }
      }
    },
    "/api/admin/{slug}/export.csv": {
      "get": {
        "operationId": "exportCsv",
        "summary": "Download votes, survey responses or hunt players as CSV",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read"] }],
        "responses": { "200": { "description": "CSV", "content": { "text/csv": { "schema": { "type": "string" } } } } }
      }
    },
    "/api/admin/{slug}/responses": {
      "get": {
        "operationId": "listResponses",
        "summary": "Survey responses, newest first (up to 500). Quizzes include each response's score.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read"] }],
        "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } } }
      }
    },
    "/api/admin/{slug}/host": {
      "post": {
        "operationId": "hostStep",
        "summary": "Run a live quiz: start, reveal, next question, finish, or pause/resume its clock",
        "description": "For quizzes with host = true. Body: { action: 'start' | 'reveal' | 'next' | 'finish' | 'pause' | 'resume' }. Steps must follow lobby → question → reveal → question … → done; anything else is 409. With hostSecs > 0 the quiz also moves on by itself (each question hostSecs seconds, each answer shown 8 s); pause/resume stop and restart that clock, and a step by hand restarts it.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read", "manage"] }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["start", "reveal", "next", "finish", "pause", "resume"] } }, "required": ["action"] } } } },
        "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } } }
      }
    },
    "/api/admin/{slug}/questions": {
      "put": {
        "operationId": "setQuestions",
        "summary": "Surveys and quizzes: replace the questions (only until the first response)",
        "description": "Body: { questions: [...] }, 1–50, in order; the whole list is replaced. Each: { type, prompt (≤300 chars), required?, options?, max?, answer?, id? }. type is single (pick one) or multi (pick any) with options (2–20 distinct texts), yesno, likert (disagree…agree), rating (max 5 or 10 stars), short or long (written). Quizzes: answer is the correct option index (single; yesno uses 1 = yes, 0 = no), a list of option indexes (multi), or a list of accepted texts (short); at least one question needs one. Send a question's id back to keep its picture. Refused with 409 once anyone has answered; the answer key can still be fixed with setAnswerKey.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read", "manage"] }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "questions": { "type": "array", "minItems": 1, "maxItems": 50, "items": { "type": "object", "properties": { "type": { "type": "string", "enum": ["single", "multi", "yesno", "likert", "rating", "short", "long"] }, "prompt": { "type": "string", "maxLength": 300 }, "required": { "type": "boolean" }, "options": { "type": "array", "items": { "type": "string", "maxLength": 100 }, "maxItems": 20 }, "max": { "type": "integer", "enum": [5, 10] }, "answer": {}, "id": { "type": "integer" } }, "required": ["type", "prompt"] } } }, "required": ["questions"] } } } },
        "responses": { "200": { "description": "The saved questions", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "409": { "description": "Locked: people have answered" } }
      }
    },
    "/api/admin/{slug}/answers": {
      "put": {
        "operationId": "setAnswerKey",
        "summary": "Fix a quiz's answer key (allowed after people have answered; every score updates)",
        "description": "Body: { answers: { <question id>: key | null } }. The key is an option index (single; yes/no uses 1 = yes, 0 = no), a list of option indexes (multi, all must match), or a list of accepted texts (short; case, accents and punctuation are ignored). null stops scoring that question. At least one question must stay scored.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }, { "signIn": ["read", "manage"] }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "answers": { "type": "object", "additionalProperties": true } }, "required": ["answers"] } } } },
        "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } } }
      }
    }
  }
}
