API documentation
Create lobbies and read their results programmatically. Generate a key from the API keys menu (sign in first) in the header, then follow the examples below.
Quickstart
- Sign in, then generate a key from API keys in the header — see Authentication.
- Create a lobby — see POST /api-v1-create-lobby. The response includes the lobby's join code.
- Fetch progress and results any time — see GET /api-v1-lobby-results.
Base URL
All endpoints are Supabase Edge Functions under:
https://hjhlsvjmxadedijpofvg.supabase.co/functions/v1/api-v1-<endpoint>Authentication
Every request needs Authorization: Bearer <key>. The raw key (vk_live_...) is shown exactly once when generated — copy it immediately, only its hash is stored afterward. A key is tied to your account and inherits your normal lobby-creation limits (including the 10-lobby cap — see Errors).
Revoked or unrecognized keys get a 401:
{ "error": "INVALID_API_KEY" }Rate limits
Each endpoint has its own bucket, separate from the web app's own limits:
| Endpoint | Limit |
|---|---|
| POST /api-v1-create-lobby | 20 requests / hour / key |
| GET /api-v1-lobby-results | 60 requests / hour / key |
Exceeding a limit returns 429 with {"error": "RATE_LIMITED"}. Lobby creation also has a second, stricter limit underneath the API's own bucket: your account can create at most 5 lobbies per 10 minutes (the same limit the web app's creation form is subject to) — whichever limit is hit first returns RATE_LIMITED.
GET/api-v1-me
Confirms a key is valid. No side effects, no rate limit.
curl https://hjhlsvjmxadedijpofvg.supabase.co/functions/v1/api-v1-me \
-H "Authorization: Bearer vk_live_..."{ "ok": true, "userId": "3f06f4ea-d868-4ce2-9308-b979b0797979" }POST/api-v1-create-lobby
Creates a lobby, identical in shape to what the web form's "Create lobby" button sends.
curl -X POST https://hjhlsvjmxadedijpofvg.supabase.co/functions/v1/api-v1-create-lobby \
-H "Authorization: Bearer vk_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Where should we eat lunch?",
"questions": [
{ "title": "Pick a spot", "type": "choice", "options": ["Tacos", "Sushi", "Salad"] }
],
"voterCap": 30,
"ballotMode": "anonymous",
"tallyVisibility": "live"
}'Body fields
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | 1–200 characters. |
questions | array | Yes | At least 1 question — see question fields below. |
voterCap | integer | Yes | 1–10,000. Max number of participants who can join. |
ballotMode | "anonymous" | "open" | Yes | "anonymous": nobody — including you — can see who voted for what, only aggregate tallies. "open": you can see each voter's individual ballot via ballotDetail on the results endpoint. |
tallyVisibility | "live" | "hidden" | Yes | "live": results are visible while the lobby is still open. "hidden": results are only visible once you close the lobby. |
closesAt | ISO 8601 timestamp | No | Schedules an automatic close. Must be in the future. Omit for no scheduled close. |
Question object fields
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | 1–200 characters. |
type | "choice" | "text" | "ranked" | Yes | "choice": pick one, or up to N — see maxSelections. "text": free-response, no options. "ranked": rank every option in order (instant-runoff tallying). |
options | string[] | For choice/ranked | At least 2 options, each 1–200 characters. Ignored for text questions. |
maxSelections | integer | No | choice only, 1..options.length. Omit for classic single-select; set above 1 for "choose up to N." Not applicable to ranked — every option is always ranked. |
Example: mixed question types
A 3-question survey combining all 3 types — one ranked-choice question, one multi-select ("choose up to N") question, and one free-text question:
{
"title": "Team offsite planning",
"questions": [
{ "title": "Rank these venues", "type": "ranked", "options": ["Beach house", "Mountain cabin", "City loft"] },
{ "title": "Which activities interest you?", "type": "choice", "options": ["Hiking", "Cooking class", "Board games", "Spa"], "maxSelections": 2 },
{ "title": "Anything else we should plan for?", "type": "text" }
],
"voterCap": 25,
"ballotMode": "open",
"tallyVisibility": "hidden"
}The response includes the generated code voters use to join at votero.app/vote/<code>.
GET/api-v1-lobby-results
Reads progress/tally/ballot-detail for a lobby you created— a foreign or nonexistent code both return 404, deliberately indistinguishable, so a wrong code can't be used to probe whether someone else's code exists.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | The lobby's human-readable join code, e.g. 7S8XDH6C. |
curl "https://hjhlsvjmxadedijpofvg.supabase.co/functions/v1/api-v1-lobby-results?code=7S8XDH6C" \
-H "Authorization: Bearer vk_live_..."{
"progress": { "joined": 12, "cap": 30, "completedCount": 10 },
"tally": [
{
"questionId": "...",
"questionTitle": "Pick a spot",
"type": "choice",
"tally": [{ "optionId": "...", "count": 7 }, { "optionId": "...", "count": 3 }]
}
],
"ballotDetail": null
}tally is null until the lobby is closed or tally visibility is live. ballotDetail is only populated for open-ballot lobbies.
Errors
All errors are {"error": "SOME_CODE"} with a matching HTTP status:
| Code | Status | Applies to | Meaning |
|---|---|---|---|
INVALID_API_KEY | 401 | All endpoints | missing, unrecognized, or revoked key |
AUTH_UNAVAILABLE | 503 | create-lobby, lobby-results | your key is valid, but minting a session for your account failed transiently — retry |
RATE_LIMITED | 429 | create-lobby, lobby-results | too many requests in the current window (see Rate limits) |
MISSING_CODE | 400 | lobby-results | code query param missing |
LOBBY_NOT_FOUND | 404 | lobby-results | no lobby with that code owned by this key's account |
AT_LEAST_ONE_QUESTION_REQUIRED | 400 | create-lobby | questions array is empty |
AT_LEAST_TWO_OPTIONS_REQUIRED | 400 | create-lobby | a choice or ranked question has fewer than 2 options |
INVALID_MAX_SELECTIONS | 400 | create-lobby | maxSelections is below 1 or above the question's option count |
INAPPROPRIATE_CONTENT | 400 | create-lobby | the lobby title, a question title, or an option label failed the profanity filter |
CLOSES_AT_MUST_BE_FUTURE | 400 | create-lobby | closesAt is not in the future |
LOBBY_LIMIT_REACHED | 400 | create-lobby | your account already has 10 lobbies (the same cap the web app enforces) |
Field-length/range violations (e.g. voterCap outside 1–10,000, a title over 200 characters) return a 400 with a raw database error message rather than one of the codes above — validate against the limits in the parameter tables above to avoid hitting these.
What's not in v1
- No vote-casting or lobby-joining via API key — that represents an end-user voting, not a server acting on their behalf.
- No standalone lobby-read endpoint — create-lobby's response already has everything a caller who just created a lobby needs.
- No integration (Zapier, HubSpot, etc.) — this is the raw API; where to point it is a separate, later decision.
Full machine-readable reference: docs/openapi.yaml in the repo.