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