{
  "openapi": "3.1.0",
  "info": {
    "title": "PDF to Markdown API Facade",
    "version": "2.0.0",
    "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"
                }
              }
            }
          }
        }
      }
    },
    "/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}": {
      "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/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"
                      }
                    }
                  }
                }
              }
            }
          },
          "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 (slots_full)",
            "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": "Batch exceeds free slots (slots_full)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/V2RateLimited"
          }
        }
      }
    },
    "/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"
                      }
                    }
                  }
                }
              }
            }
          },
          "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; upgrade=true → ignoreActivePurchase. Returns checkout_url to open in a tab; the provider userId is saved as monetize_customer_id.\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"
                }
              }
            }
          },
          "403": {
            "description": "New purchases disabled for the given channel (purchases_disabled)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "409": {
            "description": "Active subscription (active_subscription) — direct to portal",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2Error"
                }
              }
            }
          },
          "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). Requires a web session (email is taken from the session). x-api-key only on the server.",
        "security": [
          {
            "Session": []
          }
        ],
        "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/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.\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 (including duplicate)"
          },
          "401": {
            "description": "Invalid signature"
          }
        }
      }
    },
    "/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 per-channel purchase-availability flags (EP-023-T23)",
        "description": "ADMIN_SECRET (404 if not set). Returns {extension, web}.",
        "security": [
          {
            "AdminSecret": []
          }
        ],
        "responses": {
          "200": {
            "description": "Current flags",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2PurchaseAvailability"
                }
              }
            }
          },
          "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 (audited)",
        "description": "EP-023-T23. 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. CLI: deploy/set-purchase-flag.sh.\n",
        "security": [
          {
            "AdminSecret": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "channel",
                  "enabled"
                ],
                "properties": {
                  "channel": {
                    "type": "string",
                    "enum": [
                      "extension",
                      "web"
                    ]
                  },
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New flags",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/V2PurchaseAvailability"
                }
              }
            }
          },
          "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."
      }
    },
    "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
          },
          "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"
          }
        }
      },
      "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"
          }
        }
      },
      "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"
      },
      "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",
            "example": "rate_limited"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "V2Tier": {
        "type": "string",
        "enum": [
          "anonymous",
          "free",
          "builder",
          "pro",
          "business",
          "enterprise"
        ]
      },
      "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"
          }
        }
      },
      "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)"
          }
        }
      },
      "V2Me": {
        "type": "object",
        "required": [
          "account_id",
          "email",
          "tier"
        ],
        "properties": {
          "account_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string"
          },
          "display_name": {
            "type": "string",
            "nullable": true
          },
          "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
          },
          "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": "paid only; free → no hard cap"
          },
          "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": "Conversion options (additive, like v1 device_settings)",
        "properties": {
          "image_mode": {
            "$ref": "#/components/schemas/ImageMode"
          },
          "ocr_enabled": {
            "type": "boolean"
          },
          "engine": {
            "$ref": "#/components/schemas/Engine"
          },
          "conversion_mode": {
            "$ref": "#/components/schemas/ConversionMode"
          }
        }
      },
      "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,
            "description": "explicit upgrade → ignoreActivePurchase=true (EP-023-T8)"
          }
        }
      },
      "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. The UI may hide new checkout CTAs, but enforcement is done by POST /billing/checkout.",
        "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 the website/pricing/web app"
          },
          "reason": {
            "type": "string",
            "nullable": true,
            "description": "Optional operator-facing reason for disabling"
          }
        }
      }
    }
  }
}
