openapi: 3.1.0
info:
  title: KeenTools Cloud API
  description: |-
    KeenTools Cloud API for 3D head reconstruction.

    ## Quick Start

    1. **Initialize:** Send POST `/v1/avatar/init` with the number of photos (2-15). You'll receive an avatar ID and pre-signed upload URLs.
    2. **Upload:** Upload each photo directly to its pre-signed URL using HTTP PUT with the raw image bytes.
    3. **Process:** Start reconstruction with POST `/v1/avatar/{id}/process`.
    4. **Monitor:** Poll GET `/v1/avatar/{id}/get-status` until `status: completed`.
    5. **Download:** Fetch your 3D model via GET `/v1/avatar/{id}/get-3d-model`.

    ## Authentication

    Include your API key as a Bearer token in the Authorization header:

        Authorization: Bearer ak_live_...

    Obtain an API key at https://cloud.keentools.io (Settings > API Keys).

    Legacy `*.keentools.workers.dev` hosts remain supported for existing integrations; new integrations should use the server URL above.

    ## Credits

    Credit-bearing endpoints place a temporary credit hold before processing starts:
    - **`POST /v1/avatar/{id}/process`** holds generation credits when the request is accepted (2xx). Credits are captured after the session completes successfully, or released if processing fails or the session is deleted.
    - **`GET /v1/avatar/{id}/get-3d-model`** creates a new charge for **every** response with `event: redirect`, including repeated calls with identical parameters. `retry-after` and non-2xx responses release the hold.

    If a credit-bearing request is rejected outright (non-2xx), the hold is released immediately.

    Check your balance and top up at https://cloud.keentools.io.
    See https://cloud.keentools.io for current pricing.
  license:
    name: Proprietary
  version: 1.0.0
