{
  "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 response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Limits are per account: Subscriber 60 read / 30 write per minute, Lifetime 120 read / 60 write per minute.\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": "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": "Survey Responses", "description": "Survey answers from gamified promotions." },
    {
      "name": "Webhooks",
      "description": "Manage webhook subscriptions. Available events: entry.created. (contest.ended, winner.selected and daily_bonus.claimed are reserved but not yet emitted; subscribing to them returns 422.)"
    }
  ],
  "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) and status. Send at least one field; unknown fields are rejected and the body is limited to 10KB. prizes and reward_thresholds each replace the existing list wholesale. 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 but a draft page still renders publicly at its URL. 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/PayloadTooLarge" },
          "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}/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" }
        }
      }
    },
    "/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": "Get a signed upload URL for an image or video",
        "description": "Requires the write scope. Two-step upload: (1) this call returns a short-lived ticket — nothing is stored yet, so success is 200, not 201; (2) PUT the raw file bytes to the returned data.upload_url with exactly the returned data.headers. upload_url is a Supabase Storage host, not Tokei — send no Authorization header. filename is validated and echoed back but never used to name the stored object, which is always a server-generated UUID. The tokei-public bucket's 5MB signed-upload limit applies to video as well as images, so size_bytes must be 100–5,242,880 bytes; declaring an oversize file returns 422 from THIS call (field size_bytes), never 413 — the file itself never reaches Tokei in step 1. If the real bytes you PUT in step 2 exceed 5MB despite an honest declaration, step 2 itself gets a 413 from Supabase Storage, not from Tokei. Limited to 50 tickets issued per account per UTC day (429 RATE_LIMIT_EXCEEDED with a Retry-After header) — the cap counts tickets issued, not completed uploads. application/pdf is not accepted. Feed the resulting public_url 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.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateMediaUploadRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Upload ticket issued. Nothing durable has been created yet — complete step 2 (PUT the file bytes to data.upload_url) to finish the upload.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": { "type": "boolean", "example": true },
                    "data": { "$ref": "#/components/schemas/MediaUpload" }
                  }
                }
              }
            }
          },
          "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": {
      "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.\n\nDelivery payload format: { \"event\": \"entry.created\", \"timestamp\": \"2026-02-17T12:00:00Z\", \"data\": { \"contest_id\": \"...\", \"entry_id\": \"...\", \"email\": \"...\", \"full_name\": \"...\", \"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"]
                    }
                  }
                }
              }
            }
          }
        },
        "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" }
        }
      }
    }
  },
  "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" } }
        }
      },
      "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": {
      "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",
                  "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."
          },
          "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). 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) or future (dark game-style)." },
              "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", null],
            "description": "Page skin. Same value as settings.template, projected here so it reads back under the same name PATCH writes it with. 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", null],
            "description": "Content column width. null when unset (renders as max-w-2xl)."
          },
          "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": 1000,
            "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. 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": 20,
            "items": { "$ref": "#/components/schemas/Prize" },
            "description": "Replaces the prize list wholesale (max 20)."
          },
          "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 first enabled vip_status entry."
          },
          "template": {
            "type": "string",
            "enum": ["basic-new", "showcase", "future"],
            "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. 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."
          },
          "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", "max-w-2xl", "max-w-3xl", "max-w-4xl"],
            "description": "Content column width. Accepts the friendly names narrow, medium, wide, 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. 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 only — existing entries and entrants are untouched, and the page still renders publicly at its URL."
          },
          "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 your own Supabase public storage 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 your own Supabase public storage 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 your own Supabase public storage 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 your own Supabase public storage 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 your own Supabase public storage 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 your own Supabase public storage 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 your own Supabase public storage or res.cloudinary.com. null falls back to the account-level image. Set null to clear."
          }
        }
      },
      "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, linkedin_follow, linkedin_company_follow, linkedin_share, linkedin_post, steam_wishlist, steam_play_game, steam_join_group, 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",
            "description": "Points to award. Defaults to the matching entry method's configured points, or 5."
          },
          "value": { "type": "string", "description": "Context string, e.g. \"Order #12345\"." },
          "metadata": {
            "type": "object",
            "additionalProperties": true,
            "description": "Custom key-value pairs stored with the entry."
          }
        }
      },
      "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."
          }
        }
      },
      "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 Supabase storage (public) 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" }
        }
      },
      "CreateMediaUploadRequest": {
        "type": "object",
        "required": ["filename", "content_type", "size_bytes"],
        "additionalProperties": false,
        "properties": {
          "filename": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "description": "Original filename, for your own bookkeeping. Validated (no path traversal, no null bytes) and echoed back in the response data, but NOT stored anywhere — the object's stored name is always a server-generated UUID."
          },
          "content_type": {
            "type": "string",
            "enum": [
              "image/jpeg",
              "image/png",
              "image/gif",
              "image/webp",
              "video/mp4",
              "video/webm",
              "video/quicktime"
            ],
            "description": "MIME type of the file you intend to upload. application/pdf is not accepted — only images and video."
          },
          "size_bytes": {
            "type": "integer",
            "minimum": 100,
            "maximum": 5242880,
            "description": "Declared size in bytes of the file you intend to upload. Must be 100–5,242,880 bytes (5MB) — the tokei-public bucket's signed-upload limit, which applies to video as well as images. This is only a declaration, validated up front so a too-large or too-small file fails fast with 422 here rather than confusingly during the actual upload; the real bytes you PUT in step 2 are still capped at 5MB by Supabase Storage itself."
          }
        }
      },
      "MediaUpload": {
        "type": "object",
        "description": "A short-lived upload ticket. Nothing is stored yet — PUT the file bytes to upload_url (step 2) to complete the upload.",
        "properties": {
          "upload_url": {
            "type": "string",
            "format": "uri",
            "description": "Absolute Supabase Storage URL. PUT the raw file bytes here to complete step 2. This is NOT a Tokei host — send no Authorization header; the signed token in its query string is the only credential step 2 needs."
          },
          "token": {
            "type": "string",
            "description": "The signed upload token, already embedded in upload_url's query string. Provided separately for callers that construct the step 2 request URL themselves."
          },
          "method": {
            "type": "string",
            "enum": ["PUT"],
            "description": "HTTP method to use for step 2."
          },
          "headers": {
            "type": "object",
            "additionalProperties": { "type": "string" },
            "description": "Headers to send verbatim with the step 2 PUT request. Send exactly these — no Authorization header.",
            "example": { "content-type": "image/png", "x-upsert": "false" }
          },
          "bucket": {
            "type": "string",
            "example": "tokei-public",
            "description": "Storage bucket name."
          },
          "path": {
            "type": "string",
            "description": "Server-generated object path within the bucket (e.g. api-uploads/{userId}/{uuid}.{ext}). filename is never used to build this."
          },
          "public_url": {
            "type": "string",
            "format": "uri",
            "description": "The URL the file will be reachable at once step 2 succeeds. Guaranteed to pass the host allowlist on the seven PATCH /contests/{contestId} media fields — upload, then PATCH it straight in."
          },
          "content_type": {
            "type": "string",
            "description": "Echo of the request's content_type."
          },
          "filename": {
            "type": "string",
            "description": "Echo of the request's filename. Not stored anywhere."
          },
          "max_bytes": {
            "type": "integer",
            "example": 5242880,
            "description": "Maximum upload size in bytes (5MB). Applies to video as well as images."
          },
          "expires_in": {
            "type": "integer",
            "description": "Seconds until upload_url and token expire."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Timestamp upload_url and token expire at."
          }
        }
      },
      "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" }
        }
      },
      "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"]
            }
          },
          "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" }
        }
      }
    }
  }
}
