{
  "openapi": "3.1.0",
  "info": {
    "title": "SearchLayerPDF API",
    "version": "1.0.0",
    "description": "AI-powered OCR upgrade API. Converts scanned and image-only PDFs to archival-grade,\nfully searchable documents using multi-engine synthesis. Arabic, Persian, and 100+ languages.\n\n**Authentication**: All `/v1/*` endpoints require `Authorization: Bearer <api_key>` (keys look like\n`slp_live_…` / `slp_test_…`). Create one in the portal or via `POST /v1/api-keys`. **One PDF = one job.**\n\n**Output**: every completed job yields a **PDF/A-3b** archival PDF and **RAG-ready Markdown** (the v1 primary\noutput) — this is standard, not a setting. The only per-job dials are **`quality`** (Processing) and\n**`privacy`** (Privacy); `preserveMeta` controls whether document metadata is regenerated (default) or preserved.\n\n**Submitting a document — two ways:**\n\n1. **Upload the file (3-step, recommended for local files):**\n   1. `POST /v1/jobs` with an empty JSON body `{}` → returns `job_id`, `upload_url`, `process_url`, and `status: \"awaiting_upload\"`.\n   2. `PUT` the raw PDF bytes to `upload_url` (which is `/v1/jobs/{job_id}/source`) with `Content-Type: application/pdf` and the **same** `Authorization` header. It is an API endpoint, **not** a presigned/public URL.\n   3. `POST` `process_url` (`/v1/jobs/{job_id}/process`) to start processing → `status: \"processing\"`.\n   A job left at `awaiting_upload` (no PUT + no /process) never runs — don't re-POST /v1/jobs to retry; finish the upload on the existing `job_id`.\n\n2. **By URL (1-step, when the PDF is publicly fetchable):** `POST /v1/jobs` with `{ \"source_url\": \"https://…/file.pdf\" }` → SLP fetches and dispatches it immediately (`status: \"processing\"`).\n\n**Polling**: Poll `GET /v1/jobs/:id` until status is `complete` or `failed`.\nFor real-time updates, use `GET /v1/jobs/:id/progress` (Server-Sent Events).",
    "contact": {
      "name": "Support",
      "url": "https://searchlayerpdf.com/docs"
    }
  },
  "servers": [
    {
      "url": "https://searchlayerpdf.com",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Jobs",
      "description": "Submit and manage PDF processing jobs"
    },
    {
      "name": "API Keys",
      "description": "Manage API keys for programmatic access"
    },
    {
      "name": "Auth",
      "description": "Session auth for the portal UI"
    },
    {
      "name": "System",
      "description": "Health and status"
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key from the portal or POST /v1/api-keys"
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "example": "not_found"
              },
              "message": {
                "type": "string",
                "example": "Job not found"
              }
            }
          }
        }
      },
      "Job": {
        "type": "object",
        "properties": {
          "job_id": {
            "type": "string",
            "example": "job_abc123"
          },
          "doc_id": {
            "type": "string",
            "example": "doc_xyz789"
          },
          "status": {
            "type": "string",
            "enum": [
              "awaiting_upload",
              "queued",
              "processing",
              "complete",
              "failed",
              "capped"
            ],
            "description": "`awaiting_upload` = job created, PDF not yet uploaded. `processing` = running through the pipeline (see `current_stage`). `complete` = PDF + Markdown ready. `failed` = unrecoverable error. `capped` = max_cost_cents reached."
          },
          "current_stage": {
            "type": "string",
            "nullable": true,
            "example": "ocr",
            "description": "Active pipeline stage while `processing` (e.g. ocr, segment, assemble)."
          },
          "quality": {
            "type": "string",
            "enum": [
              "economy",
              "quality"
            ],
            "example": "economy"
          },
          "privacy": {
            "type": "string",
            "enum": [
              "open",
              "private"
            ],
            "example": "private"
          },
          "page_count": {
            "type": "integer",
            "example": 24
          },
          "pages_processed": {
            "type": "integer",
            "example": 24
          },
          "score_before": {
            "type": "number",
            "format": "float",
            "minimum": 0,
            "maximum": 100,
            "example": 23.4
          },
          "score_after": {
            "type": "number",
            "format": "float",
            "minimum": 0,
            "maximum": 100,
            "example": 94.1
          },
          "cost_cents": {
            "type": "integer",
            "example": 7,
            "description": "Actual cost in US cents"
          },
          "created_at": {
            "type": "integer",
            "description": "Unix ms timestamp"
          },
          "updated_at": {
            "type": "integer",
            "description": "Unix ms timestamp"
          },
          "error": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "ApiKey": {
        "type": "object",
        "properties": {
          "key_id": {
            "type": "string",
            "example": "key_abc123"
          },
          "name": {
            "type": "string",
            "example": "Production key"
          },
          "prefix": {
            "type": "string",
            "example": "slp_live_"
          },
          "created_at": {
            "type": "integer"
          },
          "last_used_at": {
            "type": "integer",
            "nullable": true
          }
        }
      },
      "PageDiagnostic": {
        "type": "object",
        "properties": {
          "page_number": {
            "type": "integer"
          },
          "score": {
            "type": "number",
            "format": "float"
          },
          "engines_used": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [
              "tesseract",
              "kraken",
              "qari_ocr"
            ]
          },
          "failure_reason": {
            "type": "string",
            "nullable": true
          },
          "processing_ms": {
            "type": "integer"
          }
        }
      }
    }
  },
  "security": [
    {
      "ApiKey": []
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "tags": [
          "System"
        ],
        "summary": "Service health",
        "security": [],
        "responses": {
          "200": {
            "description": "Operational or degraded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "operational",
                        "degraded",
                        "down"
                      ]
                    },
                    "checked_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "services": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "name": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string",
                            "enum": [
                              "operational",
                              "degraded",
                              "down",
                              "unknown"
                            ]
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Service down"
          }
        }
      }
    },
    "/v1/jobs": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "List jobs",
        "description": "Returns the authenticated account's jobs, newest first. Paginated.",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1,
              "minimum": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter by exact status (e.g. `complete`, `processing`, `awaiting_upload`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Job list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Job"
                      }
                    },
                    "page": {
                      "type": "integer"
                    },
                    "has_more": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Create a job (upload a document, or submit by URL)",
        "description": "Creates **one** job. Two modes:\n\n- **Omit `source_url`** → returns `upload_url` + `process_url` and `status: \"awaiting_upload\"`. Then `PUT` the PDF to `upload_url` and `POST` `process_url` (see those endpoints). The job does **not** run until you do both.\n- **Include `source_url`** → SLP fetches that URL and dispatches immediately (`status: \"processing\"`).\n\nIdempotent retries: pass the same `idempotency_key` to get the existing job back instead of creating a duplicate.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "source_url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Public URL of a PDF to fetch and process in one call. Omit this to upload the file instead.",
                    "example": "https://example.com/scan.pdf"
                  },
                  "quality": {
                    "type": "string",
                    "enum": [
                      "economy",
                      "quality"
                    ],
                    "default": "economy",
                    "description": "**Processing.** `economy` = OCR ensemble only, no vision API (cheaper). `quality` = escalate hard / low-confidence blocks to the vision API. Defaults to the key/account setting, else `economy`."
                  },
                  "privacy": {
                    "type": "string",
                    "enum": [
                      "open",
                      "private"
                    ],
                    "default": "private",
                    "description": "**Privacy.** `open` = cheaper third-party LLMs allowed for synthesis (incl. DeepSeek). `private` = restricted to no-train / private providers (no DeepSeek). Defaults to the key/account setting, else `private`."
                  },
                  "preserveMeta": {
                    "type": "boolean",
                    "default": false,
                    "description": "Controls the document metadata (title, author, description, language) SLP writes into the output PDF/A `/Info` + Markdown frontmatter. `false` (default, recommended): **regenerate** clean metadata from the document's content (first pages), **overwriting** whatever was in the source PDF's embedded `/Info` — that embedded metadata is frequently authoring-tool junk (e.g. `Title: ass37.doc`, `Author: jdoe`, `Creator: Microsoft Word`). `true`: keep the source PDF's **embedded** `/Info` fields, generating only the ones it is missing. NOTE: SLP only sees the PDF's **own embedded** metadata — it cannot merge against titles/authors you maintain elsewhere (e.g. a CMS or DB). To \"fill missing fields without overwriting my curated values\", use `false` (clean regenerated data) and do that non-clobbering merge on your side. (`keep_metadata` is the legacy alias.)"
                  },
                  "context": {
                    "type": "string",
                    "description": "Optional free-text describing what you already know about the document — provenance, the linking page's text/title, a short description, expected language/author. SLP feeds it into metadata extraction AND OCR synthesis as context to improve accuracy (e.g. resolving proper nouns and archaic spelling). Not stored as output metadata; recorded in the receipt for provenance. Capped at ~4000 chars.",
                    "example": "From the Star of the West archive. Link text: \"Tablet of Ahmad\". Bahá'í scripture, Arabic/Persian."
                  },
                  "max_cost_cents": {
                    "type": "integer",
                    "description": "Hard cost cap in US cents. Job enters `capped` state if reached.",
                    "example": 50
                  },
                  "idempotency_key": {
                    "type": "string",
                    "description": "Client-generated key for safe retries. Returns the existing job if the key was already used.",
                    "example": "upload-2026-05-18-doc-42"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Existing job returned (idempotency key matched)"
          },
          "201": {
            "description": "Job created. Shape depends on mode: upload jobs include `upload_url`/`process_url` and `status: \"awaiting_upload\"`; URL jobs include `orch_job_id` and `status: \"processing\"`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "job_id",
                    "doc_id",
                    "status"
                  ],
                  "properties": {
                    "job_id": {
                      "type": "string",
                      "example": "job_abc123"
                    },
                    "doc_id": {
                      "type": "string",
                      "example": "doc_xyz789"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "awaiting_upload",
                        "processing"
                      ],
                      "example": "awaiting_upload"
                    },
                    "upload_url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Upload-mode only. PUT the PDF bytes here (API endpoint, Bearer auth required).",
                      "example": "https://searchlayerpdf.com/v1/jobs/job_abc123/source"
                    },
                    "upload_method": {
                      "type": "string",
                      "example": "PUT",
                      "description": "Upload-mode only."
                    },
                    "process_url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Upload-mode only. POST here after uploading to start processing.",
                      "example": "https://searchlayerpdf.com/v1/jobs/job_abc123/process"
                    },
                    "orch_job_id": {
                      "type": "string",
                      "description": "URL-mode only. Orchestrator job id."
                    },
                    "quality": {
                      "type": "string",
                      "enum": [
                        "economy",
                        "quality"
                      ]
                    },
                    "privacy": {
                      "type": "string",
                      "enum": [
                        "open",
                        "private"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Invalid option (quality/privacy)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Dispatch to the processing cluster failed (URL mode)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/jobs/{job_id}/source": {
      "parameters": [
        {
          "name": "job_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Jobs"
        ],
        "summary": "Upload the PDF for a job",
        "description": "Step 2 of the upload flow. PUT the raw PDF bytes (request body) for a job created without a `source_url`. Requires the same `Authorization: Bearer` header. After this succeeds, call `POST /v1/jobs/{job_id}/process`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/pdf": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Uploaded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "uploaded": {
                      "type": "boolean",
                      "example": true
                    },
                    "job_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden (job belongs to another account)"
          },
          "404": {
            "description": "Job not found"
          }
        }
      }
    },
    "/v1/jobs/{job_id}/process": {
      "parameters": [
        {
          "name": "job_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Start processing an uploaded job",
        "description": "Step 3 of the upload flow. Call after the PDF has been uploaded via `PUT /v1/jobs/{job_id}/source`. Dispatches the job to the processing cluster. Idempotent — if already processing, returns the existing dispatch.",
        "responses": {
          "200": {
            "description": "Already processing (idempotent)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "example": "processing"
                    },
                    "orch_job_id": {
                      "type": "string"
                    },
                    "already": {
                      "type": "boolean",
                      "example": true
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Processing started",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "example": "processing"
                    },
                    "orch_job_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Job not found"
          },
          "409": {
            "description": "No source uploaded yet — PUT the PDF first",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Dispatch to the processing cluster failed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/jobs/{job_id}": {
      "parameters": [
        {
          "name": "job_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Get job status",
        "description": "Poll until `status` is `complete` or `failed`. `score_before`/`score_after` are the Retrievability Scores (0–100). You are only billed for pages where `score_after > score_before`.",
        "responses": {
          "200": {
            "description": "Job",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not found"
          }
        }
      },
      "delete": {
        "tags": [
          "Jobs"
        ],
        "summary": "Delete job",
        "description": "Removes the job record and associated outputs. Cannot delete active jobs.",
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/v1/jobs/{job_id}/result": {
      "parameters": [
        {
          "name": "job_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Get result (content-negotiated)",
        "description": "Available once `status` is `complete`. Response depends on the `Accept` header:\n\n- **`Accept: text/markdown`** → the structured Markdown **directly** (`text/markdown` body), with HTML-comment page anchors before each page's content for RAG chunking. Each anchor is `<!-- pdf:N -->` (physical PDF page N, a positive integer) and, when a printed page label is detected, `<!-- pdf:N, pg:LABEL -->` (LABEL is the printed page — may be non-integer, e.g. a roman numeral). The anchor immediately precedes that page's content. This is v1's primary, RAG-ready output.\n- **`Accept: application/pdf`** → the archival **PDF/A-3b** **directly** (`application/pdf` body): the original page images with our synthesized, multi-engine-corrected text as an invisible, searchable layer.\n- **Any other Accept** (e.g. `application/json`, browser `*/*`) → a JSON **envelope** with short-lived signed URLs for both formats plus scores. Re-call this endpoint to mint fresh URLs (they expire ~15 min); the underlying outputs are retained 24h.",
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "schema": {
              "type": "string",
              "enum": [
                "text/markdown",
                "application/pdf",
                "application/json"
              ],
              "default": "application/json"
            },
            "description": "Selects the response form. `text/markdown` / `application/pdf` stream the bytes; anything else returns the JSON envelope."
          }
        ],
        "responses": {
          "200": {
            "description": "Markdown or PDF bytes (with an explicit Accept), or the JSON envelope (otherwise).",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              },
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job_id": {
                      "type": "string"
                    },
                    "markdown_url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Signed URL for the Markdown (v1 primary output). ~15 min TTL."
                    },
                    "pdf_url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Signed URL for the PDF. ~15 min TTL."
                    },
                    "download_url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Back-compat alias of `pdf_url`."
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "score_before": {
                      "type": "number",
                      "nullable": true
                    },
                    "score_after": {
                      "type": "number",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Not found, or no markdown available for this job"
          },
          "409": {
            "description": "Job not yet complete",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "Output deleted (past retention)"
          }
        }
      }
    },
    "/v1/jobs/{job_id}/progress": {
      "parameters": [
        {
          "name": "job_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Real-time progress (SSE)",
        "description": "Server-Sent Events stream. Each event is a JSON object with `status`, `pages_done`, and `pages_total`. Stream closes when the job reaches a terminal state (`complete`, `failed`, `capped`). Times out after 10 minutes.",
        "responses": {
          "200": {
            "description": "SSE stream",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    },
                    "job_id": {
                      "type": "string"
                    },
                    "pages_done": {
                      "type": "integer"
                    },
                    "pages_total": {
                      "type": "integer",
                      "nullable": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/jobs/{job_id}/diagnostics": {
      "parameters": [
        {
          "name": "job_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Per-page diagnostics",
        "description": "Returns per-page quality scores, engines used, and failure reasons. Extracted text is never included (privacy policy).",
        "responses": {
          "200": {
            "description": "Diagnostics",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string"
                    },
                    "score_before": {
                      "type": "number"
                    },
                    "score_after": {
                      "type": "number"
                    },
                    "pages": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PageDiagnostic"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/jobs/{job_id}/reprocess": {
      "parameters": [
        {
          "name": "job_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Reprocess job",
        "description": "Re-queues a completed or failed job. Cannot reprocess an active job.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "page_filter": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "description": "Reprocess only these page numbers (1-indexed)"
                  },
                  "force_process": {
                    "type": "boolean",
                    "default": false,
                    "description": "Force re-OCR even if pages already scored well"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "reprocessing": {
                      "type": "boolean"
                    },
                    "job_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Job already active"
          }
        }
      }
    },
    "/v1/stats": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Account usage stats",
        "description": "Aggregate totals for the authenticated account, used by the dashboard. Month-scoped values use the current UTC month. `avg_score_improvement` is null when no scored jobs exist yet.",
        "responses": {
          "200": {
            "description": "Account aggregates",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobs_this_month": {
                      "type": "integer",
                      "example": 14
                    },
                    "pages_processed": {
                      "type": "integer",
                      "example": 312
                    },
                    "spend_cents_this_month": {
                      "type": "integer",
                      "example": 168
                    },
                    "avg_score_improvement": {
                      "type": "number",
                      "format": "float",
                      "nullable": true,
                      "example": 57.5
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        }
      }
    },
    "/v1/api-keys": {
      "get": {
        "tags": [
          "API Keys"
        ],
        "summary": "List API keys",
        "description": "Returns all active keys for the authenticated account. Key values are never returned after creation.",
        "responses": {
          "200": {
            "description": "Keys",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "keys": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ApiKey"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "API Keys"
        ],
        "summary": "Create API key",
        "description": "The full key is returned **only once** at creation. Store it securely — it cannot be retrieved again.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "example": "Production key"
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "live",
                      "test"
                    ],
                    "default": "live"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created — save the `key` value now",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "key_id": {
                      "type": "string"
                    },
                    "key": {
                      "type": "string",
                      "example": "slp_live_abc...",
                      "description": "Full key — only returned once"
                    },
                    "name": {
                      "type": "string"
                    },
                    "prefix": {
                      "type": "string"
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/api-keys/{key_id}": {
      "parameters": [
        {
          "name": "key_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "API Keys"
        ],
        "summary": "Delete API key",
        "description": "Revokes the key immediately. Any request using this key will receive 401.",
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": {
                      "type": "boolean"
                    },
                    "key_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/auth/session": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Sign in (portal)",
        "description": "OAuth session for the portal UI. Not needed for API key auth.",
        "security": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "enum": [
                      "google",
                      "github"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session created"
          },
          "401": {
            "description": "Invalid credentials"
          }
        }
      },
      "delete": {
        "tags": [
          "Auth"
        ],
        "summary": "Sign out (portal)",
        "security": [],
        "responses": {
          "200": {
            "description": "Session deleted"
          }
        }
      }
    },
    "/auth/me": {
      "get": {
        "tags": [
          "Auth"
        ],
        "summary": "Current user",
        "responses": {
          "200": {
            "description": "Authenticated account",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "account_id": {
                      "type": "string"
                    },
                    "email": {
                      "type": "string",
                      "format": "email"
                    },
                    "tier": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}