{
  "openapi": "3.1.0",
  "info": {
    "title": "meshcheck API",
    "version": "1.1.0",
    "summary": "Deterministic validation for 3D assets (GLB/glTF 2.0).",
    "description": "POST a model, get a machine-readable report card: geometry defects, UV problems, performance-budget violations, and topology-quality measurements (the TOP family — quad reconstructibility, valence regularity, triangle regularity, hard-edge share; added in report schema 1.1, all info-severity). Same input file + same profile produces a byte-identical report (SPEC_02); reports are at schema_version 1.1 (additive over 1.0 — pin against the major). Response bodies reference the published schemars schemas at /v1/schema/*.json — never redefined here. Errors are structured: { \"error\": { \"code\", \"message\", \"detail\" } }.",
    "license": { "name": "Proprietary" }
  },
  "servers": [{ "url": "https://api.meshcheck.dev", "description": "Production" }],
  "externalDocs": {
    "description": "Agent orientation (llms.txt)",
    "url": "https://api.meshcheck.dev/llms.txt"
  },
  "tags": [
    { "name": "validate", "description": "The core product." },
    { "name": "uploads", "description": "Presigned upload handshake for files above the request-body cap." },
    { "name": "jobs", "description": "Async job polling." },
    { "name": "reports", "description": "Report re-fetch within retention." },
    { "name": "account", "description": "Plan, credit balance, usage." },
    { "name": "billing", "description": "Polar checkout + customer-portal sessions." },
    { "name": "docs", "description": "Public, unauthenticated orientation + schemas." }
  ],
  "paths": {
    "/v1/validate": {
      "post": {
        "tags": ["validate"],
        "summary": "Validate a GLB/glTF model.",
        "description": "Three input shapes (one of): multipart file, JSON { url }, JSON { blob_id }. ≤ 20MB validates synchronously and returns the report directly; > 20MB (blob_id only) returns a 202 job envelope. Credit cost: 1 (checks_only). Phase 1 charges the checks_only price for both modes until the renderer ships.",
        "x-credit-cost": 1,
        "security": [{ "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": { "type": "string", "format": "binary", "description": "The .glb, or a .zip bundle for .gltf + external resources." },
                  "profile": { "type": "string", "default": "web", "description": "Budget profile name (config/profiles.toml): web | mobile | pc | hero." },
                  "mode": { "type": "string", "enum": ["full", "checks_only"], "default": "checks_only" },
                  "overrides": { "type": "string", "description": "JSON object of budget overrides, e.g. {\"max_tris\":15000}." },
                  "screenshots": { "type": "boolean", "default": false },
                  "webhook_url": { "type": "string", "format": "uri", "description": "Async only. POST target for the report on completion." }
                },
                "required": ["file"]
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": { "type": "string", "format": "uri", "description": "Server fetches (25MB cap, 10s timeout, http(s) only, SSRF-guarded, socket-pinned)." },
                  "blob_id": { "type": "string", "description": "A blob_id from POST /v1/uploads (files above the multipart cap)." },
                  "profile": { "type": "string", "default": "web" },
                  "mode": { "type": "string", "enum": ["full", "checks_only"], "default": "checks_only" },
                  "overrides": {
                    "type": "object",
                    "additionalProperties": { "type": "number" },
                    "description": "Budget overrides. Known keys only (max_tris, max_verts, max_texture_dim, max_texture_mem, max_materials, max_draw_calls, max_file_bytes, min_extent, max_extent)."
                  },
                  "screenshots": { "type": "boolean", "default": false },
                  "webhook_url": { "type": "string", "format": "uri" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Synchronous report (≤ 20MB).",
            "content": {
              "application/json": {
                "schema": { "$ref": "https://api.meshcheck.dev/v1/schema/report.json" }
              }
            }
          },
          "202": {
            "description": "Async job accepted (> 20MB). Poll via GET /v1/jobs/{id}.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/JobEnvelope" } }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "413": { "$ref": "#/components/responses/Error" },
          "415": { "$ref": "#/components/responses/Error" },
          "422": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/render": {
      "post": {
        "tags": ["render"],
        "summary": "Render stills or a turntable GIF of a model.",
        "description": "Same three input shapes as /validate. Returns the RenderReport envelope (render_id, input, stats, screenshots, no checks). Stills ≤ 20MB render synchronously; a turntable (always) and > 20MB uploads return a 202 job envelope (poll GET /v1/jobs/{id}). Credit cost: 2 (stills) / 4 (turntable). Turntable is GIF-only (format:\"mp4\" ⇒ 400).",
        "x-credit-cost": 2,
        "security": [{ "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": { "type": "string", "format": "binary", "description": "The .glb, or a .zip bundle for .gltf + external resources." },
                  "angles": { "type": "string", "description": "JSON array of up to 12 {\"rv\",\"rh\"} angles; omitted ⇒ the 6 standard angles." },
                  "size": { "type": "integer", "minimum": 128, "maximum": 1024, "default": 1024 },
                  "background": { "type": "string", "description": "\"#RRGGBB\" or \"transparent\"." },
                  "lighting": { "type": "string", "enum": ["studio"], "description": "Only the v1 studio rig." },
                  "turntable": { "type": "string", "description": "JSON {\"frames\":36,\"format\":\"gif\",\"size\":512}. Always async; GIF-only." },
                  "webhook_url": { "type": "string", "format": "uri", "description": "Async only. POST target for the report on completion." }
                }
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": { "type": "string", "format": "uri" },
                  "blob_id": { "type": "string" },
                  "angles": { "type": "array", "maxItems": 12, "items": { "type": "object", "properties": { "rv": { "type": "number" }, "rh": { "type": "number" } } } },
                  "size": { "type": "integer", "minimum": 128, "maximum": 1024, "default": 1024 },
                  "background": { "type": "string" },
                  "lighting": { "type": "string", "enum": ["studio"] },
                  "turntable": { "type": "object", "properties": { "frames": { "type": "integer" }, "format": { "type": "string", "enum": ["gif"] }, "size": { "type": "integer" } } },
                  "webhook_url": { "type": "string", "format": "uri" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Synchronous RenderReport (stills, ≤ 20MB).",
            "content": { "application/json": { "schema": { "$ref": "https://api.meshcheck.dev/v1/schema/render.json" } } }
          },
          "202": {
            "description": "Async job accepted (turntable, or > 20MB). Poll via GET /v1/jobs/{id}.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobEnvelope" } } }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "413": { "$ref": "#/components/responses/Error" },
          "415": { "$ref": "#/components/responses/Error" },
          "422": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" },
          "502": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/inspect": {
      "post": {
        "tags": ["inspect"],
        "summary": "Ask a vision model a structured question about a model's renders (VIS checks).",
        "description": "Renders the standard angle set internally (or reuses a prior render via render_id), sends the stills to a vision model, and returns a schema-constrained answer. NON-DETERMINISTIC (deterministic:false) and priced separately — the only such surface. Provide exactly one of file/url/blob_id (fresh render) or render_id (reuse). check selects the VIS shape: VIS-001 requires prompt, VIS-003 requires question, VIS-002 takes neither. Credit cost: 8 (auto-refunded if the model cannot return a valid answer).",
        "x-credit-cost": 8,
        "security": [{ "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["check"],
                "properties": {
                  "file": { "type": "string", "format": "binary", "description": "The .glb, or a .zip bundle for .gltf + external resources. Omit when using render_id." },
                  "render_id": { "type": "string", "description": "Reuse a prior /render or full /validate report's stored stills instead of rendering fresh." },
                  "check": { "type": "string", "enum": ["VIS-001", "VIS-002", "VIS-003"] },
                  "prompt": { "type": "string", "description": "Required for VIS-001 (prompt match)." },
                  "question": { "type": "string", "description": "Required for VIS-003 (freeform Q&A)." }
                }
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["check"],
                "properties": {
                  "url": { "type": "string", "format": "uri" },
                  "blob_id": { "type": "string" },
                  "render_id": { "type": "string", "description": "Reuse a prior render's stored stills instead of rendering fresh." },
                  "check": { "type": "string", "enum": ["VIS-001", "VIS-002", "VIS-003"] },
                  "prompt": { "type": "string", "description": "Required for VIS-001." },
                  "question": { "type": "string", "description": "Required for VIS-003." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The vision answer envelope (SPEC_02 /inspect). answer conforms to the per-check VIS schema.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["schema_version", "inspect_id", "deterministic", "model", "check", "answer", "screenshots"],
                  "properties": {
                    "schema_version": { "type": "string" },
                    "inspect_id": { "type": "string" },
                    "deterministic": { "type": "boolean", "enum": [false] },
                    "model": { "type": "string", "description": "The vision model version that produced the answer." },
                    "prompt_version": { "type": "integer" },
                    "check": { "type": "string", "enum": ["VIS-001", "VIS-002", "VIS-003"] },
                    "question": { "type": "string" },
                    "answer": {
                      "description": "One of the three VIS answer schemas, selected by check.",
                      "oneOf": [
                        { "$ref": "https://api.meshcheck.dev/v1/schema/vis_001.json" },
                        { "$ref": "https://api.meshcheck.dev/v1/schema/vis_002.json" },
                        { "$ref": "https://api.meshcheck.dev/v1/schema/vis_003.json" }
                      ]
                    },
                    "screenshots": { "type": "array", "description": "The renders shown to the model." }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "410": { "$ref": "#/components/responses/Error" },
          "413": { "$ref": "#/components/responses/Error" },
          "415": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" },
          "503": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/uploads": {
      "post": {
        "tags": ["uploads"],
        "summary": "Begin a presigned upload.",
        "description": "For files above the multipart request-body cap (~4.5MB). Returns a blob_id and a single-use upload_url; PUT the bytes to upload_url, then validate by blob_id. Free (0 credits).",
        "x-credit-cost": 0,
        "security": [{ "ApiKeyAuth": [] }],
        "responses": {
          "201": {
            "description": "Upload handshake.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "blob_id": { "type": "string" },
                    "upload_url": { "type": "string", "format": "uri" },
                    "max_bytes": { "type": "integer" }
                  },
                  "required": ["blob_id", "upload_url", "max_bytes"]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/uploads/{uploadId}": {
      "put": {
        "tags": ["uploads"],
        "summary": "Upload bytes to a presigned target.",
        "description": "PUT the model bytes to the upload_url returned by POST /v1/uploads. Account-scoped. Free (0 credits).",
        "x-credit-cost": 0,
        "security": [{ "ApiKeyAuth": [] }],
        "parameters": [
          { "name": "uploadId", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } }
        },
        "responses": {
          "200": {
            "description": "Stored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "blob_id": { "type": "string" },
                    "bytes": { "type": "integer" }
                  },
                  "required": ["blob_id", "bytes"]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "413": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/jobs/{id}": {
      "get": {
        "tags": ["jobs"],
        "summary": "Poll an async job.",
        "description": "Returns the job envelope. A done job embeds the full report VERBATIM in result; a failed job embeds the structured error. Free (0 credits).",
        "x-credit-cost": 0,
        "security": [{ "ApiKeyAuth": [] }],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Job envelope.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/JobEnvelope" } }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/reports/{id}": {
      "get": {
        "tags": ["reports"],
        "summary": "Re-fetch a report.",
        "description": "Returns any stored report within its 30-day retention. Free (0 credits). Expired ⇒ 410 REPORT_EXPIRED.",
        "x-credit-cost": 0,
        "security": [{ "ApiKeyAuth": [] }],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "The stored report.",
            "content": {
              "application/json": { "schema": { "$ref": "https://api.meshcheck.dev/v1/schema/report.json" } }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "410": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/account": {
      "get": {
        "tags": ["account"],
        "summary": "Account plan, balance, and usage.",
        "description": "The key's plan, credit balance, usage this period, and webhook signing secret. Free (0 credits).",
        "x-credit-cost": 0,
        "security": [{ "ApiKeyAuth": [] }],
        "responses": {
          "200": {
            "description": "Account snapshot.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "plan": {
                      "type": "object",
                      "properties": {
                        "name": { "type": "string" },
                        "monthly_credits": { "type": "integer" },
                        "rate_per_min": { "type": "integer" },
                        "max_file_bytes": { "type": "integer" }
                      }
                    },
                    "webhook_secret": { "type": "string" },
                    "subscription": {
                      "type": "object",
                      "description": "Billing linkage. provider is \"polar\" while a Polar subscription is active, else null.",
                      "properties": {
                        "provider": { "type": "string", "nullable": true, "enum": ["polar", null] },
                        "subscription_id": { "type": "string", "nullable": true }
                      }
                    },
                    "balance": { "type": "integer" },
                    "usage": {
                      "type": "object",
                      "properties": {
                        "period_start": { "type": "string", "format": "date-time" },
                        "credits_used": { "type": "integer" }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/billing/checkout": {
      "post": {
        "tags": ["billing"],
        "summary": "Create a Polar checkout session for a paid plan.",
        "description": "Returns a hosted Polar checkout URL for the given paid plan (indie | studio | scale). The free plan is provisioned off-Polar and is not purchasable. Free (0 credits).",
        "x-credit-cost": 0,
        "security": [{ "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["plan"],
                "properties": {
                  "plan": { "type": "string", "enum": ["indie", "studio", "scale"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout session created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "url": { "type": "string", "format": "uri" } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" },
          "503": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/billing/portal": {
      "post": {
        "tags": ["billing"],
        "summary": "Create a Polar customer-portal session.",
        "description": "Returns a Polar customer-portal URL for the account's linked Polar customer. 404 if the account has no Polar customer yet. Free (0 credits).",
        "x-credit-cost": 0,
        "security": [{ "ApiKeyAuth": [] }],
        "responses": {
          "200": {
            "description": "Portal session created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "url": { "type": "string", "format": "uri" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" },
          "503": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/schema/{name}.json": {
      "get": {
        "tags": ["docs"],
        "summary": "Fetch a published JSON Schema.",
        "description": "Serves the schemars-emitted schema for the given name (report, check_result). Public, no auth. Free (0 credits).",
        "x-credit-cost": 0,
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "enum": ["report", "check_result"] }
          }
        ],
        "responses": {
          "200": {
            "description": "A JSON Schema (2020-12).",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/openapi.json": {
      "get": {
        "tags": ["docs"],
        "summary": "This document.",
        "description": "The OpenAPI 3.1 description of the meshcheck API. Public, no auth. Free (0 credits).",
        "x-credit-cost": 0,
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/llms.txt": {
      "get": {
        "tags": ["docs"],
        "summary": "Agent orientation.",
        "description": "A one-fetch plain-text orientation: what meshcheck is, endpoints, credit costs, privacy commitments, and links. Public, no auth. Free (0 credits).",
        "x-credit-cost": 0,
        "responses": {
          "200": {
            "description": "Orientation text.",
            "content": { "text/plain": { "schema": { "type": "string" } } }
          },
          "429": { "$ref": "#/components/responses/Error" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "mc_live_... (or mc_demo_... for the site demo key)."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "The structured error envelope. code is one of the enumerated SPEC_03 error codes.",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "MISSING_API_KEY", "INVALID_API_KEY", "INSUFFICIENT_CREDITS", "RATE_LIMITED",
                  "BAD_REQUEST", "FILE_TOO_LARGE", "FETCH_FAILED", "UNSUPPORTED_FORMAT",
                  "MALFORMED_GLTF", "BUNDLE_INCOMPLETE", "NOT_FOUND", "JOB_NOT_FOUND",
                  "REPORT_EXPIRED", "RENDER_FAILED", "INSPECT_UNAVAILABLE", "INTERNAL"
                ]
              },
              "message": { "type": "string" },
              "detail": {}
            },
            "required": ["code", "message"]
          }
        },
        "required": ["error"]
      },
      "JobEnvelope": {
        "type": "object",
        "description": "Async job poll envelope (SPEC_02). result is null while queued/processing, the full report when done, or the structured error when failed.",
        "properties": {
          "job_id": { "type": "string" },
          "status": { "type": "string", "enum": ["queued", "processing", "done", "failed"] },
          "poll": { "type": "string", "description": "Relative poll path, e.g. /v1/jobs/{id}." },
          "result": {
            "description": "null | Report | Error.",
            "oneOf": [
              { "type": "null" },
              { "$ref": "https://api.meshcheck.dev/v1/schema/report.json" },
              { "$ref": "#/components/schemas/Error" }
            ]
          }
        },
        "required": ["job_id", "status", "poll", "result"]
      }
    },
    "responses": {
      "Error": {
        "description": "Structured error envelope.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
