{
  "openapi": "3.0.3",
  "info": {
    "title": "Tokei Public API",
    "version": "1.0.0",
    "description": "The Tokei Public API lets you manage promotions, create entries programmatically, pull analytics and leaderboards, and receive webhooks when participants enter. All endpoints are versioned under /api/v1 and return JSON.\n\nAPI access requires an active subscription or lifetime plan — trial accounts receive 403 FORBIDDEN.\n\nEvery authenticated response includes rate-limit headers in two spellings: the long-standing X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset (Reset is an absolute Unix timestamp) and the IETF-draft RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset / RateLimit-Policy (Reset is a delta in seconds). A 429 additionally carries Retry-After. A request that fails authentication (401) or the plan check (403) carries none of them, because the key is checked before the limiter. Limits are per account: Subscriber 60 read / 30 write per minute, Lifetime 120 read / 60 write per minute.\n\nVersioning and deprecation: every authenticated endpoint lives under /api/v1. Breaking changes ship under a new version prefix (/api/v2) rather than by mutating /api/v1, so a working integration keeps working. If a version is ever retired it will be announced ahead of time: the affected endpoints will carry a Sunset response header naming the retirement date, alongside a published migration timeline at https://tokei.io/docs/api#versioning. No version has been deprecated to date.\n\nUnknown paths under /api/v1 return the same JSON error envelope with code NOT_FOUND - never an HTML error page.\n\nWebhook deliveries are signed with HMAC-SHA256 in the X-TOKEI-Signature header using the secret returned once at webhook creation. Deliveries expect a 2xx within 10 seconds and are retried with backoff (5s, 30s, 5min); a subscription is auto-disabled after 10 consecutive failed deliveries.",
    "contact": {
      "url": "https://tokei.io/docs/api"
    }
  },
  "servers": [
    {
      "url": "https://tokei.io/api/v1"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    { "name": "Account", "description": "Key verification and account usage." },
    {
      "name": "Contests",
      "description": "List and retrieve your promotions. These read endpoints live under /contests for backwards compatibility; the id from POST /promotions works with all of them."
    },
    {
      "name": "Templates",
      "description": "The platform's named starting points, available to every key. Clone one by slug via POST /promotions."
    },
    {
      "name": "Actions",
      "description": "The per-type entry-action catalog — label, points, platform and per-type config fields for every action type, available to every key. The authoritative reference for entry_methods rows written via PATCH /contests/{contestId}."
    },
    { "name": "Promotions", "description": "Create promotions by cloning one you own." },
    {
      "name": "Media",
      "description": "Get a short-lived signed upload URL for an image or video, then PATCH the resulting public_url onto a promotion's media fields."
    },
    { "name": "Entries", "description": "List and create promotion entries." },
    { "name": "Analytics", "description": "Aggregated promotion analytics." },
    { "name": "Leaderboard", "description": "Participants ranked by points." },
    { "name": "Referrals", "description": "Top referrers and referral click totals." },
    { "name": "Survey Responses", "description": "Survey answers from gamified promotions." },
    {
      "name": "Winners",
      "description": "Read-only selection-run history with each run's persisted winners. Finalize-via-API is out of scope (human-approval policy); use the dashboard or session-auth selection routes to draw winners."
    },
    {
      "name": "Webhooks",
      "description": "Manage webhook subscriptions. All 5 events are subscribable and emitted: entry.created, contest.ended, winner.selected, daily_bonus.claimed, referral.converted. Per-contest creator webhooks (created in the dashboard, not this API) subscribe to a fixed trio: entry.created, winner.selected, contest.ended."
    },
    {
      "name": "Events",
      "description": "The webhook event catalog, available to every key. See what events exist and each one's payload shape via GET /events/catalog."
    },
    {
      "name": "Public Listings",
      "description": "The unauthenticated directory feed: every promotion that is live on Tokei right now. No API key, no account, no ownership scoping — it publishes exactly the same rows as sitemap.xml. Served from https://tokei.io/api/public, NOT the /api/v1 base every other endpoint in this spec uses."
    }
  ],
  "paths": {
    "/me": {
      "get": {
        "tags": ["Account"],
        "operationId": "getMe",
        "summary": "Verify API key and return account information",
        "responses": {
          "200": {
            "description": "Account information and API usage.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/X-RateLimit-Remaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/X-RateLimit-Reset" }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "$ref": "#/components/schemas/Account" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/contests": {
      "get": {
        "tags": ["Contests"],
        "operationId": "listContests",
        "summary": "List all your promotions",
        "parameters": [
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/PerPage" },
          {
            "name": "status",
            "in": "query",
            "description": "Filter by EFFECTIVE promotion status — the same derived value the status field returns, so anything you read back can be filtered on. A promotion whose end_date has passed matches completed even if its stored status is still active, and one whose start_date is still in the future matches draft. Changed 2026-07-27: this previously filtered the raw column, so ?status=active returned promotions that had already ended.",
            "schema": { "type": "string", "enum": ["draft", "active", "completed", "deleted"] }
          },
          {
            "name": "mode",
            "in": "query",
            "description": "Filter by promotion mode.",
            "schema": { "type": "string", "enum": ["competition", "gamification", "sharing_only"] }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of promotions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/ContestSummary" }
                    },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/contests/{contestId}": {
      "get": {
        "tags": ["Contests"],
        "operationId": "getContest",
        "summary": "Retrieve a specific promotion",
        "parameters": [{ "$ref": "#/components/parameters/ContestId" }],
        "responses": {
          "200": {
            "description": "The full promotion object including entry methods and settings.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "$ref": "#/components/schemas/Contest" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "patch": {
        "tags": ["Contests"],
        "operationId": "updateContest",
        "summary": "Update a promotion",
        "description": "Requires the write scope. Updates promotion copy, dates, prizes, reward thresholds, appearance (template, dark_mode_enabled, primary_color, card_width), entry_methods and status. Send at least one field; unknown fields are rejected and the body is limited to 64KB on THIS endpoint only — every other v1 write endpoint stays at the shared 10KB limit; this one was raised to fit a full 30-row entry_methods array. prizes and reward_thresholds each replace the existing list wholesale, and so does entry_methods (full-array replace, max 30 rows, no per-row patch) — GET the promotion first, modify the array, then PATCH the complete list back; [] clears every entry method and omitting the field leaves the stored array untouched. Setting end_date recomputes days_left; a future start_date pauses new entries until that time. Setting or clearing end_date on a promotion whose winners have already been drawn returns 409 WINNERS_ALREADY_SELECTED, and clearing it on an active promotion returns 422 (an active promotion must keep a deadline). status publishes (draft/other -> active) or unpublishes (-> draft) the promotion: publishing requires an end_date in the future, already stored or sent in this same request, otherwise 422 VALIDATION_ERROR; unpublishing blocks new entries and hides the page from visitors (they see a \"not open right now\" page; publishing again restores it). deleted, completed, ended and paused are rejected — a promotion can never be destroyed or resurrected through this endpoint. Returns the full updated promotion object.",
        "parameters": [{ "$ref": "#/components/parameters/ContestId" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/UpdateContestRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The full updated promotion object.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "$ref": "#/components/schemas/Contest" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/WinnersAlreadySelected" },
          "413": { "$ref": "#/components/responses/PayloadTooLargeContestPatch" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/contests/{contestId}/entries": {
      "get": {
        "tags": ["Entries"],
        "operationId": "listEntries",
        "summary": "List entries for a promotion",
        "parameters": [
          { "$ref": "#/components/parameters/ContestId" },
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/PerPage" },
          {
            "name": "email",
            "in": "query",
            "description": "Filter by participant email (normalized before matching).",
            "schema": { "type": "string", "format": "email" }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of entries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Entry" } },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["Entries"],
        "operationId": "createEntry",
        "summary": "Create a new entry",
        "description": "Requires the write scope. Idempotent on email + promotion: if the email has already entered this promotion, returns 409 CONFLICT instead of creating a duplicate. Fires the entry.created webhook event on success.",
        "parameters": [{ "$ref": "#/components/parameters/ContestId" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateEntryRequest" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Entry created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "$ref": "#/components/schemas/Entry" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": {
            "description": "Duplicate entry — this email has already entered the promotion.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/contests/{contestId}/analytics": {
      "get": {
        "tags": ["Analytics"],
        "operationId": "getContestAnalytics",
        "summary": "Aggregated analytics for a promotion",
        "parameters": [{ "$ref": "#/components/parameters/ContestId" }],
        "responses": {
          "200": {
            "description": "Aggregated analytics.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "$ref": "#/components/schemas/Analytics" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/contests/{contestId}/leaderboard": {
      "get": {
        "tags": ["Leaderboard"],
        "operationId": "getContestLeaderboard",
        "summary": "Participants ranked by entry points",
        "parameters": [
          { "$ref": "#/components/parameters/ContestId" },
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/PerPage" }
        ],
        "responses": {
          "200": {
            "description": "Paginated leaderboard sorted by entry_points descending.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/LeaderboardRow" }
                    },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/contests/{contestId}/referrals": {
      "get": {
        "tags": ["Referrals"],
        "operationId": "getContestReferrals",
        "summary": "Top referrers ranked by conversions",
        "description": "Participants who have referred at least one person, ranked by converted referrals then total referrals. Entrants with no referrals are omitted: every participant is issued a referral code, so unfiltered the list would be mostly zero rows. Owner-scoped, so names and emails are unmasked.",
        "parameters": [
          { "$ref": "#/components/parameters/ContestId" },
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/PerPage" }
        ],
        "responses": {
          "200": {
            "description": "Paginated top referrers plus whole-promotion referral totals.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/ReferrerRow" }
                    },
                    "totals": { "$ref": "#/components/schemas/ReferralTotals" },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/contests/{contestId}/winners": {
      "get": {
        "tags": ["Winners"],
        "operationId": "getContestWinners",
        "summary": "Selection-run history with each run's winners",
        "description": "Read-only, no query parameters. Runs newest-first, each with its persisted winners and the same entrant identity fields the session-auth dashboard already shows the owner. Agents receive winner.selected webhooks when a selection is finalized, but finalize-via-API is deliberately out of scope (human-approval policy) — this endpoint is how an agent looks back at what a run actually selected. No pagination: contest-scale run/winner counts make a fixed 100-run cap sufficient headroom rather than a pagination story.",
        "parameters": [{ "$ref": "#/components/parameters/ContestId" }],
        "responses": {
          "200": {
            "description": "Selection runs for this promotion, newest first, each with its winners array.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/WinnerRun" }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/contests/{contestId}/survey-responses": {
      "get": {
        "tags": ["Survey Responses"],
        "operationId": "listSurveyResponses",
        "summary": "List survey responses for a promotion",
        "parameters": [
          { "$ref": "#/components/parameters/ContestId" },
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/PerPage" }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of survey responses.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/SurveyResponse" }
                    },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/templates": {
      "get": {
        "tags": ["Templates"],
        "operationId": "getTemplates",
        "summary": "List the platform's named starting points",
        "description": "Templates are platform content, not the caller's, so this listing is not ownership-scoped — every authenticated key sees the same menu. No parameters, no pagination. Clone one by slug with POST /promotions {\"template\": \"<slug>\"}.",
        "responses": {
          "200": {
            "description": "The full list of templates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Template" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/actions/catalog": {
      "get": {
        "tags": ["Actions"],
        "operationId": "getActionsCatalog",
        "summary": "Every entry-action type Tokei supports",
        "description": "The catalog is static platform data, not the caller's, so — like GET /templates — it is NOT ownership-scoped: every authenticated key sees the same catalog, and there is no database read at all. Optional type returns just that one action type's entry; omitting it returns all 38 action types keyed by actionType. Only 27 of the 38 are writable as an entry_methods row (isEntryMethodRow: true) — the other 11 are enabled through a contest setting or a dedicated route instead (see needs on each entry). This is the authoritative per-type reference for PATCH /contests/{contestId}'s entry_methods — the config fields it documents are exactly what that endpoint accepts under config, in the order a renderer reads them.",
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "description": "Filter to one action type, e.g. \"twitter_follow\". A value matching no action type returns 400 BAD_REQUEST with every valid value listed in error.message.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "The full catalog (an object keyed by action type) when type is omitted, or one entry when it is given.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "oneOf": [
                        {
                          "type": "object",
                          "additionalProperties": { "$ref": "#/components/schemas/ActionCatalogEntry" },
                          "description": "Keyed by action type (e.g. \"twitter_follow\"). Returned when type is omitted."
                        },
                        { "$ref": "#/components/schemas/ActionCatalogEntry" }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "type matches no known action type.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/events/catalog": {
      "get": {
        "tags": ["Events"],
        "operationId": "getEventsCatalog",
        "summary": "Every webhook event Tokei's delivery engine understands",
        "description": "The catalog is static platform data, not the caller's, so — like GET /actions/catalog — it is NOT ownership-scoped: every authenticated key sees the same catalog, and there is no database read at all. Optional type returns just that one event's entry; omitting it returns all 5 events keyed by event name. Each entry's payloadSchema documents the exact data shape delivered in that event's webhook payload — see POST /webhooks for the outer envelope ({event, timestamp, data}). subscribable is true for all 5 events this stage — every one has a real emit site.",
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "description": "Filter to one event name, e.g. \"winner.selected\". A value matching no event returns 400 BAD_REQUEST with every valid value listed in error.message.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "The full catalog (an object keyed by event name) when type is omitted, or one entry when it is given.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "oneOf": [
                        {
                          "type": "object",
                          "additionalProperties": { "$ref": "#/components/schemas/EventCatalogEntry" },
                          "description": "Keyed by event name (e.g. \"winner.selected\"). Returned when type is omitted."
                        },
                        { "$ref": "#/components/schemas/EventCatalogEntry" }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "type matches no known event.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/promotions": {
      "post": {
        "tags": ["Promotions"],
        "operationId": "createPromotion",
        "summary": "Create a promotion by cloning one you own",
        "description": "Requires the write scope. Clones a promotion your account owns and applies marketing-copy overrides; the template, theme, entry methods and all other settings are copied verbatim from the source, so keep one polished master promotion per page shape and clone it. The title is applied to both the dashboard name and the public page headline. Instead of source_promotion_id, clone one of the platform's named templates (GET /templates) by slug with template; sending both source_promotion_id and template is a 422 VALIDATION_ERROR, and a template slug matching no template is a 404 NOT_FOUND. Omitting both fields clones the default starter template. Limited to 20 API-created promotions per account per UTC day (429 RATE_LIMIT_EXCEEDED with a Retry-After header). Reads stay under /contests/{contestId} — the returned id works with all of them.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreatePromotionRequest" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Promotion created. public_url is the live page; edit_url opens it in the dashboard.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "$ref": "#/components/schemas/CreatedPromotion" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": {
            "description": "Source promotion not found or not owned by your account; or the template slug matches no template (error.details has field \"template\").",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "409": {
            "description": "idempotency_key already used — the existing promotion's id is in error.details.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/media": {
      "post": {
        "tags": ["Media"],
        "operationId": "createMediaUpload",
        "summary": "Upload an image or video",
        "description": "Requires the write scope. Send the file as multipart/form-data with a single `file` part; do not set Content-Type yourself, because the boundary must match. The object is stored in this one call, so success is 201. The part's declared type is the only source of the stored extension, and the stored name is always a server-generated UUID; the filename is echoed back but never stored. For images and video/mp4 the bytes are checked against the declared type, and a mismatch is a 422. The file must be 100 bytes to 5 MB, and the 5 MB cap applies to video as well as images. application/pdf is not accepted. Limited to 50 uploads per account per UTC day (429 RATE_LIMIT_EXCEEDED with a Retry-After header); the cap is checked after validation, so a rejected upload does not use quota. Read public_url from the response rather than building one from bucket and path, then feed it into one of the seven media fields on PATCH /contests/{contestId} (image_video, secondary_image, third_image, fourth_image, fifth_image, background_image, og_image); it is guaranteed to pass their host allowlist. The older two-step JSON signed-upload ticket is retired and answers 410 GONE.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["file"],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The image or video, 100 bytes to 5 MB. Its declared type must be one of the MediaObject content_type values."
                  }
                }
              },
              "encoding": {
                "file": {
                  "contentType": "image/jpeg, image/png, image/gif, image/webp, video/mp4, video/webm, video/quicktime"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The file is stored and reachable at data.public_url.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "$ref": "#/components/schemas/MediaObject" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "410": {
            "description": "The retired JSON signed-upload ticket. A request sent as application/json gets GONE; send the file as multipart/form-data instead.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "413": {
            "description": "The file is over 5 MB.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/webhooks": {
      "get": {
        "tags": ["Webhooks"],
        "operationId": "listWebhooks",
        "summary": "List webhook subscriptions",
        "parameters": [
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/PerPage" }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of webhook subscriptions. The signing secret is never included.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/Webhook" }
                    },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["Webhooks"],
        "operationId": "createWebhook",
        "summary": "Create a webhook subscription",
        "description": "The response includes the HMAC signing secret exactly once — store it securely. Deliveries are signed with HMAC-SHA256 in the X-TOKEI-Signature header. Webhook URLs must be HTTPS. Maximum 10 active webhooks per account. All 5 events are subscribable and emitted — see GET /events/catalog for each event's full payload shape.\n\nDelivery payload format: { \"event\": \"entry.created\", \"timestamp\": \"2026-02-17T12:00:00Z\", \"data\": { \"contest_id\": \"...\", \"entry_id\": \"...\", \"email\": \"...\", \"full_name\": \"...\", \"first_name\": \"...\", \"last_name\": \"...\", \"entry_source\": \"email_signup\", \"points\": 5, \"action_type\": \"email_signup\" } }",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["url", "events"],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "HTTPS endpoint that receives event payloads.",
                    "example": "https://yourserver.com/webhooks/tokei"
                  },
                  "events": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "enum": [
                        "entry.created",
                        "contest.ended",
                        "winner.selected",
                        "daily_bonus.claimed",
                        "referral.converted"
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Webhook created. The secret is shown only once.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "allOf": [
                        { "$ref": "#/components/schemas/Webhook" },
                        {
                          "type": "object",
                          "properties": {
                            "secret": {
                              "type": "string",
                              "description": "HMAC-SHA256 signing secret. Returned only in this response.",
                              "example": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
                            }
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/webhooks/{webhookId}": {
      "delete": {
        "tags": ["Webhooks"],
        "operationId": "deleteWebhook",
        "summary": "Delete a webhook subscription",
        "description": "Permanently deletes the subscription and its pending deliveries.",
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "description": "Webhook subscription UUID.",
            "schema": { "type": "string", "format": "uuid" }
          }
        ],
        "responses": {
          "200": {
            "description": "Webhook deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "success": { "type": "boolean", "example": true } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/public/contests": {
      "servers": [{ "url": "https://tokei.io/api" }],
      "get": {
        "tags": ["Public Listings"],
        "operationId": "listPublicContests",
        "summary": "List every promotion that is live right now (no auth)",
        "description": "Unauthenticated listing of Tokei's live promotions, for directories and aggregators. \"Live\" is the same definition sitemap.xml uses: status = active and either no end date or an end date in the future. Every live promotion is listed — there is no per-creator suppression, so this feed and sitemap.xml always agree. Responses are cached for about 15 minutes. Send no Authorization header: this path sits under https://tokei.io/api/public, outside the /api/v1 base, and ignores API keys.",
        "security": [],
        "parameters": [
          {
            "name": "page_type",
            "in": "query",
            "required": false,
            "description": "Comma-separated subset of the four canonical public page types. Omit for all four. An unrecognised value is a 400 rather than an empty list.",
            "schema": { "type": "string", "example": "contest,levelup" }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows per response.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Rows to skip. `total` is the unpaginated count, so page with offset += limit until offset >= total.",
            "schema": { "type": "integer", "minimum": 0, "default": 0 }
          }
        ],
        "responses": {
          "200": {
            "description": "The live promotions, most recently updated first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": {
                      "type": "object",
                      "properties": {
                        "contests": {
                          "type": "array",
                          "items": { "$ref": "#/components/schemas/PublicListing" }
                        },
                        "total": {
                          "type": "integer",
                          "description": "Matching rows before limit/offset."
                        },
                        "limit": { "type": "integer" },
                        "offset": { "type": "integer" },
                        "generated_at": { "type": "string", "format": "date-time" }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid page_type, limit or offset.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "API key",
        "description": "API key in the form tokei_k_ followed by 32 random characters. Generate keys from Dashboard > Settings > API."
      }
    },
    "parameters": {
      "ContestId": {
        "name": "contestId",
        "in": "path",
        "required": true,
        "description": "Promotion UUID (the id from POST /promotions or a contests read). Promotions you do not own return 404.",
        "schema": { "type": "string", "format": "uuid" }
      },
      "Page": {
        "name": "page",
        "in": "query",
        "description": "Page number.",
        "schema": { "type": "integer", "minimum": 1, "default": 1 }
      },
      "PerPage": {
        "name": "per_page",
        "in": "query",
        "description": "Results per page.",
        "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 }
      }
    },
    "headers": {
      "X-RateLimit-Limit": {
        "description": "Maximum requests per minute for your plan.",
        "schema": { "type": "integer" }
      },
      "X-RateLimit-Remaining": {
        "description": "Requests remaining in the current window.",
        "schema": { "type": "integer" }
      },
      "X-RateLimit-Reset": {
        "description": "Unix timestamp when the window resets.",
        "schema": { "type": "integer" }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid parameters.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "Unauthorized": {
        "description": "Missing, invalid, or revoked API key.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "Forbidden": {
        "description": "Valid key but insufficient plan (trial) or missing scope.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "NotFound": {
        "description": "Promotion or resource not found (including resources you do not own).",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "PayloadTooLarge": {
        "description": "Request body exceeds the 10KB limit.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "PayloadTooLargeContestPatch": {
        "description": "Request body exceeds the 64KB limit — PATCH /contests/{contestId}'s own cap, raised above the shared 10KB limit to fit a full 30-row entry_methods array. No other v1 endpoint uses this cap.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "WinnersAlreadySelected": {
        "description": "Error code WINNERS_ALREADY_SELECTED. The promotion already has selected winners, so end_date cannot be changed or cleared. Clear the selection in the dashboard first, or omit end_date.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "ValidationError": {
        "description": "Request body failed validation. See the details array.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ValidationErrorResponse" }
          }
        }
      },
      "RateLimited": {
        "description": "Per-minute rate limit or daily cap exceeded. See the Retry-After header.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": { "type": "integer" }
          },
          "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" },
          "X-RateLimit-Remaining": { "$ref": "#/components/headers/X-RateLimit-Remaining" },
          "X-RateLimit-Reset": { "$ref": "#/components/headers/X-RateLimit-Reset" }
        },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      }
    },
    "schemas": {
      "PublicListing": {
        "type": "object",
        "description": "One live promotion, as published to directories by GET /api/public/contests.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "title": {
            "type": "string",
            "description": "The promotion's public name — settings.promotion_title, else the contest title. Never the creator's internal campaign_name."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "description": "Plain text; the stored value is creator HTML and is stripped here. Null when the stored value is markup only."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The canonical public URL — the same one sitemap.xml lists, so it never 308-redirects.",
            "example": "https://tokei.io/contest/9CV9Pp"
          },
          "page_type": {
            "type": "string",
            "enum": ["contest", "levelup", "promote", "discover"],
            "description": "The first path segment of url. contest = competition page with email capture, levelup = gamified page, discover = landing page, promote = capture disabled."
          },
          "image": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "The promotion's own picture where it has one, falling back to the creator's account-level image. Null when neither exists. A video resolves to a still only when it can be thumbnailed, and is skipped otherwise."
          },
          "prizes": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Prize" },
            "description": "The promotion's prizes, in the same Prize shape GET /v1/contests/{id} returns. Empty for promotions that are not giveaways — landing and gamified pages often have none."
          },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" },
          "ends_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Null means no end date is set. A past value never appears — those rows are not live."
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "success": { "type": "boolean", "example": false },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "BAD_REQUEST",
                  "UNAUTHORIZED",
                  "FORBIDDEN",
                  "NOT_FOUND",
                  "CONFLICT",
                  "WINNERS_ALREADY_SELECTED",
                  "GONE",
                  "PAYLOAD_TOO_LARGE",
                  "VALIDATION_ERROR",
                  "RATE_LIMIT_EXCEEDED",
                  "INTERNAL_ERROR"
                ]
              },
              "message": { "type": "string" },
              "status": { "type": "integer" }
            }
          }
        }
      },
      "ValidationErrorResponse": {
        "allOf": [
          { "$ref": "#/components/schemas/ErrorResponse" },
          {
            "type": "object",
            "properties": {
              "error": {
                "type": "object",
                "properties": {
                  "details": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "field": { "type": "string" },
                        "message": { "type": "string" }
                      }
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "Pagination": {
        "type": "object",
        "properties": {
          "page": { "type": "integer", "example": 1 },
          "per_page": { "type": "integer", "example": 25 },
          "total_pages": { "type": "integer", "example": 4 },
          "total_count": { "type": "integer", "example": 87 }
        }
      },
      "Account": {
        "type": "object",
        "properties": {
          "user_id": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "plan": { "type": "string", "enum": ["subscriber", "lifetime", "admin"] },
          "api_usage": {
            "type": "object",
            "properties": {
              "requests_today": {
                "type": "integer",
                "description": "API requests made today (UTC)."
              },
              "daily_limit": { "type": "integer" },
              "rate_limit_per_minute": { "type": "integer" }
            }
          },
          "active_contests": { "type": "integer" }
        }
      },
      "ContestSummary": {
        "type": "object",
        "description": "One row of GET /contests. This is a SUBSET of Contest — the list endpoint returns exactly the eleven fields below and nothing else. For description, public_url, settings, entry_methods, prizes, reward_thresholds, appearance, media or the *_enabled flags, fetch GET /contests/{contestId}. Corrected 2026-07-27: this response previously referenced the full Contest schema, so generated clients expected fields the endpoint has never returned.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "title": {
            "type": "string",
            "description": "The visible page headline (settings.promotion_title), falling back to contests.title."
          },
          "status": {
            "type": "string",
            "enum": ["draft", "active", "completed", "deleted"],
            "description": "The EFFECTIVE status — identical derivation to Contest.status."
          },
          "mode": { "type": "string", "enum": ["competition", "gamification", "sharing_only"] },
          "type": { "type": "string", "enum": ["promotion", "contest"] },
          "contest_url": { "type": ["string", "null"] },
          "total_entries": {
            "type": "integer",
            "description": "Count of entry actions — identical source to Contest.total_entries."
          },
          "total_points_awarded": { "type": "integer" },
          "start_date": { "type": ["string", "null"], "format": "date-time" },
          "end_date": { "type": ["string", "null"], "format": "date-time" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "Contest": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "title": {
            "type": "string",
            "description": "The visible page headline. Falls back to the internal dashboard name when a page has never had a headline set."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "description": "Public page description as stored HTML (sanitized). null when unset. Supports rich-text tags (b, i, em, strong, u, br, p, ul, ol, li, a, span, h1, h2, img); span elements preserve class and data-yt attributes (the latter marks YouTube video embeds)."
          },
          "status": {
            "type": "string",
            "enum": ["draft", "active", "completed", "deleted"],
            "description": "The EFFECTIVE status, derived from contests.status and the dates the same way the dashboard and the entry engine derive it: a promotion whose end_date has passed reads as completed, and one whose start_date is still in the future reads as draft, whatever the column holds. The ?status= filter on GET /contests filters this same derived value. Changed 2026-07-27 — it previously returned the raw column, and because nothing marks a promotion completed when its end_date passes, ended promotions reported active indefinitely unless their owner closed them by hand."
          },
          "mode": { "type": "string", "enum": ["competition", "gamification", "sharing_only"] },
          "type": { "type": "string", "enum": ["promotion", "contest"] },
          "contest_url": {
            "type": ["string", "null"],
            "description": "URL slug. Null if the promotion has never been given one."
          },
          "public_url": {
            "type": ["string", "null"],
            "description": "Full public page URL — no follow-up GET needed. NULL when contest_url is null (the promotion has no public page yet) — check before presenting it to a user as a link."
          },
          "start_date": { "type": "string", "format": "date-time" },
          "end_date": { "type": "string", "format": "date-time" },
          "days_left": {
            "type": "integer",
            "nullable": true,
            "description": "Whole days remaining, computed from end_date at read time (partial final day rounded up; 0 once end_date has passed). Falls back to the contests.days_left column only when the promotion has no end_date, and is null when neither is set. Changed 2026-07-27 — it previously served the stored column, which is only rewritten when end_date is written, so a 30-day promotion reported 30 for its entire run."
          },
          "total_entries": {
            "type": "integer",
            "description": "Number of entry actions recorded for this promotion (count of rows in contest_entries). Matches total_entries in the GET /contests/{contestId}/analytics response. Changed 2026-07-27 — it previously returned a points total (the sum of contest_users.entry_points); use total_points_awarded for that number now."
          },
          "total_points_awarded": {
            "type": "integer",
            "description": "Total points awarded across all entry actions (sum of contest_entries.points_earned). A claim the creator declined on the Verify page counts 0. Matches total_points_awarded in the GET /contests/{contestId}/analytics response. This is the closest replacement for the value total_entries used to return, though not byte-identical to it, since the old figure summed a different, drifting table."
          },
          "entry_methods": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "label": { "type": "string" },
                "points": {
                  "type": "integer",
                  "description": "Points this action actually awards. Where the owner has configured a per-action override (settings.<action_type>_entry_value), that value is applied here so it matches the live page, the widget and the points engine. Changed 2026-07-27 — the raw stored value was returned before, so a rebalanced action reported its original points."
                },
                "actionType": { "type": "string" },
                "link": { "type": "string" }
              }
            }
          },
          "settings": {
            "type": "object",
            "properties": {
              "template": { "type": "string", "description": "Page skin: basic-new (the classic Gleam-style entry-list card; default), showcase (two-column product showcase), future (dark game-style) or simple (the Custom template — bare structural markup the creator styles via custom_css)." },
              "color": {
                "type": "string",
                "nullable": true,
                "deprecated": true,
                "description": "Legacy alias of primary_color. Use primary_color."
              }
            }
          },
          "template": {
            "type": "string",
            "nullable": true,
            "enum": ["basic-new", "showcase", "future", "simple", null],
            "description": "Page skin. Same value as settings.template, projected here so it reads back under the same name PATCH writes it with. simple is the Custom template — bare structural markup the creator styles via custom_css. null when unset (renders as basic-new)."
          },
          "primary_color": {
            "type": "string",
            "nullable": true,
            "description": "Brand colour of the page as a CSS colour value (e.g. #7d78c6). null when the page uses the template default."
          },
          "card_width": {
            "type": "string",
            "nullable": true,
            "enum": ["max-w-2xl", "max-w-3xl", "max-w-4xl", "max-w-7xl", null],
            "description": "Content column width. null when unset (renders as max-w-2xl)."
          },
          "custom_css": {
            "type": "string",
            "nullable": true,
            "description": "Creator CSS for the Custom (simple) template, re-sanitised on read. null when unset, or when the stored CSS fails sanitisation."
          },
          "image_video": {
            "type": "string",
            "nullable": true,
            "description": "Hero media URL — an image or a video. May carry dimension hints as query params; returned exactly as stored."
          },
          "secondary_image": { "type": "string", "nullable": true },
          "third_image": { "type": "string", "nullable": true },
          "fourth_image": { "type": "string", "nullable": true },
          "fifth_image": { "type": "string", "nullable": true },
          "background_image": { "type": "string", "nullable": true },
          "og_image": {
            "type": "string",
            "nullable": true,
            "description": "Social-share preview image for this page. null falls back to the account-level image."
          },
          "campaign_name": {
            "type": "string",
            "nullable": true,
            "description": "Read-only. Internal dashboard/metadata name — never shown on the page."
          },
          "project_name": {
            "type": "string",
            "nullable": true,
            "description": "Read-only. The small subheading under the headline."
          },
          "prizes": {
            "type": "array",
            "description": "The promotion's prize list. Empty when none are configured.",
            "items": { "$ref": "#/components/schemas/Prize" }
          },
          "reward_thresholds": {
            "type": "array",
            "description": "Gamification reward tiers. Empty when none are configured.",
            "items": { "$ref": "#/components/schemas/RewardThreshold" }
          },
          "daily_bonus_enabled": { "type": "boolean" },
          "referral_enabled": { "type": "boolean" },
          "dark_mode_enabled": { "type": "boolean" },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" }
        }
      },
      "Prize": {
        "type": "object",
        "required": ["name", "winners"],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Prize name (trimmed; must be non-empty)."
          },
          "winners": {
            "type": "integer",
            "minimum": 1,
            "maximum": 500,
            "description": "Number of winners for this prize."
          },
          "value": {
            "type": "number",
            "minimum": 0,
            "description": "Optional monetary value of the prize."
          },
          "currency": {
            "type": "string",
            "minLength": 1,
            "maxLength": 10,
            "description": "Optional currency code for value, e.g. USD."
          }
        }
      },
      "RewardThreshold": {
        "type": "object",
        "required": ["id", "points", "rewardType", "rewardDescription", "isEnabled"],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Stable identifier for the reward tier."
          },
          "points": {
            "type": "integer",
            "minimum": 0,
            "description": "Points required to unlock this tier."
          },
          "rewardType": {
            "type": "string",
            "enum": ["vip_status", "special_gift", "grand_prize", "custom"]
          },
          "rewardDescription": { "type": "string", "minLength": 1, "maxLength": 500 },
          "rewardDetails": {
            "type": "string",
            "maxLength": 2000,
            "description": "Optional longer details for the reward."
          },
          "isEnabled": { "type": "boolean" }
        }
      },
      "UpdateContestRequest": {
        "type": "object",
        "additionalProperties": false,
        "minProperties": 1,
        "description": "At least one field is required; unknown fields are rejected. prizes and reward_thresholds each replace the existing list wholesale.",
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Trimmed to 1–100 characters. Sets the dashboard name AND the visible page headline, so it reads back unchanged on the next GET."
          },
          "description": {
            "type": "string",
            "maxLength": 2000,
            "nullable": true,
            "description": "Public page description. Basic rich-text HTML is accepted (b, i, em, strong, u, br, p, ul, ol, li, a, span, h1, h2, img) and sanitized on write — anything else is stripped, so read-back may differ from what you sent. span elements preserve class and data-yt attributes (the latter marks YouTube video embeds with an 11-character id matching [A-Za-z0-9_-]{11}). Pass null to clear it."
          },
          "start_date": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "ISO 8601 datetime (offset allowed) or null. A future start_date pauses new entries until that time. Pass null to clear it."
          },
          "end_date": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "ISO 8601 datetime that must be in the future, or null. Setting it recomputes days_left (ceil of days remaining, min 1); null clears both end_date and days_left. Clearing is only allowed on a promotion that is not active — an active promotion must keep a deadline, so clearing one returns 422 VALIDATION_ERROR on field end_date (unpublish in the same request with status: \"draft\", or send a replacement date). Once winners have been drawn, any end_date write — setting or clearing — returns 409 WINNERS_ALREADY_SELECTED."
          },
          "prizes": {
            "type": "array",
            "maxItems": 50,
            "items": { "$ref": "#/components/schemas/Prize" },
            "description": "Replaces the prize list wholesale (max 50)."
          },
          "reward_thresholds": {
            "type": "array",
            "maxItems": 50,
            "items": { "$ref": "#/components/schemas/RewardThreshold" },
            "description": "Replaces reward tiers wholesale (max 50). The VIP points threshold is recomputed from the cheapest enabled vip_status entry."
          },
          "template": {
            "type": "string",
            "enum": ["basic-new", "showcase", "future", "simple"],
            "description": "Page skin. basic-new is the classic entry-list giveaway card (the format popularized by Gleam) and the platform default; showcase is a two-column, product-forward layout suited to Product Hunt-style launches; future is a dark, game-style look. simple is the Custom template — bare structural markup the creator styles via custom_css. Stored verbatim in settings.template."
          },
          "dark_mode_enabled": {
            "type": "boolean",
            "description": "Creator-side dark mode toggle for the page (column contests.dark_mode_enabled). There is no visitor prefers-color-scheme behavior — this is the only thing that controls dark mode."
          },
          "custom_css": {
            "type": "string",
            "nullable": true,
            "maxLength": 20000,
            "description": "Creator CSS for the Custom (simple) template — applied on both the hosted page and the widget embed, styled via --tokei-* custom properties and .tokei-simple-* class hooks. Max 20KB; server-sanitised on write (unsafe constructs are rejected with 422 VALIDATION_ERROR naming the reason), so read-back may differ from what was sent. null clears it. GET returns a previously stored custom_css."
          },
          "primary_color": {
            "type": "string",
            "nullable": true,
            "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
            "description": "Brand colour as a hex literal only (3, 4, 6 or 8 digits, e.g. #7d78c6) — the value is interpolated into the server-rendered <style> tag, so other CSS colour formats (rgb(), named colours) are rejected. null resets to the template default.",
            "example": "#7d78c6"
          },
          "card_width": {
            "type": "string",
            "enum": ["narrow", "medium", "wide", "xl", "max-w-2xl", "max-w-3xl", "max-w-4xl", "max-w-7xl"],
            "description": "Content column width. Accepts the friendly names narrow, medium, wide, xl, or the raw stored Tailwind class directly. Friendly names map to classes as narrow -> max-w-2xl, medium -> max-w-3xl, wide -> max-w-4xl, xl -> max-w-7xl. GET always returns the stored class, never the friendly name — e.g. PATCH \"wide\" and the next GET returns \"max-w-4xl\"."
          },
          "status": {
            "type": "string",
            "enum": ["draft", "active"],
            "description": "Publish (active) or unpublish (draft) the promotion. deleted, completed, ended and paused are rejected (422) — a promotion can never be destroyed or resurrected through this endpoint. Publishing (a transition to active from any other status) requires an end_date in the future, either already stored or sent in this same request, otherwise returns 422 VALIDATION_ERROR; re-sending \"active\" on an already-active promotion is a no-op and skips that check. Unpublishing blocks new entries and hides the page from visitors, who see a \"not open right now\" page — existing entries and entrants are untouched, and publishing again restores it."
          },
          "image_video": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "format": "uri",
            "description": "Hero media URL — an image or a video. Must be an https URL on the Tokei media host (media.tokei.io) or res.cloudinary.com (the two hosts the page can render) — typically the public_url from POST /media. Set null to clear."
          },
          "secondary_image": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "format": "uri",
            "description": "Additional layout block image URL. Must be an https URL on media.tokei.io or res.cloudinary.com. Set null to clear."
          },
          "third_image": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "format": "uri",
            "description": "Additional layout block image URL. Must be an https URL on media.tokei.io or res.cloudinary.com. Set null to clear."
          },
          "fourth_image": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "format": "uri",
            "description": "Additional layout block image URL. Must be an https URL on media.tokei.io or res.cloudinary.com. Set null to clear."
          },
          "fifth_image": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "format": "uri",
            "description": "Additional layout block image URL. Must be an https URL on media.tokei.io or res.cloudinary.com. Set null to clear."
          },
          "background_image": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "format": "uri",
            "description": "Page background image URL. Must be an https URL on media.tokei.io or res.cloudinary.com. Set null to clear."
          },
          "og_image": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "format": "uri",
            "description": "Social-share preview image URL for this page. Must be an https URL on media.tokei.io or res.cloudinary.com. null falls back to the account-level image. Set null to clear."
          },
          "entry_methods": {
            "type": "array",
            "maxItems": 30,
            "description": "Full-array REPLACE of contests.entry_methods (a top-level jsonb column, not a settings key) — there is no per-row patch, so GET the promotion first, modify the array, then PATCH the complete list back. [] clears every entry method; omitting the field leaves the stored array untouched (null is rejected). Each element is either an action row (EntryMethodActionRowWrite) or a link row (EntryMethodLinkRowWrite, no actionType) — see GET /actions/catalog for the authoritative per-type config field reference. Unknown keys — icon, config.type, and the ten legacy <platform>Config duplicates such as productHuntConfig — are silently stripped rather than rejected, so an agent can GET a live row and PATCH it back unmodified. icon and config.type are always server-derived from actionType and CANNOT be set by the client: whatever you send for them is ignored and overwritten. A row that keeps its id but changes target keeps credit for entrants who already completed it (the server records the old target in read-only previousTargets and targetSuffix); send the row without its id to make it a new action.",
            "items": {
              "anyOf": [
                { "$ref": "#/components/schemas/EntryMethodActionRowWrite" },
                { "$ref": "#/components/schemas/EntryMethodLinkRowWrite" }
              ]
            }
          }
        }
      },
      "EntryMethodActionRowWrite": {
        "type": "object",
        "required": ["actionType", "label"],
        "description": "One entry_methods row backed by a catalog action type. See GET /actions/catalog for the writable actionType values and each type's config fields.",
        "properties": {
          "actionType": {
            "type": "string",
            "description": "One of the 27 writable action types (GET /actions/catalog, isEntryMethodRow: true). Any other value — including one of the 11 non-row types — is rejected with 422 VALIDATION_ERROR naming why."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Trimmed; 1-100 characters."
          },
          "points": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "0 is treated as unset — the renderer falls back to the action type's default points. GET already returns the EFFECTIVE points (any settings.<actionType>_entry_value override applied), so an unmodified GET -> PATCH round-trip persists that effective value into the raw stored points rather than the value originally set. Product Hunt (producthunt_follow, producthunt_vote) and all three Steam types additionally have their displayed points hard-substituted with the platform default at render time, regardless of what is stored here or read back."
          },
          "config": {
            "type": "object",
            "additionalProperties": true,
            "description": "Per-type fields — see the fields array on the matching GET /actions/catalog entry for exactly which keys apply, which are required, and which are validator-only aliases a renderer never reads (use the FIRST field of a group; the others satisfy validation but render a dead button on their own). {} is valid when the type needs no config. Sending config.type is a no-op — it is always overwritten with the server's own discriminator."
          },
          "requireVerification": {
            "type": "boolean",
            "description": "Optional. Duplicated by some dashboard-written configs but this flag is authoritative."
          },
          "actionsRequired": {
            "type": "integer",
            "minimum": 0,
            "maximum": 20,
            "description": "Accepted only for tokei_cross_promote, where it hides the row until the entrant has completed that many other actions. Dropped if sent on any other actionType. For tokei_cross_promote, points on the row is also what a claim awards, so each duplicate row can differ."
          }
        }
      },
      "EntryMethodLinkRowWrite": {
        "type": "object",
        "required": ["label", "link"],
        "description": "The 28th entry_methods write shape: a plain link, with no actionType.",
        "properties": {
          "label": { "type": "string", "minLength": 1, "maxLength": 100 },
          "points": { "type": "integer", "minimum": 0, "maximum": 100 },
          "link": {
            "type": "string",
            "format": "uri",
            "maxLength": 500,
            "description": "Must be an http(s) URL — javascript:, data:, vbscript: and file: are rejected. A label starting exactly with \"Visit Our \" renders as the clickable, credited campaign link, and that path additionally requires the promotion to have a campaign_url configured; any other label renders as a plain, non-clickable row."
          },
          "actionsRequired": {
            "type": "integer",
            "minimum": 0,
            "maximum": 20,
            "description": "Hides this row until the entrant has completed that many other actions. Action rows accept it only for tokei_cross_promote; on every other actionType the threshold lives in settings and this field is dropped if sent. Omitting it leaves it absent rather than writing 0, so an untouched round-trip does not rewrite live rows."
          }
        }
      },
      "Entry": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "email": { "type": "string", "format": "email" },
          "full_name": { "type": "string" },
          "entry_points": { "type": "integer" },
          "action_type": {
            "type": "string",
            "description": "How the entry was earned. May be any canonical action type (see the action_type field on CreateEntryRequest), a read-only analytics type (oauth_login, social_share, survey), or a custom value previously submitted via the API."
          },
          "referral_code": { "type": "string" },
          "country_code": { "type": "string", "description": "ISO 3166-1 alpha-2." },
          "country_name": { "type": "string" },
          "city": { "type": "string" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "CreateEntryRequest": {
        "type": "object",
        "required": ["email"],
        "properties": {
          "email": { "type": "string", "format": "email" },
          "name": { "type": "string", "description": "Full name." },
          "action_type": {
            "type": "string",
            "default": "api_import",
            "description": "Action type for the entry. Defaults to api_import. Canonical settable types: email_signup, visit_website, referral_share, referral_conversion_bonus, twitter_follow, twitter_like, twitter_retweet, twitter_view_post, twitter_tweet, instagram_follow, instagram_view_post, instagram_share_photo, tiktok_follow, tiktok_watch, facebook_visit_page, facebook_view_post, facebook_join_group, discord_join, twitch_follow, twitch_subscribe, youtube_visit_channel, linkedin_follow, linkedin_company_follow, linkedin_share, linkedin_post, steam_wishlist, steam_play_game, steam_join_group, tokei_cross_promote, producthunt_follow, producthunt_vote, daily_bonus, user_upload, api_import. The types oauth_login, social_share, and survey are read-only (they appear in analytics but are not settable here). Custom string values are also accepted and must match ^[a-z0-9_-]+$ (case-insensitive)."
          },
          "points": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000,
            "description": "Points to award. Defaults to the matching entry method's configured points, or 5. Values above 10000 are rejected with 422."
          },
          "value": { "type": "string", "description": "Context string, e.g. \"Order #12345\"." },
          "metadata": {
            "type": "object",
            "additionalProperties": true,
            "description": "Custom key-value pairs stored with the entry."
          },
          "marketing_consent": {
            "type": "boolean",
            "description": "GDPR marketing consent the participant gave elsewhere. Sent at all, it is recorded on the participant; true also stamps the time and consent-text version, and queues the participant for the contest's connected email provider. Send true only when consent was genuinely collected: a backfill with true on every row pushes the whole list to that provider. Omitted, nothing is recorded and nothing syncs."
          }
        }
      },
      "Template": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "slug": { "type": "string", "description": "Pass as template to POST /promotions to clone this template." },
          "name": { "type": "string" },
          "skin": {
            "type": "string",
            "enum": ["basic-new", "showcase", "future"],
            "nullable": true,
            "description": "The page skin this template is built on — same vocabulary as PATCH /contests/{contestId}'s template field."
          },
          "entry_method_count": {
            "type": "integer",
            "description": "Number of entry actions this template ships with."
          }
        }
      },
      "ActionCatalogEntry": {
        "type": "object",
        "description": "One action type's full definition, from GET /actions/catalog — everything needed to build a valid entry_methods row without reading source.",
        "properties": {
          "label": { "type": "string" },
          "description": { "type": "string" },
          "requiresAuth": {
            "type": "boolean",
            "description": "The participant must be signed in to a provider to complete this action."
          },
          "defaultPoints": {
            "type": "integer",
            "description": "Fallback points when a written row's points is unset (0); overridable per contest when entryValueSettingKey is non-null."
          },
          "platform": {
            "type": "string",
            "nullable": true,
            "enum": [
              "producthunt", "tiktok", "instagram", "facebook", "discord",
              "twitch", "youtube", "linkedin", "steam", "tokei", "twitter", null
            ]
          },
          "trustBased": {
            "type": "boolean",
            "description": "No OAuth required — points are awarded on trust, with no automatic verification."
          },
          "apiVerification": {
            "type": "boolean",
            "description": "Always false. No X or LinkedIn API verification runs; the field is kept for compatibility."
          },
          "manualVerifiable": {
            "type": "boolean",
            "description": "True when this action type can be verified by hand on the admin Verify page."
          },
          "entryValueSettingKey": {
            "type": "string",
            "nullable": true,
            "description": "Contest settings key holding an admin-configured points override for this type (e.g. twitter_follow_entry_value), or null when none exists."
          },
          "isEntryMethodRow": {
            "type": "boolean",
            "description": "True for the 27 types writable as an entry_methods row (EntryMethodActionRowWrite.actionType). False for the 11 settings-toggle / synthetic-claim types, which needs explains. The generic link row (EntryMethodLinkRowWrite) is a 28th write shape with no actionType and is not a catalog key at all."
          },
          "fields": {
            "type": "array",
            "description": "Per-type config field specs, read from entry_methods[].config.<key>. Empty for non-row types. Groups are ordered RENDERER-READ-FIRST — for a field carrying a group, write the FIRST field of that group; the rest are claim-validator aliases that satisfy validation but render a dead button on their own (see each field's note).",
            "items": { "$ref": "#/components/schemas/CatalogFieldSpec" }
          },
          "needs": {
            "type": "string",
            "nullable": true,
            "description": "A deployment prerequisite this action type needs beyond the contest itself (e.g. participant OAuth configured on this deployment) — warn the human before writing a row of this type. null when there is none."
          }
        }
      },
      "CatalogFieldSpec": {
        "type": "object",
        "required": ["key", "type"],
        "description": "One config field on an action type's definition.",
        "properties": {
          "key": { "type": "string", "description": "Config key, read from entry_methods[].config.<key>." },
          "type": { "type": "string", "enum": ["string", "boolean", "string[]"] },
          "required": {
            "type": "boolean",
            "description": "Required on its own. Mutually exclusive with group."
          },
          "group": {
            "type": "string",
            "description": "Alternatives set: at least ONE field sharing this group name must be present."
          },
          "maxLength": {
            "type": "integer",
            "description": "Max characters for a string field; max characters PER ITEM for a string[] field."
          },
          "maxItems": { "type": "integer", "description": "Max array items — string[] fields only." },
          "pattern": { "type": "string", "description": "Regex source, when the validator enforces a format." },
          "urlHosts": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Allowed hostnames when the validator host-checks a URL value."
          },
          "definitionOnly": {
            "type": "boolean",
            "description": "Stored on the definition; the participant-side claim validator never reads it."
          },
          "note": {
            "type": "string",
            "description": "Human-readable rule for this field — read it before using an aliased key; several satisfy validation but render nothing on their own."
          }
        }
      },
      "EventCatalogEntry": {
        "type": "object",
        "description": "One webhook event's full definition, from GET /events/catalog — everything needed to know what fires it and what shape its payload takes without reading source.",
        "properties": {
          "description": { "type": "string" },
          "payloadSchema": {
            "type": "object",
            "description": "JSON-Schema-style object describing the shape of this event's data field (see the delivery envelope on POST /webhooks). Not validated against a schema library — a minimal structural shape only.",
            "additionalProperties": true
          },
          "emitSites": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Repo-relative path strings of where this event fires."
          },
          "subscribable": {
            "type": "boolean",
            "description": "Whether POST /webhooks currently accepts this event in events. True for all 5 events this stage."
          }
        }
      },
      "CreatePromotionRequest": {
        "type": "object",
        "required": ["title"],
        "properties": {
          "source_promotion_id": {
            "type": "string",
            "format": "uuid",
            "description": "Promotion to clone; must be owned by your account (404 otherwise). Optional — when omitted, clones the platform starter template."
          },
          "template": {
            "type": "string",
            "description": "Slug of a platform template to clone instead (from GET /templates), e.g. \"product-hunt\". Alternative to source_promotion_id — sending both is a 422. A slug matching no template is a 404. Omitting both fields clones the default starter template."
          },
          "title": {
            "type": "string",
            "maxLength": 100,
            "description": "Dashboard name AND public page headline."
          },
          "description": {
            "type": "string",
            "maxLength": 2000,
            "description": "Public page description (HTML or plain text). Defaults to the source's."
          },
          "prize": {
            "type": "string",
            "maxLength": 200,
            "description": "Replaces the first prize's name (value/currency/winners carry over), or creates a prize if the source has none. If the source's headline mentions the prize, override title together with prize."
          },
          "end_date": {
            "type": "string",
            "format": "date-time",
            "description": "Must be in the future. Defaults to now + the source's original duration; days_left is recomputed."
          },
          "campaign_url": {
            "type": "string",
            "maxLength": 500,
            "description": "HTTPS URL. Defaults to the source's."
          },
          "status": {
            "type": "string",
            "enum": ["draft", "active"],
            "default": "draft",
            "description": "Active promotions are live immediately at the returned public_url."
          },
          "idempotency_key": {
            "type": "string",
            "maxLength": 100,
            "description": "Reusing a key returns 409 CONFLICT with the existing promotion's id instead of creating a duplicate."
          },
          "image_url": {
            "type": "string",
            "format": "uri",
            "maxLength": 500,
            "description": "Hero image URL. Must be an https media.tokei.io or res.cloudinary.com URL — other hosts will not render."
          }
        }
      },
      "CreatedPromotion": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "title": { "type": "string" },
          "status": { "type": "string", "enum": ["draft", "active"] },
          "mode": { "type": "string", "enum": ["competition", "gamification", "sharing_only"] },
          "type": { "type": "string", "example": "promotion" },
          "contest_url": { "type": "string", "description": "URL slug." },
          "public_url": {
            "type": "string",
            "description": "Full public page URL — no follow-up GET needed."
          },
          "edit_url": {
            "type": "string",
            "description": "Dashboard edit page for the new promotion."
          },
          "source_promotion_id": { "type": "string", "format": "uuid" },
          "end_date": { "type": "string", "format": "date-time", "nullable": true },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "MediaObject": {
        "type": "object",
        "description": "A stored image or video.",
        "properties": {
          "bucket": {
            "type": "string",
            "example": "tokei-media",
            "description": "Storage bucket name."
          },
          "path": {
            "type": "string",
            "description": "Server-generated object path within the bucket (api-uploads/{userId}/{uuid}.{ext}). The filename is never used to build this."
          },
          "public_url": {
            "type": "string",
            "format": "uri",
            "description": "Where the file is reachable. Read it from here rather than building one from bucket and path. Guaranteed to pass the host allowlist on the seven PATCH /contests/{contestId} media fields."
          },
          "content_type": {
            "type": "string",
            "enum": [
              "image/jpeg",
              "image/png",
              "image/gif",
              "image/webp",
              "video/mp4",
              "video/webm",
              "video/quicktime"
            ],
            "description": "The file part's declared type, which also sets the stored extension."
          },
          "filename": {
            "type": "string",
            "description": "Echo of the uploaded filename. Not stored anywhere."
          },
          "size_bytes": {
            "type": "integer",
            "minimum": 100,
            "maximum": 5242880,
            "description": "Size of the stored file in bytes."
          },
          "backend": {
            "type": "string",
            "enum": ["r2"],
            "description": "The store the object landed in."
          }
        }
      },
      "Analytics": {
        "type": "object",
        "properties": {
          "contest_id": { "type": "string", "format": "uuid" },
          "total_entries": { "type": "integer" },
          "unique_participants": { "type": "integer" },
          "total_points_awarded": { "type": "integer" },
          "actions_breakdown": {
            "type": "object",
            "additionalProperties": { "type": "integer" }
          },
          "daily_entries": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": { "type": "string", "format": "date" },
                "count": { "type": "integer" }
              }
            }
          },
          "top_countries": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "country_code": { "type": "string" },
                "country_name": { "type": "string" },
                "count": { "type": "integer" }
              }
            }
          },
          "referral_stats": {
            "type": "object",
            "properties": {
              "total_referral_clicks": { "type": "integer" },
              "total_referral_conversions": { "type": "integer" },
              "conversion_rate": { "type": "number" }
            }
          }
        }
      },
      "LeaderboardRow": {
        "type": "object",
        "properties": {
          "rank": { "type": "integer" },
          "full_name": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "entry_points": { "type": "integer" },
          "referral_code": { "type": "string" },
          "country_code": { "type": "string" }
        }
      },
      "ReferrerRow": {
        "type": "object",
        "properties": {
          "rank": { "type": "integer" },
          "referrer_id": {
            "type": "string",
            "format": "uuid",
            "description": "The referring participant's contest_users id."
          },
          "referral_code": {
            "type": "string",
            "description": "The code that appears in this referrer's share links."
          },
          "full_name": { "type": "string", "nullable": true },
          "email": { "type": "string", "format": "email", "nullable": true },
          "total_referrals": {
            "type": "integer",
            "description": "People who joined with this referrer's code."
          },
          "converted_referrals": {
            "type": "integer",
            "description": "Referred people who went on to complete at least one entry action other than sharing. This is the ranking key."
          },
          "bonus_points_earned": {
            "type": "integer",
            "description": "COUNT of referral_conversion_bonus entries credited to this referrer — a number of bonus entries, NOT a points total, despite the name. The name matches the underlying referral_stats view and the dashboard."
          }
        }
      },
      "ReferralTotals": {
        "type": "object",
        "properties": {
          "total_referrers": {
            "type": "integer",
            "description": "Participants who have referred at least one person."
          },
          "total_referrals": {
            "type": "integer",
            "description": "Participants who joined via someone's referral link."
          },
          "total_clicks": { "type": "integer" },
          "converted_clicks": { "type": "integer" },
          "click_conversion_rate": {
            "type": "number",
            "example": 2.5,
            "description": "converted_clicks / total_clicks as a percentage, to one decimal place. 0 when there are no clicks."
          }
        }
      },
      "WinnerRun": {
        "type": "object",
        "description": "One selection run, newest-first, with its persisted winners nested.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "created_at": { "type": "string", "format": "date-time" },
          "seed": { "type": "string", "nullable": true },
          "algorithm_version": { "type": "string", "nullable": true },
          "status": { "type": "string" },
          "requested_by_email": { "type": "string", "format": "email", "nullable": true },
          "finalized_at": { "type": "string", "format": "date-time", "nullable": true },
          "total_candidates": { "type": "integer", "nullable": true },
          "total_winners_selected": { "type": "integer", "nullable": true },
          "winners_count": { "type": "integer", "description": "winners.length for this run." },
          "winners": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/WinnerRow" }
          }
        }
      },
      "WinnerRow": {
        "type": "object",
        "description": "One persisted winner within a selection run, with entrant identity resolved from contest_users.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "contest_user_id": { "type": "string", "format": "uuid" },
          "email": { "type": "string", "format": "email", "nullable": true },
          "full_name": { "type": "string", "nullable": true },
          "entry_points": { "type": "integer", "nullable": true },
          "created_at": { "type": "string", "format": "date-time", "nullable": true },
          "country_name": { "type": "string", "nullable": true },
          "city": { "type": "string", "nullable": true },
          "prize_tier": { "type": "string" },
          "prize_description": { "type": "string", "nullable": true },
          "prize_value": { "type": "number", "nullable": true },
          "selected_at": { "type": "string", "format": "date-time", "nullable": true },
          "notified_at": { "type": "string", "format": "date-time", "nullable": true },
          "notification_method": { "type": "string", "nullable": true },
          "verified": { "type": "boolean" },
          "claimed_at": { "type": "string", "format": "date-time", "nullable": true }
        }
      },
      "SurveyResponse": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "contest_user_id": { "type": "string", "format": "uuid" },
          "response": { "type": "string" },
          "question_index": { "type": "integer" },
          "points_awarded": { "type": "integer" },
          "is_completed": { "type": "boolean" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "Webhook": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "url": { "type": "string", "format": "uri" },
          "events": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "entry.created",
                "contest.ended",
                "winner.selected",
                "daily_bonus.claimed",
                "referral.converted"
              ]
            }
          },
          "is_active": { "type": "boolean" },
          "last_triggered_at": { "type": "string", "format": "date-time", "nullable": true },
          "failure_count": {
            "type": "integer",
            "description": "Consecutive failed deliveries. The subscription is auto-disabled at 10."
          },
          "created_at": { "type": "string", "format": "date-time" }
        }
      }
    }
  }
}
