{
  "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."
      }
    },
    "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": {
      "Error": { "type": "object", "properties": { "error": { "type": "string" }, "code": { "type": "string" } }, "required": ["error"] },
      "Campaign": {
        "type": "object",
        "properties": {
          "kind": { "type": "string", "enum": ["contest", "survey", "hunt"] },
          "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" },
          "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", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "const": "ok" } } } } } } }
      }
    },
    "/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. Written survey answers are never included here.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{}, { "organizerKey": [] }],
        "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": [] }],
        "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": [] }],
        "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"] },
                  "theme": { "type": "string", "description": "classic, halloween, harvest, winter, garden, carnival, beach, sports, hearts, stars, or custom:<accent hex>-<second hex> (e.g. custom:1a73e8-ff8a5b)", "pattern": "^(classic|halloween|harvest|winter|garden|carnival|beach|sports|hearts|stars|custom:[0-9a-f]{6}-[0-9a-f]{6})$" },
                  "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}/entrants": {
      "post": {
        "operationId": "addEntries",
        "summary": "Add entries (contests) or checkpoints (hunts)",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }],
        "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": [] }],
        "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)",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "security": [{ "organizerKey": [] }],
        "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } } }
      }
    }
  }
}
