openapi: 3.1.0
info:
  title: SenseForm Public API
  version: "1.0.0"
  description: |
    Public REST API for SenseForm image generation.

    Authenticate generation, job, and style routes with
    `Authorization: Bearer sf_live_...`.

    Mint keys while signed in (browser session / Supabase cookie):
    `POST /api/v1/keys`. The raw secret is returned once.

    Image generation is asynchronous. `POST /api/v1/generations` returns 202
    and a job id; poll `GET /api/v1/jobs/{id}` until `succeeded` or `failed`.
    Credits use the same debit path as the website (1K=10, 2K=20, 4K=30, 4K Pro=40).

    Private generated images are returned as `https://www.senseform.net/api/blob-url?...`
    URLs (HMAC query, ~10 minutes). A cookie-less GET of that URL returns image/jpeg
    or image/png. After expiry, re-poll the job for a fresh URL, or GET the same
    proxy with `Authorization: Bearer sf_live_...`. Direct Vercel private blob
    URLs are never returned; blob store tokens are never included.

    A succeeded job also inserts/updates a `projects` row for the API key's user
    (same table as website /create → 我的项目), using the job idempotency key so
    retries do not create duplicates. Credits are still deducted only by
    `/api/ai/generate`.

  contact:
    url: https://www.senseform.net
servers:
  - url: https://www.senseform.net
    description: Production
paths:
  /api/v1/generations:
    post:
      operationId: createGeneration
      summary: Queue an image generation job
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateGenerationRequest"
            example:
              title: "春季新品发布会"
              copy: "限时开放报名"
              material: poster
              aspect_ratio: "3:4"
              quality: "2k"
              model: nano-banana
              style_id: "00000000-0000-0000-0000-000000000000"
              use_brand: false
      responses:
        "202":
          description: Job queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QueuedJob"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
  /api/v1/jobs/{id}:
    get:
      operationId: getJob
      summary: Get a generation job owned by this API key's user
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Job status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/styles:
    get:
      operationId: listStyles
      summary: List published style cards
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 200
      responses:
        "200":
          description: Published styles
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StyleList"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/v1/keys:
    get:
      operationId: listKeys
      summary: List API key prefixes for the signed-in user
      description: Cookie / Supabase session required. Does not accept API keys.
      responses:
        "200":
          description: Key metadata (never the secret)
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createKey
      summary: Create an API key
      description: Cookie / Supabase session required. The raw `sf_live_...` secret is returned once.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
      responses:
        "201":
          description: Key created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreatedKey"
        "401":
          $ref: "#/components/responses/Unauthorized"
    delete:
      operationId: revokeKey
      summary: Revoke an API key
      description: Cookie / Supabase session required.
      parameters:
        - name: id
          in: query
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Key revoked
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: sf_live_
      description: SenseForm live API key. Example `Authorization: Bearer sf_live_...`
  responses:
    BadRequest:
      description: Invalid request body
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid API key / session
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    PaymentRequired:
      description: Insufficient credits
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found (including jobs owned by another user)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
        error_code:
          type: string
    CreateGenerationRequest:
      type: object
      required: [title]
      properties:
        title:
          type: string
        copy:
          type: string
        material:
          type: string
          description: poster | flyer | long | banner | landing | standee | backdrop | social | invite | card (aliases map onto the website enums)
        aspect_ratio:
          type: string
          example: "3:4"
        quality:
          type: string
          enum: [1k, 2k, 4k, 4k_pro]
        model:
          type: string
          enum: [nano-banana, gemini, gpt-image-2]
        style_id:
          type: string
          format: uuid
        use_brand:
          type: boolean
        product_image_urls:
          type: array
          items:
            type: string
            format: uri
          maxItems: 5
    QueuedJob:
      type: object
      required: [id, status, credits_estimated]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [queued]
        credits_estimated:
          type: integer
    Job:
      type: object
      required: [id, status, output_urls]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [queued, running, succeeded, failed]
        output_urls:
          type: array
          items:
            type: string
            format: uri
          description: >
            Fetchable image URLs. Private blobs are rewritten through
            GET /api/blob-url on https://www.senseform.net (signed query and/or
            the same API key). Do not expect raw *.private.blob.vercel-storage.com
            links to be anonymously downloadable.
        credits_used:
          type: integer
          nullable: true
        credits_estimated:
          type: integer
        error:
          type: string
          nullable: true
    StyleList:
      type: object
      properties:
        styles:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              name:
                type: string
              description:
                type: string
                nullable: true
              cover_image_url:
                type: string
                nullable: true
                description: >
                  Absolute URL. Private or CORS-blocked covers are rewritten
                  through GET /api/blob-url (public published styles may be
                  fetched without a signature).
              tags:
                type: array
                items:
                  type: string
                nullable: true
              category:
                type: string
                nullable: true
              supported_ratios:
                type: array
                items:
                  type: string
                nullable: true
    CreatedKey:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        prefix:
          type: string
        key:
          type: string
          description: Raw secret, shown only once
        created_at:
          type: string
          format: date-time
        warning:
          type: string