paths:
  /v1/avatar/init:
    post:
      tags:
      - Build a head
      summary: /v1/avatar/init
      description: |-
        Creates a new avatar job for a given number of photos.
        Returns avatar id and pre-signed AWS S3 PUT URLs for direct photo upload.

        To upload photos, send an HTTP PUT request to each returned URL with the raw image bytes as the request body
        and the appropriate Content-Type header (e.g., image/jpeg or image/png).
      operationId: avatar_init
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AvatarInitRequest'
        required: true
      responses:
        '200':
          description: Avatar initialized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AvatarInitResponse'
        '400':
          description: Too many photos. Reduce number to max 15 / Too few photos. Provide at least 2
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing, malformed, or invalid API key
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Unexpected internal error
          content:
            text/plain:
              schema:
                type: string
        '502':
          description: Cloud infrastructure unexpected error. Please try again later
          content:
            text/plain:
              schema:
                type: string
  /v1/avatar/{avatar_id}/process:
    post:
      tags:
      - Build a head
      summary: /v1/avatar/{avatar_id}/process [BILLED]
      description: |-
        **Billed request:** This request is billed. See [pricing details](/pricing).

        Start avatar reconstruction. Call this after uploading all photos to the pre-signed URLs from /init.
        This endpoint charges generation only; model downloads are charged separately by `/get-3d-model`.

        Returns 400 if reconstruction is already in progress for this avatar.
      operationId: avatar_process
      parameters:
      - name: avatar_id
        in: path
        description: Avatar ID
        required: true
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AvatarProcessRequest'
        required: true
      responses:
        '200':
          description: Avatar reconstruction started
        '400':
          description: 'Malformed Focal Length data: the quantity of focal length values does not match the quantity of photos / Reconstruction already in progress'
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing, malformed, or invalid API key
          content:
            text/plain:
              schema:
                type: string
        '402':
          description: Insufficient credits
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: Avatar not found
          content:
            text/plain:
              schema:
                type: string
        '410':
          description: The requested avatar is no longer supported because the API was upgraded. Please initialise a new avatar
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Internal server error (e.g. credit hold infrastructure failure)
          content:
            text/plain:
              schema:
                type: string
        '502':
          description: Cloud infrastructure unexpected error. Please try again later
          content:
            text/plain:
              schema:
                type: string
  /v1/avatar/{avatar_id}/get-status:
    get:
      tags:
      - Manage a session
      summary: /v1/avatar/{avatar_id}/get-status
      description: |-
        Check reconstruction status. Poll this endpoint after calling /process.

        Returns one of five statuses: not_started, running (with progress 0.0–1.0), completed, failed (with error message), or deleted.
      operationId: avatar_get_status
      parameters:
      - name: avatar_id
        in: path
        description: Avatar ID
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Avatar reconstruction status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AvatarGetStatusResponse'
        '401':
          description: Missing, malformed, or invalid API key
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: No avatar found
          content:
            text/plain:
              schema:
                type: string
        '502':
          description: Cloud infrastructure encountered an unexpected error. Please try again later
          content:
            text/plain:
              schema:
                type: string
  /v1/avatar/{avatar_id}/get-3d-model:
    get:
      tags:
      - Build a head
      summary: /v1/avatar/{avatar_id}/get-3d-model [BILLED]
      description: |-
        > ⚠️ **EVERY response with `event: redirect` creates a new charge.**
        >
        > Calling this endpoint again, even with the same avatar and identical parameters, charges your account again. Stop polling immediately after the first `redirect` response and download `data.url` directly. Responses with `event: retry-after` and non-2xx responses are not charged.

        See [pricing details](/pricing).

        Generates and returns head mesh data.

        This endpoint does NOT return the mesh directly. Instead, it uses a polling protocol:

        1. If the mesh is still being generated, returns `{ "event": "retry-after", "data": { "time_sec": N } }`. Wait N seconds, then call again.
        2. When the mesh is ready, returns `{ "event": "redirect", "data": { "url": "https://..." } }`. **Stop polling** and download the mesh from that pre-signed URL.

        ### Correct polling

        ~~~javascript
        for (;;) {
          const response = await fetch(modelEndpoint, {
            headers: { Authorization: "Bearer " + apiKey },
          });
          const result = await response.json();

          if (result.event === "retry-after") {
            await delay(result.data.time_sec * 1000);
            continue;
          }

          // This redirect response is billed. Stop polling now.
          await download(result.data.url);
          break;
        }
        ~~~

        ### Incorrect: continues charging after the model is ready

        ~~~javascript
        // Never poll on a fixed interval without stopping after redirect.
        setInterval(() => fetch(modelEndpoint), 5000);
        ~~~

        **OBJ format note:** Returns a ZIP archive (.obj + .mtl + texture files).

        **GLB format note:** The GLB file contains two meshes - one textured mesh with 4 primitives (Head, EyeLeft, EyeRight, Teeth) each with its own texture, and one wireframe mesh with 4 line primitives. All textures are JPEG/PNG, up to 4096x4096 each.

        **Blendshape support:** GLB supports `expression` and `arkit` blendshapes. OBJ supports `expression` blendshapes only.

        **Blendshapes serialization:** The `blendshapes` array parameter must be serialized as a single comma-separated value (e.g., `?blendshapes=arkit,expression`). Using repeated query parameters will result in a "duplicate field" error.
      operationId: avatar_get_3d_model
      parameters:
      - name: avatar_id
        in: path
        description: Avatar ID
        required: true
        schema:
          type: string
      - name: mesh_format
        in: query
        description: |-
          Mesh format.
          - `glb`: Single binary file with embedded textures, morph targets, and wireframe edges. Recommended for most use cases.
          - `obj`: Wavefront OBJ. Returns a ZIP archive (.obj + .mtl + texture files).
        required: false
        schema:
          oneOf:
          - type: string
            description: |-
              Output mesh format:
              - `glb`: glTF Binary. A single file with embedded textures (up to 4096×4096 JPEG each), morph targets, and optional wireframe edges. Recommended for most use cases. ~40 MB uncompressed.
              - `obj`: Wavefront OBJ. Always delivered as a ZIP archive (.obj + .mtl + texture) since all output files must be bundled together.
            enum:
            - obj
            - glb
          default: obj
      - name: mesh_lod
        in: query
        description: Level of detail
        required: false
        schema:
          oneOf:
          - type: string
            enum:
            - high_poly
          default: high_poly
      - name: blendshapes
        in: query
        description: |-
          Blendshape groups to include in the output mesh. Serialized as comma-separated values, NOT repeated params.

          - `expression`: Numbered expression morphs (Expression 01, Expression 02, ...). Available for GLB and OBJ when `expressions_enabled=true` was set during processing.
          - `arkit`: 51 ARKit-compatible morph targets (browDownLeft, eyeBlinkLeft, mouthSmileRight, etc.). GLB only.

          **Format support:**
          - **GLB:** Supports both `expression` and `arkit` blendshapes.
          - **OBJ:** Supports `expression` blendshapes only.
        required: false
        schema:
          type: array
          items:
            type: string
            enum:
            - expression
            - arkit
          default: []
        style: form
        explode: false
      - name: texture
        in: query
        description: Add texture
        required: false
        schema:
          oneOf:
          - type: 'null'
          - type: string
            enum:
            - jpg
            - png
          default: null
      - name: edges
        in: query
        description: Add wireframe edge lines to the mesh (GLB only)
        required: false
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: |-
            Check the "event" field in the response:
            - `retry-after`: Model is still being generated. Wait `data.time_sec` seconds and retry.
            - `redirect`: Model is ready. Download from the pre-signed URL in `data.url`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Get3DModelResponse'
        '401':
          description: Missing, malformed, or invalid API key
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: No avatar found
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Unsupported request. Possibly outdated or wrong format. Please check current API documentation
          content:
            text/plain:
              schema:
                type: string
        '422':
          description: Avatar reconstruction failed. Try initialising a new one with different photos
          content:
            text/plain:
              schema:
                type: string
        '425':
          description: Avatar is reconstructing. Please try again later
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Unexpected internal error. Please check documentation or try again later
          content:
            text/plain:
              schema:
                type: string
        '502':
          description: Cloud infrastructure encountered an unexpected error. Please try again later
          content:
            text/plain:
              schema:
                type: string
  /v1/avatar/{avatar_id}/get-info:
    get:
      tags:
      - Manage a session
      summary: /v1/avatar/{avatar_id}/get-info
      description: |-
        Returns reconstruction metadata including estimated camera positions and projections for each input image.

        Camera matrices are returned as 4×4 nested arrays in row-major order. Entries are null for images that failed processing.

        - **camera_positions**: World-to-camera (view) matrices. To get camera-to-world for 3D reconstruction, transpose (row-major → column-major) then invert.
        - **camera_projections**: Intrinsic projection matrices (NOT standard OpenGL format). Encodes focal length in pixels and image dimensions. See API description for decomposition formulas.

        `img_urls` contains pre-signed download URLs for the preprocessed input images.
      operationId: avatar_get_info
      parameters:
      - name: avatar_id
        in: path
        description: Avatar ID
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Avatar reconstruction metadata
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AvatarGetInfoResponse'
        '401':
          description: Missing, malformed, or invalid API key
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: Avatar not found
          content:
            text/plain:
              schema:
                type: string
        '422':
          description: Avatar reconstruction failed. Try initialising a new one with different photos
          content:
            text/plain:
              schema:
                type: string
        '425':
          description: Avatar is not ready yet
          content:
            text/plain:
              schema:
                type: string
        '502':
          description: Cloud infrastructure encountered an unexpected error. Please try again later
          content:
            text/plain:
              schema:
                type: string
  /v1/avatar/{avatar_id}:
    delete:
      tags:
      - Manage a session
      summary: /v1/avatar/{avatar_id}
      description: Permanently deletes the avatar together with all its photos and generated 3D assets
      operationId: avatar_delete
      parameters:
      - name: avatar_id
        in: path
        description: Avatar ID
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Avatar deleted
        '401':
          description: Missing, malformed, or invalid API key
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: Avatar not found
          content:
            text/plain:
              schema:
                type: string
        '423':
          description: Cannot delete avatar - processing in progress
          content:
            text/plain:
              schema:
                type: string
        '502':
          description: Cloud infrastructure unexpected error. Please try again later
          content:
            text/plain:
              schema:
                type: string
