openapi: 3.1.0
info:
  title: vibecoded.startup Mobile API
  version: 1.0.0
  summary: Stable REST contract for the iOS and Android applications.
  description: |
    The public, versioned API used by native mobile clients.

    Authenticated operations expect a Clerk session JWT in the `Authorization`
    header as `Bearer <token>`. Public discovery operations do not require a token.

    Internal RPC, administration, scheduled-job, upload, payment-webhook, and
    legacy web routes are deliberately excluded because they are not mobile APIs.
  contact:
    name: vibecoded.startup
    url: https://www.vibecodedstartup.com
externalDocs:
  description: vibecoded.startup website
  url: https://www.vibecodedstartup.com
servers:
  - url: https://api.vibecodedstartup.com
    description: Production
  - url: http://localhost:8787
    description: Local backend
tags:
  - name: System
    description: Service availability.
  - name: Discovery
    description: Public products, categories, weekly boards, and maker rankings.
  - name: Account
    description: Session, profile, bookmarks, and notifications.
  - name: Engagement
    description: Votes and product discussions.
  - name: Launches
    description: The authenticated user's product submissions.
paths:
  /api/health:
    get:
      tags: [System]
      operationId: getHealth
      summary: Check API health
      security: []
      responses:
        '200':
          description: The API process is healthy.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
  /api/v1/products:
    get:
      tags: [Discovery]
      operationId: listProducts
      summary: List, search, filter, and sort products
      security: []
      parameters:
        - name: q
          in: query
          description: Case-insensitive search over product metadata.
          schema: { type: string }
        - name: category
          in: query
          schema: { type: string }
        - name: stack
          in: query
          schema: { type: string }
        - name: pricing
          in: query
          schema: { type: string }
        - name: feedback
          in: query
          description: When true, return products that are open to feedback.
          schema: { type: boolean, default: false }
        - name: sort
          in: query
          schema:
            type: string
            enum: [upvoted, newest, trending, stack, pricing, feedback, updated]
            default: upvoted
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          $ref: '#/components/responses/ProductList'
  /api/v1/products/{slug}:
    get:
      tags: [Discovery]
      operationId: getProduct
      summary: Get a product and its changelog
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Product details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductDetailResponse'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/products/{slug}/vote:
    post:
      tags: [Engagement]
      operationId: toggleProductVote
      summary: Toggle the current user's vote
      description: Adds a vote when one does not exist and removes the existing vote otherwise.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Current vote state and aggregate count.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoteResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/products/{slug}/comments:
    get:
      tags: [Engagement]
      operationId: listProductComments
      summary: List a product's threaded comments
      description: An optional token marks comments liked by the current user.
      security:
        - bearerAuth: []
        - {}
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Top-level comments with one level of replies.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommentListResponse'
    post:
      tags: [Engagement]
      operationId: createProductComment
      summary: Add a comment or reply
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCommentRequest'
      responses:
        '201':
          description: Comment created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedCommentResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/weeks/{year}/{week}:
    get:
      tags: [Discovery]
      operationId: getWeeklyBoard
      summary: Get a ranked weekly launch board
      security: []
      parameters:
        - name: year
          in: path
          required: true
          schema: { type: integer, minimum: 2020 }
        - name: week
          in: path
          required: true
          schema: { type: integer, minimum: 1, maximum: 53 }
      responses:
        '200':
          $ref: '#/components/responses/ProductList'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/makers:
    get:
      tags: [Discovery]
      operationId: listMakers
      summary: Get the maker leaderboard
      security: []
      parameters:
        - name: period
          in: query
          schema: { type: string, enum: [all, month], default: all }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: Ranked makers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MakerListResponse'
  /api/v1/categories:
    get:
      tags: [Discovery]
      operationId: listCategories
      summary: List product categories
      security: []
      responses:
        '200':
          description: Available product categories.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryListResponse'
  /api/v1/session:
    get:
      tags: [Account]
      operationId: getSession
      summary: Resolve the optional current session
      description: 'Returns `data: null` for an anonymous request.'
      security:
        - bearerAuth: []
        - {}
      responses:
        '200':
          description: Current user or null.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionResponse'
  /api/v1/me:
    get:
      tags: [Account]
      operationId: getCurrentUser
      summary: Get the authenticated profile
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Authenticated profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    patch:
      tags: [Account]
      operationId: updateCurrentUser
      summary: Update the authenticated profile
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateUserRequest'
      responses:
        '200':
          description: Profile accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MutationAcceptedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/v1/me/bookmarks:
    get:
      tags: [Account]
      operationId: listBookmarks
      summary: List the current user's saved products
      security:
        - bearerAuth: []
      responses:
        '200':
          $ref: '#/components/responses/ProductList'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/v1/me/bookmarks/{slug}:
    put:
      tags: [Account]
      operationId: saveBookmark
      summary: Save a product
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Product saved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookmarkResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Account]
      operationId: removeBookmark
      summary: Remove a saved product
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Product removed from bookmarks.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookmarkResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/notifications:
    get:
      tags: [Account]
      operationId: listNotifications
      summary: List notifications
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Up to 50 latest notifications.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    patch:
      tags: [Account]
      operationId: markNotificationsRead
      summary: Mark one or all notifications read
      description: Send an `id` to mark one notification. Omit it to mark all notifications.
      security:
        - bearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationIdRequest'
      responses:
        '200':
          description: Read state updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationReadResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Account]
      operationId: deleteNotification
      summary: Delete a notification
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationIdRequestRequired'
      responses:
        '200':
          description: Notification deleted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationDeletedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/submissions:
    get:
      tags: [Launches]
      operationId: listSubmissions
      summary: List the current user's launch submissions
      security:
        - bearerAuth: []
      responses:
        '200':
          description: The current user's submissions, newest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubmissionListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Launches]
      operationId: upsertSubmission
      summary: Create or update a launch submission
      description: |
        Send `id` to update a draft or queued submission owned by the current user.
        Paid boosts are saved as drafts and return `checkoutRequired: true`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertSubmissionRequest'
      responses:
        '200':
          description: Draft saved, checkout requested, or free launch published.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UpsertSubmissionResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Clerk JWT
      description: Paste the Clerk session JWT only; Swagger adds the `Bearer` prefix.
  parameters:
    Slug:
      name: slug
      in: path
      required: true
      description: Stable product slug.
      schema:
        type: string
        minLength: 1
  schemas:
    HealthResponse:
      type: object
      additionalProperties: false
      required: [ok, service, version, time]
      properties:
        ok: { type: boolean, const: true }
        service: { type: string, example: vibecoded-startup-api }
        version: { type: string, example: 1.0.0 }
        time: { type: string, format: date-time }
    ErrorResponse:
      type: object
      required: [ok, error]
      properties:
        ok: { type: boolean, const: false }
        error:
          type: string
          examples: [auth_required, bad_request, not_found, rate_limited]
        progress:
          type: object
          additionalProperties: true
    Product:
      type: object
      required:
        - slug
        - name
        - tagline
        - description
        - category
        - tags
        - maker
        - stack
        - stackShort
        - pricing
        - votes
        - comments
        - verified
        - openToFeedback
        - week
        - year
        - rank
        - url
        - liveUrl
        - badgeUrl
        - createdAt
      properties:
        slug: { type: string, example: shipfast }
        name: { type: string, example: ShipFast }
        tagline: { type: string }
        description: { type: string }
        category: { type: string }
        tags: { type: array, items: { type: string } }
        maker: { type: string }
        stack: { type: array, items: { type: string } }
        stackShort: { type: string }
        pricing: { type: string }
        votes: { type: integer, minimum: 0 }
        comments: { type: integer, minimum: 0 }
        verified: { type: boolean }
        openToFeedback: { type: boolean }
        week: { type: integer, minimum: 1, maximum: 53 }
        year: { type: integer }
        rank: { type: integer, minimum: 0 }
        url: { type: string, format: uri }
        liveUrl: { type: [string, 'null'], format: uri }
        badgeUrl: { type: string, format: uri }
        createdAt: { type: string, format: date-time }
    ProductUpdate:
      type: object
      required: [title, body, createdAt]
      properties:
        title: { type: string }
        body: { type: string }
        createdAt: { type: string, format: date-time }
    ProductDetail:
      allOf:
        - $ref: '#/components/schemas/Product'
        - type: object
          required: [updates]
          properties:
            updates:
              type: array
              items:
                $ref: '#/components/schemas/ProductUpdate'
    ProductListMeta:
      type: object
      additionalProperties: true
      required: [total]
      properties:
        total: { type: integer, minimum: 0 }
        returned: { type: integer, minimum: 0 }
        sort: { type: string }
        year: { type: integer }
        week: { type: integer }
    ProductListResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        meta:
          $ref: '#/components/schemas/ProductListMeta'
        data:
          type: array
          items:
            $ref: '#/components/schemas/Product'
    ProductDetailResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          $ref: '#/components/schemas/ProductDetail'
    VoteState:
      type: object
      required: [slug, votes, voted]
      properties:
        slug: { type: string }
        votes: { type: integer, minimum: 0 }
        voted: { type: boolean }
    VoteResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          $ref: '#/components/schemas/VoteState'
    Comment:
      type: object
      required: [id, productSlug, userId, authorName, body, flagged, createdAt]
      properties:
        id: { type: integer, minimum: 1 }
        productSlug: { type: string }
        userId: { type: string }
        authorName: { type: string }
        authorImage: { type: [string, 'null'], format: uri }
        body: { type: string, maxLength: 2000 }
        parentId: { type: [integer, 'null'] }
        flagged: { type: boolean }
        createdAt:
          type: integer
          format: int64
          description: Unix time in milliseconds.
        likes: { type: integer, minimum: 0 }
        liked: { type: boolean }
    ThreadedComment:
      allOf:
        - $ref: '#/components/schemas/Comment'
        - type: object
          required: [likes, liked, replies]
          properties:
            replies:
              type: array
              items:
                $ref: '#/components/schemas/Comment'
    CommentListResponse:
      type: object
      required: [ok, meta, data]
      properties:
        ok: { type: boolean, const: true }
        meta:
          type: object
          required: [total]
          properties:
            total: { type: integer, minimum: 0 }
        data:
          type: array
          items:
            $ref: '#/components/schemas/ThreadedComment'
    CreateCommentRequest:
      type: object
      additionalProperties: false
      required: [body]
      properties:
        body: { type: string, minLength: 1, maxLength: 2000 }
        parentId: { type: [integer, 'null'], minimum: 1 }
    CreatedCommentResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          $ref: '#/components/schemas/Comment'
    Category:
      type: object
      required: [slug, num, name, count]
      properties:
        slug: { type: string }
        num: { type: string }
        name: { type: string }
        count: { type: integer, minimum: 0 }
        sponsor: { type: [string, 'null'] }
    CategoryListResponse:
      type: object
      required: [ok, meta, data]
      properties:
        ok: { type: boolean, const: true }
        meta:
          type: object
          required: [total]
          properties:
            total: { type: integer, minimum: 0 }
        data:
          type: array
          items:
            $ref: '#/components/schemas/Category'
    MakerTopProduct:
      type: object
      required: [slug, name, votes, url]
      properties:
        slug: { type: string }
        name: { type: string }
        votes: { type: integer, minimum: 0 }
        url: { type: string, format: uri }
    Maker:
      type: object
      required: [rank, maker, launches, votes, comments, topProduct, profileUrl]
      properties:
        rank: { type: integer, minimum: 1 }
        maker: { type: string }
        launches: { type: integer, minimum: 0 }
        votes: { type: integer, minimum: 0 }
        comments: { type: integer, minimum: 0 }
        topProduct:
          oneOf:
            - $ref: '#/components/schemas/MakerTopProduct'
            - type: 'null'
        profileUrl: { type: [string, 'null'], format: uri }
    MakerListResponse:
      type: object
      required: [ok, meta, data]
      properties:
        ok: { type: boolean, const: true }
        meta:
          type: object
          required: [period, total]
          properties:
            period: { type: string, enum: [all, month] }
            total: { type: integer, minimum: 0 }
        data:
          type: array
          items:
            $ref: '#/components/schemas/Maker'
    User:
      type: object
      required: [id, name, email, isPro, isAdmin, loginCount, notificationPrefs, createdAt]
      properties:
        id: { type: string, description: Clerk user ID. }
        publicId: { type: [string, 'null'] }
        name: { type: string }
        email: { type: string, format: email }
        imageUrl: { type: [string, 'null'], format: uri }
        bio: { type: [string, 'null'], maxLength: 500 }
        website: { type: [string, 'null'] }
        twitter: { type: [string, 'null'] }
        github: { type: [string, 'null'] }
        profileData:
          type: string
          description: JSON-encoded extended profile fields.
        isPro: { type: boolean }
        isAdmin: { type: boolean }
        stripeCustomerId: { type: [string, 'null'] }
        lastLoginAt:
          type: [integer, 'null']
          format: int64
          description: Unix time in milliseconds.
        loginCount: { type: integer, minimum: 0 }
        notificationPrefs:
          type: string
          description: JSON-encoded notification preferences.
        createdAt:
          type: integer
          format: int64
          description: Unix time in milliseconds.
    SessionUser:
      type: object
      required: [id, name, email, isPro, isAdmin, notificationPrefs, createdAt]
      properties:
        id: { type: string }
        publicId: { type: [string, 'null'] }
        name: { type: string }
        email: { type: string, format: email }
        imageUrl: { type: [string, 'null'], format: uri }
        bio: { type: [string, 'null'] }
        website: { type: [string, 'null'] }
        twitter: { type: [string, 'null'] }
        github: { type: [string, 'null'] }
        isPro: { type: boolean }
        isAdmin: { type: boolean }
        notificationPrefs: { type: string }
        createdAt: { type: integer, format: int64 }
    SessionResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          oneOf:
            - $ref: '#/components/schemas/SessionUser'
            - type: 'null'
    UserResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          $ref: '#/components/schemas/User'
    ExtendedProfile:
      type: object
      additionalProperties: false
      properties:
        bannerUrl: { type: string, format: uri }
        headline: { type: string, maxLength: 140 }
        city: { type: string, maxLength: 80 }
        country: { type: string, maxLength: 80 }
        calendarUrl: { type: string }
        youtube: { type: string }
        linkedin: { type: string }
        twitch: { type: string }
        productHunt: { type: string }
    UpdateUserRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name: { type: string, minLength: 1, maxLength: 80 }
        bio: { type: [string, 'null'], maxLength: 500 }
        imageUrl: { type: [string, 'null'], format: uri }
        website: { type: [string, 'null'] }
        twitter: { type: [string, 'null'] }
        github: { type: [string, 'null'] }
        profileData:
          $ref: '#/components/schemas/ExtendedProfile'
    MutationAcceptedResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          type: object
          required: [ok]
          properties:
            ok: { type: boolean, const: true }
    BookmarkState:
      type: object
      required: [slug, saved]
      properties:
        slug: { type: string }
        saved: { type: boolean }
    BookmarkResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          $ref: '#/components/schemas/BookmarkState'
    Notification:
      type: object
      required: [id, userId, type, message, read, createdAt]
      properties:
        id: { type: integer, minimum: 1 }
        userId: { type: string }
        type: { type: string }
        message: { type: string }
        link: { type: [string, 'null'] }
        read: { type: boolean }
        createdAt:
          type: integer
          format: int64
          description: Unix time in milliseconds.
    NotificationListResponse:
      type: object
      required: [ok, meta, data]
      properties:
        ok: { type: boolean, const: true }
        meta:
          type: object
          required: [total, unread]
          properties:
            total: { type: integer, minimum: 0 }
            unread: { type: integer, minimum: 0 }
        data:
          type: array
          items:
            $ref: '#/components/schemas/Notification'
    NotificationIdRequest:
      type: object
      additionalProperties: false
      properties:
        id: { type: integer, minimum: 1 }
    NotificationIdRequestRequired:
      type: object
      additionalProperties: false
      required: [id]
      properties:
        id: { type: integer, minimum: 1 }
    NotificationReadResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          oneOf:
            - type: object
              required: [id, read]
              properties:
                id: { type: integer, minimum: 1 }
                read: { type: boolean, const: true }
            - type: object
              required: [allRead]
              properties:
                allRead: { type: boolean, const: true }
    NotificationDeletedResponse:
      type: object
      required: [ok, data]
      properties:
        ok: { type: boolean, const: true }
        data:
          type: object
          required: [id, deleted]
          properties:
            id: { type: integer, minimum: 1 }
            deleted: { type: boolean, const: true }
    Submission:
      type: object
      required: [id, name, status, createdAt]
      properties:
        id: { type: integer, minimum: 1 }
        ownerId: { type: [string, 'null'] }
        name: { type: string }
        tagline: { type: [string, 'null'] }
        category: { type: [string, 'null'] }
        liveUrl: { type: [string, 'null'], format: uri }
        pricing: { type: [string, 'null'] }
        twitter: { type: [string, 'null'] }
        screenshotUrl: { type: [string, 'null'], format: uri }
        importUrl: { type: [string, 'null'], format: uri }
        boost: { type: [string, 'null'] }
        extras:
          type: [string, 'null']
          description: JSON-encoded non-indexed submission fields.
        status: { type: string, enum: [draft, queued, published, rejected] }
        scheduledFor: { type: [integer, 'null'], format: int64 }
        productSlug: { type: [string, 'null'] }
        stripeSessionId: { type: [string, 'null'] }
        paidAt: { type: [integer, 'null'], format: int64 }
        createdAt: { type: integer, format: int64 }
    SubmissionListResponse:
      type: object
      required: [ok, meta, data]
      properties:
        ok: { type: boolean, const: true }
        meta:
          type: object
          required: [total]
          properties:
            total: { type: integer, minimum: 0 }
        data:
          type: array
          items:
            $ref: '#/components/schemas/Submission'
    UpsertSubmissionRequest:
      type: object
      required: [name, tagline, description, liveUrl, logoUrl, screenshots, categories, stack]
      properties:
        id: { type: integer, minimum: 1, description: Existing draft or queued submission to update. }
        name: { type: string, minLength: 1, maxLength: 40 }
        tagline: { type: string, minLength: 1, maxLength: 60 }
        description: { type: string, minLength: 1, maxLength: 500 }
        liveUrl: { type: string, format: uri }
        logoUrl: { type: string, format: uri }
        screenshots:
          type: array
          minItems: 1
          maxItems: 5
          items: { type: string, format: uri }
        videoUrl: { type: string, format: uri }
        categories:
          type: array
          minItems: 1
          maxItems: 3
          items: { type: string }
        stack:
          oneOf:
            - type: array
              minItems: 1
              maxItems: 8
              items: { type: string }
            - type: string
              minLength: 1
              maxLength: 240
        pricing:
          type: string
          enum: ['', Free, Freemium, Paid, Subscription, One-time payment, Contact for pricing]
        twitter: { type: string }
        searchQuery: { type: string, maxLength: 100 }
        firstComment: { type: string, maxLength: 200 }
        scheduledFor:
          type: integer
          format: int64
          description: Unix time in milliseconds.
        draft: { type: boolean, default: false }
        boost:
          type: string
          enum: [free, premium, starter, standard, promotion-7, promotion-30]
          default: free
      additionalProperties: true
    UpsertSubmissionResponse:
      oneOf:
        - type: object
          required: [ok, id, status]
          properties:
            ok: { type: boolean, const: true }
            id: { type: integer, minimum: 1 }
            status: { type: string, enum: [draft, queued] }
        - type: object
          required: [ok, id, status, checkoutRequired, kind]
          properties:
            ok: { type: boolean, const: true }
            id: { type: integer, minimum: 1 }
            status: { type: string, const: draft }
            checkoutRequired: { type: boolean, const: true }
            kind: { type: string }
        - type: object
          required: [ok, id, status, slug]
          properties:
            ok: { type: boolean, const: true }
            id: { type: integer, minimum: 1 }
            status: { type: string, const: published }
            slug: { type: string }
  responses:
    ProductList:
      description: Product list.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProductListResponse'
    BadRequest:
      description: The request body or parameters are invalid.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: A valid Clerk bearer token is required.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: The authenticated user cannot perform this action.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: The requested resource does not exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimited:
      description: Too many requests. Retry later.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    ServerError:
      description: The request failed unexpectedly.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
