{
  "x-generator": "NSwag v14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))",
  "openapi": "3.0.0",
  "info": {
    "title": "Votinova Public API",
    "description": "The published Votinova API. Within v1 every change is additive: a published field never changes name, type or meaning. Breaking changes ship as a new version, and the previous one stays supported for at least 12 months from the changelog announcement.",
    "version": "v1"
  },
  "servers": [
    {
      "url": "https://api.votinova.com"
    }
  ],
  "paths": {
    "/public/v1/presentations": {
      "get": {
        "tags": [
          "Presentations"
        ],
        "description": "Lists the organization's presentations, newest first.",
        "operationId": "PublicPresentations_List",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50,
              "maximum": 200.0,
              "minimum": 1.0
            },
            "x-position": 1
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 2
          },
          {
            "name": "before",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 3
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresentationResponseOut"
                },
                "example": {
                  "limit": 50,
                  "real": 1,
                  "has_more": false,
                  "next_cursor": null,
                  "prev_cursor": null,
                  "presentations": [
                    {
                      "id": "68b2e3d4c5f607182930a4f1",
                      "title": "Quarterly all-hands",
                      "slide_count": 24,
                      "created_at": "2026-07-30T10:04:11Z",
                      "updated_at": "2026-08-01T16:20:05Z"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/presentations/{presentationId}/questions": {
      "get": {
        "tags": [
          "Questions"
        ],
        "description": "Lists a presentation's questions in deck order.",
        "operationId": "PublicQuestions_List",
        "parameters": [
          {
            "name": "presentationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuestionResponseOut"
                },
                "example": {
                  "limit": 2,
                  "real": 2,
                  "has_more": false,
                  "next_cursor": null,
                  "prev_cursor": null,
                  "questions": [
                    {
                      "id": "68c3d4e5f60718293a4b5c6d",
                      "type": "MULTIPLE_CHOICE",
                      "prompt": "Which feature should we build next?",
                      "order": 0,
                      "inactive": false,
                      "inactive_reason": null,
                      "upgrade_to": null,
                      "created_at": "2026-08-01T16:20:05Z"
                    },
                    {
                      "id": "68c3d4e5f60718293a4b5c6e",
                      "type": "WORD_CLOUD",
                      "prompt": "One word for this quarter",
                      "order": 1,
                      "inactive": false,
                      "inactive_reason": null,
                      "upgrade_to": null,
                      "created_at": "2026-08-01T16:22:38Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      },
      "post": {
        "tags": [
          "Questions"
        ],
        "description": "Creates a question (FR-010/FR-011).",
        "operationId": "PublicQuestions_Create",
        "parameters": [
          {
            "name": "presentationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "requestBody": {
          "x-name": "body",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateQuestionBody"
              },
              "example": {
                "type": "MULTIPLE_CHOICE",
                "prompt": "Which feature should we build next?",
                "options": [
                  {
                    "text": "Live translations"
                  },
                  {
                    "text": "Breakout rooms"
                  },
                  {
                    "text": "Slide analytics"
                  }
                ],
                "settings": {
                  "allow_multiple": false,
                  "show_results": true
                }
              }
            }
          },
          "required": true,
          "x-position": 2
        },
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuestionOut"
                },
                "example": {
                  "id": "68c3d4e5f60718293a4b5c6d",
                  "type": "MULTIPLE_CHOICE",
                  "prompt": "Which feature should we build next?",
                  "order": 0,
                  "inactive": false,
                  "inactive_reason": null,
                  "upgrade_to": null,
                  "created_at": "2026-08-01T16:20:05Z"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/presentations/{presentationId}/questions/{questionId}": {
      "patch": {
        "tags": [
          "Questions"
        ],
        "description": "Updates a question (FR-010).",
        "operationId": "PublicQuestions_Update",
        "parameters": [
          {
            "name": "presentationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          },
          {
            "name": "questionId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 2
          }
        ],
        "requestBody": {
          "x-name": "body",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateQuestionBody"
              },
              "example": {
                "prompt": "Which capability should we ship next quarter?"
              }
            }
          },
          "required": true,
          "x-position": 3
        },
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuestionOut"
                },
                "example": {
                  "id": "68c3d4e5f60718293a4b5c6d",
                  "type": "MULTIPLE_CHOICE",
                  "prompt": "Which capability should we ship next quarter?",
                  "order": 0,
                  "inactive": false,
                  "inactive_reason": null,
                  "upgrade_to": null,
                  "created_at": "2026-08-01T16:20:05Z"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "409": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/sessions": {
      "get": {
        "tags": [
          "Sessions"
        ],
        "description": "Lists the organization's sessions, newest first.",
        "operationId": "PublicSessions_List",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50,
              "maximum": 200.0,
              "minimum": 1.0
            },
            "x-position": 1
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 2
          },
          {
            "name": "before",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 3
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionResponseOut"
                },
                "example": {
                  "limit": 50,
                  "real": 1,
                  "has_more": false,
                  "next_cursor": null,
                  "prev_cursor": null,
                  "sessions": [
                    {
                      "id": "68b2e3d4c5f607182930a5bc",
                      "presentation_id": "68b2e3d4c5f607182930a4f1",
                      "join_code": "483920",
                      "state": "LIVE",
                      "mode": null,
                      "participant_identity_mode": "ANONYMOUS",
                      "active_question_id": "68c3d4e5f60718293a4b5c6d",
                      "started_at": "2026-08-03T09:30:12Z",
                      "ended_at": null,
                      "created_at": "2026-08-03T09:28:41Z"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      },
      "post": {
        "tags": [
          "Sessions"
        ],
        "description": "Creates a session from one of the organization's presentations (FR-008).",
        "operationId": "PublicSessions_Create",
        "requestBody": {
          "x-name": "body",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateSessionBody"
              },
              "example": {
                "presentation_id": "68b2e3d4c5f607182930a4f1",
                "participant_identity_mode": "ANONYMOUS"
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionOut"
                },
                "example": {
                  "id": "68b2e3d4c5f607182930a5bc",
                  "presentation_id": "68b2e3d4c5f607182930a4f1",
                  "join_code": "483920",
                  "state": "DRAFT",
                  "mode": null,
                  "participant_identity_mode": "ANONYMOUS",
                  "active_question_id": null,
                  "started_at": null,
                  "ended_at": null,
                  "created_at": "2026-08-03T09:28:41Z"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/sessions/{id}": {
      "get": {
        "tags": [
          "Sessions"
        ],
        "description": "One session by id, scoped to the credential's organization.",
        "operationId": "PublicSessions_Get",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionOut"
                },
                "example": {
                  "id": "68b2e3d4c5f607182930a5bc",
                  "presentation_id": "68b2e3d4c5f607182930a4f1",
                  "join_code": "483920",
                  "state": "LIVE",
                  "mode": null,
                  "participant_identity_mode": "ANONYMOUS",
                  "active_question_id": "68c3d4e5f60718293a4b5c6d",
                  "started_at": "2026-08-03T09:30:12Z",
                  "ended_at": null,
                  "created_at": "2026-08-03T09:28:41Z"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/sessions/{id}/kpis": {
      "get": {
        "tags": [
          "Sessions"
        ],
        "description": "Session-level KPIs (FR-012), projected from the R-A summary the product already computes.",
        "operationId": "PublicSessions_Kpis",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionKpisOut"
                },
                "example": {
                  "session_id": "68b2e3d4c5f607182930a5bc",
                  "status": "READY",
                  "participants": 152,
                  "voters": 148,
                  "questions_asked": 6,
                  "total_responses": 812,
                  "response_rate_avg": 0.89,
                  "duration_seconds": 2712,
                  "has_quiz": true,
                  "started_at": "2026-08-03T09:30:12Z",
                  "ended_at": "2026-08-03T10:15:24Z"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/sessions/{id}/results": {
      "get": {
        "tags": [
          "Sessions"
        ],
        "description": "Per-question aggregates (FR-012).",
        "operationId": "PublicSessions_Results",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          },
          {
            "name": "question_id",
            "x-originalName": "questionId",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 2
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuestionResultsOut"
                },
                "example": {
                  "session_id": "68b2e3d4c5f607182930a5bc",
                  "question_id": "68c3d4e5f60718293a4b5c6d",
                  "question_type": "MULTIPLE_CHOICE",
                  "total_voters": 148,
                  "option_counts": {
                    "68c3d4e5f60718293a4b0001": 91,
                    "68c3d4e5f60718293a4b0002": 42,
                    "68c3d4e5f60718293a4b0003": 15
                  },
                  "value_distribution": null,
                  "average": null,
                  "words": null,
                  "texts": null,
                  "correct_option_ids": null,
                  "nps": null,
                  "pair_stats": null,
                  "correct_pairs": null,
                  "correct_order": null,
                  "placement_accuracy": null,
                  "most_common_order": null,
                  "blank_stats": null,
                  "allocation_totals": null,
                  "spin_outcomes": null
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/sessions/{id}/qa": {
      "get": {
        "tags": [
          "Sessions"
        ],
        "description": "The session's Q&A entries with their moderation state (FR-013).",
        "operationId": "PublicSessions_Qa",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50,
              "maximum": 200.0,
              "minimum": 1.0
            },
            "x-position": 2
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QaEntryResponseOut"
                },
                "example": {
                  "limit": 50,
                  "real": 1,
                  "has_more": false,
                  "next_cursor": null,
                  "prev_cursor": null,
                  "entries": [
                    {
                      "id": "qa_5f2b8c1d9e3a4b6c",
                      "question_id": "68c3d4e5f60718293a4b5c6f",
                      "text": "Will the recording be shared afterwards?",
                      "upvotes": 12,
                      "asker_name": null,
                      "lifecycle": "ANSWERED",
                      "pinned_order": null,
                      "public_answer": "Yes — the link goes out to everyone tomorrow morning.",
                      "created_at": "2026-08-03T09:41:02Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/sessions/{id}/export": {
      "get": {
        "tags": [
          "Sessions"
        ],
        "description": "The session report in the product's own tabular formats (FR-012).",
        "operationId": "PublicSessions_Export",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "csv",
              "enum": [
                "csv",
                "xlsx"
              ]
            },
            "x-position": 2
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "text/csv": {
                "example": "Question,Type,Total voters\n\"Which feature should we build next?\",MULTIPLE_CHOICE,148\n\"One word for this quarter\",WORD_CLOUD,131\n"
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-binary-response": true,
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/sessions/{id}/open": {
      "post": {
        "tags": [
          "Sessions"
        ],
        "description": "Opens a session so the room can join (FR-009).",
        "operationId": "PublicSessions_Open",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionOut"
                },
                "example": {
                  "id": "68b2e3d4c5f607182930a5bc",
                  "presentation_id": "68b2e3d4c5f607182930a4f1",
                  "join_code": "483920",
                  "state": "LIVE",
                  "mode": null,
                  "participant_identity_mode": "ANONYMOUS",
                  "active_question_id": null,
                  "started_at": "2026-08-03T09:30:12Z",
                  "ended_at": null,
                  "created_at": "2026-08-03T09:28:41Z"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/sessions/{id}/questions/{questionId}/activate": {
      "post": {
        "tags": [
          "Sessions"
        ],
        "description": "Puts a question on the room's screen (FR-010).",
        "operationId": "PublicSessions_ActivateQuestion",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          },
          {
            "name": "questionId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 2
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionOut"
                },
                "example": {
                  "id": "68b2e3d4c5f607182930a5bc",
                  "presentation_id": "68b2e3d4c5f607182930a4f1",
                  "join_code": "483920",
                  "state": "LIVE",
                  "mode": null,
                  "participant_identity_mode": "ANONYMOUS",
                  "active_question_id": "68c3d4e5f60718293a4b5c6d",
                  "started_at": "2026-08-03T09:30:12Z",
                  "ended_at": null,
                  "created_at": "2026-08-03T09:28:41Z"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/sessions/{id}/schedule": {
      "post": {
        "tags": [
          "Sessions"
        ],
        "description": "Schedules when a self-paced session opens and closes (FR-009).",
        "operationId": "PublicSessions_Schedule",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "requestBody": {
          "x-name": "body",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScheduleSessionBody"
              },
              "example": {
                "opens_at": "2026-08-10T08:00:00Z",
                "closes_at": "2026-08-14T18:00:00Z",
                "timezone": "Europe/Madrid"
              }
            }
          },
          "required": true,
          "x-position": 2
        },
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionOut"
                },
                "example": {
                  "id": "68b2e3d4c5f607182930a5bd",
                  "presentation_id": "68b2e3d4c5f607182930a4f1",
                  "join_code": "774158",
                  "state": "DRAFT",
                  "mode": "SELF_PACED",
                  "participant_identity_mode": "ANONYMOUS",
                  "active_question_id": null,
                  "started_at": null,
                  "ended_at": null,
                  "created_at": "2026-08-03T09:52:10Z"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/sessions/{id}/close": {
      "post": {
        "tags": [
          "Sessions"
        ],
        "description": "Closes a session (FR-009).",
        "operationId": "PublicSessions_Close",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionOut"
                },
                "example": {
                  "id": "68b2e3d4c5f607182930a5bc",
                  "presentation_id": "68b2e3d4c5f607182930a4f1",
                  "join_code": "483920",
                  "state": "CLOSED",
                  "mode": null,
                  "participant_identity_mode": "ANONYMOUS",
                  "active_question_id": null,
                  "started_at": "2026-08-03T09:30:12Z",
                  "ended_at": "2026-08-03T10:15:24Z",
                  "created_at": "2026-08-03T09:28:41Z"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/sessions/{id}/participants": {
      "get": {
        "tags": [
          "Sessions"
        ],
        "description": "Who was in the room (FR-014) — aggregates only when the session was anonymous.",
        "operationId": "PublicSessions_Participants",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ParticipantsOut"
                },
                "example": {
                  "session_id": "68b2e3d4c5f607182930a5bc",
                  "identity_mode": "IDENTIFIED",
                  "total": 2,
                  "participants": [
                    {
                      "display_name": "Ana Ruiz",
                      "email": "ana.ruiz@example.com",
                      "joined_at": "2026-08-03T09:29:03Z",
                      "identity_seal": "SSO_VERIFIED"
                    },
                    {
                      "display_name": "Jon Aldalur",
                      "email": null,
                      "joined_at": "2026-08-03T09:31:47Z",
                      "identity_seal": "GUEST"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/webhooks": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "description": "Registers an endpoint (FR-017). Self-serve: no support step and no review.\n            \nThe signing secret is returned exactly once, here. It cannot be read back afterwards —\nan endpoint that can hand out its own verification secret to any credential that lists it\nturns a leaked read-only key into the ability to forge deliveries.\n            \nResponds with a Location header naming where this subscription is\ncancelled. Power Platform's webhook triggers learn their unsubscribe URL from exactly\nthat header and from nowhere else: without it, deleting the flow leaves Power Automate with\nno address to call, the endpoint stays registered, and deliveries keep arriving at a flow\nits owner believes they removed. The status stays 200 and the body is untouched\n(Rule A1) — this is a header on the existing response, not a 201.",
        "operationId": "PublicWebhooks_Register",
        "requestBody": {
          "x-name": "body",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RegisterWebhookBody"
              },
              "example": {
                "target_url": "https://example.com/hooks/votinova",
                "event_types": [
                  "session.ended",
                  "report.ready"
                ]
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookCreatedOut"
                },
                "example": {
                  "id": "68d4e5f60718293a4b5c6d7e",
                  "target_url": "https://example.com/hooks/votinova",
                  "event_types": [
                    "session.ended",
                    "report.ready"
                  ],
                  "active": true,
                  "auto_disabled": false,
                  "created_at": "2026-08-03T09:15:00Z",
                  "signing_secret": "whsec_1f2e3d4c5b6a798081726354453627181f2e3d4c5b6a79808172635445362718"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      },
      "get": {
        "tags": [
          "Webhooks"
        ],
        "description": "Lists the organization's registered endpoints.",
        "operationId": "PublicWebhooks_List",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50,
              "maximum": 200.0,
              "minimum": 1.0
            },
            "x-position": 1
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 2
          },
          {
            "name": "before",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 3
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookResponseOut"
                },
                "example": {
                  "limit": 50,
                  "real": 1,
                  "has_more": false,
                  "next_cursor": null,
                  "prev_cursor": null,
                  "webhooks": [
                    {
                      "id": "68d4e5f60718293a4b5c6d7e",
                      "target_url": "https://example.com/hooks/votinova",
                      "event_types": [
                        "session.ended",
                        "report.ready"
                      ],
                      "active": true,
                      "auto_disabled": false,
                      "created_at": "2026-08-03T09:15:00Z"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/webhooks/{webhookId}": {
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "description": "Removes an endpoint (FR-017), which is what a connector calls when a Zap is turned off.\n            \nA connector may only remove what it registered. The endpoint the data team created by\nhand survives a Zap being deleted — and it has to, because losing it is invisible: no error,\nno notification, just deliveries that stop.",
        "operationId": "PublicWebhooks_Deactivate",
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          },
          {
            "name": "connector",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true,
              "enum": [
                "ZAPIER",
                "POWER_AUTOMATE",
                null
              ]
            },
            "x-position": 2
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookRemovedOut"
                },
                "example": {
                  "message": "Webhook removed."
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/webhooks/{webhookId}/enable": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "description": "Re-enables an endpoint that the platform paused after repeated failures (FR-020).\n            \nThe half of auto-disable that was missing. Pausing a failing endpoint protects both\nsides; pausing it with no way back means one bad afternoon takes an integration off the air\npermanently, and the only remedy is registering a new endpoint — which loses the delivery\nhistory the dead-lettered events are replayed from.\n            \nRe-enabling grants a fresh failure budget: an endpoint that comes back still broken\ngets paused again rather than sitting one failure away from it forever.",
        "operationId": "PublicWebhooks_Enable",
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookOut"
                },
                "example": {
                  "id": "68d4e5f60718293a4b5c6d7e",
                  "target_url": "https://example.com/hooks/votinova",
                  "event_types": [
                    "session.ended",
                    "report.ready"
                  ],
                  "active": true,
                  "auto_disabled": false,
                  "created_at": "2026-08-03T09:15:00Z"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/webhooks/{webhookId}/deliveries": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "description": "The delivery history of one endpoint, newest first (FR-020).",
        "operationId": "PublicWebhooks_Deliveries",
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50,
              "maximum": 200.0,
              "minimum": 1.0
            },
            "x-position": 2
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 3
          },
          {
            "name": "before",
            "in": "query",
            "schema": {
              "type": "string",
              "nullable": true
            },
            "x-position": 4
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookDeliveryResponseOut"
                },
                "example": {
                  "limit": 50,
                  "real": 1,
                  "has_more": false,
                  "next_cursor": null,
                  "prev_cursor": null,
                  "deliveries": [
                    {
                      "id": "68e5f60718293a4b5c6d7e8f",
                      "event_type": "session.ended",
                      "status": "DELIVERED",
                      "attempt_count": 1,
                      "last_status_code": 200,
                      "dead_lettered_at": null,
                      "redelivery_of": null,
                      "created_at": "2026-08-03T10:15:25Z"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "results:read"
      }
    },
    "/public/v1/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "description": "Queues a manual retry of a dead-lettered delivery (FR-020).",
        "operationId": "PublicWebhooks_Redeliver",
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          },
          {
            "name": "deliveryId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 2
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookDeliveryOut"
                },
                "example": {
                  "id": "68e5f60718293a4b5c6d7e90",
                  "event_type": "session.ended",
                  "status": "PENDING",
                  "attempt_count": 0,
                  "last_status_code": null,
                  "dead_lettered_at": null,
                  "redelivery_of": "68e5f60718293a4b5c6d7e8f",
                  "created_at": "2026-08-03T10:22:41Z"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          },
          "402": {
            "description": "The organization's plan refuses this request: either the plan does not include the public API at all, or a plan gate refuses this specific capability (report export, session capacity, an uncovered question type). `code` is `PLAN_LIMIT_REACHED` and `upgrade_to` names the tier that lifts the limit. Waiting does not help — upgrading does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "content:write"
      }
    },
    "/public/v1/changelog": {
      "get": {
        "tags": [
          "Changelog"
        ],
        "description": "The change history of a published API version, newest first.",
        "operationId": "ApiChangelog_List",
        "parameters": [
          {
            "name": "version",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "v1"
            },
            "x-position": 1
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 100,
              "maximum": 500.0,
              "minimum": 1.0
            },
            "x-position": 2
          }
        ],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ApiChangelogEntryOut"
                  }
                },
                "example": [
                  {
                    "_id": "68842f0a9d1c2b3a4e5f6a7b",
                    "version": "v1",
                    "released_on": "2026-07-26T00:00:00Z",
                    "kind": "ADDITIVE",
                    "summary": "v1 launch: presentations, questions, sessions, results, webhooks and the anonymous discovery endpoints.",
                    "capabilities": [
                      "GET /public/v1/presentations",
                      "GET /public/v1/sessions"
                    ],
                    "created_at": "2026-07-26T00:00:00Z"
                  }
                ]
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "none"
      }
    },
    "/public/v1/limits": {
      "get": {
        "tags": [
          "Limits"
        ],
        "description": "The published limits: per-plan request quotas, the webhook retry schedule, and the event\ncatalogue — served from the same constants the platform enforces, so they cannot drift from\nwhat a request actually experiences. Anonymous: read them before you have a credential.",
        "operationId": "ApiLimits_Get",
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiLimitsOut"
                },
                "example": {
                  "quota_tiers": [
                    {
                      "plan_code": "PRO",
                      "requests_per_window": 120,
                      "window_seconds": 60
                    },
                    {
                      "plan_code": "TEAM",
                      "requests_per_window": 600,
                      "window_seconds": 60
                    },
                    {
                      "plan_code": "ENTERPRISE",
                      "requests_per_window": 3000,
                      "window_seconds": 60
                    }
                  ],
                  "webhook_retry_schedule_seconds": [
                    21,
                    45,
                    93,
                    166,
                    360,
                    760,
                    1472
                  ],
                  "webhook_max_attempts": 8,
                  "webhook_event_catalogue": [
                    "presentation.import.completed",
                    "presentation.import.failed",
                    "question.activated",
                    "question.closed",
                    "question.results.finalized",
                    "report.ready",
                    "session.ended",
                    "session.paused",
                    "session.resumed",
                    "session.started"
                  ]
                }
              }
            }
          },
          "429": {
            "description": "The organization has spent its request budget for the current window — the per-plan budget `GET /public/v1/limits` publishes. `retry_after_seconds` (and the `Retry-After` header) say when the window resets; every response on this surface also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorOut"
                }
              }
            }
          }
        },
        "x-votinova-scope": "none"
      }
    }
  },
  "components": {
    "schemas": {
      "ResponseBase": {
        "type": "object",
        "description": "Base class for all paginated list responses. Pagination is keyset (Relay-style) cursor only —\nthere is no offset/skip/page path and no TotalCount. Clients page forward with\nNextCursor (sent back as the cursor query param) and backward with\nPrevCursor (sent back as the before query param). HasMore reflects whether\nmore items exist in the direction that was requested, computed via the limit+1 fetch strategy.",
        "additionalProperties": false,
        "properties": {
          "limit": {
            "type": "integer",
            "description": "Maximum number of items requested per page.",
            "format": "int32",
            "nullable": true
          },
          "real": {
            "type": "integer",
            "description": "Actual number of items returned in this response.",
            "format": "int32",
            "nullable": true
          },
          "has_more": {
            "type": "boolean",
            "description": "Whether more items exist beyond the current page. Uses limit+1 fetch strategy (no TotalCount).",
            "nullable": true
          },
          "next_cursor": {
            "type": "string",
            "description": "Opaque keyset cursor pointing past the last item of this page. Non-null whenever a following\npage may exist (forward paging sets it only when HasMore; backward paging always\nsets it because the caller arrived from a later page). Clients pass it back as the cursor\nquery param to fetch the next page in O(limit) regardless of depth. Treat as opaque — never\nparse or construct it.",
            "nullable": true
          },
          "prev_cursor": {
            "type": "string",
            "description": "Opaque keyset cursor pointing before the first item of this page. Non-null whenever a preceding\npage may exist (forward paging sets it once the caller has moved past the first page; backward\npaging sets it only while more items remain before this one). Clients pass it back as the\nbefore query param to fetch the previous page. Treat as opaque.",
            "nullable": true
          }
        }
      },
      "QuestionCategory": {
        "type": "object",
        "description": "Spec 026 — a presenter-named group on the BRAINSTORM board.",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "The category's id; omit when creating, the platform assigns one.",
            "nullable": true
          },
          "name": {
            "type": "string",
            "description": "The category as the presenter named it.",
            "nullable": true
          }
        }
      },
      "QuestionPair": {
        "type": "object",
        "description": "Spec 026 — one MATCHING pair: a left item and the right item that answers it.\nThe right side is a REFERENCE to an option, not a copy of its text. Renaming an option in the\neditor must not leave a pair pointing at a string that no longer exists.",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "The pair's id.",
            "nullable": true
          },
          "left_text": {
            "type": "string",
            "description": "The left-hand item as the audience reads it.",
            "nullable": true
          },
          "right_id": {
            "type": "string",
            "description": "The Id of the correct right-column item.",
            "nullable": true
          }
        }
      },
      "QuestionBlank": {
        "type": "object",
        "description": "Spec 026 — one FILL_BLANKS gap and everything that counts as filling it.",
        "additionalProperties": false,
        "properties": {
          "index": {
            "type": "integer",
            "description": "Zero-based position of the blank in the prompt.",
            "format": "int32",
            "nullable": true
          },
          "accepted_answers": {
            "type": "array",
            "description": "Up to 10. Compared after normalization, never raw (see BlankMatching).",
            "nullable": true,
            "items": {
              "type": "string"
            }
          }
        }
      },
      "PresentationResponseOut": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ResponseBase"
          },
          {
            "type": "object",
            "description": "Keyset-paginated list of presentations (Rule A3/A4). No page, no total_count: the\npublic surface paginates the same way the rest of the platform does, and publishing an offset\nAPI we would later have to withdraw is precisely what the additive-versioning promise forbids.",
            "additionalProperties": false,
            "properties": {
              "presentations": {
                "type": "array",
                "description": "The presentations of this page, newest first.",
                "nullable": true,
                "items": {
                  "$ref": "#/components/schemas/PresentationOut"
                }
              }
            }
          }
        ]
      },
      "PresentationOut": {
        "type": "object",
        "description": "A presentation as the published API describes it (spec 027, /public/v1).\n            \nDeliberately not PresentationOut. That type is the contract between the backend and\nour own frontend, and its freedom to change is what has allowed twenty-six specs of movement.\nThis one promises twelve months of strictly additive stability (FR-027). The moment an\nintegrator depends on a field, renaming it for the editor's convenience breaks a paying\ncustomer — so the two shapes are separate files that have to be edited on purpose.\n            \nThe mapping cost is the price of being able to keep moving the inside.",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "The presentation identifier, stable across the life of the presentation.",
            "nullable": true
          },
          "title": {
            "type": "string",
            "description": "Its title as the author wrote it.",
            "nullable": true
          },
          "slide_count": {
            "type": "integer",
            "description": "How many slides it has.",
            "format": "int64",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "description": "When it was created, UTC.",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "description": "When it was last modified, UTC.",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "ApiErrorOut": {
        "type": "object",
        "description": "The error envelope every refusal of the published API actually carries (spec 036 US3, research\nR4).\n            \nThis class is documentation of the existing wire format, not a new one. The public\nsurface has always answered its refusals with this envelope — Unauthorized(new { error })\nfrom the scope filter, { error, code, upgrade_to } from the plan gates,\n{ error, retry_after_seconds } from the quota middleware — while the generated document\ntyped them all as RFC 7807 ProblemDetails, a shape the API has never emitted once. An\nintegrator who generated a client from the reference would parse title and\ndetail and read nothing. This type exists so the document tells the truth; it does not\nchange a byte of what is sent.\n            \nOnly error is always present. Every other field appears when the refusal has the\nstructured fact it names (Rule A6: prose for humans, fields for code). A few refusals attach\nadditional context fields beyond these — for example the deck-lock 409 names the\nsession_id holding the deck — and those are described on their own operations.",
        "additionalProperties": false,
        "properties": {
          "error": {
            "type": "string",
            "description": "A human-readable explanation of the refusal. Always present. Prose for people, never for code — the machine-readable facts travel in the other fields.",
            "nullable": true
          },
          "code": {
            "type": "string",
            "description": "A stable machine-readable cause, when one exists: PLAN_LIMIT_REACHED on a 402, RATE_LIMITED on a lock-contention 429, SELF_PACED_WINDOW_OPEN or PRESENTATION_READ_ONLY on a deck-lock 409.",
            "nullable": true
          },
          "required_scope": {
            "type": "string",
            "description": "On a scope refusal (401): the scope the credential must carry to reach this operation — results:read or content:write.",
            "nullable": true,
            "enum": [
              "content:write",
              "results:read",
              null
            ]
          },
          "policy": {
            "type": "string",
            "description": "On a governance refusal (401): the organization policy that disabled the call — PROGRAMMATIC_ACCESS_DISABLED when an administrator turned programmatic access off.",
            "nullable": true,
            "enum": [
              "PROGRAMMATIC_ACCESS_DISABLED",
              null
            ]
          },
          "upgrade_to": {
            "type": "string",
            "description": "On a plan refusal (402): the plan tier that lifts this limit, so a client can target its upgrade call to action.",
            "nullable": true,
            "enum": [
              "ENTERPRISE",
              "PRO",
              "TEAM",
              null
            ]
          },
          "retry_after_seconds": {
            "type": "integer",
            "description": "On a quota refusal (429): seconds until the request window resets. Also stated in the Retry-After response header.",
            "format": "int32",
            "nullable": true
          }
        }
      },
      "QuestionResponseOut": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ResponseBase"
          },
          {
            "type": "object",
            "description": "A presentation's questions, in deck order.",
            "additionalProperties": false,
            "properties": {
              "questions": {
                "type": "array",
                "description": "The deck's questions, in presentation order.",
                "nullable": true,
                "items": {
                  "$ref": "#/components/schemas/QuestionOut"
                }
              }
            }
          }
        ]
      },
      "QuestionOut": {
        "type": "object",
        "description": "A question as the published API describes it (spec 027 FR-010/FR-011).\n            \nType stays a plain string on the wire, and settings are documented per type\nrather than modelled as a closed union. That is what lets the supported set grow without a new\nAPI version (FR-010): the six types spec 026 added already fit, and the seventeenth will too.\nThe document now also names the members (PublishedEnumAttribute) — a string\nthat lists what it currently accepts, not a union that would have to be versioned to grow. An\nintegrator could otherwise only discover the catalogue by being refused.",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "The question identifier.",
            "nullable": true
          },
          "type": {
            "type": "string",
            "description": "The question's type — one of the published catalogue enumerated below.",
            "nullable": true,
            "enum": [
              "MULTIPLE_CHOICE",
              "SCALE",
              "NPS",
              "WORD_CLOUD",
              "OPEN_ENDED",
              "QUIZ",
              "RANKING",
              "NUMERIC_ESTIMATE",
              "IMAGE_PIN",
              "JOIN_CODE",
              "MATCHING",
              "CORRECT_ORDER",
              "FILL_BLANKS",
              "BRAINSTORM",
              "PRIORITIZATION",
              "SPINNER_WHEEL",
              null
            ]
          },
          "prompt": {
            "type": "string",
            "description": "The question as the audience reads it.",
            "nullable": true
          },
          "order": {
            "type": "integer",
            "description": "Position in the deck.",
            "format": "int32",
            "nullable": true
          },
          "inactive": {
            "type": "boolean",
            "description": "Whether the organization's plan can present this question (FR-011).\n            \nDerived at read time, never stored. Upgrading a plan therefore activates\neverything already written, with no migration and no re-save — and downgrading blocks it\nagain without deleting anything.\n            \nPresent on every question, not only the blocked ones: a field that appears only when\nsomething is wrong is a field integrations forget exists until it bites them."
          },
          "inactive_reason": {
            "type": "string",
            "description": "Why it is inactive, or null when it is not. A code to switch on, not prose.",
            "nullable": true,
            "enum": [
              "PLAN_TYPE_NOT_COVERED",
              null
            ]
          },
          "upgrade_to": {
            "type": "string",
            "description": "The plan that would activate it, or null when nothing needs to change.",
            "nullable": true,
            "enum": [
              "ENTERPRISE",
              "PRO",
              "TEAM",
              null
            ]
          },
          "created_at": {
            "type": "string",
            "description": "When the question was created, UTC.",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "CreateQuestionBody": {
        "type": "object",
        "description": "Body for creating a question: a prompt, a type from the published catalogue, and the type's options and settings.",
        "additionalProperties": false,
        "required": [
          "type",
          "prompt"
        ],
        "properties": {
          "type": {
            "type": "string",
            "description": "The question type to create — one of the published catalogue enumerated below.",
            "minLength": 1,
            "enum": [
              "MULTIPLE_CHOICE",
              "SCALE",
              "NPS",
              "WORD_CLOUD",
              "OPEN_ENDED",
              "QUIZ",
              "RANKING",
              "NUMERIC_ESTIMATE",
              "IMAGE_PIN",
              "JOIN_CODE",
              "MATCHING",
              "CORRECT_ORDER",
              "FILL_BLANKS",
              "BRAINSTORM",
              "PRIORITIZATION",
              "SPINNER_WHEEL"
            ]
          },
          "prompt": {
            "type": "string",
            "description": "The question as the audience will read it.",
            "maxLength": 1000,
            "minLength": 1
          },
          "slide_id": {
            "type": "string",
            "description": "Slide to anchor the question to (RF-3.6); omit to keep it in the library only.",
            "maxLength": 64,
            "nullable": true
          },
          "options": {
            "type": "array",
            "description": "The choices, for the types that have them. The author's answer key travels in is_correct.",
            "maxItems": 100,
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/QuestionOptionBody"
            }
          },
          "settings": {
            "description": "Per-type behaviour. Only the fields the chosen type reads are honoured.",
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/QuestionSettingsBody"
              }
            ]
          }
        }
      },
      "QuestionOptionBody": {
        "type": "object",
        "description": "One choice of a question, as authored.",
        "additionalProperties": false,
        "required": [
          "text"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The option's id; omit when creating, the platform assigns one.",
            "maxLength": 64,
            "nullable": true
          },
          "text": {
            "type": "string",
            "description": "The choice as the audience reads it.",
            "maxLength": 500,
            "minLength": 1
          },
          "image_url": {
            "type": "string",
            "description": "An image for the choice, on the types that render one.",
            "maxLength": 2000,
            "nullable": true
          },
          "is_correct": {
            "type": "boolean",
            "description": "QUIZ: whether this choice is a right answer.",
            "nullable": true
          }
        }
      },
      "QuestionSettingsBody": {
        "type": "object",
        "description": "Per-type question behaviour. Only the fields the question's type reads are honoured; the rest are ignored and never stored as promises.",
        "additionalProperties": false,
        "properties": {
          "allow_multiple": {
            "type": "boolean",
            "description": "MULTIPLE_CHOICE: whether several options may be selected.",
            "nullable": true
          },
          "max_selections": {
            "type": "integer",
            "description": "MULTIPLE_CHOICE: cap on selections when several are allowed.",
            "format": "int32",
            "maximum": 100.0,
            "minimum": 1.0,
            "nullable": true
          },
          "min_value": {
            "type": "number",
            "description": "SCALE / NUMERIC_ESTIMATE: lower bound of the range.",
            "format": "double",
            "nullable": true
          },
          "max_value": {
            "type": "number",
            "description": "SCALE / NUMERIC_ESTIMATE: upper bound of the range.",
            "format": "double",
            "nullable": true
          },
          "min_label": {
            "type": "string",
            "description": "SCALE: the label shown at the lower end.",
            "maxLength": 100,
            "nullable": true
          },
          "max_label": {
            "type": "string",
            "description": "SCALE: the label shown at the upper end.",
            "maxLength": 100,
            "nullable": true
          },
          "step": {
            "type": "number",
            "description": "SCALE: the increment between selectable values.",
            "format": "double",
            "nullable": true
          },
          "max_words": {
            "type": "integer",
            "description": "WORD_CLOUD: how many words each participant may submit.",
            "format": "int32",
            "maximum": 10.0,
            "minimum": 1.0,
            "nullable": true
          },
          "moderation_enabled": {
            "type": "boolean",
            "description": "OPEN_ENDED: hold entries for host approval before the room sees them.",
            "nullable": true
          },
          "allow_upvotes": {
            "type": "boolean",
            "description": "OPEN_ENDED: let participants upvote each other's entries.",
            "nullable": true
          },
          "scoring_enabled": {
            "type": "boolean",
            "description": "QUIZ: score answers against the answer key.",
            "nullable": true
          },
          "speed_scoring": {
            "type": "boolean",
            "description": "QUIZ (spec 014 FR-001): SPEED scoring mode — requires a time limit.",
            "nullable": true
          },
          "partial_scoring": {
            "type": "boolean",
            "description": "QUIZ multi-answer (spec 014 FR-006): proportional credit instead of all-or-nothing.",
            "nullable": true
          },
          "wrong_penalty": {
            "type": "boolean",
            "description": "QUIZ partial scoring (spec 014 FR-007): wrong picks deduct correct-pick shares.",
            "nullable": true
          },
          "time_limit_seconds": {
            "type": "integer",
            "description": "Voting window in seconds; the question auto-closes when it elapses (RF-6.4).\nCap raised to 4 h (spec 011 FR-014): the adaptive countdown renders h:mm:ss above\none hour, so multi-hour windows must be reachable through the product.",
            "format": "int32",
            "maximum": 14400.0,
            "minimum": 5.0,
            "nullable": true
          },
          "image_url": {
            "type": "string",
            "description": "IMAGE_PIN: the image participants drop their pin on.",
            "maxLength": 2000,
            "nullable": true
          },
          "show_results": {
            "type": "boolean",
            "description": "Whether the room is shown results when the question closes.",
            "nullable": true
          },
          "show_live_results": {
            "type": "boolean",
            "description": "RF-6.12: render results updating live while the question is open (absent = enabled).",
            "nullable": true
          },
          "send_score_to_participants": {
            "type": "boolean",
            "description": "Evaluative types: send each participant their score when the question closes (RF-6.5).",
            "nullable": true
          },
          "pairs": {
            "type": "array",
            "description": "MATCHING: the left/right pairs, answer key included.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/QuestionPair"
            }
          },
          "all_or_nothing": {
            "type": "boolean",
            "description": "MATCHING: score only a fully correct set of matches.",
            "nullable": true
          },
          "blanks": {
            "type": "array",
            "description": "FILL_BLANKS: the blanks with their accepted answers.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/QuestionBlank"
            }
          },
          "typo_tolerance": {
            "type": "boolean",
            "description": "FILL_BLANKS: accept close misspellings of an accepted answer.",
            "nullable": true
          },
          "point_budget": {
            "type": "integer",
            "description": "PRIORITIZATION: the points each participant distributes.",
            "format": "int32",
            "maximum": 1000.0,
            "minimum": 10.0,
            "nullable": true
          },
          "idea_limit_per_participant": {
            "type": "integer",
            "description": "BRAINSTORM: how many ideas each participant may submit.",
            "format": "int32",
            "maximum": 50.0,
            "minimum": 1.0,
            "nullable": true
          },
          "categories": {
            "type": "array",
            "description": "BRAINSTORM: the presenter-named groups ideas are filed under.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/QuestionCategory"
            }
          },
          "wheel_source": {
            "type": "string",
            "description": "SPINNER_WHEEL: where the wheel's entries come from.",
            "nullable": true,
            "enum": [
              "JOINED_PARTICIPANTS",
              "CUSTOM_LIST",
              null
            ]
          },
          "wheel_entries": {
            "type": "array",
            "description": "SPINNER_WHEEL: the wheel's entries, when the source is a custom list.",
            "nullable": true,
            "items": {
              "type": "string"
            }
          },
          "remove_winner": {
            "type": "boolean",
            "description": "SPINNER_WHEEL: take each winner out of the following spins.",
            "nullable": true
          }
        }
      },
      "UpdateQuestionBody": {
        "type": "object",
        "description": "Body for updating a question. Every field is optional; options and settings replace the stored sub-documents wholesale when present.",
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "description": "A new question type, when changing it. The untouched settings are re-validated against it.",
            "nullable": true,
            "enum": [
              "MULTIPLE_CHOICE",
              "SCALE",
              "NPS",
              "WORD_CLOUD",
              "OPEN_ENDED",
              "QUIZ",
              "RANKING",
              "NUMERIC_ESTIMATE",
              "IMAGE_PIN",
              "JOIN_CODE",
              "MATCHING",
              "CORRECT_ORDER",
              "FILL_BLANKS",
              "BRAINSTORM",
              "PRIORITIZATION",
              "SPINNER_WHEEL",
              null
            ]
          },
          "prompt": {
            "type": "string",
            "description": "New prompt text.",
            "maxLength": 1000,
            "minLength": 1,
            "nullable": true
          },
          "slide_id": {
            "type": "string",
            "description": "New slide anchor (RF-3.6). Send an empty string to unanchor (library-only).",
            "maxLength": 64,
            "nullable": true
          },
          "options": {
            "type": "array",
            "description": "Replacement options — the FULL list, not a diff; the stored options are replaced wholesale.",
            "maxItems": 100,
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/QuestionOptionBody"
            }
          },
          "settings": {
            "description": "Replacement settings — the FULL object, not a diff; the stored settings are replaced wholesale.",
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/QuestionSettingsBody"
              }
            ]
          }
        }
      },
      "SessionResponseOut": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ResponseBase"
          },
          {
            "type": "object",
            "description": "Keyset-paginated list of sessions (Rule A3/A4).",
            "additionalProperties": false,
            "properties": {
              "sessions": {
                "type": "array",
                "description": "The sessions of this page, newest first.",
                "nullable": true,
                "items": {
                  "$ref": "#/components/schemas/SessionOut"
                }
              }
            }
          }
        ]
      },
      "SessionOut": {
        "type": "object",
        "description": "A session as the published API describes it (spec 027, /public/v1).\n            \nMapped field by field from SessionDb, never copied wholesale. The internal document\ncarries a great deal an integrator has no business receiving — pause intervals, themed\nprojection ids, demo bookkeeping, the spin log — and a mapper that copied everything would\npublish whatever the internal model gains next. Spec 026 is the cautionary tale: an envelope\nthat attached the whole settings bag shipped the answer key of a question to the person being\nasked to answer it. Adding a field to a public contract has to be a decision.",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "The session identifier.",
            "nullable": true
          },
          "presentation_id": {
            "type": "string",
            "description": "The presentation this session runs.",
            "nullable": true
          },
          "join_code": {
            "type": "string",
            "description": "The code the room types to join.\n            \nPublished because an integration that creates a session needs to tell people how to\nreach it — printing it on a badge, posting it to a channel. It is not a secret: it is\ndisplayed on the projector to everyone in the room.",
            "nullable": true
          },
          "state": {
            "type": "string",
            "description": "DRAFT, LIVE, PAUSED or CLOSED.",
            "nullable": true,
            "enum": [
              "DRAFT",
              "LIVE",
              "PAUSED",
              "CLOSED",
              null
            ]
          },
          "mode": {
            "type": "string",
            "description": "Whether the room is answered live or at each participant's own pace.",
            "nullable": true,
            "enum": [
              "LIVE",
              "SELF_PACED",
              null
            ]
          },
          "participant_identity_mode": {
            "type": "string",
            "description": "ANONYMOUS, IDENTIFIED or AUTHENTICATED — decides what participant data exists at all.",
            "nullable": true,
            "enum": [
              "ANONYMOUS",
              "IDENTIFIED",
              "AUTHENTICATED",
              null
            ]
          },
          "active_question_id": {
            "type": "string",
            "description": "The question currently open, if any.",
            "nullable": true
          },
          "started_at": {
            "type": "string",
            "description": "When the session went live, UTC. Null while it is a draft.",
            "format": "date-time",
            "nullable": true
          },
          "ended_at": {
            "type": "string",
            "description": "When the session closed, UTC. Null until then.",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "description": "When the session was created, UTC.",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "SessionKpisOut": {
        "type": "object",
        "description": "Session-level KPIs as the published API describes them (spec 027 FR-012).\n            \nProjected from the R-A summary of the session report the product already computes. The\nspec's own assumption says as much: connectors reuse the existing KPIs, no new analytics are\ninvented for them. Two numbers derived independently would eventually disagree, and the one an\nintegrator would quote back at us is whichever they saw first.",
        "additionalProperties": false,
        "properties": {
          "session_id": {
            "type": "string",
            "description": "The session these KPIs describe.",
            "nullable": true
          },
          "status": {
            "type": "string",
            "description": "Whether the numbers below are final.\n            \nPublished rather than hidden because a report is computed asynchronously after a\nsession closes. Returning zeros for a session that simply has not been processed yet would\nbe indistinguishable from a session nobody attended — and an integration writing that into a\nspreadsheet would record the wrong thing permanently, with no way to know it should look\nagain.",
            "nullable": true,
            "enum": [
              "PENDING",
              "COMPUTING",
              "READY",
              "FAILED",
              null
            ]
          },
          "participants": {
            "type": "integer",
            "description": "Distinct people who joined.",
            "format": "int64",
            "nullable": true
          },
          "voters": {
            "type": "integer",
            "description": "Distinct people who answered at least one question.",
            "format": "int64",
            "nullable": true
          },
          "questions_asked": {
            "type": "integer",
            "description": "How many questions the session ran.",
            "format": "int64",
            "nullable": true
          },
          "total_responses": {
            "type": "integer",
            "description": "Answers received across every question.",
            "format": "int64",
            "nullable": true
          },
          "response_rate_avg": {
            "type": "number",
            "description": "Mean share of the room that answered, across questions.",
            "format": "double",
            "nullable": true
          },
          "duration_seconds": {
            "type": "integer",
            "description": "How long the session ran.",
            "format": "int64",
            "nullable": true
          },
          "has_quiz": {
            "type": "boolean",
            "description": "Whether any scored quiz ran — tells a connector whether a gradebook exists to fetch.",
            "nullable": true
          },
          "started_at": {
            "type": "string",
            "description": "When the session went live, UTC.",
            "format": "date-time",
            "nullable": true
          },
          "ended_at": {
            "type": "string",
            "description": "When the session closed, UTC.",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "QuestionResultsOut": {
        "type": "object",
        "description": "Per-question aggregates as the published API describes them (spec 027 FR-012).\n            \nOnly the sections a question type populates are present; the rest are null. The shape is\nmapped field by field from the internal snapshot rather than forwarded, for the reason spec 026\npaid for: an envelope that carried the internal model wholesale shipped a question's answer key\nto the person being asked to answer it.\n            \nPins are not published. An IMAGE_PIN heatmap is a list of one coordinate per\nperson; in an anonymous room, publishing the raw points is the individual-record disclosure\nR16 rules out, and there is no aggregate form of it in the internal snapshot to publish\ninstead. It becomes available additively the day one exists.",
        "additionalProperties": false,
        "properties": {
          "session_id": {
            "type": "string",
            "description": "The session the answers were collected in.",
            "nullable": true
          },
          "question_id": {
            "type": "string",
            "description": "The question these aggregates describe.",
            "nullable": true
          },
          "question_type": {
            "type": "string",
            "description": "The question's type — it decides which of the sections below are populated.",
            "nullable": true,
            "enum": [
              "MULTIPLE_CHOICE",
              "SCALE",
              "NPS",
              "WORD_CLOUD",
              "OPEN_ENDED",
              "QUIZ",
              "RANKING",
              "NUMERIC_ESTIMATE",
              "IMAGE_PIN",
              "JOIN_CODE",
              "MATCHING",
              "CORRECT_ORDER",
              "FILL_BLANKS",
              "BRAINSTORM",
              "PRIORITIZATION",
              "SPINNER_WHEEL",
              null
            ]
          },
          "total_voters": {
            "type": "integer",
            "description": "Distinct people who answered — people, not entries (spec 026's counting fix).",
            "format": "int64",
            "nullable": true
          },
          "option_counts": {
            "type": "object",
            "description": "Choice types: votes per option id.",
            "nullable": true,
            "additionalProperties": {
              "type": "integer",
              "format": "int64"
            }
          },
          "value_distribution": {
            "type": "object",
            "description": "Scale, NPS and estimate types: how many participants chose each value.",
            "nullable": true,
            "additionalProperties": {
              "type": "integer",
              "format": "int64"
            }
          },
          "average": {
            "type": "number",
            "description": "Numeric types: the mean of every answer.",
            "format": "double",
            "nullable": true
          },
          "words": {
            "type": "array",
            "description": "WORD_CLOUD: each word and how many participants said it.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/WordCountOut"
            }
          },
          "texts": {
            "type": "array",
            "description": "Open-ended types: the answers, with their upvotes.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/TextAnswerOut"
            }
          },
          "correct_option_ids": {
            "type": "array",
            "description": "QUIZ: the correct options, revealed once the question closes.",
            "nullable": true,
            "items": {
              "type": "string"
            }
          },
          "nps": {
            "description": "NPS: the promoter / passive / detractor split.",
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/NpsBreakdownOut"
              }
            ]
          },
          "pair_stats": {
            "type": "array",
            "description": "MATCHING: per left-hand item, how the room matched it.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/PairStatOut"
            }
          },
          "correct_pairs": {
            "type": "array",
            "description": "MATCHING: the answer key, revealed once the question closes.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/PairStatOut"
            }
          },
          "correct_order": {
            "type": "array",
            "description": "CORRECT_ORDER: the right sequence, revealed once the question closes.",
            "nullable": true,
            "items": {
              "type": "string"
            }
          },
          "placement_accuracy": {
            "type": "array",
            "description": "CORRECT_ORDER: per item, where the room placed it and how often correctly.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/PlacementAccuracyOut"
            }
          },
          "most_common_order": {
            "type": "array",
            "description": "CORRECT_ORDER: the sequence the room most often produced.",
            "nullable": true,
            "items": {
              "type": "string"
            }
          },
          "blank_stats": {
            "type": "array",
            "description": "FILL_BLANKS: per blank, the accepted answers and what the room wrote.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/BlankStatOut"
            }
          },
          "allocation_totals": {
            "type": "array",
            "description": "PRIORITIZATION: the points the room allocated per option, with share and rank.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/AllocationTotalOut"
            }
          },
          "spin_outcomes": {
            "type": "array",
            "description": "SPINNER_WHEEL: the session's spin log for this question — the wheel's only result, since it\naggregates nothing else. It is a list of entries the wheel itself was configured with, not of\npeople's answers, so nothing about it is an individual record (R16).",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/SpinOutcomeOut"
            }
          }
        }
      },
      "WordCountOut": {
        "type": "object",
        "description": "One word of a WORD_CLOUD and how many participants said it.",
        "additionalProperties": false,
        "properties": {
          "word": {
            "type": "string",
            "description": "The word, as the platform aggregated it.",
            "nullable": true
          },
          "count": {
            "type": "integer",
            "description": "How many participants said it.",
            "format": "int64",
            "nullable": true
          }
        }
      },
      "TextAnswerOut": {
        "type": "object",
        "description": "A free-text answer as the published API describes it (spec 027 FR-012).\n            \nNo participant identifier — research R16. The internal entry carries one because\nupvoting needs it; publishing it would let an integrator group a session's answers by person,\nwhich is the individual record FR-014 prohibits even though that ephemeral id identifies nobody\noutside the session. An aggregate is an aggregate.",
        "additionalProperties": false,
        "properties": {
          "text": {
            "type": "string",
            "description": "The answer as the participant wrote it.",
            "nullable": true
          },
          "upvotes": {
            "type": "integer",
            "description": "How many participants upvoted it.",
            "format": "int64",
            "nullable": true
          }
        }
      },
      "NpsBreakdownOut": {
        "type": "object",
        "description": "The NPS split of a question's answers.",
        "additionalProperties": false,
        "properties": {
          "promoters": {
            "type": "integer",
            "description": "Answers of 9 or 10.",
            "format": "int64",
            "nullable": true
          },
          "passives": {
            "type": "integer",
            "description": "Answers of 7 or 8.",
            "format": "int64",
            "nullable": true
          },
          "detractors": {
            "type": "integer",
            "description": "Answers of 0 to 6.",
            "format": "int64",
            "nullable": true
          }
        }
      },
      "PairStatOut": {
        "type": "object",
        "description": "One left-hand item of a MATCHING question and how the room matched it.",
        "additionalProperties": false,
        "properties": {
          "left_id": {
            "type": "string",
            "description": "The left-hand item's option id.",
            "nullable": true
          },
          "left_text": {
            "type": "string",
            "description": "The left-hand item as the audience read it.",
            "nullable": true
          },
          "right_id": {
            "type": "string",
            "description": "The correct match — populated only once the question is closed.\n            \nWhile it is open, the internal snapshot already nulls these (spec 026's answer-key\nleak, fixed there). The public surface inherits that gate rather than re-deriving it: one\nplace decides when an answer may be seen.",
            "nullable": true
          },
          "right_text": {
            "type": "string",
            "description": "The matched right-hand item's text, on the same terms as its id.",
            "nullable": true
          },
          "correct_percent": {
            "type": "number",
            "description": "Share of participants who matched it correctly, 0-100.",
            "format": "double",
            "nullable": true
          }
        }
      },
      "PlacementAccuracyOut": {
        "type": "object",
        "description": "One item of a CORRECT_ORDER question and where the room placed it.",
        "additionalProperties": false,
        "properties": {
          "item_id": {
            "type": "string",
            "description": "The item's option id.",
            "nullable": true
          },
          "text": {
            "type": "string",
            "description": "The item as the audience read it.",
            "nullable": true
          },
          "correct_position": {
            "type": "integer",
            "description": "Its position in the answer key, zero-based.",
            "format": "int32",
            "nullable": true
          },
          "correct_percent": {
            "type": "number",
            "description": "Share of participants who placed it correctly, 0-100.",
            "format": "double",
            "nullable": true
          },
          "counts_by_position": {
            "type": "array",
            "description": "How many people put this item in each position, indexed by position. It is the aggregate an\nintegrator actually charts — the percentage alone says how many got it right and nothing\nabout where the rest thought it went.",
            "nullable": true,
            "items": {
              "type": "integer",
              "format": "int64"
            }
          }
        }
      },
      "BlankStatOut": {
        "type": "object",
        "description": "One blank of a FILL_BLANKS question and what the room wrote into it.",
        "additionalProperties": false,
        "properties": {
          "index": {
            "type": "integer",
            "description": "Which blank this is — zero-based, in prompt order.",
            "format": "int32",
            "nullable": true
          },
          "accepted_answers": {
            "type": "array",
            "description": "The answers the author accepts, revealed on the answer key's terms.",
            "nullable": true,
            "items": {
              "type": "string"
            }
          },
          "answers": {
            "type": "array",
            "description": "What participants wrote, aggregated by distinct answer.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/BlankAnswerOut"
            }
          }
        }
      },
      "BlankAnswerOut": {
        "type": "object",
        "description": "One distinct answer written into a blank, with its count.",
        "additionalProperties": false,
        "properties": {
          "answer": {
            "type": "string",
            "description": "The text participants wrote into the blank.",
            "nullable": true
          },
          "count": {
            "type": "integer",
            "description": "How many participants wrote it.",
            "format": "int64",
            "nullable": true
          },
          "accepted": {
            "type": "boolean",
            "description": "Whether it was accepted — revealed on the same terms as any other answer key.",
            "nullable": true
          }
        }
      },
      "AllocationTotalOut": {
        "type": "object",
        "description": "One option of a PRIORITIZATION question and the points the room gave it.",
        "additionalProperties": false,
        "properties": {
          "option_id": {
            "type": "string",
            "description": "The option's id.",
            "nullable": true
          },
          "text": {
            "type": "string",
            "description": "The option as the audience read it.",
            "nullable": true
          },
          "points": {
            "type": "number",
            "description": "Total points the room allocated to it.",
            "format": "double",
            "nullable": true
          },
          "share_percent": {
            "type": "number",
            "description": "Its share of every point allocated, 0-100.",
            "format": "double",
            "nullable": true
          },
          "rank": {
            "type": "integer",
            "description": "Position by points allocated; 1 is the room's top priority.",
            "format": "int32",
            "nullable": true
          }
        }
      },
      "SpinOutcomeOut": {
        "type": "object",
        "description": "One spin of a wheel, as the published API describes it (spec 026 FR-026).",
        "additionalProperties": false,
        "properties": {
          "entry": {
            "type": "string",
            "description": "The wheel entry that won this spin.",
            "nullable": true
          },
          "spun_at": {
            "type": "string",
            "description": "When the wheel was spun, UTC.",
            "format": "date-time",
            "nullable": true
          },
          "removed_after": {
            "type": "boolean",
            "description": "Whether this winner was taken out of the following spins.",
            "nullable": true
          }
        }
      },
      "QaEntryResponseOut": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ResponseBase"
          },
          {
            "type": "object",
            "description": "Keyset-paginated Q&A entries (Rule A3/A4).",
            "additionalProperties": false,
            "properties": {
              "entries": {
                "type": "array",
                "description": "The Q&A entries of this page.",
                "nullable": true,
                "items": {
                  "$ref": "#/components/schemas/QaEntryOut"
                }
              }
            }
          }
        ]
      },
      "QaEntryOut": {
        "type": "object",
        "description": "A Q&A entry as the published API describes it (spec 027 FR-013).\n            \nThe internal entry id is {participantId}~{n}. Publishing it would hand an\nintegrator the author of every question by splitting on a tilde — the individual record FR-014\nprohibits, wearing the costume of an opaque identifier. Id is therefore derived,\nstable and one-way.",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "An opaque identifier, stable across polls and not reversible to a participant.",
            "nullable": true
          },
          "question_id": {
            "type": "string",
            "description": "The OPEN_ENDED question this entry belongs to.",
            "nullable": true
          },
          "text": {
            "type": "string",
            "description": "The question as the participant asked it.",
            "nullable": true
          },
          "upvotes": {
            "type": "integer",
            "description": "How many participants upvoted it.",
            "format": "int64",
            "nullable": true
          },
          "asker_name": {
            "type": "string",
            "description": "The asker's display name — present only when the session collected one.\n            \nNot a leak: in an IDENTIFIED session the room already sees it on the projector. In an\nANONYMOUS one there is no name to carry and the field is simply absent.",
            "nullable": true
          },
          "lifecycle": {
            "type": "string",
            "description": "Where the host has taken it (FR-013). Forwarded from the host toolkit's own lifecycle, so\nthe published domain is that whole lifecycle — not the three states a reader might assume\nfrom the states a moderated queue happens to spend most of its time in.",
            "nullable": true,
            "enum": [
              "PENDING",
              "LIVE",
              "ANSWERED",
              "ARCHIVED",
              "DISMISSED",
              null
            ]
          },
          "pinned_order": {
            "type": "integer",
            "description": "Whether the host pinned it, and where in the pinned order.",
            "format": "int32",
            "nullable": true
          },
          "public_answer": {
            "type": "string",
            "description": "The answer the host published to the room.\n            \nOnly the public one. A private answer is addressed to the asker, and an integration\nposting Q&A into a team channel would be republishing a one-to-one reply to an audience\nit was deliberately kept from.",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "description": "When it was asked, UTC.",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "CreateSessionBody": {
        "type": "object",
        "description": "Body for POST /public/v1/sessions (spec 027 FR-008). Its own class, per Rule A5.",
        "additionalProperties": false,
        "required": [
          "presentation_id"
        ],
        "properties": {
          "presentation_id": {
            "type": "string",
            "description": "The presentation to run. Must belong to the credential's organization.",
            "minLength": 1
          },
          "participant_identity_mode": {
            "type": "string",
            "description": "ANONYMOUS (default), IDENTIFIED or AUTHENTICATED.\n            \nDefaulting to anonymous rather than to whatever the deck last used: identity\ncollection is a decision about the audience, and an API that silently inherits it could\nstart asking a room for names because a previous session did.",
            "nullable": true,
            "enum": [
              "ANONYMOUS",
              "IDENTIFIED",
              "AUTHENTICATED",
              null
            ]
          },
          "mode": {
            "type": "string",
            "description": "SELF_PACED for a window people answer at their own pace; omitted for a live room.",
            "nullable": true,
            "enum": [
              "LIVE",
              "SELF_PACED",
              null
            ]
          }
        }
      },
      "ScheduleSessionBody": {
        "type": "object",
        "description": "Body for POST /public/v1/sessions/{id}/schedule (spec 027 FR-009).",
        "additionalProperties": false,
        "properties": {
          "opens_at": {
            "type": "string",
            "description": "When the window opens, UTC. Must be in the future.",
            "format": "date-time",
            "nullable": true
          },
          "closes_at": {
            "type": "string",
            "description": "When the window closes, UTC. Must be after the opening.",
            "format": "date-time",
            "nullable": true
          },
          "timezone": {
            "type": "string",
            "description": "IANA zone the organizer thinks in, for display. Optional.",
            "nullable": true
          }
        }
      },
      "ParticipantsOut": {
        "type": "object",
        "description": "The participants of a session (FR-014).\n            \nAn anonymous session yields counts and nothing else, and that is structural rather\nthan filtered. When the identity mode is anonymous the mapper never builds a\nPublicParticipantOut, so Participants is null — there is no list to\nforget to strip. Spec 026 shipped a leak precisely because the stripping was a list of fields\nsomebody had to maintain; a shape that cannot hold the data does not need maintaining.",
        "additionalProperties": false,
        "properties": {
          "session_id": {
            "type": "string",
            "description": "The session these participants joined.",
            "nullable": true
          },
          "identity_mode": {
            "type": "string",
            "description": "ANONYMOUS, IDENTIFIED or AUTHENTICATED — tells a caller why the list may be absent.",
            "nullable": true,
            "enum": [
              "ANONYMOUS",
              "IDENTIFIED",
              "AUTHENTICATED",
              null
            ]
          },
          "total": {
            "type": "integer",
            "description": "How many people joined. Always present, in every identity mode.",
            "format": "int64",
            "nullable": true
          },
          "participants": {
            "type": "array",
            "description": "The people, when the session collected identities. Null in an anonymous session.\n            \nNull rather than an empty array on purpose: an empty array says \"nobody joined\", and a\ncaller counting rows would report an anonymous session of two hundred people as deserted.\nNull says \"this session does not have that information\", which is the truth.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/ParticipantOut"
            }
          }
        }
      },
      "ParticipantOut": {
        "type": "object",
        "description": "One participant of an identified session, as the published API describes them\n(spec 027 FR-014).\n            \nReached only when the session collected identities. There is no anonymous variant of this\ntype — see PublicParticipantsOut for why that absence is the mechanism rather than\na check.",
        "additionalProperties": false,
        "properties": {
          "display_name": {
            "type": "string",
            "description": "The name they gave when joining.",
            "nullable": true
          },
          "email": {
            "type": "string",
            "description": "Their email, when the session asked for one.",
            "nullable": true
          },
          "joined_at": {
            "type": "string",
            "description": "When they joined, UTC.",
            "format": "date-time",
            "nullable": true
          },
          "identity_seal": {
            "type": "string",
            "description": "Spec 030 FR-015 (T114): cómo se describe la identidad de esta persona — SSO_VERIFIED,\nGUEST o UNVERIFIED_RELAXED, y null cuando la sesión no exigió identidad.\n            \nFR-015 nombra al consumidor de la API pública como destinatario del sello, y este\nendpoint devolvía nombre y correo SIN él: un integrador que exporta asistencia a su LMS o a su\nhoja de cumplimiento no podía distinguir a quien el proveedor de identidad avala de quien entró\npor un enlace de invitado o durante una relajación. Los tres «estuvieron», y solo uno probó\nquién era.\n            \nAditivo y opcional a propósito: un cliente que no lo lea sigue funcionando igual, y la\ndefinición de OpenAPI regenera los conectores sin romper a nadie.",
            "nullable": true,
            "enum": [
              "SSO_VERIFIED",
              "GUEST",
              "UNVERIFIED_RELAXED",
              null
            ]
          }
        }
      },
      "WebhookCreatedOut": {
        "allOf": [
          {
            "$ref": "#/components/schemas/WebhookOut"
          },
          {
            "type": "object",
            "description": "What registering an endpoint returns: the endpoint, plus its signing secret.\n            \nA separate shape from PublicWebhookOut on purpose. Sharing one type would\nput signing_secret in the published schema of the LIST response too, and an integrator\nreading the reference would reasonably build a flow that reads the secret back from a list —\nwhich would then always be null, at the worst possible moment. The contract has to say exactly\nwhich call hands it over, because that call is the only chance to keep it.",
            "additionalProperties": false,
            "properties": {
              "signing_secret": {
                "type": "string",
                "description": "The HMAC signing secret. Shown once; it cannot be read back.",
                "nullable": true
              }
            }
          }
        ]
      },
      "WebhookOut": {
        "type": "object",
        "description": "A registered webhook endpoint as the published API describes it (spec 027 FR-017).",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "The endpoint identifier.",
            "nullable": true
          },
          "target_url": {
            "type": "string",
            "description": "The HTTPS address deliveries are POSTed to.",
            "nullable": true
          },
          "event_types": {
            "type": "array",
            "description": "What this endpoint is subscribed to.\n            \nThe domain here is the whole event catalogue, not the subset v1 lets you\nsubscribe to (Published). This endpoint lists the\norganization's endpoints, and one registered from the admin panel may carry a billing,\nmembership or support event. Publishing the narrower set would be a schema that is a\npromise the API does not keep — and a generated client validating against it would reject a\nperfectly valid response. What you may register is narrower, and that is stated\nwhere registration is described.",
            "nullable": true,
            "items": {
              "type": "string",
              "enum": [
                "event_pass.purchased",
                "invoice.issued",
                "member.invited",
                "member.removed",
                "member.role_changed",
                "presentation.import.completed",
                "presentation.import.failed",
                "question.activated",
                "question.closed",
                "question.results.finalized",
                "refund.processed",
                "report.ready",
                "session.ended",
                "session.paused",
                "session.resumed",
                "session.started",
                "subscription.canceled",
                "subscription.created",
                "subscription.updated",
                "support.ticket.created",
                "support.ticket.replied",
                "support.ticket.status_changed"
              ]
            }
          },
          "active": {
            "type": "boolean",
            "description": "Whether it is currently receiving deliveries.",
            "nullable": true
          },
          "auto_disabled": {
            "type": "boolean",
            "description": "Whether the platform paused it after sustained failure (FR-020).\n            \nReported separately from Active because \"I turned this off\" and \"you\nturned this off because my server was down\" are different situations with different\nremedies, and an admin seeing only active: false would look for the wrong one.",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "description": "When the endpoint was registered, UTC.",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "RegisterWebhookBody": {
        "type": "object",
        "description": "Registering a webhook from the published surface (spec 027 FR-017).\n            \nIts own class rather than the internal register body, because the published one carries\nconnector and the internal one must not: a browser session registering an endpoint is the\nadmin doing it by hand, and there is no marketplace to attribute it to.",
        "additionalProperties": false,
        "required": [
          "target_url",
          "event_types"
        ],
        "properties": {
          "target_url": {
            "type": "string",
            "description": "The absolute HTTPS URL deliveries will be POSTed to.",
            "maxLength": 2048,
            "minLength": 1
          },
          "event_types": {
            "type": "array",
            "description": "What to subscribe to. Only the events v1 publishes — a narrower set than the ones a\ndelivery can report, deliberately: publishing an event commits us to its shape for twelve\nmonths (FR-027/FR-028), so the catalogue is opened one event at a time.",
            "maxItems": 40,
            "items": {
              "type": "string",
              "enum": [
                "presentation.import.completed",
                "presentation.import.failed",
                "question.activated",
                "question.closed",
                "question.results.finalized",
                "report.ready",
                "session.ended",
                "session.paused",
                "session.resumed",
                "session.started"
              ]
            }
          },
          "connector": {
            "type": "string",
            "description": "The marketplace registering on a user's behalf, when one is (ZAPIER,\nPOWER_AUTOMATE). Absent means the caller is registering for itself.\n            \nWhat it buys: deauthorizing that marketplace removes what the marketplace created and\nleaves everything else alone. Without it the only options are to delete every endpoint —\nincluding the one the data team registered by hand, whose deliveries would simply stop with\nnothing to explain why — or to delete none and keep signing deliveries for a Zap that no\nlonger exists.",
            "maxLength": 40,
            "nullable": true,
            "enum": [
              "ZAPIER",
              "POWER_AUTOMATE",
              null
            ]
          },
          "external_subscription_id": {
            "type": "string",
            "description": "The marketplace's own id for the subscription, when it supplies one.",
            "maxLength": 200,
            "nullable": true
          }
        }
      },
      "WebhookRemovedOut": {
        "type": "object",
        "description": "What removing an endpoint returns (spec 036 T025 — documentation of the existing body).\n            \nThe DELETE answers 200 with a confirmation message (Rule A1: 200 for every\nsuccess), and the generated document typed that response as an empty body — so Power Automate's\ngenerated unsubscribe, which parses what the reference promises, was told nothing comes back.\nThis names the shape that has always been sent; it changes nothing on the wire.",
        "additionalProperties": false,
        "properties": {
          "message": {
            "type": "string",
            "description": "States what happened: the endpoint was removed, or there was nothing for the named connector to remove.",
            "nullable": true
          }
        }
      },
      "WebhookResponseOut": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ResponseBase"
          },
          {
            "type": "object",
            "description": "Keyset-paginated list of registered endpoints (Rule A3/A4).",
            "additionalProperties": false,
            "properties": {
              "webhooks": {
                "type": "array",
                "description": "The organization's registered endpoints, newest first.",
                "nullable": true,
                "items": {
                  "$ref": "#/components/schemas/WebhookOut"
                }
              }
            }
          }
        ]
      },
      "WebhookDeliveryResponseOut": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ResponseBase"
          },
          {
            "type": "object",
            "description": "Keyset-paginated delivery history of one endpoint (Rule A3/A4).",
            "additionalProperties": false,
            "properties": {
              "deliveries": {
                "type": "array",
                "description": "The delivery attempts of this page, newest first.",
                "nullable": true,
                "items": {
                  "$ref": "#/components/schemas/WebhookDeliveryOut"
                }
              }
            }
          }
        ]
      },
      "WebhookDeliveryOut": {
        "type": "object",
        "description": "A delivery attempt, as the published API describes it (FR-020).",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "The delivery identifier — what a redelivery names.",
            "nullable": true
          },
          "event_type": {
            "type": "string",
            "description": "The event that was delivered. The whole catalogue, for the reason given on\nEventTypes: a delivery of an endpoint registered from the\npanel can carry an event v1 does not let you subscribe to.",
            "nullable": true,
            "enum": [
              "event_pass.purchased",
              "invoice.issued",
              "member.invited",
              "member.removed",
              "member.role_changed",
              "presentation.import.completed",
              "presentation.import.failed",
              "question.activated",
              "question.closed",
              "question.results.finalized",
              "refund.processed",
              "report.ready",
              "session.ended",
              "session.paused",
              "session.resumed",
              "session.started",
              "subscription.canceled",
              "subscription.created",
              "subscription.updated",
              "support.ticket.created",
              "support.ticket.replied",
              "support.ticket.status_changed",
              null
            ]
          },
          "status": {
            "type": "string",
            "description": "PENDING, DELIVERED or FAILED.",
            "nullable": true,
            "enum": [
              "PENDING",
              "DELIVERED",
              "FAILED",
              null
            ]
          },
          "attempt_count": {
            "type": "integer",
            "description": "How many delivery attempts have been made so far.",
            "format": "int32",
            "nullable": true
          },
          "last_status_code": {
            "type": "integer",
            "description": "The HTTP status the endpoint last answered with, when it answered at all.",
            "format": "int32",
            "nullable": true
          },
          "dead_lettered_at": {
            "type": "string",
            "description": "When it stopped being retried — the field the dead-letter view filters on.",
            "format": "date-time",
            "nullable": true
          },
          "redelivery_of": {
            "type": "string",
            "description": "Set when this attempt is a manual replay of an earlier one.",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "description": "When the delivery was enqueued, UTC.",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "ApiChangelogEntryOut": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ApiChangelogEntryBase"
          },
          {
            "type": "object",
            "description": "One changelog entry as the published API serves it.",
            "additionalProperties": false
          }
        ]
      },
      "ApiChangelogEntryBase": {
        "type": "object",
        "description": "One dated record of an observable change to the published contract (spec 027 FR-027, SC-009).\n            \nStored rather than kept in a Markdown file because the portal serves it and a test reads\nit. A changelog in a file can be written with yesterday's date; one the platform serves and a\ntest compares against the shipped definition cannot.",
        "additionalProperties": false,
        "properties": {
          "_id": {
            "type": "string",
            "description": "The entry identifier.",
            "nullable": true
          },
          "version": {
            "type": "string",
            "description": "The API version this change belongs to.",
            "nullable": true
          },
          "released_on": {
            "type": "string",
            "description": "The date SC-009 measures: the change must be recorded on or before its release.",
            "format": "date-time",
            "nullable": true
          },
          "kind": {
            "type": "string",
            "description": "ADDITIVE, DEPRECATION or RETIREMENT.",
            "nullable": true,
            "enum": [
              "ADDITIVE",
              "DEPRECATION",
              "RETIREMENT",
              null
            ]
          },
          "summary": {
            "type": "string",
            "description": "What changed, in a sentence an integrator can act on.",
            "nullable": true
          },
          "capabilities": {
            "type": "array",
            "description": "The published paths this entry touches.\n            \nRecorded so SC-009 can be checked mechanically rather than by reading prose: a new\npath in the definition with no entry naming it is a change that shipped undocumented.",
            "nullable": true,
            "items": {
              "type": "string"
            }
          },
          "created_at": {
            "type": "string",
            "description": "When the entry was recorded, UTC.",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "ApiLimitsOut": {
        "type": "object",
        "description": "The published limits and the retry schedule (spec 027 FR-006, FR-020).",
        "additionalProperties": false,
        "properties": {
          "quota_tiers": {
            "type": "array",
            "description": "The request budget of every plan that includes the API, from the table the quota middleware enforces.",
            "nullable": true,
            "items": {
              "$ref": "#/components/schemas/ApiQuotaTierOut"
            }
          },
          "webhook_retry_schedule_seconds": {
            "type": "array",
            "description": "Seconds between webhook delivery attempts, in order.",
            "nullable": true,
            "items": {
              "type": "integer",
              "format": "int32"
            }
          },
          "webhook_max_attempts": {
            "type": "integer",
            "description": "How many attempts a delivery gets before it is dead-lettered.",
            "format": "int32"
          },
          "webhook_event_catalogue": {
            "type": "array",
            "description": "Every event type v1 publishes, sorted so the list is stable to diff.",
            "nullable": true,
            "items": {
              "type": "string",
              "enum": [
                "presentation.import.completed",
                "presentation.import.failed",
                "question.activated",
                "question.closed",
                "question.results.finalized",
                "report.ready",
                "session.ended",
                "session.paused",
                "session.resumed",
                "session.started"
              ]
            }
          }
        }
      },
      "ApiQuotaTierOut": {
        "type": "object",
        "description": "One plan's published request budget.",
        "additionalProperties": false,
        "properties": {
          "plan_code": {
            "type": "string",
            "description": "The plan this budget belongs to. FREE is absent: it has no API access at all, not a smaller budget.",
            "nullable": true,
            "enum": [
              "ENTERPRISE",
              "PRO",
              "TEAM",
              null
            ]
          },
          "requests_per_window": {
            "type": "integer",
            "description": "How many requests the plan may make per window, across the whole organization.",
            "format": "int32"
          },
          "window_seconds": {
            "type": "integer",
            "description": "The length of the budget window, in seconds.",
            "format": "int32"
          }
        }
      }
    },
    "securitySchemes": {
      "ApiKey": {
        "type": "apiKey",
        "description": "An organization API key: `Authorization: Bearer vz_live_…`",
        "name": "Authorization",
        "in": "header"
      }
    }
  },
  "security": [
    {
      "ApiKey": []
    }
  ],
  "tags": [
    {
      "name": "Sessions",
      "description": "Run the room programmatically: list and read sessions, create one from a presentation, open, schedule and close it, put a question on the screen, and read what came back — results, KPIs, Q&A, participants and exports."
    },
    {
      "name": "Questions",
      "description": "Author a presentation's interactive questions: list the deck in order, add a question of any published type, update one — with the plan's coverage verdict stated on every question rather than discovered mid-session."
    },
    {
      "name": "Presentations",
      "description": "The organization's presentations: the decks that sessions run and questions belong to, listed newest first with keyset pagination."
    },
    {
      "name": "Webhooks",
      "description": "Outbound event delivery: register HTTPS endpoints against the published event catalogue, list them, read their delivery history, re-enable one the platform paused, and replay a dead-lettered delivery."
    },
    {
      "name": "Limits",
      "description": "Discovery, no credential required: the per-plan request quotas, the webhook retry schedule and the published event catalogue — served from the same constants the platform enforces, so they cannot drift from what a request experiences."
    },
    {
      "name": "Changelog",
      "description": "Discovery, no credential required: the dated change history of the published API under its additive versioning promise — within v1 every change is additive, and breaking changes ship as a new version."
    }
  ]
}