components:
  schemas:
    AvatarGetInfoResponse:
      type: object
      required:
      - img_urls
      - camera_positions
      - camera_projections
      - focal_length_type
      - expressions_enabled
      properties:
        camera_positions:
          type: array
          items:
            type:
            - array
            - 'null'
            items:
              type: array
              items:
                type: number
                format: float
              maxItems: 4
              minItems: 4
            maxItems: 4
            minItems: 4
          examples:
          - - - 1
              - 0
              - 0
              - 0
            - - 0
              - 1
              - 0
              - 0
            - - 0
              - 0
              - 1
              - 0
            - - 0
              - 0
              - 0
              - 1
        camera_projections:
          type: array
          items:
            type:
            - array
            - 'null'
            items:
              type: array
              items:
                type: number
                format: float
              maxItems: 4
              minItems: 4
            maxItems: 4
            minItems: 4
          examples:
          - - - 1
              - 0
              - 0
              - 0
            - - 0
              - 1
              - 0
              - 0
            - - 0
              - 0
              - 1
              - 0
            - - 0
              - 0
              - 0
              - 1
        expressions_enabled:
          type: boolean
          description: Whether the avatar was created with facial expressions enabled
        focal_length_type:
          oneOf:
          - type: string
            enum:
            - manual
            - exif
            - estimated_common
            - estimated_per_image
          description: |-
            The focal length method that was actually used during reconstruction.
            Note: this is an OUTPUT field and may differ from the input focal_length_type:
            - "manual": user-provided focal length values were used
            - "exif": all photos had valid FocalLengthIn35mmFilm EXIF data, which was used instead of estimation (possible when input was "estimate_common" or "estimate_per_image")
            - "estimated_common": a single shared focal length was estimated
            - "estimated_per_image": individual focal lengths were estimated per image (EXIF data was not found)
        img_urls:
          type:
          - array
          - 'null'
          items:
            type: string
          description: URLs for downloading preprocessed images.
    AvatarGetStatusResponse:
      oneOf:
      - type: object
        title: NotStartedResponse
        description: The reconstruction has not started yet
        required:
        - status
        properties:
          status:
            type: string
            enum:
            - not_started
      - type: object
        title: RunningResponse
        description: The reconstruction is in progress
        required:
        - data
        - status
        properties:
          data:
            type: object
            description: The reconstruction is in progress
            required:
            - progress
            properties:
              progress:
                type: number
                format: float
                description: Reconstruction progress (0 = just started, 0.5 = 50%, 1 = completed)
                maximum: 1
                minimum: 0
          status:
            type: string
            enum:
            - running
      - type: object
        title: FailedResponse
        description: Reconstruction failed
        required:
        - data
        - status
        properties:
          data:
            type: object
            description: Reconstruction failed
            required:
            - error_message
            properties:
              error_message:
                type: string
                description: Error message
          status:
            type: string
            enum:
            - failed
      - type: object
        title: CompletedResponse
        description: Reconstruction finished successfully
        required:
        - status
        properties:
          status:
            type: string
            enum:
            - completed
      - type: object
        title: DeletedResponse
        description: Session was deleted
        required:
        - status
        properties:
          status:
            type: string
            enum:
            - deleted
    AvatarInitRequest:
      type: object
      required:
      - image_count
      properties:
        image_count:
          type: integer
          description: The number of photos to use for reconstruction (2–15)
          maximum: 15
          minimum: 2
    AvatarInitResponse:
      type: object
      required:
      - avatar_id
      - img_urls
      properties:
        avatar_id:
          type: string
          description: Avatar ID
        img_urls:
          type: array
          items:
            type: string
          description: |-
            Pre-signed AWS S3 PUT URLs for direct photo upload.
            Upload each photo by sending an HTTP PUT request to the corresponding URL with:
            - The raw image bytes as the request body
            - Content-Type header set to the image MIME type (e.g., image/jpeg, image/png)
    AvatarProcessRequest:
      type: object
      required:
      - focal_length_type
      properties:
        expressions_enabled:
          type: boolean
          description: |-
            Create avatar with facial expressions.
            When true, the "expression" blendshape group becomes available in /get-3d-model.
          default: false
        focal_length_type:
          $ref: '#/components/schemas/FocalLengthType'
          description: Select how the 35 mm equivalent focal length should be handled for reconstruction
    FocalLengthType:
      oneOf:
      - type: object
        title: ManualList
        description: |-
          Provide explicit 35mm-equivalent focal length values, one per image.
          Use this when you know the exact focal length (e.g., from a controlled capture setup or client-side EXIF parsing).
          The array must contain exactly the same number of values as input images.
          Typical smartphone values: 24–28mm. DSLR varies by lens.
        required:
        - focal_length_values
        - focal_length_type
        properties:
          focal_length_type:
            type: string
            enum:
            - manual
          focal_length_values:
            type: array
            items:
              type: number
              format: float
            description: |-
              Provide explicit 35mm-equivalent focal length values, one per image.
              Use this when you know the exact focal length (e.g., from a controlled capture setup or client-side EXIF parsing).
              The array must contain exactly the same number of values as input images.
              Typical smartphone values: 24–28mm. DSLR varies by lens.
      - type: object
        title: SingleEstimated
        description: |-
          Estimate one shared focal length value that works best across all provided photos.
          If ALL photos contain valid FocalLengthIn35mmFilm EXIF data, it will be used instead of estimation
          and /get-info will report focal_length_type as "exif".
          **All photos must have the same resolution**, otherwise reconstruction will fail.
          Best when all photos were taken with the same camera/lens at the same zoom level.
        required:
        - focal_length_type
        properties:
          focal_length_type:
            type: string
            enum:
            - estimate_common
      - type: object
        title: PerImageEstimation
        description: |-
          Estimate an individual focal length for each photo independently.
          If ALL photos contain valid FocalLengthIn35mmFilm EXIF data, it will be used instead of estimation
          and /get-info will report focal_length_type as "exif".
          If even one photo is missing valid EXIF data, estimation is used for all photos.
          Best for mixed-camera sets or when photos were taken at different zoom levels.
        required:
        - focal_length_type
        properties:
          focal_length_type:
            type: string
            enum:
            - estimate_per_image
      description: |-
        Controls how the 35mm-equivalent focal length is determined for each input image.
        This significantly affects reconstruction quality, so choose the mode that best matches your data.
    Get3DModelResponse:
      oneOf:
      - type: object
        title: RetryAfter
        description: |-
          The mesh is still being generated. Wait the specified number of seconds, then call the endpoint again.
          Typical generation takes 10–60 seconds depending on mesh options.
        required:
        - data
        - event
        properties:
          data:
            type: object
            description: |-
              The mesh is still being generated. Wait the specified number of seconds, then call the endpoint again.
              Typical generation takes 10–60 seconds depending on mesh options.
            required:
            - time_sec
            properties:
              time_sec:
                type: integer
                description: Number of seconds to wait before retrying
                minimum: 0
          event:
            type: string
            enum:
            - retry-after
      - type: object
        title: Redirect
        description: |-
          The mesh is ready. Download it from the pre-signed S3 URL.
          - For GLB: the URL points to a binary GLB file
          - For OBJ: the URL points to a ZIP archive (.obj + .mtl + texture files)
        required:
        - data
        - event
        properties:
          data:
            type: object
            description: |-
              The mesh is ready. Download it from the pre-signed S3 URL.
              - For GLB: the URL points to a binary GLB file
              - For OBJ: the URL points to a ZIP archive (.obj + .mtl + texture files)
            required:
            - url
            properties:
              url:
                type: string
                description: Pre-signed S3 download URL for the generated mesh
          event:
            type: string
            enum:
            - redirect
      description: |-
        The get-3d-model endpoint uses a polling protocol instead of returning the mesh directly.
        Check the "event" field to determine the response type.
      examples:
      - data:
          time_sec: 2
        event: retry-after
      - data:
          url: https://awesome.url
        event: redirect
    MeshFormat:
      type: string
      description: |-
        Output mesh format:
        - `glb`: glTF Binary. A single file with embedded textures (up to 4096×4096 JPEG each), morph targets, and optional wireframe edges. Recommended for most use cases. ~40 MB uncompressed.
        - `obj`: Wavefront OBJ. Always delivered as a ZIP archive (.obj + .mtl + texture) since all output files must be bundled together.
      enum:
      - obj
      - glb
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: KeenTools Cloud API key from https://cloud.keentools.io (Settings > API Keys)
security:
- apiKey: []
tags:
- name: Build a head
  description: Create a session, run reconstruction, and fetch the 3D model.
- name: Manage a session
  description: Inspect status, read session info, and delete sessions.
servers:
- url: https://api.keentools.io
  description: Production API
