{
  "openapi": "3.1.0",
  "info": {
    "title": "CardGrade API",
    "version": "1.0.0",
    "summary": "AI trading-card pre-grading as a REST API.",
    "description": "Submit a card image and receive a full AI grade: overall grade, per-zone corner/edge/surface scores, centering metrics, AI prose analysis, market value, and a shareable report image.\n\nAuthentication uses a Bearer API key (prefixed `cgrd_`) generated in your dashboard under Settings -> API. API access requires an ACTIVE Pro or Business subscription (trials, which have status `trialing`, do not qualify).\n\nResponses are bare JSON objects (there is no `{success, data}` envelope). Errors are bare `{error, code}` objects with the matching HTTP status. Every response carries `X-Request-Id` and, where applicable, `X-RateLimit-Limit` / `X-RateLimit-Remaining` headers; `429` responses include `Retry-After`.\n\nKeys are scoped. A Read-only key carries `grades:read` + `account:read` and cannot submit gradings (no credit spend). A Full-access key adds `grades:write`. Legacy keys created before scopes existed are unrestricted.",
    "contact": { "name": "CardGrade Support", "email": "support@cardgrade.io", "url": "https://cardgrade.io/api-docs" }
  },
  "servers": [{ "url": "https://cardgrade.io", "description": "Production" }],
  "security": [{ "bearerAuth": [] }],
  "tags": [
    { "name": "Grading", "description": "Submit and retrieve card gradings." },
    { "name": "Account", "description": "Account, plan, and credit information." }
  ],
  "paths": {
    "/api/v1/grade": {
      "post": {
        "tags": ["Grading"],
        "operationId": "submitGrade",
        "summary": "Submit a card for grading",
        "description": "Uploads a card image and queues an AI grade. Deducts one credit. Required scope: `grades:write`.\n\nPass an `Idempotency-Key` header (any UUID) to make retries safe: if a request times out and you resend it with the same key, you receive the original grading back (HTTP 200, `idempotent: true`) instead of being charged a second credit.\n\nThe grade runs asynchronously — poll `GET /api/v1/grade/{id}` until `status` is `completed`.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["frontImage"],
                "properties": {
                  "frontImage": { "type": "string", "format": "binary", "description": "Front of the card. JPEG, PNG, or WebP. Max 10 MB." },
                  "backImage": { "type": "string", "format": "binary", "description": "Back of the card (optional but recommended for full grading). Same constraints." },
                  "gradingCompany": { "type": "string", "default": "CGI", "description": "Grading standard to grade against." },
                  "cardType": { "type": "string", "default": "Pokemon Card", "description": "Card category hint for identification." },
                  "userNotes": { "type": "string", "description": "Optional free-text note (not stored on the grade)." }
                }
              }
            }
          }
        },
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "responses": {
          "201": {
            "description": "Grade accepted and queued.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/X-Request-Id" }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, "X-RateLimit-Remaining": { "$ref": "#/components/headers/X-RateLimit-Remaining" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GradeAccepted" } } }
          },
          "200": {
            "description": "Idempotent replay — the grade for this Idempotency-Key already exists; no credit was charged.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GradeAccepted" }, "example": { "id": "d71bb389-...", "status": "completed", "creditsRemaining": 79, "idempotent": true } } }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/InsufficientCredits" },
          "403": { "$ref": "#/components/responses/InsufficientScope" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/v1/grades": {
      "get": {
        "tags": ["Grading"],
        "operationId": "listGrades",
        "summary": "List your gradings",
        "description": "Returns a paginated list of your gradings, newest first. Required scope: `grades:read`.",
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, "description": "Page size (1-100)." },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 }, "description": "Number of records to skip." },
          { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["pending", "completed", "failed"] }, "description": "Filter by grading status." }
        ],
        "responses": {
          "200": {
            "description": "A page of gradings.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/X-Request-Id" }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, "X-RateLimit-Remaining": { "$ref": "#/components/headers/X-RateLimit-Remaining" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GradeList" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/InsufficientScope" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/grade/{id}": {
      "get": {
        "tags": ["Grading"],
        "operationId": "getGrade",
        "summary": "Get a grading",
        "description": "Returns a single grading. The `results` object is present only when `status` is `completed`. Required scope: `grades:read`.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Grading ID from the submit response." }],
        "responses": {
          "200": {
            "description": "The grading.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/X-Request-Id" }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, "X-RateLimit-Remaining": { "$ref": "#/components/headers/X-RateLimit-Remaining" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GradeDetail" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/InsufficientScope" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/grade/{id}/report": {
      "get": {
        "tags": ["Grading"],
        "operationId": "getGradeReport",
        "summary": "Get the report image",
        "description": "Returns the 1200x1800 eBay-ready grading report JPG. On a cache hit returns a `302` redirect to the immutable image URL; on first render streams the JPG directly (`200`, `image/jpeg`). Auto-generates the certificate if missing — the grading must be `completed`. Required scope: `grades:read`. Clients must follow redirects.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": { "description": "Rendered report image.", "content": { "image/jpeg": { "schema": { "type": "string", "format": "binary" } } } },
          "302": { "description": "Redirect to the hosted report image URL.", "headers": { "Location": { "schema": { "type": "string", "format": "uri" } } } },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/InsufficientScope" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/account": {
      "get": {
        "tags": ["Account"],
        "operationId": "getAccount",
        "summary": "Get account info",
        "description": "Returns your account, plan, credit balances, and rate-limit tiers. Required scope: `account:read`.",
        "responses": {
          "200": {
            "description": "Account information.",
            "headers": { "X-Request-Id": { "$ref": "#/components/headers/X-Request-Id" }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, "X-RateLimit-Remaining": { "$ref": "#/components/headers/X-RateLimit-Remaining" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/InsufficientScope" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Your CardGrade API key (prefixed `cgrd_`), sent as `Authorization: Bearer cgrd_...`. Generate one under Settings -> API. Available scopes: `grades:read`, `grades:write`, `account:read`."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": { "type": "string" },
        "description": "Optional unique key (e.g. a UUID). Retrying a submission with the same key returns the original grading instead of charging a second credit."
      }
    },
    "headers": {
      "X-Request-Id": { "schema": { "type": "string" }, "description": "Unique ID for this request, useful in support tickets." },
      "X-RateLimit-Limit": { "schema": { "type": "integer" }, "description": "Requests allowed in the current 60-second window for this bucket." },
      "X-RateLimit-Remaining": { "schema": { "type": "integer" }, "description": "Requests remaining in the current window." },
      "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds to wait before retrying (on 429)." }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error", "code"],
        "properties": {
          "error": { "type": "string", "description": "Human-readable message." },
          "code": { "type": "string", "enum": ["UNAUTHORIZED", "FORBIDDEN", "PLAN_REQUIRED", "INSUFFICIENT_SCOPE", "RATE_LIMITED", "INSUFFICIENT_CREDITS", "INVALID_INPUT", "NOT_FOUND", "INTERNAL_ERROR"], "description": "Stable machine-readable code." }
        }
      },
      "GradeAccepted": {
        "type": "object",
        "required": ["id", "status", "creditsRemaining"],
        "properties": {
          "id": { "type": "string", "description": "Grading ID — poll GET /api/v1/grade/{id}." },
          "status": { "type": "string", "enum": ["pending", "completed", "failed"] },
          "creditsRemaining": { "type": "integer" },
          "idempotent": { "type": "boolean", "description": "Present and true when this was an idempotent replay (no credit charged)." }
        }
      },
      "GradeSummary": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "status": { "type": "string", "enum": ["pending", "completed", "failed"] },
          "cardName": { "type": ["string", "null"] },
          "cardYear": { "type": ["string", "null"] },
          "cardSet": { "type": ["string", "null"] },
          "gradingCompany": { "type": ["string", "null"] },
          "overallGrade": { "type": ["number", "null"] },
          "createdAt": { "type": "string", "format": "date-time" },
          "updatedAt": { "type": "string", "format": "date-time" }
        }
      },
      "GradeList": {
        "type": "object",
        "required": ["gradings", "limit", "offset"],
        "properties": {
          "gradings": { "type": "array", "items": { "$ref": "#/components/schemas/GradeSummary" } },
          "limit": { "type": "integer" },
          "offset": { "type": "integer" }
        }
      },
      "GradeDetail": {
        "type": "object",
        "required": ["id", "status"],
        "properties": {
          "id": { "type": "string" },
          "status": { "type": "string", "enum": ["pending", "completed", "failed"] },
          "cardName": { "type": ["string", "null"] },
          "cardYear": { "type": ["string", "null"] },
          "cardSet": { "type": ["string", "null"] },
          "cardNumber": { "type": ["string", "null"] },
          "gradingCompany": { "type": ["string", "null"] },
          "overallGrade": { "type": ["number", "null"] },
          "frontImageUrl": { "type": ["string", "null"], "format": "uri" },
          "backImageUrl": { "type": ["string", "null"], "format": "uri" },
          "createdAt": { "type": "string", "format": "date-time" },
          "updatedAt": { "type": "string", "format": "date-time" },
          "certificateUrl": { "type": ["string", "null"], "format": "uri", "description": "Public verification page." },
          "reportImageUrl": { "type": ["string", "null"], "format": "uri", "description": "Rendered report JPG, when generated." },
          "shareToken": { "type": ["string", "null"] },
          "results": { "$ref": "#/components/schemas/GradeResults" }
        }
      },
      "GradeResults": {
        "type": "object",
        "description": "Present only when status is 'completed'.",
        "properties": {
          "centering": { "type": ["number", "null"] },
          "corners": { "type": ["number", "null"] },
          "edges": { "type": ["number", "null"] },
          "surface": { "type": ["number", "null"] },
          "overallGrade": { "type": ["number", "null"] },
          "psaPrediction": { "type": ["number", "null"] },
          "bgsPrediction": { "type": ["number", "null"] },
          "cgcPrediction": { "type": ["number", "null"] },
          "gradeLabel": { "type": ["string", "null"], "description": "e.g. 'Gem Mint', 'Mint'." },
          "limitingFactor": { "type": ["string", "null"], "description": "Which axis held the grade back." },
          "gradeJustification": { "type": ["string", "null"], "description": "AI prose explaining the grade." },
          "overallAnalysis": { "type": ["string", "null"] },
          "zoneAnalysis": { "type": ["object", "null"], "additionalProperties": true },
          "frontCornerScores": { "type": ["object", "null"], "additionalProperties": true },
          "frontEdgeScores": { "type": ["object", "null"], "additionalProperties": true },
          "backCornerScores": { "type": ["object", "null"], "additionalProperties": true },
          "backEdgeScores": { "type": ["object", "null"], "additionalProperties": true },
          "cvMetrics": { "type": ["object", "null"], "additionalProperties": true, "description": "Per-zone geometric metrics; centering = {score, left_pct, right_pct, top_pct, bottom_pct}." },
          "backCvMetrics": { "type": ["object", "null"], "additionalProperties": true },
          "frontLlmObservations": { "type": ["object", "null"], "additionalProperties": true, "description": "Per-zone defect classes (none|minor|moderate|severe)." },
          "backLlmObservations": { "type": ["object", "null"], "additionalProperties": true },
          "marketValueLow": { "type": ["number", "null"] },
          "marketValueHigh": { "type": ["number", "null"] },
          "marketAnalysis": { "type": ["string", "null"] },
          "zoneCropUrls": { "type": ["object", "null"], "additionalProperties": { "type": "string", "format": "uri" }, "description": "Per-corner, per-edge, surface-defect, and full rectified card image URLs." },
          "details": { "type": ["object", "null"], "additionalProperties": true, "description": "Full raw result blob (back-compat)." }
        }
      },
      "Account": {
        "type": "object",
        "required": ["id", "plan", "credits", "packCredits", "totalCredits", "rateLimits"],
        "properties": {
          "id": { "type": "string" },
          "name": { "type": ["string", "null"] },
          "email": { "type": ["string", "null"] },
          "plan": { "type": ["string", "null"] },
          "planStatus": { "type": ["string", "null"] },
          "credits": { "type": "integer" },
          "packCredits": { "type": "integer" },
          "totalCredits": { "type": "integer" },
          "rateLimits": {
            "type": "object",
            "properties": {
              "gradesPerMinute": { "type": "integer" },
              "readsPerMinute": { "type": "integer" }
            }
          }
        }
      }
    },
    "responses": {
      "Unauthorized": { "description": "Missing, invalid, or expired token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Invalid API token", "code": "UNAUTHORIZED" } } } },
      "InsufficientScope": { "description": "The key lacks the scope required for this operation.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "This API key is read-only. Submitting gradings requires a key with the 'grades:write' scope.", "code": "INSUFFICIENT_SCOPE" } } } },
      "InsufficientCredits": { "description": "No credits remaining.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Insufficient credits. Purchase more credits to continue grading.", "code": "INSUFFICIENT_CREDITS" } } } },
      "InvalidInput": { "description": "Malformed request.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "frontImage is required", "code": "INVALID_INPUT" } } } },
      "NotFound": { "description": "Resource not found (or not owned by this account).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Grading not found", "code": "NOT_FOUND" } } } },
      "RateLimited": { "description": "Rate limit exceeded for this window.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Rate limit exceeded. Please try again later.", "code": "RATE_LIMITED" } } } },
      "InternalError": { "description": "Unexpected server error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Failed to create grading record", "code": "INTERNAL_ERROR" } } } }
    }
  }
}
