{
  "openapi": "3.1.0",
  "info": {
    "title": "PDF to Markdown API Facade",
    "version": "2.1.1",
    "description": "API facade for the PDF → Markdown Chrome extension. Core logic for the queue and result delivery,\ndevice identification via a WebCrypto keypair + device_token, signed requests (see docs/security.md).\n\nv2.0.0 (EP-023): additively introduced `/api/v2/*` endpoints for accounts, API keys, hosted MCP, and\npaid tiers. Authentication for `/api/v2/*` — `Authorization: Bearer p2m_...` (API key, over TLS, without\nan ES256 signature) OR an OAuth session (extension/web). See docs/v2.0.0-roadmap.md §6-7, docs/security.md.\n\nBACKWARD COMPATIBILITY (hard invariant): all v1 paths (`/register`, `/jobs`, `/jobs/{id}`,\n`/jobs/{id}/download`, `/settings`) and their schemas do NOT change — the old and new extensions work\nsimultaneously against backend 2.0.0 without errors (docs/v2.0.0-roadmap.md §6.9).\n"
  },
  "servers": [
    {
      "url": "https://pdf2md.dev",
      "description": "Production — PRIMARY origin (EP-024, promoted). v1 at root (/register, /jobs, …); v2 under /api/v2/*. The API is served identically on both production domains; OAuth redirect_uri and billing return URLs follow the request origin (allow-list PUBLIC_ORIGINS), so the OAuth callback host matches whichever domain you start on.\n"
    },
    {
      "url": "https://pdf2md.huskyhaul.online",
      "description": "Production — alternative origin (kept for the v1 extension). Same API surface."
    },
    {
      "url": "http://localhost:8080",
      "description": "Local docker nginx (same routing — v1 root + /api/v2/*)"
    }
  ],
  "security": [
    {
      "DeviceToken": []
    }
  ],
  "tags": [
    {
      "name": "Identity",
      "description": "Device registration and token issuance"
    },
    {
      "name": "Jobs",
      "description": "Creating, retrieving, and deleting conversion jobs"
    },
    {
      "name": "Settings",
      "description": "Device conversion settings"
    },
    {
      "name": "v2 Account",
      "description": "v2 (EP-023): account, limits, usage"
    },
    {
      "name": "v2 API Keys",
      "description": "v2 (EP-023): API key lifecycle"
    },
    {
      "name": "v2 Jobs",
      "description": "v2 (EP-023): jobs for agents/integrations (API key or session)"
    },
    {
      "name": "v2 Webhooks",
      "description": "v2 (EP-023): registered webhooks (§6.8)"
    },
    {
      "name": "v2 Billing",
      "description": "v2 (EP-023): monetize.software, proxied through our domain (v2.0.0-billing-monetize.md)"
    }
  ],
  "paths": {
    "/register": {
      "post": {
        "tags": [
          "Identity"
        ],
        "summary": "Register device",
        "description": "Idempotent device registration by public key (ES256). Returns `device_id` and `device_token`.\nFor request signing, anti-replay, and limit details, see docs/security.md.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RegisterRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Device registered",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RegisterResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid public key or request format",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Registration limits exceeded (per IP/ASN)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/config": {
      "get": {
        "tags": [
          "Config"
        ],
        "summary": "Public client config",
        "description": "Public, unauthenticated client bootstrap config. The extension and web app\nread it (TTL, upload limit, slot count, support URL, optional announcement).\n",
        "responses": {
          "200": {
            "description": "Client config",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job_ttl_seconds": {
                      "type": "integer",
                      "description": "How long a ready result is retained before TTL cleanup, for the ANONYMOUS tier (this endpoint is unauthenticated). Signed-in clients must use /api/v2/limits.ready_ttl_sec instead — retention is per-tier and each job expires by its own snapshot value."
                    },
                    "max_file_size": {
                      "type": "integer",
                      "format": "int64",
                      "description": "Device-signed (v1) upload limit in bytes."
                    },
                    "max_slots": {
                      "type": "integer",
                      "description": "Recent-jobs slots shown in the UI."
                    },
                    "support_url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "announcement": {
                      "type": "object",
                      "nullable": true,
                      "description": "Optional launch/PH announcement strip (EP-034); null when inactive.",
                      "properties": {
                        "active": {
                          "type": "boolean"
                        },
                        "id": {
                          "type": "string"
                        },
                        "kind": {
                          "type": "string"
                        },
                        "url": {
                          "type": "string",
                          "format": "uri"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/jobs": {
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Create a conversion job",
        "description": "Accepts a PDF file (multipart) or a link to a PDF (JSON). Maximum file size — 10 MB.\nPriority is assigned by the number of device runs per day for the selected converter (minimum 4).\nProcessing lasts up to 5 minutes; if exceeded, the job fails with an error. The result is stored for 1 hour after it is ready.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/Timestamp"
          },
          {
            "$ref": "#/components/parameters/Nonce"
          },
          {
            "$ref": "#/components/parameters/BodySHA256"
          },
          {
            "$ref": "#/components/parameters/Signature"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/CreateJobFromFileRequest"
              }
            },
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateJobFromUrlRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Job queued",
            "headers": {
              "Location": {
                "description": "Absolute link to the job list (`/jobs`)",
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobResource"
                }
              }
            }
          },
          "400": {
            "description": "Invalid data (no file/URL, not a PDF, broken link)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired device_token / request signature",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "Device blocked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Replay detected (nonce already used)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "413": {
            "description": "File exceeds the 10 MB limit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Device or IP limits exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Get the device's most recent jobs",
        "description": "Returns up to 3 device jobs, sorted for display in the popup.\nFor the `ready` status, the client computes the auto-deletion time as `status_since + 1h`.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/Timestamp"
          },
          {
            "$ref": "#/components/parameters/Nonce"
          },
          {
            "$ref": "#/components/parameters/BodySHA256"
          },
          {
            "$ref": "#/components/parameters/Signature"
          }
        ],
        "responses": {
          "200": {
            "description": "Job list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "maxItems": 3,
                      "items": {
                        "$ref": "#/components/schemas/JobResource"
                      }
                    }
                  },
                  "required": [
                    "items"
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired device_token / request signature",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "Device blocked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Replay detected (nonce already used)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Device or IP limits exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/jobs/{jobId}": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Get a job by id (EP-031-T6)",
        "description": "Per-job status lookup by id, device-signed (same ownership check as the v1\nlist). Used to re-query a job that has aged out of the recent list.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          },
          {
            "$ref": "#/components/parameters/Timestamp"
          },
          {
            "$ref": "#/components/parameters/Nonce"
          },
          {
            "$ref": "#/components/parameters/BodySHA256"
          },
          {
            "$ref": "#/components/parameters/Signature"
          }
        ],
        "responses": {
          "200": {
            "description": "The job resource",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobResource"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired device_token / request signature",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Job not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Jobs"
        ],
        "summary": "Delete or cancel a job",
        "description": "If the job is in the `queued` or `processing` status, marks it as canceled and deletes the input file.\nIf `ready` or `error`, deletes the Markdown and the record. The operation is idempotent.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          },
          {
            "$ref": "#/components/parameters/Timestamp"
          },
          {
            "$ref": "#/components/parameters/Nonce"
          },
          {
            "$ref": "#/components/parameters/BodySHA256"
          },
          {
            "$ref": "#/components/parameters/Signature"
          }
        ],
        "responses": {
          "200": {
            "description": "Job deleted or canceled",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "status": {
                      "$ref": "#/components/schemas/JobStatus"
                    },
                    "message": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "job_id",
                    "status"
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired device_token / request signature",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "Device blocked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Job not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Replay detected (nonce already used)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Device or IP limits exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/jobs/{jobId}/download": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Download the ready Markdown",
        "description": "Streaming delivery of Markdown for jobs in the `ready` status. Returns 409 if the job is not yet ready,\nand 410 if the file was deleted by its TTL or manually.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          },
          {
            "$ref": "#/components/parameters/Timestamp"
          },
          {
            "$ref": "#/components/parameters/Nonce"
          },
          {
            "$ref": "#/components/parameters/BodySHA256"
          },
          {
            "$ref": "#/components/parameters/Signature"
          }
        ],
        "responses": {
          "200": {
            "description": "Ready Markdown",
            "headers": {
              "Content-Disposition": {
                "description": "Suggested file name",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string",
                  "description": "Markdown content"
                }
              },
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired device_token / request signature",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "Device blocked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Job not found or not accessible to the device",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Job not yet ready",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "410": {
            "description": "File deleted by its TTL or manually",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Device or IP limits exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/settings": {
      "get": {
        "tags": [
          "Settings"
        ],
        "summary": "Get device settings",
        "description": "Returns the device's current conversion settings. For settings that are not present,\ndefault values are returned.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/Timestamp"
          },
          {
            "$ref": "#/components/parameters/Nonce"
          },
          {
            "$ref": "#/components/parameters/BodySHA256"
          },
          {
            "$ref": "#/components/parameters/Signature"
          }
        ],
        "responses": {
          "200": {
            "description": "Device settings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsResponse"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired device_token / request signature",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "Device blocked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Replay detected (nonce already used)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": [
          "Settings"
        ],
        "summary": "Update device settings",
        "description": "Partial update of the device's conversion settings. Only the\nfields being changed are sent. Returns the full settings object after the update.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/Timestamp"
          },
          {
            "$ref": "#/components/parameters/Nonce"
          },
          {
            "$ref": "#/components/parameters/BodySHA256"
          },
          {
            "$ref": "#/components/parameters/Signature"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SettingsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Settings updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid data (incorrect setting value)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired device_token / request signature",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "Device blocked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Replay detected (nonce already used)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v2/auth/google/start": {
      "get": {
        "tags": [
          "v2 Auth"
        ],
        "summary": "Start Google login (redirect to consent)",
        "description": "Browser navigation. Optional `ticket` (from /account/link/start) associates the device with the flow for linking.",
        "security": [],
        "parameters": [
          {
            "name": "ticket",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One-time device-link ticket (device-signed mint)"
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to Google OAuth consent"
          },
          "400": {
            "description": "Expired/invalid ticket",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "OAuth not configured (no credentials) — v1 works as before"
          }
        }
      }
    },
    "/api/v2/auth/google/callback": {
      "get": {
        "tags": [
          "v2 Auth"
        ],
        "summary": "Google OAuth callback",
        "description": "Exchanges the code, upserts the account (email unique → a claimable account is auto-linked), sets the HttpOnly session cookie `p2m_session`, and, if `state` contains a device, links device→account and re-attaches the device's active jobs (owner_type=account_device). Returns an HTML success/error page.\n",
        "security": [],
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "error",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful login (HTML)",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Invalid/expired state or code (HTML)",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "502": {
            "description": "Exchange error with Google (HTML)",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "OAuth not configured"
          }
        }
      }
    },
    "/api/v2/auth/logout": {
      "post": {
        "tags": [
          "v2 Auth"
        ],
        "summary": "Logout (revoke web session)",
        "description": "Deletes the server-side session and clears the `p2m_session` cookie. Does not touch device identity/local jobs.",
        "security": [
          {
            "Session": []
          },
          {}
        ],
        "responses": {
          "200": {
            "description": "Session ended",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "logged_out": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v2/account": {
      "get": {
        "tags": [
          "v2 Account"
        ],
        "summary": "Device-to-account link status (device-signed)",
        "description": "Device-signed (ES256). Returns linked=false for an anonymous device.",
        "security": [
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2AccountStatus"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/account/link/start": {
      "post": {
        "tags": [
          "v2 Account"
        ],
        "summary": "Start device linking (device-signed)",
        "description": "Device-signed (ES256). Issues a one-time ticket bound to the VERIFIED device, and returns the web login URL (`login_url`) to open in a tab. Only the genuine device (holding the private key) can obtain a ticket → another device_id cannot be linked to someone else's account.\n",
        "security": [
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Login URL",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2LinkStart"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "503": {
            "description": "OAuth not configured"
          }
        }
      }
    },
    "/api/v2/account/link/attach": {
      "post": {
        "tags": [
          "v2 Account"
        ],
        "summary": "Link device to the current session's account (device-signed + session)",
        "description": "Device-signed (ES256) AND session-authenticated (cookie). Links the VERIFIED device to the account of the CURRENT web session directly (no OAuth round-trip) and migrates the device's anonymous jobs to that account. Idempotent. The web app calls this on /app/ load in account mode to self-heal a \"session present but device unlinked\" desync (a sign-in that skipped the device-link ticket, or a checkout-intent session) — otherwise device-signed job creation silently lands on the anonymous device and never appears in the account-scoped list. Both proofs are required: the device signature proves the device; the session proves the account.\n",
        "security": [
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Linked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2AccountStatus"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/account/unlink": {
      "post": {
        "tags": [
          "v2 Account"
        ],
        "summary": "Unlink device from account / sign out in the extension (device-signed)",
        "description": "Device-signed (ES256). Removes the link and returns the device's active jobs to anonymous mode (owner_type=device, tier=anonymous) without data loss. Idempotent (for an already anonymous device — linked=false).\n",
        "security": [
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Unlinked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2AccountStatus"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/me": {
      "get": {
        "tags": [
          "v2 Account"
        ],
        "summary": "Current account",
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "responses": {
          "200": {
            "description": "Account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Me"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/limits": {
      "get": {
        "tags": [
          "v2 Account"
        ],
        "summary": "Live actor entitlements",
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "responses": {
          "200": {
            "description": "Limits",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Limits"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/usage": {
      "get": {
        "tags": [
          "v2 Account"
        ],
        "summary": "Usage (fair-use progress)",
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "responses": {
          "200": {
            "description": "Usage",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Usage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/api-keys": {
      "get": {
        "tags": [
          "v2 API Keys"
        ],
        "summary": "List keys (secret is not returned)",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Keys",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "items"
                  ],
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V2ApiKey"
                      }
                    },
                    "limit": {
                      "type": "integer",
                      "description": "Plan's api_keys_limit (EP-023 §7.3)"
                    },
                    "used": {
                      "type": "integer",
                      "description": "Active (non-revoked) keys"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "403": {
            "description": "Device is not linked to an account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "v2 API Keys"
        ],
        "summary": "Create a key (secret is shown once)",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/V2ApiKeyCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2ApiKeyCreated"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/V2BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "403": {
            "description": "Not linked / key limit reached",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v2/api-keys/{keyId}": {
      "delete": {
        "tags": [
          "v2 API Keys"
        ],
        "summary": "Revoke a key (immediately)",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "parameters": [
          {
            "name": "keyId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Revoked"
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/V2NotFound"
          }
        }
      }
    },
    "/api/v2/jobs": {
      "post": {
        "tags": [
          "v2 Jobs"
        ],
        "summary": "Create a job (upload or URL)",
        "description": "EP-023-T3 (implemented). In v2.0.0, creating a job requires an API key (scope jobs:create; owner_type=api_key) — there is no web UI for creating jobs, and a session actor gets 403. Idempotency-Key is optional: a repeat with the same key within the account returns the same job (200), without duplicating it.\n",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/V2CreateJobFromFileRequest"
              }
            },
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/V2CreateJobFromUrlRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Idempotent repeat — existing job (same Idempotency-Key)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Job"
                }
              }
            }
          },
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Job"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/V2BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "403": {
            "description": "API key required / no jobs:create scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "409": {
            "description": "slots_full — all slots are in use (clears as jobs finish) | quota_exceeded — the monthly page allowance is spent (clears on the 1st). EP-070-T7: the quota body also carries `used`, `limit` and `reset_at` (RFC3339, start of the next UTC month) so a client can say which limit and when, in its own language. 409 rather than 429 on purpose: 429 renders as \"retry in a moment\", which is wrong for something that returns next month.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/V2TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/V2RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "v2 Jobs"
        ],
        "summary": "List the actor's jobs",
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/JobStatus"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "items"
                  ],
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V2Job"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/jobs/batch": {
      "post": {
        "tags": [
          "v2 Jobs"
        ],
        "summary": "Batch create (≤ the number of free slots)",
        "description": "EP-023-T3 (implemented). API key + jobs:create. URL-only. Atomicity — all-or-nothing: if the number of items > free slots → 409 slots_full (nothing is created); on an error in any item the whole batch is rolled back.\n",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/V2BatchCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "items"
                  ],
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V2Job"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "409": {
            "description": "slots_full — the batch exceeds free slots | quota_exceeded — the monthly page allowance is spent (see POST /jobs)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/V2RateLimited"
          }
        }
      }
    },
    "/api/v2/jobs/upload-ticket": {
      "post": {
        "tags": [
          "v2 Jobs"
        ],
        "summary": "Mint a paid upload ticket (device-signed)",
        "description": "EP-048. Device-signed (ES256). A linked, paid device mints a one-time, short-TTL ticket bound server-side to {device, account, tier, byte cap}, then streams a tier-sized file to POST /api/v2/jobs/upload. This lets the web app and the extension upload files larger than the free/v1 10 MB limit, which the body-hashed v1 /jobs cannot stream. Returns eligible=false (200) for an anonymous/unlinked device or a tier at or below the free limit, so the client stays on v1 /jobs.\n",
        "security": [
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Ticket (eligible=true) or not eligible (eligible=false)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2UploadTicket"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/V2RateLimited"
          }
        }
      }
    },
    "/api/v2/jobs/upload": {
      "post": {
        "tags": [
          "v2 Jobs"
        ],
        "summary": "Streamed upload with a ticket (paid UI upload)",
        "description": "EP-048. Authenticated ONLY by a one-time Upload-Ticket header obtained from POST /api/v2/jobs/upload-ticket (no per-body ES256 signature, which is why this lane can STREAM, unlike v1 /jobs). Enforced at the ticket's per-tier byte cap; creates an owner_type=account_device job for the ticket's account. Single-use: the ticket is consumed on arrival.\n",
        "security": [
          {
            "UploadTicket": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/V2CreateJobFromFileRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Idempotent repeat — existing job (same Idempotency-Key)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Job"
                }
              }
            }
          },
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Job"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/V2BadRequest"
          },
          "401": {
            "description": "Missing, invalid, expired, or already-used upload ticket",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "409": {
            "description": "slots_full — all slots are in use (clears as jobs finish) | quota_exceeded — the monthly page allowance is spent (clears on the 1st). EP-070-T7: the quota body also carries `used`, `limit` and `reset_at` (RFC3339, start of the next UTC month) so a client can say which limit and when, in its own language. 409 rather than 429 on purpose: 429 renders as \"retry in a moment\", which is wrong for something that returns next month.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/V2TooLarge"
          }
        }
      }
    },
    "/api/v2/jobs/{jobId}": {
      "get": {
        "tags": [
          "v2 Jobs"
        ],
        "summary": "Job status",
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          }
        ],
        "responses": {
          "200": {
            "description": "Job",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Job"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/V2NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "v2 Jobs"
        ],
        "summary": "Delete/cancel a job",
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted/canceled",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Job"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/V2NotFound"
          }
        }
      }
    },
    "/api/v2/jobs/{jobId}/download": {
      "get": {
        "tags": [
          "v2 Jobs"
        ],
        "summary": "Download Markdown (ready only)",
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          }
        ],
        "responses": {
          "200": {
            "description": "Markdown",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/V2NotFound"
          },
          "409": {
            "description": "Not yet ready (not_ready)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "410": {
            "description": "Deleted by TTL (gone)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v2/webhooks": {
      "get": {
        "tags": [
          "v2 Webhooks"
        ],
        "summary": "List registered webhook endpoints",
        "description": "EP-023-T10 (implemented, §6.8). Account-scoped (web session OR device-signed-linked, like api-keys). Paid feature (webhooks_enabled).",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "List",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "items"
                  ],
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V2Webhook"
                      }
                    },
                    "limit": {
                      "type": "integer",
                      "description": "Plan's webhooks_limit"
                    },
                    "used": {
                      "type": "integer"
                    },
                    "enabled": {
                      "type": "boolean",
                      "description": "Whether webhooks are available on the plan"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "v2 Webhooks"
        ],
        "summary": "Register a webhook (signing secret is shown once)",
        "description": "EP-023-T10. The server generates a signing secret whsec_… (returned ONCE), used to sign deliveries (X-P2M-Signature). url must be https and public (SSRF-guard). events — filter (empty = all). 403 if the tier has no webhooks_enabled or the tier's endpoint limit is reached.\n",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/V2WebhookCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2WebhookCreated"
                }
              }
            }
          },
          "400": {
            "description": "Invalid url/events",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "403": {
            "description": "Webhooks unavailable on the tier or limit reached",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v2/webhooks/deliveries": {
      "get": {
        "tags": [
          "v2 Webhooks"
        ],
        "summary": "Delivery history (debugging/support)",
        "description": "EP-023-T10 acceptance: visible delivery history (incl. failed) — status, attempts, response code, error.",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Recent deliveries (newest first)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V2WebhookDelivery"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/webhooks/{webhookId}": {
      "delete": {
        "tags": [
          "v2 Webhooks"
        ],
        "summary": "Delete a webhook",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted"
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/V2NotFound"
          }
        }
      }
    },
    "/api/v2/mcp": {
      "post": {
        "tags": [
          "v2 MCP"
        ],
        "summary": "Hosted MCP endpoint (JSON-RPC 2.0 / Streamable HTTP)",
        "description": "EP-023-T4 (implemented). A thin adapter over the same service layer as the v2 API. Authentication — API key (`Authorization: Bearer p2m_...`); the tools respect the key's scopes, tier limits, and ownership. JSON-RPC methods: `initialize`, `tools/list`, `tools/call`, `ping`; notifications (without id) → 202 with no body. Tools: `pdf_to_markdown_create_job_from_url`/`_from_upload` (require scope jobs:create), `_list_jobs`/`_get_job` (jobs:read), `_get_markdown` (jobs:download), `_delete_job` (jobs:delete), `_get_limits`. tools/call responses include slot_usage and tier. For MCP client setup — see docs/architecture.md (Hosted MCP).\n",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "JSON-RPC 2.0 request {jsonrpc,id,method,params}",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response (result or error)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "202": {
            "description": "Notification accepted (no body)"
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          }
        }
      }
    },
    "/api/v2/billing/plans": {
      "get": {
        "tags": [
          "v2 Billing"
        ],
        "summary": "Plans (proxied prices, price_id→tier mapping)",
        "description": "EP-023-T8 + T23 (implemented). Public. Plans from billing_price_map; live-amount best-effort from monetize GET /paywall/{id}/prices. `current` is flagged when a session is present. `purchase_availability` (T23) shows whether new purchases can be started from the extension and website (on a read error — both false).",
        "security": [],
        "responses": {
          "200": {
            "description": "Plans",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "plans"
                  ],
                  "properties": {
                    "plans": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/V2BillingPlan"
                      }
                    },
                    "purchase_availability": {
                      "$ref": "#/components/schemas/V2PurchaseAvailability"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Billing not configured"
          }
        }
      }
    },
    "/api/v2/billing/checkout": {
      "post": {
        "tags": [
          "v2 Billing"
        ],
        "summary": "Create checkout (server-side start-checkout)",
        "description": "EP-023-T8 (implemented). Session OR anonymous+email (paywall without login; anonymous → claimable account created_via=billing). The server calls monetize start-checkout with our x-api-key, userMeta.p2m_account_id, success/error/shop URLs on our domain. Returns checkout_url to open in a tab; the provider userId is saved as monetize_customer_id. EP-065-T3: checkout creation is serialized per account (a short lock). A repeat of the SAME plan while one is in flight replays the same checkout_url; a DIFFERENT plan gets 409 checkout_in_progress. Without the lock two concurrent requests both bypassed the provider's active-purchase protection, which is a double charge. EP-062-T4: whether the provider's active-purchase protection is bypassed (ignoreActivePurchase) is decided SERVER-SIDE by comparing the requested price with the account's stored subscription — tier ranks, then billing interval (month &lt; year &lt; lifetime). Only a proven upgrade bypasses it; the same plan, a lower tier, a shorter interval or an unknown stored interval get 409 active_subscription with a message directing the buyer to the billing portal (starting a second checkout would charge them twice). EP-066-T1: the bypass requires an AUTHENTICATED caller (session or device-signed intent_token). An anonymous email checkout never receives it — the account was resolved from a string in the request body, so nothing proves the caller owns that address — and it therefore never takes the per-account lock either. Consequently checkout_in_progress can only be caused by the account's own signed-in requests. EP-066-T7: a subscription that is CANCELLED and merely running out its paid period is a re-subscribe, not a repeat purchase: the checkout is allowed (without the bypass) instead of answering 409 same_plan. `trialing` is not included — a trial converts at the provider.\n",
        "security": [
          {
            "Session": []
          },
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/V2CheckoutRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2CheckoutResponse"
                }
              }
            }
          },
          "400": {
            "description": "Email required (email_required) or unknown plan",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "409": {
            "description": "active_subscription — direct to portal (carries `reason` for a SIGNED-IN buyer only, EP-065-T8) | checkout_in_progress — another checkout for this account is being created (EP-065-T3)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/V2RateLimited"
          },
          "502": {
            "description": "Provider unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "503": {
            "description": "Billing not configured"
          }
        }
      }
    },
    "/api/v2/billing/portal": {
      "post": {
        "tags": [
          "v2 Billing"
        ],
        "summary": "Customer portal (server-side get-customer-portal)",
        "description": "EP-023-T8 (implemented). EP-025-T5: now reachable by a web session OR a device-signed linked device (the extension), resolved by AccountAuthMiddleware — so \"Manage billing\" works from the extension, which cannot send a SameSite=Lax session cookie cross-site. Email/account are taken from the resolved actor. x-api-key only on the server.",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Portal URL",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2PortalResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "502": {
            "description": "Provider unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "503": {
            "description": "Billing not configured"
          }
        }
      }
    },
    "/api/v2/billing/refresh": {
      "post": {
        "tags": [
          "v2 Billing"
        ],
        "summary": "Refresh subscription from the provider (post-checkout fast plan update)",
        "description": "EP-039-T6. Pulls the provider's CURRENT subscription state server-side (GET /paywall/{id}/user, x-api-key only) and reconciles our DB, then returns the (possibly just-updated) effective plan — so the web app / extension flip to the paid tier immediately after a checkout-success redirect instead of waiting for the webhook. Webhooks remain the source of truth; this only fills gaps (a webhook the provider gave up retrying) and confirms fast. Reachable by a web session OR a device-signed linked device (AccountAuthMiddleware); identity for the read is the account email (or stored monetize_customer_id). Best-effort: a provider read error still returns the stored state. The read never leaves api-facade.\n",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Effective plan after reconcile",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "tier",
                    "refreshed"
                  ],
                  "properties": {
                    "tier": {
                      "type": "string",
                      "description": "Effective tier after the provider read (free|builder|pro|business|...)"
                    },
                    "refreshed": {
                      "type": "boolean",
                      "description": "true when the read changed our stored subscription state"
                    },
                    "subscription": {
                      "nullable": true,
                      "type": "object",
                      "properties": {
                        "status": {
                          "type": "string"
                        },
                        "cancel_at_period_end": {
                          "type": "boolean"
                        },
                        "current_period_end": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true
                        },
                        "trial_end": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true
                        },
                        "lifetime": {
                          "type": "boolean",
                          "description": "EP-069-T13: a live one-time unlock is held."
                        },
                        "manageable": {
                          "type": "boolean",
                          "description": "EP-070-T5: false when the only purchase is the unlock (nothing to cancel)."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "503": {
            "description": "Billing not configured"
          }
        }
      }
    },
    "/api/v2/billing/checkout-intent": {
      "post": {
        "tags": [
          "v2 Billing"
        ],
        "summary": "Mint a checkout-intent token (extension Upgrade → web checkout binding)",
        "description": "EP-028. Device-signed (or web session) via AccountAuthMiddleware. Mints a one-time, short-lived token (DEVICE_LINK_CODE_TTL_SEC) naming the acting account to charge. The extension opens the web pricing page with ?ci=<ci_token>, so the resulting checkout binds to the extension's account even if the browser is signed into a different web account (or none). Stored server-side (Redis), single-use (consumed by POST /billing/checkout).\n",
        "security": [
          {
            "Session": []
          },
          {
            "DeviceToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Intent token",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ci_token"
                  ],
                  "properties": {
                    "ci_token": {
                      "type": "string"
                    },
                    "expires_in_sec": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "403": {
            "description": "Device is not linked to an account (sign in first)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v2/billing/checkout-intent/redeem": {
      "post": {
        "tags": [
          "v2 Billing"
        ],
        "summary": "Redeem a checkout-intent token for a web session (public)",
        "description": "EP-028. Called by the pricing page when arriving from the extension Upgrade (?ci=...). Consumes the token (single-use) and establishes a web SESSION for its account on this domain (Set-Cookie: session + readable p2m_auth hint, mirroring the OAuth login), so the page then behaves as a normal signed-in buyer — current plan, Manage billing, checkout and retries — with no email prompt. Returns the account email for the \"Upgrading as ...\" label. 404 on invalid/expired.\n",
        "security": [
          {}
        ],
        "parameters": [
          {
            "name": "ci",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Session established",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "email"
                  ],
                  "properties": {
                    "email": {
                      "type": "string",
                      "format": "email"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Invalid or expired checkout link",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v2/billing/webhook": {
      "post": {
        "tags": [
          "v2 Billing"
        ],
        "summary": "Webhook from monetize.software (server→server)",
        "description": "EP-023-T14 (implemented). Called by monetize, NOT by the extension. Authentication — the X-Monetize-Signature header (HMAC-SHA256 of the raw body, secret MONETIZE_WEBHOOK_SECRET, timing-safe, optional sha256= prefix). Idempotency by event.id (billing_events; duplicate→200 no-op), anti out-of-order by last_event_at. Account resolution: userMeta.p2m_account_id → otherwise customer.email. subscription.created/updated/cancelled + payment.completed + refund.created → subscriptions (status+tier from price_id→billing_price_map). 5xx on a transient DB error (the provider retries). See v2.0.0-billing-monetize.md §5. Scheduled downgrade — maintenance. EP-063-T1/EP-064-T2: webhooks are ACCOUNT-scoped at monetize, so events of another paywall arrive here too. An event naming a different paywall is ignored (200, no DB access); an event naming NO paywall is applied only when the product is proven independently — our userMeta.p2m_account_id, a price in billing_price_map, or a provider_subscription_id we already store — otherwise 200 + ALERT without any write. EP-064-T1: an event whose created_at does not parse is NOT applied (200, ALERT) — an unorderable event would otherwise outrank every stored state; the sole exception is an explicit revocation, which is applied with the delivery time. EP-065-T2: paywall ids are compared as numbers whenever both denote a whole number, in ANY spelling (827, 0827, +827, 827.0, 8.27e2); a fractional id denotes no whole paywall and stays foreign. EP-065-T6: proof of product is also required from events that carry MONEY (payment.completed, refund.created) — billing_events is what revenue is reconstructed from, so a foreign payment recorded there is counted as our sale. EP-069-T9: `payment.completed` also GRANTS the one-time lifetime unlock, so it is now an entitlement-changing event. Which kind it is (a lifetime purchase vs the first charge of a subscription) is decided by looking `data.price.id` up in billing_price_map — never by the event's own `price.interval`, because both arrive as the same type and branching on the event field races subscription.created for the same account. An unmapped price grants nothing. EP-065-T4: a repository error while checking ownership is a TRANSIENT failure (5xx, the provider retries), never a terminal 200 — the two used to be indistinguishable and silently dropped paid sales. EP-066-T2: the same rule now covers ACCOUNT RESOLUTION. A database failure while looking up p2m_account_id or creating the claimable account by email answers 5xx BEFORE the event is claimed; only a successful lookup that named nobody gives 200 skipped_no_account. EP-066-T3: an empty MONETIZE_PAYWALL_ID answers 503 (with an ALERT) instead of applying events blind — without the id every cross-paywall guard degrades to \"allow\".\n",
        "security": [],
        "parameters": [
          {
            "name": "X-Monetize-Signature",
            "in": "header",
            "required": true,
            "description": "sha256=<hmac> of the raw body (see security.md)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "monetize event {id,type,created_at,data}",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted/processed. status: processed | duplicate | skipped_no_account | ignored_foreign_paywall | ignored_unproven_paywall | skipped_unorderable_event"
          },
          "500": {
            "description": "Transient error — the provider must retry. Includes 'we could not CHECK whether the event is ours' (EP-065-T4) and 'we could not RESOLVE the account' (EP-066-T2); both are deliberately NOT answered with 200."
          },
          "401": {
            "description": "Invalid signature"
          },
          "429": {
            "$ref": "#/components/responses/V2RateLimited"
          },
          "503": {
            "description": "Webhook not configured: MONETIZE_WEBHOOK_SECRET or MONETIZE_PAYWALL_ID missing (EP-066-T3). Without the paywall id the cross-paywall guard cannot work, so events are refused rather than applied blind; the provider retries."
          }
        }
      }
    },
    "/api/v2/admin/account/{accountId}": {
      "get": {
        "tags": [
          "v2 Admin"
        ],
        "summary": "Read-only support view of an account (tier/limits/usage/slots)",
        "description": "EP-023-T9 (implemented; roadmap §8 phase-8 exit). Read-only, no mutations. Auth — ADMIN_SECRET (Authorization: Bearer <secret> or X-Admin-Secret); if the secret is not set → 404 (surface hidden). Returns account, effective_tier, over_fair_use, limits, subscription, usage_this_cycle, slots_in_use.\n",
        "security": [
          {
            "AdminSecret": []
          }
        ],
        "parameters": [
          {
            "name": "accountId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Account summary",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "404": {
            "description": "Account not found / view disabled"
          }
        }
      }
    },
    "/api/v2/admin/purchase-flags": {
      "get": {
        "tags": [
          "v2 Admin"
        ],
        "summary": "Read purchase-availability flags (EP-023-T23, EP-024 per-domain web)",
        "description": "ADMIN_SECRET (404 if not set). Returns the extension flag and the per-domain web flags ({extension, web:{host:bool}}).",
        "security": [
          {
            "AdminSecret": []
          }
        ],
        "responses": {
          "200": {
            "description": "Current flags",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2AdminPurchaseFlags"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "404": {
            "description": "Surface disabled (ADMIN_SECRET not set)"
          }
        }
      },
      "post": {
        "tags": [
          "v2 Admin"
        ],
        "summary": "Enable/disable new purchases for a channel/domain (audited)",
        "description": "EP-023-T23 (+ EP-024 per-domain web). Server-authoritative runtime switch, without a restart; written through this endpoint, so the change is audited ([AUDIT] purchase_flag_changed). Does not block existing subscribers. For channel=web, `domain` is required and must be an allow-listed public host (each site domain has its own switch). CLI: deploy/v2-tools/purchase-flags.sh.\n",
        "security": [
          {
            "AdminSecret": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "channel",
                  "enabled"
                ],
                "properties": {
                  "channel": {
                    "type": "string",
                    "enum": [
                      "extension",
                      "web"
                    ]
                  },
                  "domain": {
                    "type": "string",
                    "description": "Required for channel=web: which public host (e.g. pdf2md.dev). Ignored for extension."
                  },
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New flags",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2AdminPurchaseFlags"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/V2ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/V2Unauthorized"
          },
          "404": {
            "description": "Surface disabled (ADMIN_SECRET not set)"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "AdminSecret": {
        "type": "http",
        "scheme": "bearer",
        "description": "v2 (EP-023-T9): support read-view secret (ADMIN_SECRET). Server-only; empty → /api/v2/admin/* 404."
      },
      "DeviceToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "device_token, issued at `/register`. All protected requests are also signed (timestamp, nonce, body hash, signature) —\nsee docs/security.md (canonical string `METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nBODY_SHA256`, ±300s window, nonce TTL 5 min).\n"
      },
      "ApiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "opaque",
        "description": "v2 (EP-023): API key `Authorization: Bearer p2m_...` over TLS. WITHOUT an ES256 body signature and anti-replay\n(unlike DeviceToken) — this is a deliberate perimeter shift (docs/security.md §0-2). Scopes/limits/ownership\nare checked by the server; the tier is not stored on the key (entitlements are resolved live).\n"
      },
      "Session": {
        "type": "apiKey",
        "in": "cookie",
        "name": "p2m_session",
        "description": "v2 (EP-023): the extension/web OAuth session (Google login). Used by the UI for /api/v2/me, /api/v2/limits, /api/v2/api-keys."
      },
      "UploadTicket": {
        "type": "apiKey",
        "in": "header",
        "name": "Upload-Ticket",
        "description": "v2 (EP-048): a one-time, short-TTL ticket from POST /api/v2/jobs/upload-ticket, bound\nserver-side to {device, account, tier, byte cap}. Authenticates the streamed\nPOST /api/v2/jobs/upload WITHOUT a per-body ES256 signature (so the body can stream).\nSingle-use (consumed on arrival) and short-lived, which bounds theft/replay.\n"
      }
    },
    "responses": {
      "V2Unauthorized": {
        "description": "Missing/invalid API key or session",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/V2Error"
            }
          }
        }
      },
      "V2Forbidden": {
        "description": "Insufficient scope or action not allowed by the tier",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/V2Error"
            }
          }
        }
      },
      "V2NotFound": {
        "description": "Not found or no access (ownership)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/V2Error"
            }
          }
        }
      },
      "V2BadRequest": {
        "description": "Invalid data (not a PDF, no url/file, broken link)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/V2Error"
            }
          }
        }
      },
      "V2TooLarge": {
        "description": "File exceeds the tier limit (max_file_size)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/V2Error"
            }
          }
        }
      },
      "V2RateLimited": {
        "description": "Rate limit exceeded",
        "headers": {
          "Retry-After": {
            "description": "Seconds until retry",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/V2Error"
            }
          }
        }
      }
    },
    "parameters": {
      "JobId": {
        "name": "jobId",
        "in": "path",
        "required": true,
        "description": "Job identifier (UUID)",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Idempotency key for safe retries of job-creation requests (ttl 24h)",
        "schema": {
          "type": "string",
          "maxLength": 128,
          "example": "req-123e4567"
        }
      },
      "Timestamp": {
        "name": "X-Timestamp",
        "in": "header",
        "required": true,
        "description": "Unix time (sec) for the request signature (±300s window, see docs/security.md)",
        "schema": {
          "type": "integer",
          "format": "int64",
          "example": 1700000000
        }
      },
      "Nonce": {
        "name": "X-Nonce",
        "in": "header",
        "required": true,
        "description": "Random string (base64url), unique per request (TTL 5 minutes, anti-replay)",
        "schema": {
          "type": "string",
          "maxLength": 128,
          "example": "bXlfbm9uY2UtdjE"
        }
      },
      "BodySHA256": {
        "name": "X-Body-SHA256",
        "in": "header",
        "required": true,
        "description": "hex sha256 of the request body (for GET/DELETE — sha256 of an empty body)",
        "schema": {
          "type": "string",
          "pattern": "^[A-Fa-f0-9]{64}$",
          "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        }
      },
      "Signature": {
        "name": "X-Signature",
        "in": "header",
        "required": true,
        "description": "Signature of the canonical string (ES256), see docs/security.md",
        "schema": {
          "type": "string",
          "maxLength": 512,
          "example": "MEYCIQCZqlk3E1p5k2y1hZ0ykjW9tzjO4GDBWJ3QYw9UWF2gtwIhAKuQKk8NfZp7uA9u6YzKxHnD7bHkHHRp2tkMZ0QYpQMb"
        }
      }
    },
    "schemas": {
      "JobStatus": {
        "type": "string",
        "description": "Job status",
        "enum": [
          "queued",
          "processing",
          "ready",
          "error",
          "canceled",
          "deleted"
        ]
      },
      "JobResource": {
        "type": "object",
        "required": [
          "job_id",
          "converter_code",
          "status",
          "status_since",
          "priority",
          "priority_assigned_at",
          "file_name"
        ],
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "converter_code": {
            "type": "string",
            "example": "pdf_to_md"
          },
          "status_since": {
            "type": "string",
            "format": "date-time",
            "description": "Time of entry into the current status (UTC)"
          },
          "priority": {
            "type": "integer",
            "format": "int32",
            "description": "A lower number — higher priority. v2 (EP-023, §7.7): a band scale, `min` is no longer 4 — the client treats the value as opaque (timers are computed from `status_since`/`ready_at`, not from priority).\n"
          },
          "priority_assigned_at": {
            "type": "string",
            "format": "date-time",
            "description": "When priority was assigned/lowered"
          },
          "error_message": {
            "type": "string",
            "nullable": true
          },
          "error_code": {
            "type": "string",
            "nullable": true,
            "description": "Stable machine error code for a failed job (EP-031). Additive — absent on\nsuccess and on older records. Clients localize by this code; the human\nerror_message is a fallback.\n"
          },
          "file_name": {
            "type": "string",
            "maxLength": 255
          },
          "output_size": {
            "type": "integer",
            "format": "int64",
            "nullable": true,
            "description": "Markdown size (bytes), only for `ready`"
          },
          "download_url": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "Populated for `ready`"
          },
          "pages": {
            "type": "integer",
            "nullable": true,
            "description": "Number of pages in the source PDF (EP-018). An additive field — absent on the old backend/old jobs; clients should ignore it if missing.\n"
          },
          "truncated": {
            "type": "boolean",
            "default": false,
            "description": "`true` — the result is partial: conversion reached the soft time budget and a disclaimer was appended to the end of the Markdown (EP-018). Additive, backward-compatible: absence/`false` = full result.\n"
          }
        }
      },
      "RegisterRequest": {
        "type": "object",
        "required": [
          "public_key_spki_b64",
          "alg"
        ],
        "properties": {
          "public_key_spki_b64": {
            "type": "string",
            "description": "base64(SPKI) of the ES256 public key",
            "example": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsT3..."
          },
          "alg": {
            "type": "string",
            "enum": [
              "ES256"
            ]
          },
          "telemetry": {
            "type": "object",
            "description": "Optional, for analytics (not used for blocking in MVP)",
            "properties": {
              "fingerprint_v1": {
                "type": "string"
              },
              "ext_version": {
                "type": "string"
              },
              "browser": {
                "type": "string"
              },
              "platform": {
                "type": "string"
              }
            }
          }
        }
      },
      "RegisterResponse": {
        "type": "object",
        "required": [
          "device_id",
          "device_token",
          "expires_at"
        ],
        "properties": {
          "device_id": {
            "type": "string",
            "format": "uuid",
            "example": "4b6b4b1c-7b6d-4c5d-9f70-2f4d8e7b1234"
          },
          "device_token": {
            "type": "string",
            "description": "bearer token for requests (see docs/security.md)",
            "example": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "device_token expiration time",
            "example": "2024-12-31 23:59:59+00:00"
          }
        }
      },
      "CreateJobFromFileRequest": {
        "type": "object",
        "required": [
          "file",
          "converter_code"
        ],
        "properties": {
          "file": {
            "type": "string",
            "format": "binary",
            "description": "PDF file, maximum 10 MB"
          },
          "file_name": {
            "type": "string",
            "maxLength": 255,
            "description": "File name for display (optional)",
            "example": "paper.pdf"
          },
          "converter_code": {
            "type": "string",
            "description": "Converter code (MVP: pdf_to_md)",
            "example": "pdf_to_md"
          },
          "ocr_lang": {
            "$ref": "#/components/schemas/OCRLang"
          }
        }
      },
      "CreateJobFromUrlRequest": {
        "type": "object",
        "required": [
          "url",
          "converter_code"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Direct link to a PDF",
            "example": "https://example.com/files/report.pdf"
          },
          "file_name": {
            "type": "string",
            "maxLength": 255,
            "description": "File name for display (optional)",
            "example": "report.pdf"
          },
          "converter_code": {
            "type": "string",
            "description": "Converter code (MVP: pdf_to_md)",
            "example": "pdf_to_md"
          },
          "ocr_lang": {
            "$ref": "#/components/schemas/OCRLang"
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Short error code",
            "enum": [
              "validation_error",
              "token_invalid_or_expired",
              "signature_invalid",
              "replay_detected",
              "rate_limited",
              "device_blocked",
              "not_ready",
              "gone"
            ],
            "example": "signature_invalid"
          },
          "message": {
            "type": "string",
            "example": "Invalid request signature"
          }
        }
      },
      "ImageMode": {
        "type": "string",
        "description": "Image handling mode in Markdown",
        "enum": [
          "embedded",
          "placeholder"
        ],
        "default": "embedded"
      },
      "Engine": {
        "type": "string",
        "description": "Conversion engine (EP-015). Both engine containers are running; at most one is\nresident (single-resident, EP-016). The old extension does not send this field →\nthe backend uses the default. EP-016: default `mineru` (reliable on scans/Cyrillic);\nchanged via env DEFAULT_ENGINE. `docling` — optional for simple PDFs.\n",
        "enum": [
          "docling",
          "mineru"
        ],
        "default": "mineru"
      },
      "ConversionMode": {
        "type": "string",
        "description": "Conversion mode / fast-path (EP-014). `simple` — the lightweight docling pipeline,\n`auto` — autodetect + fallback to full, `full` — the full pipeline.\nBackward compatibility: when the field is absent the backend uses `full`\n(behavior identical to before). The new extension's UI defaults to `auto`.\n",
        "enum": [
          "simple",
          "auto",
          "full"
        ],
        "default": "full"
      },
      "OCRLang": {
        "type": "string",
        "description": "OCR language hint (EP-070, additive). Selects the OCR recognition model for\nscanned pages / forced OCR in BOTH engines (MinerU maps ja→japan and\nes/pt/de/fr→latin; docling builds a per-request EasyOCR list <lang>+en).\nOnly affects OCR: PDFs with a digital text layer are extracted, not\nrecognized, and ignore this hint. Absent → the engine's env default\n(current ru+en behavior). Unknown values are rejected with 400.\n",
        "enum": [
          "ru",
          "en",
          "ja",
          "es",
          "pt",
          "de",
          "fr"
        ]
      },
      "SettingsRequest": {
        "type": "object",
        "description": "Partial settings update (only the fields being changed are sent).\nThe `engine`/`conversion_mode` fields are optional and additive — old clients\ndo not send them, and their absence does not affect already saved values.\n",
        "properties": {
          "image_mode": {
            "$ref": "#/components/schemas/ImageMode"
          },
          "ocr_enabled": {
            "type": "boolean",
            "description": "Enable OCR for text recognition on scans (default false)"
          },
          "engine": {
            "$ref": "#/components/schemas/Engine"
          },
          "conversion_mode": {
            "$ref": "#/components/schemas/ConversionMode"
          }
        }
      },
      "SettingsResponse": {
        "type": "object",
        "description": "The full set of settings. `engine`/`conversion_mode` were added additively —\nold clients unaware of these fields ignore them (backward compatibility).\n",
        "required": [
          "image_mode",
          "ocr_enabled"
        ],
        "properties": {
          "image_mode": {
            "$ref": "#/components/schemas/ImageMode"
          },
          "ocr_enabled": {
            "type": "boolean",
            "description": "Enable OCR for text recognition on scans",
            "default": false
          },
          "engine": {
            "$ref": "#/components/schemas/Engine"
          },
          "conversion_mode": {
            "$ref": "#/components/schemas/ConversionMode"
          }
        }
      },
      "V2Error": {
        "type": "object",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "v2 codes: validation_error, unauthorized, forbidden, not_found, slots_full, rate_limited, not_ready, gone, idempotency_conflict, active_subscription, checkout_in_progress, quota_exceeded, file_too_large",
            "example": "rate_limited"
          },
          "message": {
            "type": "string"
          },
          "reason": {
            "type": "string",
            "description": "EP-065-T8. Machine-readable sub-case, currently only on 409 active_subscription from /billing/checkout: same_plan | downgrade | shorter_interval | current_interval_unknown. Lets a localized client render its own wording instead of the English `message`. ABSENT for an anonymous caller by design — the per-case detail is what made the endpoint answer \"does this address pay us\" (EP-064-T7). Additive and optional: clients that ignore it keep working.\n",
            "enum": [
              "same_plan",
              "downgrade",
              "shorter_interval",
              "current_interval_unknown"
            ]
          },
          "used": {
            "type": "integer",
            "format": "int64",
            "description": "EP-070-T7: on 409 quota_exceeded — pages already spent this month."
          },
          "limit": {
            "type": "integer",
            "description": "EP-070-T7: on 409 quota_exceeded — the monthly allowance that was reached."
          },
          "reset_at": {
            "type": "string",
            "format": "date-time",
            "description": "EP-070-T7: on 409 quota_exceeded — when the allowance returns (start of the next UTC calendar month, the exact boundary the counter rolls over on). Computed server-side so a client never guesses our timezone."
          }
        }
      },
      "V2Tier": {
        "type": "string",
        "enum": [
          "anonymous",
          "free",
          "lifetime",
          "builder",
          "pro",
          "business",
          "enterprise"
        ],
        "description": "EP-069: `lifetime` is the one-time unlock, ranked BETWEEN free and builder — it raises the interactive limits and carries 600 pages/month, but grants no API keys, hosted MCP, webhooks or batch, so a subscription is genuinely more and its holder may still buy one.\n"
      },
      "V2AccountStatus": {
        "type": "object",
        "description": "EP-023-T1: device-to-account link status (device-signed GET/POST /api/v2/account*).",
        "required": [
          "linked"
        ],
        "properties": {
          "linked": {
            "type": "boolean",
            "description": "true — the device is linked to an account"
          },
          "account_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string"
          },
          "display_name": {
            "type": "string",
            "nullable": true
          },
          "avatar_url": {
            "type": "string",
            "nullable": true
          },
          "tier": {
            "$ref": "#/components/schemas/V2Tier"
          },
          "affected_jobs": {
            "type": "integer",
            "description": "How many jobs were re-attached/returned on link/unlink"
          },
          "limits": {
            "allOf": [
              {
                "$ref": "#/components/schemas/V2Limits"
              }
            ],
            "nullable": true,
            "description": "EP-025-T2 (additive): live entitlements for a linked device so the device-signed extension can show tier-correct limits without a session/API key (it cannot reach /api/v2/limits). Omitted when the device is anonymous or the resolver is unavailable."
          },
          "slots_in_use": {
            "type": "integer",
            "nullable": true,
            "description": "EP-025-T2 (additive): active jobs currently owned by the linked account."
          },
          "subscription": {
            "type": "object",
            "nullable": true,
            "description": "EP-025-T5 (additive): subscription status/period for the extension Account & Plan section. Omitted when the device is anonymous and has neither a subscription nor a live lifetime unlock.",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "free",
                  "trialing",
                  "active",
                  "past_due",
                  "canceled",
                  "paused"
                ]
              },
              "cancel_at_period_end": {
                "type": "boolean"
              },
              "current_period_end": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "trial_end": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "lifetime": {
                "type": "boolean",
                "description": "EP-069-T13: the account holds a LIVE one-time unlock. Server-computed so four client surfaces do not each infer it and disagree."
              },
              "manageable": {
                "type": "boolean",
                "description": "EP-069-T13/EP-070-T5: there is something the provider portal can change or cancel. FALSE for an account whose only purchase is the unlock — it has no schedule and cannot lapse, so offering \"cancel\" there is a support ticket at best. Built by ONE server-side builder shared by /me and /account: the two used to disagree, and the endpoint the extension reads never set it, so every recurring subscriber lost their Manage billing button."
              }
            }
          }
        }
      },
      "V2LinkStart": {
        "type": "object",
        "description": "EP-023-T1: response of device-signed POST /api/v2/account/link/start.",
        "required": [
          "login_url",
          "expires_in_sec"
        ],
        "properties": {
          "login_url": {
            "type": "string",
            "description": "Web login URL with a one-time ticket — open in a tab"
          },
          "expires_in_sec": {
            "type": "integer",
            "description": "ticket TTL (seconds)"
          }
        }
      },
      "V2UploadTicket": {
        "type": "object",
        "description": "EP-048: response of device-signed POST /api/v2/jobs/upload-ticket.",
        "required": [
          "eligible",
          "max_file_size_bytes",
          "tier"
        ],
        "properties": {
          "eligible": {
            "type": "boolean",
            "description": "true when a paid ticket was minted; false when the device is anonymous/unlinked or at/below the free limit (client uses v1 /jobs)"
          },
          "ticket": {
            "type": "string",
            "description": "one-time ticket for the Upload-Ticket header (present only when eligible)"
          },
          "max_file_size_bytes": {
            "type": "integer",
            "description": "the byte cap enforced by the upload (tier limit when eligible, else the free limit)"
          },
          "tier": {
            "type": "string",
            "description": "the account's tier (or 'anonymous' when unlinked)"
          },
          "expires_in_sec": {
            "type": "integer",
            "description": "ticket TTL in seconds (present only when eligible)"
          }
        }
      },
      "V2Me": {
        "type": "object",
        "required": [
          "account_id",
          "email",
          "tier"
        ],
        "properties": {
          "account_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string"
          },
          "display_name": {
            "type": "string",
            "nullable": true
          },
          "avatar_url": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "Google profile photo URL (EP-024-T11; used by the site header)"
          },
          "tier": {
            "$ref": "#/components/schemas/V2Tier"
          },
          "subscription_status": {
            "type": "string",
            "enum": [
              "free",
              "trialing",
              "active",
              "past_due",
              "canceled",
              "paused"
            ]
          }
        }
      },
      "V2Limits": {
        "type": "object",
        "description": "Live actor entitlements (resolved from the subscription, §7.3)",
        "properties": {
          "tier": {
            "$ref": "#/components/schemas/V2Tier"
          },
          "max_active_slots": {
            "type": "integer",
            "example": 3
          },
          "max_concurrent_conversions": {
            "type": "integer",
            "example": 1
          },
          "max_file_size_bytes": {
            "type": "integer",
            "format": "int64",
            "example": 10485760
          },
          "processing_soft_budget_sec": {
            "type": "integer",
            "example": 900,
            "description": "The tier's visible \"time limit\": the engine runs up to it and returns a partial result + the tier disclaimer (truncated=true), rather than a rejection. Invariant: < processing_timeout_sec. Shown on pricing and in the disclaimer."
          },
          "processing_timeout_sec": {
            "type": "integer",
            "example": 1200,
            "description": "The worker's internal hard-backstop (= soft + ~5 min); not a marketing limit."
          },
          "ready_ttl_sec": {
            "type": "integer",
            "example": 3600
          },
          "priority_base": {
            "type": "integer",
            "example": 100,
            "description": "§7.7, step-20 scale (anonymous=100)"
          },
          "pool_eligibility": {
            "type": "string",
            "enum": [
              "free_only",
              "free_and_paid"
            ]
          },
          "monthly_pages_soft_limit": {
            "type": "integer",
            "nullable": true,
            "description": "EP-069: the monthly page allowance. SOFT on subscription tiers (past it the actor drops to the free pool for the rest of the cycle, never refused). HARD on anonymous (150), free (400) and lifetime (600) — those are refused with 409 quota_exceeded. Resolved for THIS actor: an API-key caller does not get the lifetime unlock (EP-070-T6)."
          },
          "monthly_pages_used": {
            "type": "integer",
            "format": "int64",
            "description": "EP-070-T7: pages converted this UTC calendar month. Present here as well as on /api/v2/limits because the extension cannot reach that path (it needs an API key or a session cookie, and a cross-site extension fetch has neither)."
          },
          "webhooks_enabled": {
            "type": "boolean"
          },
          "slots_in_use": {
            "type": "integer",
            "description": "slots occupied right now"
          },
          "rate_limits": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "per-class: api_requests_per_min, job_creates_per_min, upload_bytes_per_hour, download_requests_per_min, mcp_tool_calls_per_min"
          }
        }
      },
      "V2Usage": {
        "type": "object",
        "description": "EP-023-T9: monthly fair-use + durable counters (usage_ledger).",
        "properties": {
          "period_start": {
            "type": "string",
            "format": "date"
          },
          "pages": {
            "type": "integer",
            "format": "int64"
          },
          "jobs": {
            "type": "integer"
          },
          "upload_bytes": {
            "type": "integer",
            "format": "int64"
          },
          "download_count": {
            "type": "integer"
          },
          "mcp_calls": {
            "type": "integer"
          },
          "slots_in_use": {
            "type": "integer"
          },
          "max_active_slots": {
            "type": "integer"
          },
          "monthly_pages_soft_limit": {
            "type": "integer",
            "nullable": true,
            "description": "EP-069: soft on subscription tiers, HARD on anonymous/free/lifetime"
          },
          "monthly_pages_pct": {
            "type": "integer",
            "description": "% of soft limit"
          },
          "warned": {
            "type": "boolean",
            "description": "true at ≥80% (§5)"
          },
          "over_fair_use": {
            "type": "boolean",
            "description": "soft-degraded to free pool/band this cycle (≥100%)"
          }
        }
      },
      "V2ApiKey": {
        "type": "object",
        "required": [
          "id",
          "name",
          "prefix",
          "scopes",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "type": "string",
            "example": "p2m_AbC123"
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "last_used_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "V2ApiKeyCreateRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 128
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "jobs:create",
                "jobs:read",
                "jobs:download",
                "jobs:delete",
                "settings:read",
                "settings:write"
              ]
            }
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "V2ApiKeyCreated": {
        "allOf": [
          {
            "$ref": "#/components/schemas/V2ApiKey"
          },
          {
            "type": "object",
            "required": [
              "secret"
            ],
            "properties": {
              "secret": {
                "type": "string",
                "description": "Full secret p2m_... — shown ONCE; only the hash is stored on the server (security.md)",
                "example": "p2m_AbC123def456..."
              }
            }
          }
        ]
      },
      "V2JobOptions": {
        "type": "object",
        "description": "Per-job conversion options (additive, like v1 device_settings). Applied by the v2 create endpoints (EP-026); each unset field falls back to the server default. Unlike v1 (which copies the device's stored /settings at create), v2 API jobs are deviceless, so options are passed per request here.",
        "properties": {
          "image_mode": {
            "$ref": "#/components/schemas/ImageMode"
          },
          "ocr_enabled": {
            "type": "boolean"
          },
          "engine": {
            "$ref": "#/components/schemas/Engine"
          },
          "conversion_mode": {
            "$ref": "#/components/schemas/ConversionMode"
          },
          "ocr_lang": {
            "$ref": "#/components/schemas/OCRLang"
          }
        }
      },
      "V2Job": {
        "type": "object",
        "required": [
          "job_id",
          "status",
          "file_name",
          "created_at",
          "tier"
        ],
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "file_name": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "status_since": {
            "type": "string",
            "format": "date-time"
          },
          "pages": {
            "type": "integer",
            "nullable": true
          },
          "truncated": {
            "type": "boolean",
            "default": false
          },
          "output_size": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          },
          "download_url": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "populated for ready"
          },
          "tier": {
            "$ref": "#/components/schemas/V2Tier"
          },
          "slot_usage": {
            "type": "object",
            "properties": {
              "used": {
                "type": "integer"
              },
              "limit": {
                "type": "integer"
              }
            }
          },
          "external_id": {
            "type": "string",
            "nullable": true
          },
          "tags": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          }
        }
      },
      "V2CreateJobFromUrlRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "file_name": {
            "type": "string",
            "maxLength": 255
          },
          "options": {
            "$ref": "#/components/schemas/V2JobOptions"
          },
          "external_id": {
            "type": "string"
          },
          "tags": {
            "type": "object",
            "additionalProperties": true
          },
          "callback_url": {
            "type": "string",
            "format": "uri",
            "description": "v2 §6.8 per-job callback (optional)"
          },
          "callback_secret": {
            "type": "string",
            "description": "secret for signing the per-job callback"
          }
        }
      },
      "V2CreateJobFromFileRequest": {
        "type": "object",
        "required": [
          "file"
        ],
        "properties": {
          "file": {
            "type": "string",
            "format": "binary"
          },
          "file_name": {
            "type": "string",
            "maxLength": 255
          },
          "options": {
            "$ref": "#/components/schemas/V2JobOptions"
          },
          "external_id": {
            "type": "string"
          },
          "callback_url": {
            "type": "string",
            "format": "uri",
            "description": "v2 §6.8 per-job callback (optional, paid-gated, https/public)"
          },
          "callback_secret": {
            "type": "string",
            "description": "secret for X-P2M-Signature of the per-job callback"
          }
        }
      },
      "V2BatchCreateRequest": {
        "type": "object",
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "description": "≤ the number of the actor's free slots; otherwise 409 slots_full (batch does not bypass the slot limit, §4)",
            "items": {
              "$ref": "#/components/schemas/V2CreateJobFromUrlRequest"
            }
          }
        }
      },
      "V2Webhook": {
        "type": "object",
        "required": [
          "id",
          "url",
          "events",
          "enabled"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "events": {
            "type": "array",
            "description": "subscribed events; [] = all",
            "items": {
              "type": "string",
              "enum": [
                "job.ready",
                "job.error",
                "job.deleted",
                "job.truncated"
              ]
            }
          },
          "description": {
            "type": "string"
          },
          "enabled": {
            "type": "boolean"
          },
          "failure_count": {
            "type": "integer",
            "description": "consecutive failed exhausted deliveries"
          },
          "last_delivery_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "V2WebhookCreateRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "https, public host (SSRF-guard)"
          },
          "events": {
            "type": "array",
            "description": "empty = subscribe to all events",
            "items": {
              "type": "string",
              "enum": [
                "job.ready",
                "job.error",
                "job.deleted",
                "job.truncated"
              ]
            }
          },
          "description": {
            "type": "string",
            "maxLength": 256
          }
        }
      },
      "V2WebhookCreated": {
        "allOf": [
          {
            "$ref": "#/components/schemas/V2Webhook"
          },
          {
            "type": "object",
            "required": [
              "secret"
            ],
            "properties": {
              "secret": {
                "type": "string",
                "description": "Signing secret (whsec_…) for X-P2M-Signature — shown ONCE"
              }
            }
          }
        ]
      },
      "V2WebhookDelivery": {
        "type": "object",
        "description": "EP-023-T10: one delivery attempt (history for debugging/support).",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "webhook_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "null = per-job callback"
          },
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "event": {
            "type": "string",
            "enum": [
              "job.ready",
              "job.error",
              "job.deleted",
              "job.truncated"
            ]
          },
          "target_url": {
            "type": "string",
            "format": "uri"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "sending",
              "delivered",
              "failed",
              "exhausted"
            ]
          },
          "attempts": {
            "type": "integer"
          },
          "max_attempts": {
            "type": "integer",
            "description": "per-tier retry budget"
          },
          "response_code": {
            "type": "integer",
            "nullable": true
          },
          "last_error": {
            "type": "string",
            "nullable": true
          },
          "last_attempt_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "V2BillingPlan": {
        "type": "object",
        "required": [
          "tier_code",
          "interval",
          "amount",
          "currency",
          "price_id"
        ],
        "properties": {
          "tier_code": {
            "$ref": "#/components/schemas/V2Tier"
          },
          "interval": {
            "type": "string",
            "enum": [
              "month",
              "year"
            ]
          },
          "amount": {
            "type": "number",
            "example": 9
          },
          "currency": {
            "type": "string",
            "example": "USD"
          },
          "price_id": {
            "type": "string",
            "description": "monetize priceId",
            "example": "10127"
          },
          "recommended": {
            "type": "boolean",
            "default": false
          },
          "current": {
            "type": "boolean",
            "default": false,
            "description": "the active subscription's plan (if the session is known)"
          }
        }
      },
      "V2CheckoutRequest": {
        "type": "object",
        "description": "Specify price_id OR (tier_code+interval). email is required only for the anonymous flow.",
        "properties": {
          "price_id": {
            "type": "string"
          },
          "tier_code": {
            "$ref": "#/components/schemas/V2Tier"
          },
          "interval": {
            "type": "string",
            "enum": [
              "month",
              "year"
            ]
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "for anonymous checkout (if there is no session)"
          },
          "channel": {
            "type": "string",
            "enum": [
              "extension",
              "web"
            ],
            "description": "Checkout source for runtime purchase flags (EP-023-T23). Treat absence as web for site/pricing or by the calling client."
          },
          "trial_days": {
            "type": "integer",
            "nullable": true
          },
          "upgrade": {
            "type": "boolean",
            "default": false,
            "deprecated": true,
            "description": "IGNORED since EP-062-T4 (the server decides from the stored subscription whether ignoreActivePurchase applies). Still accepted so already-published extension builds keep working."
          },
          "intent_token": {
            "type": "string",
            "description": "EP-028: device-signed checkout-intent token (from POST /billing/checkout-intent). When valid it names the account to charge (the extension's account), overriding the web session. One-time consume; invalid/expired falls back to session/email."
          }
        }
      },
      "V2CheckoutResponse": {
        "type": "object",
        "required": [
          "checkout_url"
        ],
        "properties": {
          "checkout_url": {
            "type": "string",
            "format": "uri",
            "description": "open in a new tab (hosted by Stripe/Paddle)"
          }
        }
      },
      "V2PortalResponse": {
        "type": "object",
        "required": [
          "portal_url"
        ],
        "properties": {
          "portal_url": {
            "type": "string",
            "format": "uri",
            "description": "customer portal",
            "open in a new tab": null
          }
        }
      },
      "V2PurchaseAvailability": {
        "type": "object",
        "description": "EP-023-T23 runtime flags returned by GET /billing/plans. The extension and website hide/disable new-checkout CTAs based on these; checkout itself is NOT gated server-side (UI-only).",
        "required": [
          "extension",
          "web"
        ],
        "properties": {
          "extension": {
            "type": "boolean",
            "description": "Whether new purchases can be started from the extension UI"
          },
          "web": {
            "type": "boolean",
            "description": "Whether new purchases can be started on THIS request's website domain (EP-024: resolved per-domain server-side)"
          },
          "reason": {
            "type": "string",
            "nullable": true,
            "description": "Optional operator-facing reason for disabling"
          }
        }
      },
      "V2AdminPurchaseFlags": {
        "type": "object",
        "description": "EP-024 admin view of purchase flags: the extension flag plus the per-domain web flags.",
        "required": [
          "extension",
          "web"
        ],
        "properties": {
          "extension": {
            "type": "boolean",
            "description": "Whether new extension purchases are enabled"
          },
          "web": {
            "type": "object",
            "additionalProperties": {
              "type": "boolean"
            },
            "description": "Per-domain web purchase flags keyed by public host, e.g. {\"pdf2md.dev\": true, \"pdf2md.huskyhaul.online\": false}"
          }
        }
      }
    }
  }
}
