{
  "openapi": "3.1.0",
  "info": {
    "title": "Recon API",
    "description": "Domain intelligence API for .se domains. Live whois, CommonCrawl depth, backlinks, webgraph, Wayback snapshots, LLM identity summaries.",
    "version": "1.0.0",
    "contact": {
      "name": "Recon",
      "url": "https://recon.blpk.cc"
    }
  },
  "servers": [
    {
      "url": "https://recon.blpk.cc",
      "description": "Production"
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "API key — use value `ddash`"
      }
    },
    "schemas": {
      "InvestigateResponse": {
        "type": "object",
        "properties": {
          "domain": {
            "type": "string",
            "description": "The investigated domain",
            "example": "example.se"
          },
          "in_database": {
            "type": "boolean",
            "description": "Whether domain exists in recon.domains"
          },
          "whois": {
            "$ref": "#/components/schemas/Whois"
          },
          "identity_summary": {
            "type": "string",
            "nullable": true,
            "description": "LLM-generated summary of what the site was about (Gemma via llm.bofrid.dev)",
            "example": "Wildlife Sweden is a tourism website offering nature-based experiences such as wildlife watching and fly fishing at Camp Ängra."
          },
          "authority": {
            "$ref": "#/components/schemas/Authority"
          },
          "cc": {
            "$ref": "#/components/schemas/CommonCrawl"
          },
          "backlinks": {
            "$ref": "#/components/schemas/Backlinks"
          },
          "wayback": {
            "$ref": "#/components/schemas/WaybackSnapshot"
          },
          "content": {
            "$ref": "#/components/schemas/Content"
          },
          "quality": {
            "$ref": "#/components/schemas/Quality"
          },
          "history": {
            "$ref": "#/components/schemas/History"
          },
          "db_row": {
            "$ref": "#/components/schemas/DbRow"
          }
        }
      },
      "Whois": {
        "type": "object",
        "description": "Live whois result from Domänregister (always performed, persists to recon.domains.whois_*)",
        "properties": {
          "available": {
            "type": "boolean",
            "description": "Domain is free to register"
          },
          "state": {
            "type": "string",
            "nullable": true,
            "description": "IIS state: active, expired, quarantine, deactivated, free",
            "example": "quarantine"
          },
          "registrar": {
            "type": "string",
            "nullable": true,
            "example": "InterNetX GmbH"
          },
          "holder": {
            "type": "string",
            "nullable": true,
            "example": "(not shown)"
          },
          "created": {
            "type": "string",
            "nullable": true,
            "example": "2017-05-26"
          },
          "expires": {
            "type": "string",
            "nullable": true,
            "example": "2026-05-26"
          },
          "release_at": {
            "type": "string",
            "nullable": true,
            "description": "Date domain becomes free (quarantine end)",
            "example": "2026-06-05"
          },
          "deactivation_date": {
            "type": "string",
            "nullable": true
          },
          "nameservers": {
            "type": "string",
            "nullable": true,
            "description": "Newline-separated NS records"
          },
          "dnssec": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "nullable": true,
            "description": "IIS status flags (ok, inactive, pendingDelete, serverHold)"
          },
          "registry_lock": {
            "type": "string",
            "nullable": true
          },
          "raw": {
            "type": "string",
            "nullable": true,
            "description": "Full raw whois response text"
          },
          "error": {
            "type": "string",
            "nullable": true
          },
          "related_domains": {
            "type": "object",
            "nullable": true,
            "description": "OPR, expiring, zone data from the persist_whois_lookup side-effects",
            "properties": {
              "opr": {
                "type": "object",
                "nullable": true,
                "properties": {
                  "rank": {
                    "type": "integer"
                  },
                  "rank_decimal": {
                    "type": "number"
                  },
                  "checked_at": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              },
              "expiring_release_at": {
                "type": "string",
                "nullable": true
              },
              "zone_status": {
                "type": "string",
                "nullable": true
              }
            }
          }
        }
      },
      "Authority": {
        "type": "object",
        "nullable": true,
        "description": "Authority signals from OpenPageRank + CC webgraph",
        "properties": {
          "opr_score": {
            "type": "number",
            "nullable": true,
            "description": "OpenPageRank score (0-10)",
            "example": 4.16
          },
          "opr_rank_decimal": {
            "type": "number",
            "nullable": true
          },
          "opr_status": {
            "type": "string",
            "nullable": true
          },
          "cc_pagerank": {
            "type": "number",
            "nullable": true,
            "description": "CommonCrawl PageRank"
          },
          "cc_harmonic": {
            "type": "number",
            "nullable": true,
            "description": "CommonCrawl harmonic centrality"
          }
        }
      },
      "CommonCrawl": {
        "type": "object",
        "nullable": true,
        "description": "CommonCrawl signals: pages, crawls, webgraph edges, per-crawl breakdowns",
        "properties": {
          "crawls_seen": {
            "type": "integer",
            "description": "Number of CC crawls this domain appeared in",
            "example": 15
          },
          "inbound_edges": {
            "type": "integer",
            "nullable": true,
            "description": "CC webgraph inbound edges (other domains linking in)"
          },
          "outbound_edges": {
            "type": "integer",
            "nullable": true,
            "description": "CC webgraph outbound edges"
          },
          "inbound_periods": {
            "type": "integer",
            "nullable": true
          },
          "first_seen": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_seen": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "top_pages": {
            "type": "array",
            "description": "Top captured URLs (up to 50)",
            "items": {
              "type": "object",
              "properties": {
                "url": {
                  "type": "string"
                },
                "captures": {
                  "type": "integer"
                },
                "fetch_status": {
                  "type": "integer",
                  "nullable": true
                },
                "content_mime": {
                  "type": "string",
                  "nullable": true
                },
                "first_seen_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "last_seen_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "crawl": {
                  "type": "string"
                }
              }
            }
          },
          "redirects": {
            "type": "array",
            "description": "CC redirect observations (up to 30)",
            "items": {
              "type": "object",
              "properties": {
                "from_url": {
                  "type": "string"
                },
                "fetch_status": {
                  "type": "integer"
                },
                "to_url": {
                  "type": "string",
                  "nullable": true
                },
                "to_host": {
                  "type": "string",
                  "nullable": true
                },
                "captures": {
                  "type": "integer"
                },
                "crawl": {
                  "type": "string"
                }
              }
            }
          },
          "domain_seen": {
            "type": "array",
            "description": "Per-crawl summary (captures, unique URLs, Swedish/English page counts)",
            "items": {
              "type": "object",
              "properties": {
                "crawl": {
                  "type": "string",
                  "example": "CC-MAIN-2024-22"
                },
                "subset": {
                  "type": "string"
                },
                "captures": {
                  "type": "integer"
                },
                "unique_urls": {
                  "type": "integer"
                },
                "unique_hosts": {
                  "type": "integer"
                },
                "days_seen": {
                  "type": "integer"
                },
                "swe_pages": {
                  "type": "integer"
                },
                "eng_pages": {
                  "type": "integer"
                },
                "html_pages": {
                  "type": "integer"
                },
                "non_html_pages": {
                  "type": "integer"
                },
                "first_seen_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "last_seen_at": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          }
        }
      },
      "Backlinks": {
        "type": "object",
        "description": "CC backlinks (WAT-extracted <a href> links pointing to this domain)",
        "properties": {
          "agg": {
            "type": "object",
            "nullable": true,
            "description": "Aggregate from cc_backlinks_agg materialized view",
            "properties": {
              "target_host": {
                "type": "string"
              },
              "ref_domains": {
                "type": "integer"
              },
              "ref_links": {
                "type": "integer"
              },
              "first_crawl": {
                "type": "string"
              },
              "last_crawl": {
                "type": "string"
              },
              "crawls_seen": {
                "type": "integer"
              }
            }
          },
          "rows": {
            "type": "array",
            "description": "Sample backlink rows (up to 100)",
            "items": {
              "type": "object",
              "properties": {
                "referring_host": {
                  "type": "string",
                  "example": "gamedev.net"
                },
                "referring_url": {
                  "type": "string"
                },
                "anchor_text": {
                  "type": "string",
                  "nullable": true
                },
                "target_path": {
                  "type": "string"
                },
                "crawl_id": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "WaybackSnapshot": {
        "type": "object",
        "nullable": true,
        "description": "Most recent Wayback Machine HTML snapshot — extracted title, description, h1, text sample",
        "properties": {
          "title": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "h1": {
            "type": "string",
            "nullable": true
          },
          "text_sample": {
            "type": "string",
            "description": "First ~500 chars of visible text (tags stripped)",
            "nullable": true
          },
          "snapshot_url": {
            "type": "string",
            "description": "Wayback Machine URL",
            "example": "https://web.archive.org/web/20210226044928/wildlifesweden.se"
          }
        }
      },
      "Content": {
        "type": "object",
        "nullable": true,
        "description": "Scraped meta + LLM classification from recon.domains",
        "properties": {
          "meta_title": {
            "type": "string",
            "nullable": true
          },
          "meta_description": {
            "type": "string",
            "nullable": true
          },
          "primary_category": {
            "type": "string",
            "nullable": true,
            "example": "education"
          },
          "primary_category_score": {
            "type": "number",
            "nullable": true
          },
          "categories": {
            "type": "object",
            "nullable": true,
            "description": "Full category classification (jsonb)"
          }
        }
      },
      "Quality": {
        "type": "object",
        "nullable": true,
        "description": "LLM quality scores (0-100) from domain name scoring",
        "properties": {
          "overall": {
            "type": "integer",
            "nullable": true
          },
          "swedish_word": {
            "type": "integer",
            "nullable": true,
            "description": "How much the name reads as Swedish"
          },
          "brand": {
            "type": "integer",
            "nullable": true,
            "description": "Recognized brand score"
          },
          "link_magnet": {
            "type": "integer",
            "nullable": true,
            "description": "Organic backlink potential"
          },
          "reasoning": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "History": {
        "type": "object",
        "nullable": true,
        "description": "Domain lifecycle — zone file presence, certificate transparency, Wayback years",
        "properties": {
          "first_source": {
            "type": "string",
            "nullable": true,
            "description": "How this domain was discovered (zone, expiring, commoncrawl, wayback, crtsh)"
          },
          "first_seen_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "zone_status": {
            "type": "string",
            "nullable": true,
            "description": "IIS .se zone file status (active, released)"
          },
          "zone_first_seen_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "zone_last_seen_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "zone_dropped_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "crtsh_first_seen": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "crtsh_last_seen": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "wayback_first_year": {
            "type": "integer",
            "nullable": true
          },
          "wayback_last_year": {
            "type": "integer",
            "nullable": true
          }
        }
      },
      "DbRow": {
        "type": "object",
        "nullable": true,
        "description": "Internal recon.domains state",
        "properties": {
          "id": {
            "type": "integer"
          },
          "available": {
            "type": "boolean"
          },
          "buy_status": {
            "type": "string",
            "nullable": true
          },
          "is_watched": {
            "type": "boolean"
          },
          "notes": {
            "type": "string",
            "nullable": true
          },
          "whois_db": {
            "type": "object",
            "description": "Last persisted whois state (may differ from whois_live)",
            "properties": {
              "state": {
                "type": "string",
                "nullable": true
              },
              "registrar": {
                "type": "string",
                "nullable": true
              },
              "release_at": {
                "type": "string",
                "nullable": true
              },
              "expires_at": {
                "type": "string",
                "nullable": true
              },
              "last_checked_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "nameservers": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "nullable": true
              }
            }
          }
        }
      },
      "LookupResponse": {
        "type": "object",
        "description": "Full availability cascade result for one domain",
        "properties": {
          "domain": {
            "type": "string",
            "example": "example.se"
          },
          "available": {
            "type": "boolean"
          },
          "whois_state": {
            "type": "string",
            "nullable": true,
            "example": "active"
          },
          "whois_registrar": {
            "type": "string",
            "nullable": true
          },
          "whois_release_at": {
            "type": "string",
            "nullable": true
          },
          "whois_expires_at": {
            "type": "string",
            "nullable": true
          },
          "opr_score": {
            "type": "number",
            "nullable": true
          },
          "opr_rank": {
            "type": "integer",
            "nullable": true
          },
          "meta_title": {
            "type": "string",
            "nullable": true
          },
          "meta_description": {
            "type": "string",
            "nullable": true
          },
          "primary_category": {
            "type": "string",
            "nullable": true
          },
          "zone_status": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "WhoisLookupResponse": {
        "type": "object",
        "description": "Single whois lookup result (persisted to recon.domains)",
        "properties": {
          "domain": {
            "type": "string",
            "example": "example.se"
          },
          "available": {
            "type": "boolean"
          },
          "state": {
            "type": "string",
            "nullable": true
          },
          "registrar": {
            "type": "string",
            "nullable": true
          },
          "release_at": {
            "type": "string",
            "nullable": true
          },
          "expires_at": {
            "type": "string",
            "nullable": true
          },
          "nameservers": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "nullable": true
          },
          "raw": {
            "type": "string",
            "nullable": true
          },
          "error": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "ProspectsPageRequest": {
        "type": "object",
        "properties": {
          "page": {
            "type": "integer",
            "default": 1
          },
          "limit": {
            "type": "integer",
            "default": 50,
            "maximum": 200
          },
          "sort": {
            "type": "string",
            "enum": [
              "opr.desc",
              "opr.asc",
              "release.asc",
              "release.desc",
              "domain.asc",
              "domain.desc"
            ],
            "default": "opr.desc"
          },
          "filters": {
            "type": "object",
            "properties": {
              "is_prospect": {
                "type": "boolean"
              },
              "is_watched": {
                "type": "boolean"
              },
              "available": {
                "type": "boolean"
              },
              "release_bucket": {
                "type": "string",
                "enum": [
                  "today",
                  "this_week",
                  "this_month",
                  "future",
                  "occupied"
                ]
              },
              "tld": {
                "type": "string",
                "example": "se"
              },
              "domain_contains": {
                "type": "string"
              },
              "opr_min": {
                "type": "number"
              },
              "opr_max": {
                "type": "number"
              },
              "category": {
                "type": "string"
              },
              "buy_status": {
                "type": "string"
              }
            }
          }
        }
      },
      "ProspectsPageResponse": {
        "type": "object",
        "properties": {
          "rows": {
            "type": "array",
            "items": {
              "type": "object",
              "description": "recon.domains row (subset of columns for list view)"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total matching rows (for pagination)"
          },
          "page": {
            "type": "integer"
          },
          "limit": {
            "type": "integer"
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          }
        }
      }
    }
  },
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "paths": {
    "/api/investigate": {
      "get": {
        "tags": [
          "Investigation"
        ],
        "operationId": "investigateDomain",
        "summary": "Full investigation of a single domain",
        "description": "Returns every signal we have for a domain: live whois (always, persists to DB), OPR authority, CC pages/crawls/inbound/outbound edges, backlinks, redirects, Wayback HTML snapshot, LLM identity summary, quality scores, zone/cert history.",
        "parameters": [
          {
            "name": "domain",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "example.se"
            },
            "description": "Domain to investigate"
          },
          {
            "name": "wayback",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "0",
                "1"
              ],
              "default": "1"
            },
            "description": "Fetch Wayback Machine HTML snapshot (default on)"
          },
          {
            "name": "llm",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "0",
                "1"
              ],
              "default": "1"
            },
            "description": "Generate LLM identity summary (default on)"
          }
        ],
        "responses": {
          "200": {
            "description": "Full domain investigation",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvestigateResponse"
                }
              }
            }
          },
          "400": {
            "description": "Missing domain parameter",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/lookup": {
      "post": {
        "tags": [
          "Lookup"
        ],
        "operationId": "lookupDomain",
        "summary": "Full availability cascade for one domain",
        "description": "Cheapest-signal-first cascade: zone check → whois → OPR → meta → classify. Persists results to recon.domains.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string",
                    "example": "example.se"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Availability cascade result",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LookupResponse"
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid domain",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/lookup/bulk": {
      "post": {
        "tags": [
          "Lookup"
        ],
        "operationId": "lookupBulk",
        "summary": "Bulk availability lookup",
        "description": "Run the full availability cascade on multiple domains in parallel.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domains"
                ],
                "properties": {
                  "domains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "example": [
                      "example.se",
                      "test.se"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Array of lookup results",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/LookupResponse"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/whois/lookup": {
      "post": {
        "tags": [
          "Whois"
        ],
        "operationId": "whoisLookup",
        "summary": "Single whois lookup (persists to DB)",
        "description": "Scrapes Domänregister for the given domain and persists to recon.domains.whois_*.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string",
                    "example": "example.se"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Whois result",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WhoisLookupResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/whois/bulk": {
      "post": {
        "tags": [
          "Whois"
        ],
        "operationId": "whoisBulk",
        "summary": "Fire-and-forget bulk whois run",
        "description": "Kicks off an async bulk whois fill. Returns a run_id to track progress.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "limit": {
                    "type": "integer",
                    "default": 100
                  },
                  "tld": {
                    "type": "string",
                    "example": "se"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Run started",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "run_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/whois/bulk/{id}/cancel": {
      "post": {
        "tags": [
          "Whois"
        ],
        "operationId": "whoisBulkCancel",
        "summary": "Cancel a bulk whois run",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Run ID returned from POST /api/whois/bulk"
          }
        ],
        "responses": {
          "200": {
            "description": "Run cancelled"
          }
        }
      }
    },
    "/api/whois/fill-missing": {
      "post": {
        "tags": [
          "Whois"
        ],
        "operationId": "whoisFillMissing",
        "summary": "SSE stream — fill missing whois",
        "description": "Server-sent events stream. Runs until all domains missing whois are checked or the connection is closed.",
        "responses": {
          "200": {
            "description": "SSE stream of fill progress events",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/whois/fill-missing/tick": {
      "post": {
        "tags": [
          "Whois"
        ],
        "operationId": "whoisFillMissingTick",
        "summary": "Single fill tick — whois",
        "description": "Runs one batch of whois lookups on domains with missing whois. Driven by the web fill-tick (Coolify schedule); also hit manually.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100
            },
            "description": "Max domains to check per tick"
          },
          {
            "name": "triggered_by",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "tui"
            },
            "description": "Audit tag (tui, manual, cron)"
          },
          {
            "name": "tld",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "se"
            },
            "description": "Restrict to a single TLD"
          }
        ],
        "responses": {
          "200": {
            "description": "Tick summary with ok/fail counts"
          }
        }
      }
    },
    "/api/whois/recheck-released/tick": {
      "post": {
        "tags": [
          "Whois"
        ],
        "operationId": "whoisRecheckReleasedTick",
        "summary": "Release-aware whois recheck tick",
        "description": "Re-verifies .se/.nu domains whose IIS bardate (whois_release_at) just passed but were never whois-checked since the drop, so `available` stops reflecting stale pre-drop state. Targets from recon.whois_release_recheck_targets, OPR-desc. Drive from a Coolify scheduled task.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 200
            },
            "description": "Max domains to recheck per tick (max 1000)"
          },
          {
            "name": "concurrency",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 6
            },
            "description": "Parallel lookups (max 12)"
          },
          {
            "name": "delay_ms",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 0
            },
            "description": "Gap between lookups in ms (max 5000)"
          },
          {
            "name": "triggered_by",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "coolify"
            },
            "description": "Audit tag"
          }
        ],
        "responses": {
          "200": {
            "description": "Tick summary with succeeded/failed + freed (newly available) count"
          }
        }
      }
    },
    "/api/digest/buy-candidates/tick": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "buyCandidatesDigestTick",
        "summary": "Daily dropcatch buy digest",
        "description": "Pulls the top buyable .se/.nu prospects (recon.buy_digest_candidates) — available-now ranked by live BLA, plus dropping-soon dropcatch targets — and emails them via mejl.to to DIGEST_EMAIL_TO. Recipient is env-fixed (no relay). Drive from a Coolify daily scheduled task.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20
            },
            "description": "Rows per section (max 100)"
          },
          {
            "name": "drop_days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 30
            },
            "description": "Dropcatch horizon in days (max 90)"
          },
          {
            "name": "dry_run",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "1"
            },
            "description": "1 = compute + count only, don't send"
          }
        ],
        "responses": {
          "200": {
            "description": "Digest summary: available_now/dropping_soon counts, sent flag, email_id"
          }
        }
      }
    },
    "/api/whois/recheck-stale/tick": {
      "post": {
        "tags": [
          "Whois"
        ],
        "operationId": "whoisRecheckStaleTick",
        "summary": "Permanent-coverage whois recheck tick",
        "description": "Companion to recheck-released. Drains the tail the release window can't see — not-available .se/.nu occupied rows with no scheduled drop (whois_release_at IS NULL) or a drop older than the window — re-verifying any row not whois-checked in 30 days, so silent lapses still surface under iis=available. Targets from recon.whois_stale_recheck_targets, OPR-desc. Low-frequency Coolify scheduled task.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100
            },
            "description": "Max domains to recheck per tick (max 1000)"
          },
          {
            "name": "concurrency",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 6
            },
            "description": "Parallel lookups (max 12)"
          },
          {
            "name": "delay_ms",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 0
            },
            "description": "Gap between lookups in ms (max 5000)"
          },
          {
            "name": "triggered_by",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "coolify"
            },
            "description": "Audit tag"
          }
        ],
        "responses": {
          "200": {
            "description": "Tick summary with succeeded/failed + freed (newly available) count"
          }
        }
      }
    },
    "/api/domains/{domain}/cc": {
      "get": {
        "tags": [
          "Domains"
        ],
        "operationId": "getDomainCc",
        "summary": "CC signals for a domain",
        "description": "Returns CommonCrawl top pages, redirects, and per-crawl domain_seen rows for the given domain.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "example.se"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "CC data",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommonCrawl"
                }
              }
            }
          }
        }
      }
    },
    "/api/domains/{domain}/cc/refresh": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "refreshDomainCc",
        "summary": "Refresh CC data for a domain",
        "description": "Triggers a fresh CommonCrawl ingest/lookup for this domain.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Refresh result"
          }
        }
      }
    },
    "/api/domains/{domain}/backlinks-cc": {
      "get": {
        "tags": [
          "Domains"
        ],
        "operationId": "getDomainBacklinksCC",
        "summary": "CC backlinks for a domain",
        "description": "Returns WAT-extracted backlink rows pointing to this domain from the CC corpus.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Backlinks",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Backlinks"
                }
              }
            }
          }
        }
      }
    },
    "/api/domains/{domain}/bla": {
      "get": {
        "tags": [
          "Domains"
        ],
        "operationId": "getDomainBlaBreakdown",
        "summary": "Backlink Authority (BLA) breakdown for a domain",
        "description": "Transparent breakdown of recon.domains.backlinks_authority_score. Returns the summary (distinct_referrers, raw, score, denom_raw) plus the ordered list of CC-webgraph referring domains, each with its cc_inbound_edges and the per-referrer contribution = ln(1 + inbound) it adds to `raw`. Wraps recon.get_bla_breakdown(domain, limit).",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Max referrers to return (1-5000, default 1000), ordered by contribution.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "BLA breakdown",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "found": {
                      "type": "boolean"
                    },
                    "domain": {
                      "type": "string"
                    },
                    "score": {
                      "type": [
                        "number",
                        "null"
                      ],
                      "description": "stored backlinks_authority_score (0-100)"
                    },
                    "distinct_referrers": {
                      "type": "integer",
                      "description": "distinct source domains linking here in the CC host-level webgraph"
                    },
                    "raw": {
                      "type": "number",
                      "description": "sum of per-referrer contributions = Σ ln(1 + cc_inbound_edges)"
                    },
                    "denom_raw": {
                      "type": "number",
                      "description": "raw value that maps to score=100 (currently 5000)"
                    },
                    "shown": {
                      "type": "integer",
                      "description": "referrers returned (capped by limit)"
                    },
                    "referrers": {
                      "type": "array",
                      "description": "source domains ordered by contribution desc",
                      "items": {
                        "type": "object",
                        "properties": {
                          "domain": {
                            "type": "string",
                            "description": "the referring (source) domain"
                          },
                          "cc_inbound_edges": {
                            "type": [
                              "integer",
                              "null"
                            ],
                            "description": "the referrer's own inbound-edge count"
                          },
                          "ref_bla": {
                            "type": [
                              "number",
                              "null"
                            ],
                            "description": "the referrer's own backlinks_authority_score"
                          },
                          "opr_rank": {
                            "type": [
                              "integer",
                              "null"
                            ]
                          },
                          "available": {
                            "type": [
                              "boolean",
                              "null"
                            ],
                            "description": "is the referring domain itself buyable"
                          },
                          "whois_released_at": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          },
                          "http_status": {
                            "type": [
                              "integer",
                              "null"
                            ],
                            "description": "last http-check status of the referrer (is it still live)"
                          },
                          "is_parked": {
                            "type": [
                              "boolean",
                              "null"
                            ]
                          },
                          "http_checked_at": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          },
                          "dns_resolves": {
                            "type": [
                              "boolean",
                              "null"
                            ]
                          },
                          "contribution": {
                            "type": [
                              "number",
                              "null"
                            ],
                            "description": "ln(1 + cc_inbound_edges), this referrer's add to raw"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Domain not found"
          }
        }
      }
    },
    "/api/domains/{domain}/identity": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "domainIdentity",
        "summary": "Gemini identity evaluation",
        "description": "Runs a Gemini LLM identity eval for the domain — generates or refreshes the identity_summary.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Identity summary result"
          }
        }
      }
    },
    "/api/domains/{domain}/site-identity": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "domainSiteIdentity",
        "summary": "Site identity from archived CC + Wayback + model knowledge",
        "description": "Generates a full site identity by reasoning over archived signals — CommonCrawl backlinks across every ingested crawl, the most-recent crawl's top pages, redirects, per-crawl language/size stats, and Wayback HTML snapshots — together with the model's own knowledge of the domain/brand. Persists the complete recon.domains.identity_* set (description, theme, topics[], keywords[], reasoning, score 0-100, score_reasoning) plus backlinks_count/sample. Distinct from POST /api/domains/{domain}/identity, which uses Gemini grounded web search.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Full identity_* set + model + backlinks_count/sample + inputs + persisted flag"
          },
          "500": {
            "description": "Analysis failed"
          }
        }
      }
    },
    "/api/domains/{domain}/semrush/refresh": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "domainSemrushRefresh",
        "summary": "Refresh Semrush Authority Score",
        "description": "Fetches the Semrush Authority Score for the domain via the Semrush MCP and persists it to recon.domains.semrush_ascore.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Authority score refreshed"
          },
          "500": {
            "description": "Refresh failed"
          }
        }
      }
    },
    "/api/identity/bulk": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "identityBulk",
        "summary": "Bulk site-identity (fire-and-forget)",
        "description": "Kicks off an async bulk site-identity run over a list of domains (CC backlinks + Wayback + model knowledge → identity_*). Inserts a recon.bulk_identity_runs row and returns its run_id; progress is broadcast via Supabase realtime.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domains"
                ],
                "properties": {
                  "domains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "concurrency": {
                    "type": "integer",
                    "default": 4
                  },
                  "triggered_by": {
                    "type": "string",
                    "default": "dashboard"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Run started",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "run_id": {
                      "type": "integer"
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "No valid domains"
          }
        }
      }
    },
    "/api/identity/bulk/{id}/cancel": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "identityBulkCancel",
        "summary": "Cancel a bulk site-identity run",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Run ID returned from POST /api/identity/bulk"
          }
        ],
        "responses": {
          "200": {
            "description": "Run cancellation requested"
          }
        }
      }
    },
    "/api/semrush/bulk": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "semrushBulk",
        "summary": "Bulk-fill Semrush Authority Score (SSE)",
        "description": "Streams progress (SSE) while filling Semrush Authority Score for a batch of domains.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "domains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "limit": {
                    "type": "integer",
                    "default": 100
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "SSE progress stream",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/expiring/sync": {
      "get": {
        "tags": [
          "Recon"
        ],
        "operationId": "expiringSyncStatus",
        "summary": "IIS expiring-feed sync status",
        "description": "Returns the status of the IIS expiring-domains feed sync.",
        "responses": {
          "200": {
            "description": "Sync status"
          }
        }
      },
      "post": {
        "tags": [
          "Recon"
        ],
        "operationId": "expiringSyncRun",
        "summary": "Trigger IIS expiring-feed sync",
        "description": "Triggers a sync of the IIS nightly expiring-domains feed into recon.",
        "responses": {
          "200": {
            "description": "Sync triggered"
          }
        }
      }
    },
    "/api/domains/{domain}/web-search": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "domainWebSearch",
        "summary": "Gemini web search for a domain",
        "description": "Performs a Gemini-grounded web search to gather current signals about the domain.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Web search result"
          }
        }
      }
    },
    "/api/domains/{domain}/classify": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "domainClassify",
        "summary": "Reclassify domain",
        "description": "Re-runs the Gemini embed taxonomy classification for this domain and updates primary_category on recon.domains.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Classification result"
          }
        }
      }
    },
    "/api/domains/follow-meta": {
      "patch": {
        "tags": [
          "Domains"
        ],
        "operationId": "updateDomainMeta",
        "summary": "Update note or buy_status for a domain",
        "description": "Patches notes and/or buy_status on recon.domains for the given domain.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  },
                  "notes": {
                    "type": "string",
                    "nullable": true
                  },
                  "buy_status": {
                    "type": "string",
                    "nullable": true,
                    "enum": [
                      "watching",
                      "targeting",
                      "bought",
                      "passed"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated row"
          }
        }
      }
    },
    "/api/domains/watch": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "setDomainWatch",
        "summary": "Set watch_mode for a domain",
        "description": "Enables or disables watching on a domain (is_watched, watch_mode columns).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  },
                  "watch_mode": {
                    "type": "string",
                    "nullable": true,
                    "enum": [
                      "aggressive",
                      "passive",
                      null
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Watch state updated"
          }
        }
      }
    },
    "/api/prospects/page": {
      "post": {
        "tags": [
          "Prospects"
        ],
        "operationId": "prospectsPage",
        "summary": "Paginated filtered domain list",
        "description": "Main data-table endpoint powering /dashboard/domains. Calls recon.get_domains_page. Supports sorting, filtering by availability/release bucket/OPR/category/TLD, and the is_prospect toggle.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProspectsPageRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Page of domains with total count",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProspectsPageResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/prospects/matching-ids": {
      "post": {
        "tags": [
          "Prospects"
        ],
        "operationId": "prospectsMatchingIds",
        "summary": "Get IDs for bulk select",
        "description": "Returns all domain IDs matching the given filters — used for bulk-select operations in the UI.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "filters": {
                    "type": "object"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Array of matching domain IDs"
          }
        }
      }
    },
    "/api/prospects/add": {
      "post": {
        "tags": [
          "Prospects"
        ],
        "operationId": "prospectsAdd",
        "summary": "Add domain to prospects (SSE)",
        "description": "Adds a domain to the prospect list (sets is_prospect=true), optionally running a full investigation. Streams progress via SSE.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "SSE progress stream",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/prospects/{domain}/resolve": {
      "post": {
        "tags": [
          "Prospects"
        ],
        "operationId": "prospectResolve",
        "summary": "Soft-remove a prospect",
        "description": "Clears is_prospect=false for the given domain.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Prospect removed"
          }
        }
      }
    },
    "/api/prospects/whois-lookup": {
      "post": {
        "tags": [
          "Prospects"
        ],
        "operationId": "prospectsWhoisLookup",
        "summary": "Bulk whois SSE for prospects",
        "description": "Runs whois on all prospect domains that need a recheck. Streams progress via SSE.",
        "responses": {
          "200": {
            "description": "SSE stream",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/prospects/fill-meta": {
      "post": {
        "tags": [
          "Prospects"
        ],
        "operationId": "prospectsFillMeta",
        "summary": "Bulk fill meta for prospects",
        "description": "Scrapes HTML meta title/description for prospect domains that are missing it.",
        "responses": {
          "200": {
            "description": "Fill result"
          }
        }
      }
    },
    "/api/prospects/reembed": {
      "post": {
        "tags": [
          "Prospects"
        ],
        "operationId": "prospectsReembed",
        "summary": "Re-embed and reclassify prospects",
        "description": "Re-runs Gemini embedding + taxonomy classification for prospects with stale or missing embeddings.",
        "responses": {
          "200": {
            "description": "Re-embed result"
          }
        }
      }
    },
    "/api/prospects/semantic-search": {
      "post": {
        "tags": [
          "Prospects"
        ],
        "operationId": "prospectsSemanticSearch",
        "summary": "Semantic search over prospects",
        "description": "Queries the Gemini embedding index to find prospects semantically similar to the given query.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "query"
                ],
                "properties": {
                  "query": {
                    "type": "string"
                  },
                  "limit": {
                    "type": "integer",
                    "default": 20
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Matching domains by semantic similarity"
          }
        }
      }
    },
    "/api/openpagerank": {
      "get": {
        "tags": [
          "OPR"
        ],
        "operationId": "oprLookupGet",
        "summary": "Single OPR lookup",
        "description": "Queries the OpenPageRank API for a domain without persisting.",
        "parameters": [
          {
            "name": "domain",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "example.se"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OPR score and rank"
          }
        }
      },
      "post": {
        "tags": [
          "OPR"
        ],
        "operationId": "oprLookupPost",
        "summary": "OPR lookup + persist",
        "description": "Queries OPR for a domain and persists the result to recon.domains.opr_*.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OPR result + persisted status"
          }
        }
      }
    },
    "/api/openpagerank/bulk": {
      "post": {
        "tags": [
          "OPR"
        ],
        "operationId": "oprBulk",
        "summary": "Bulk OPR lookup + persist",
        "description": "Runs OPR lookups on multiple domains and persists results.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domains"
                ],
                "properties": {
                  "domains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Bulk OPR results"
          }
        }
      }
    },
    "/api/opr/fill-missing": {
      "post": {
        "tags": [
          "OPR"
        ],
        "operationId": "oprFillMissing",
        "summary": "SSE stream — fill missing OPR",
        "description": "Streams fill progress while running OPR lookups on domains missing opr_rank.",
        "responses": {
          "200": {
            "description": "SSE stream",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/opr/fill-missing/tick": {
      "post": {
        "tags": [
          "OPR"
        ],
        "operationId": "oprFillMissingTick",
        "summary": "Single fill tick — OPR",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 500
            }
          },
          {
            "name": "triggered_by",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tick summary"
          }
        }
      }
    },
    "/api/opr/backfill-expiring": {
      "post": {
        "tags": [
          "OPR"
        ],
        "operationId": "oprBackfillExpiring",
        "summary": "Backfill OPR for expiring domains",
        "description": "Prioritises OPR lookups for domains that are about to expire/release.",
        "responses": {
          "200": {
            "description": "Backfill result"
          }
        }
      }
    },
    "/api/quality": {
      "post": {
        "tags": [
          "Quality"
        ],
        "operationId": "scoreQuality",
        "summary": "Score domains with Gemma",
        "description": "Runs the Gemma LLM quality scorer on the given domains, updating quality_* columns on recon.domains.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "domains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Quality score results"
          }
        }
      }
    },
    "/api/quality/backfill-expiring": {
      "post": {
        "tags": [
          "Quality"
        ],
        "operationId": "qualityBackfillExpiring",
        "summary": "Backfill quality scores for expiring domains",
        "description": "Runs quality scoring on expiring/releasing domains that lack quality_overall_score.",
        "responses": {
          "200": {
            "description": "Backfill result"
          }
        }
      }
    },
    "/api/wayback/check": {
      "post": {
        "tags": [
          "Wayback"
        ],
        "operationId": "waybackCheck",
        "summary": "Check one domain on Wayback Machine",
        "description": "Fetches the most recent Wayback snapshot for a domain and updates wayback_* columns.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Wayback snapshot result",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaybackSnapshot"
                }
              }
            }
          }
        }
      }
    },
    "/api/wayback/fill-missing/tick": {
      "post": {
        "tags": [
          "Wayback"
        ],
        "operationId": "waybackFillMissingTick",
        "summary": "Tick fill — Wayback",
        "description": "Runs a batch of Wayback checks on domains missing wayback data.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tick summary"
          }
        }
      }
    },
    "/api/wayback/history": {
      "post": {
        "tags": [
          "Wayback"
        ],
        "operationId": "waybackHistory",
        "summary": "CDX timeline for a domain",
        "description": "Returns the Wayback CDX API timeline — all snapshot dates for a domain.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "CDX timeline entries"
          }
        }
      }
    },
    "/api/cc/snapshot": {
      "get": {
        "tags": [
          "CommonCrawl"
        ],
        "operationId": "ccSnapshot",
        "summary": "Historical HTML for a CC top-pages row",
        "description": "Fetches the raw HTML from the CommonCrawl WARC archive for a specific cc_top_pages row ID.",
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "cc_top_pages row id"
          }
        ],
        "responses": {
          "200": {
            "description": "Raw HTML from WARC",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/cc-backlinks/{id}/html": {
      "get": {
        "tags": [
          "CommonCrawl"
        ],
        "operationId": "ccBacklinkHtml",
        "summary": "WARC record for a CC backlink",
        "description": "Fetches the raw WARC HTML record for the referring page of a CC backlink row.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "CC backlink row ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Raw HTML the referring page served at crawl time (headers x-cc-referring-url / x-cc-crawl-id carry the source URL + crawl id)",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "invalid id"
          },
          "404": {
            "description": "no such cc_backlinks row"
          },
          "409": {
            "description": "WARC pointer not yet resolved (warc_filename/offset/length null) — run cc-cdx-resolver to backfill"
          },
          "502": {
            "description": "S3 WARC fetch failed or no response record in the chunk"
          }
        }
      }
    },
    "/api/recon/released-today": {
      "get": {
        "tags": [
          "Recon"
        ],
        "operationId": "reconReleasedToday",
        "summary": "Domains freed recently",
        "description": "Returns .se domains that have been released (quarantine ended) in the past 24 hours.",
        "responses": {
          "200": {
            "description": "List of recently released domains"
          }
        }
      }
    },
    "/api/recon/zone-sync/run": {
      "post": {
        "tags": [
          "Recon"
        ],
        "operationId": "reconZoneSyncRun",
        "summary": "Run .se zone AXFR + diff",
        "description": "Triggers a full .se zone file AXFR and diffs against the current zone_* columns in recon.domains.",
        "responses": {
          "200": {
            "description": "Zone sync result"
          }
        }
      }
    },
    "/api/jobs": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "operationId": "listJobs",
        "summary": "List all jobs",
        "description": "Returns all rows from recon.jobs — knob store for manual operator control.",
        "responses": {
          "200": {
            "description": "Job list"
          }
        }
      }
    },
    "/api/jobs/{source}": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "operationId": "getJob",
        "summary": "Get one job by source",
        "parameters": [
          {
            "name": "source",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "whois"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Job row"
          }
        }
      },
      "patch": {
        "tags": [
          "Jobs"
        ],
        "operationId": "updateJob",
        "summary": "Update job knobs",
        "description": "Update desired_state, filters, or other knobs on a job row.",
        "parameters": [
          {
            "name": "source",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated job row"
          }
        }
      }
    },
    "/api/jobs/{source}/start": {
      "post": {
        "tags": [
          "Jobs"
        ],
        "operationId": "startJob",
        "summary": "Flip job desired_state to running",
        "description": "Sets desired_state=running on the job row. Note: no driver picks this up automatically — see CLAUDE.md Jobs section.",
        "parameters": [
          {
            "name": "source",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Job row updated"
          }
        }
      }
    },
    "/api/jobs/{source}/stop": {
      "post": {
        "tags": [
          "Jobs"
        ],
        "operationId": "stopJob",
        "summary": "Flip job desired_state to stopped",
        "parameters": [
          {
            "name": "source",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Job row updated"
          }
        }
      }
    },
    "/api/jobs/{source}/runs": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "operationId": "getJobRuns",
        "summary": "Recent runs for a job",
        "description": "Returns recent tick audit rows from recon.{source}_fill_runs.",
        "parameters": [
          {
            "name": "source",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of run rows"
          }
        }
      }
    },
    "/api/owned-domains": {
      "get": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "listOwnedDomains",
        "summary": "List owned domains",
        "description": "Returns all rows from recon.owned_domains with current onboarding state.",
        "responses": {
          "200": {
            "description": "List of owned domains"
          }
        }
      }
    },
    "/api/public/owned-domains": {
      "get": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "listOwnedDomainsPublic",
        "summary": "Public list of owned domains",
        "description": "Returns a public subset of owned domains — no auth required.",
        "security": [],
        "responses": {
          "200": {
            "description": "Public domain list"
          }
        }
      }
    },
    "/api/owned/{domain}": {
      "delete": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "deleteOwnedDomain",
        "summary": "Remove an owned domain",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Domain removed"
          }
        }
      }
    },
    "/api/owned/{domain}/watch": {
      "get": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "getOwnedWatch",
        "summary": "Get activation watch state",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Watch state"
          }
        }
      },
      "post": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "setOwnedWatch",
        "summary": "Enable activation watch",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Watch enabled"
          }
        }
      },
      "delete": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "deleteOwnedWatch",
        "summary": "Disable activation watch",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Watch disabled"
          }
        }
      }
    },
    "/api/owned/{domain}/site-live": {
      "get": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "getOwnedSiteLive",
        "summary": "Probe site liveness",
        "description": "GETs https://{domain}/ and checks for the funnel-sites sentinel (data-template-slug attribute).",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Site probe result"
          }
        }
      }
    },
    "/api/owned/{domain}/site-config": {
      "patch": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "updateOwnedSiteConfig",
        "summary": "Update owned domain site config",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Config updated"
          }
        }
      }
    },
    "/api/owned/{domain}/site-template": {
      "patch": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "updateOwnedSiteTemplate",
        "summary": "Override site template for a domain",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "site_template": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Template updated"
          }
        }
      }
    },
    "/api/owned/{domain}/strato-account": {
      "patch": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "updateOwnedStratoAccount",
        "summary": "Fix/update Strato account label for a domain",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "strato_account": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Account label updated"
          }
        }
      }
    },
    "/api/owned/sync-all": {
      "post": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "ownedSyncAll",
        "summary": "Sync domains from all registrars",
        "description": "Fetches domain lists from Strato, Loopia, and Spaceship, reconciling against recon.owned_domains.",
        "responses": {
          "200": {
            "description": "Sync result"
          }
        }
      }
    },
    "/api/owned/remove": {
      "post": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "ownedRemoveBulk",
        "summary": "Bulk remove owned domains",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "domains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Removed count"
          }
        }
      }
    },
    "/api/owned/ensure-onboarding-runs": {
      "post": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "ensureOnboardingRuns",
        "summary": "Create missing onboarding runs",
        "description": "Inserts recon.onboarding_runs rows for any owned domains that don't have one yet.",
        "responses": {
          "200": {
            "description": "Created count"
          }
        }
      }
    },
    "/api/owned/ignore-onboarding": {
      "post": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "ignoreOnboarding",
        "summary": "Mark domains as ignore-onboarding",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "domains": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          }
        }
      }
    },
    "/api/owned/ns-propagation/tick": {
      "post": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "nsPropagationTick",
        "summary": "NS propagation poll tick",
        "description": "Checks DNS propagation for domains in the NS-flip stage of onboarding. Scheduled every 3 minutes by Coolify.",
        "responses": {
          "200": {
            "description": "Propagation tick result"
          }
        }
      }
    },
    "/api/owned/{domain}/seo/status": {
      "get": {
        "tags": [
          "Owned SEO"
        ],
        "operationId": "ownedSeoStatus",
        "summary": "SEO status for an owned domain",
        "description": "Returns Bing verification, IndexNow, and GSC status for the domain.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "SEO status"
          }
        }
      }
    },
    "/api/owned/{domain}/seo/setup-bing": {
      "post": {
        "tags": [
          "Owned SEO"
        ],
        "operationId": "setupBing",
        "summary": "Setup Bing Webmaster for a domain",
        "description": "Calls AddSite + CNAME DNS verify via OAuth MCP. See Bing Webmaster API limits in CLAUDE.md.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Bing setup result"
          }
        }
      }
    },
    "/api/owned/{domain}/seo/verify-bing": {
      "post": {
        "tags": [
          "Owned SEO"
        ],
        "operationId": "verifyBing",
        "summary": "Trigger Bing site verification",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Verification result"
          }
        }
      }
    },
    "/api/owned/{domain}/seo/setup-indexnow": {
      "post": {
        "tags": [
          "Owned SEO"
        ],
        "operationId": "setupIndexNow",
        "summary": "Setup IndexNow for a domain",
        "description": "Generates a per-domain IndexNow key and creates the key file in funnel-sites.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "IndexNow setup result"
          }
        }
      }
    },
    "/api/owned/{domain}/seo/submit-urls": {
      "post": {
        "tags": [
          "Owned SEO"
        ],
        "operationId": "submitIndexNowUrls",
        "summary": "Submit URLs to IndexNow",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "urls": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Submission result"
          }
        }
      }
    },
    "/api/owned/{domain}/gsc/fetch": {
      "post": {
        "tags": [
          "Owned SEO"
        ],
        "operationId": "gscFetch",
        "summary": "GSC fetch for a domain",
        "description": "Triggers Google Search Console verify + addSite via the recon service account.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "GSC result"
          }
        }
      }
    },
    "/api/onboard-domain": {
      "post": {
        "tags": [
          "Onboarding"
        ],
        "operationId": "onboardDomainFull",
        "summary": "Full onboarding pipeline",
        "description": "Runs the full sequential pipeline: CF zone → Worker route → NS flip → propagation → placeholder row → render verify → Bing → IndexNow → GSC.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Pipeline result with per-step output"
          }
        }
      }
    },
    "/api/onboard-domain/status": {
      "get": {
        "tags": [
          "Onboarding"
        ],
        "operationId": "onboardDomainStatus",
        "summary": "Onboarding status check",
        "description": "Polls whois to check if a newly purchased domain has been activated by IIS (flips waiting_strato_activation → pending_manual).",
        "responses": {
          "200": {
            "description": "Status for waiting domains"
          }
        }
      }
    },
    "/api/onboard-domain/step": {
      "post": {
        "tags": [
          "Onboarding"
        ],
        "operationId": "onboardDomainStep",
        "summary": "Run a single onboarding step",
        "description": "Runs one step of the pipeline (e.g. setup_cloudflare, flip_ns, verify_placeholder). Persists result to owned_domains.onboarding_steps.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain",
                  "step"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  },
                  "step": {
                    "type": "string",
                    "example": "setup_cloudflare"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Step result"
          }
        }
      }
    },
    "/api/onboard-domain/check-activation": {
      "post": {
        "tags": [
          "Onboarding"
        ],
        "operationId": "checkActivation",
        "summary": "Check Strato activation via whois",
        "description": "Calls lookupWhois on waiting domains; flips to pending_manual when IIS confirms registration.",
        "responses": {
          "200": {
            "description": "Activation check result"
          }
        }
      }
    },
    "/api/onboard-domain/strato-mail-webhook": {
      "post": {
        "tags": [
          "Onboarding"
        ],
        "operationId": "stratoMailWebhook",
        "summary": "Strato mail webhook",
        "description": "Accepts forwarded Strato confirmation emails. Extracts order_number or domain token and flips status. Requires bearer auth via ADMIN_API_TOKEN.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "subject": {
                    "type": "string"
                  },
                  "body": {
                    "type": "string"
                  },
                  "order_number": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Webhook processed"
          }
        }
      }
    },
    "/api/cloudflare/owned-status": {
      "get": {
        "tags": [
          "Cloudflare"
        ],
        "operationId": "cfOwnedStatus",
        "summary": "Bulk Cloudflare zone status",
        "description": "Returns CF zone status for all owned domains.",
        "responses": {
          "200": {
            "description": "Zone status map"
          }
        }
      }
    },
    "/api/cloudflare/setup-zone": {
      "post": {
        "tags": [
          "Cloudflare"
        ],
        "operationId": "cfSetupZone",
        "summary": "Create a Cloudflare zone",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Zone created"
          }
        }
      }
    },
    "/api/cloudflare/emails": {
      "get": {
        "tags": [
          "Cloudflare"
        ],
        "operationId": "cfEmails",
        "summary": "Cloudflare notification emails",
        "description": "Returns CF notification email addresses configured for the account.",
        "responses": {
          "200": {
            "description": "Email list"
          }
        }
      }
    },
    "/api/strato/accounts": {
      "get": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoAccounts",
        "summary": "List Strato accounts",
        "responses": {
          "200": {
            "description": "Account list"
          }
        }
      }
    },
    "/api/strato/domains": {
      "get": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoDomains",
        "summary": "Sync + list Strato domains",
        "description": "Fetches domains from Strato API and reconciles with recon.owned_domains.",
        "responses": {
          "200": {
            "description": "Domain list"
          }
        }
      }
    },
    "/api/strato/emails": {
      "get": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoEmails",
        "summary": "Strato order emails",
        "description": "Returns recent order confirmation emails from the Strato account.",
        "responses": {
          "200": {
            "description": "Email list"
          }
        }
      }
    },
    "/api/strato/dns/{domain}": {
      "put": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoDnsUpdate",
        "summary": "Update DNS for a Strato domain",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "DNS updated"
          }
        }
      }
    },
    "/api/strato/buy": {
      "post": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoBuy",
        "summary": "Buy a domain via Strato",
        "description": "Places a domain purchase order via the philip-private Strato account. Creates an onboarding_run row.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string",
                    "example": "example.se"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Purchase result"
          }
        }
      }
    },
    "/api/strato/buy/schedule": {
      "post": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoBuySchedule",
        "summary": "Schedule a domain buy",
        "description": "Adds a domain to recon.scheduled_buys for purchase at the specified time.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain",
                  "scheduled_at"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  },
                  "scheduled_at": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Scheduled buy created"
          }
        }
      },
      "get": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoBuyScheduleList",
        "summary": "List scheduled buys",
        "responses": {
          "200": {
            "description": "Scheduled buy list"
          }
        }
      },
      "delete": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoBuyScheduleCancel",
        "summary": "Cancel a scheduled buy",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Scheduled buy cancelled"
          }
        }
      }
    },
    "/api/strato/buy/scheduled/tick": {
      "post": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoBuyScheduledTick",
        "summary": "Drain scheduled buys",
        "description": "Processes scheduled_buys rows whose scheduled_at is past — places purchase orders.",
        "responses": {
          "200": {
            "description": "Tick result"
          }
        }
      }
    },
    "/api/strato/buy/cancel": {
      "post": {
        "tags": [
          "Strato"
        ],
        "operationId": "stratoBuyCancel",
        "summary": "Cancel a running buy",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Buy cancelled"
          }
        }
      }
    },
    "/api/loopia/domains": {
      "get": {
        "tags": [
          "Loopia"
        ],
        "operationId": "loopiaDomains",
        "summary": "Sync + list Loopia domains",
        "description": "Fetches domains from all Loopia accounts via XML-RPC and reconciles with recon.owned_domains.",
        "responses": {
          "200": {
            "description": "Domain list"
          }
        }
      }
    },
    "/api/loopia/accounts": {
      "get": {
        "tags": [
          "Loopia"
        ],
        "operationId": "loopiaAccounts",
        "summary": "List configured Loopia accounts",
        "description": "Secret-free directory of the Loopia API accounts the server has credentials for (from LOOPIA_ACCOUNTS + legacy LOOPIA_API_USER). Returns account names + usernames and the default account; powers the buy-dialog account picker. Credentials never leave the server.",
        "responses": {
          "200": {
            "description": "{ ok, accounts: [{ name, user }], default }"
          }
        }
      }
    },
    "/api/loopia/balance": {
      "get": {
        "tags": [
          "Loopia"
        ],
        "operationId": "loopiaBalance",
        "summary": "Get Loopia prepaid credit balance",
        "description": "Reads the Loopia prepaid credit balance via XML-RPC (getCreditsAmount). This is the pool orderDomain charges against; amount in SEK for the .se account. Optional ?account= selects which configured account to read (defaults to the configured default).",
        "parameters": [
          {
            "name": "account",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Loopia account name (from /api/loopia/accounts); omit for the default account."
          }
        ],
        "responses": {
          "200": {
            "description": "Balance (SEK) with request/response observe block"
          }
        }
      }
    },
    "/api/loopia/buy": {
      "post": {
        "tags": [
          "Loopia"
        ],
        "operationId": "loopiaBuy",
        "summary": "Buy a domain via Loopia",
        "description": "Places a domain purchase via the Loopia XML-RPC API (orderDomain). Creates an onboarding_run row.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string",
                    "example": "example.se"
                  },
                  "account": {
                    "type": "string",
                    "description": "Loopia account key to use (defaults to first available)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Purchase result"
          }
        }
      }
    },
    "/api/spaceship/domains": {
      "get": {
        "tags": [
          "Spaceship"
        ],
        "operationId": "spaceshipDomains",
        "summary": "Sync + list Spaceship domains",
        "description": "Fetches domains from the Spaceship API and reconciles with recon.owned_domains.",
        "responses": {
          "200": {
            "description": "Domain list"
          }
        }
      }
    },
    "/api/agents": {
      "get": {
        "tags": [
          "Agents"
        ],
        "operationId": "listAgents",
        "summary": "List agent kinds",
        "description": "Returns available agent types (e.g. gsc-doctor).",
        "responses": {
          "200": {
            "description": "Agent kind list"
          }
        }
      }
    },
    "/api/agents/runs": {
      "get": {
        "tags": [
          "Agents"
        ],
        "operationId": "listAgentRuns",
        "summary": "List agent runs",
        "responses": {
          "200": {
            "description": "Run list"
          }
        }
      },
      "post": {
        "tags": [
          "Agents"
        ],
        "operationId": "startAgentRun",
        "summary": "Start an agent run",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "kind"
                ],
                "properties": {
                  "kind": {
                    "type": "string"
                  },
                  "params": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Run started"
          }
        }
      }
    },
    "/api/agents/runs/{runId}": {
      "get": {
        "tags": [
          "Agents"
        ],
        "operationId": "getAgentRun",
        "summary": "Agent run detail",
        "parameters": [
          {
            "name": "runId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Run detail with findings"
          }
        }
      }
    },
    "/api/agents/runs/{runId}/findings/{findingId}/repost": {
      "post": {
        "tags": [
          "Agents"
        ],
        "operationId": "repostFinding",
        "summary": "Repost an agent finding",
        "parameters": [
          {
            "name": "runId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "findingId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Reposted"
          }
        }
      }
    },
    "/api/agents/gsc-doctor/start": {
      "get": {
        "tags": [
          "Agents"
        ],
        "operationId": "gscDoctorStartGet",
        "summary": "GSC Doctor status / trigger (GET)",
        "responses": {
          "200": {
            "description": "GSC Doctor status"
          }
        }
      },
      "post": {
        "tags": [
          "Agents"
        ],
        "operationId": "gscDoctorStart",
        "summary": "Trigger GSC Doctor agent",
        "description": "Starts a GSC Doctor run that diagnoses and fixes GSC verification issues for owned domains.",
        "responses": {
          "200": {
            "description": "Run started"
          }
        }
      }
    },
    "/api/agents/models": {
      "get": {
        "tags": [
          "Agents"
        ],
        "operationId": "listAgentModels",
        "summary": "List available LLM models",
        "description": "Returns the LLM models available to agent runners (Gemma, Gemini, etc).",
        "responses": {
          "200": {
            "description": "Model list"
          }
        }
      }
    },
    "/api/openapi": {
      "get": {
        "tags": [
          "System"
        ],
        "operationId": "getOpenApiSpec",
        "summary": "OpenAPI spec",
        "description": "Returns this OpenAPI 3.1.0 specification.",
        "security": [],
        "responses": {
          "200": {
            "description": "OpenAPI spec",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/owned/{domain}/recover-html": {
      "post": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "recoverOwnedHtml",
        "summary": "Recover archived HTML",
        "description": "Pulls the domain owns archived URLs from cc_top_pages (own pages) + cc_backlinks (inbound-edge referrers), resolves each to its Wayback raw snapshot, and persists the inventory to recon.recovered_html + owned_domains.html_* summary counts so the old site can be replicated.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Recovery summary (pages_ok/total, referrers_ok/total, sample entries)"
          }
        }
      },
      "get": {
        "tags": [
          "Owned Domains"
        ],
        "operationId": "getOwnedRecoveredHtml",
        "summary": "List recovered HTML inventory",
        "description": "Returns the stored recovered_html inventory (pages + referrers with Wayback snapshot URLs) for the domain. Does not fetch.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Stored recovery inventory"
          }
        }
      }
    },
    "/api/domains/{domain}/http-check": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "domainHttpCheck",
        "summary": "Live-site HTTP check",
        "description": "Probes the domain's root page (HEAD then GET fallback, with parking detection) and persists http_status, http_checked_at, is_parked, parked_signal and dns_resolves to recon.domains. Powers the Live column on the Backlink Authority panel.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Probe result (http_status, is_parked, dns_resolves, …)"
          },
          "404": {
            "description": "Domain not in recon.domains"
          },
          "500": {
            "description": "Persist failed"
          }
        }
      }
    },
    "/api/domains/{domain}/backlinks": {
      "get": {
        "tags": [
          "Domains"
        ],
        "operationId": "listDomainBacklinks",
        "summary": "URL-level backlinks (CommonCrawl)",
        "description": "Distinct exact referring URLs that link (or once linked) to this domain, from recon.cc_backlinks (CommonCrawl WAT scan), newest crawl per URL, each with its CC anchor/rel plus the latest per-URL verification (still_linked/http_status/anchor_now/rel_now).",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 500,
              "maximum": 5000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Backlink list + per-URL check state",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "found": {
                      "type": "boolean"
                    },
                    "domain": {
                      "type": "string"
                    },
                    "total": {
                      "type": "integer",
                      "description": "distinct referring URLs in recon.cc_backlinks for this target"
                    },
                    "shown": {
                      "type": "integer",
                      "description": "rows returned (capped by the limit parameter)"
                    },
                    "backlinks": {
                      "type": "array",
                      "description": "one row per distinct referring URL (newest crawl per URL), each joined to its latest verification in recon.backlink_link_checks",
                      "items": {
                        "type": "object",
                        "properties": {
                          "referring_url": {
                            "type": "string",
                            "description": "exact source URL that linked here (from the CC WAT <a href> extraction)"
                          },
                          "referring_host": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "host of the referring URL"
                          },
                          "anchor_text": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "anchor text recorded by CommonCrawl at crawl time"
                          },
                          "rel_attr": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "rel attribute at crawl time (nofollow/sponsored/ugc; null = dofollow)"
                          },
                          "target_path": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "path on THIS domain the link pointed at"
                          },
                          "crawl_id": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "CommonCrawl crawl id the link was seen in (e.g. CC-MAIN-2026-17)"
                          },
                          "still_linked": {
                            "type": [
                              "boolean",
                              "null"
                            ],
                            "description": "latest re-fetch: the source URL still contains an <a href> to this domain (null = never verified)"
                          },
                          "http_status": {
                            "type": [
                              "integer",
                              "null"
                            ],
                            "description": "HTTP status of the source URL on last verify"
                          },
                          "anchor_now": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "anchor text of the matched link on last verify"
                          },
                          "rel_now": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "rel attribute of the matched link on last verify"
                          },
                          "matched_href": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "the absolute href matched on last verify"
                          },
                          "checked_at": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time",
                            "description": "when the source URL was last re-fetched"
                          },
                          "check_error": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "fetch/parse error from the last verify, if any"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "domain required"
          },
          "500": {
            "description": "Lookup failed"
          }
        }
      }
    },
    "/api/domains/{domain}/backlinks/verify": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "verifyDomainBacklink",
        "summary": "Verify a backlink still exists",
        "description": "Re-fetches one referring URL and checks whether it still contains an <a href> pointing at this domain; re-captures the current anchor + rel. Persists the verdict to recon.backlink_link_checks keyed by (target_host, referring_url).",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "referring_url"
                ],
                "properties": {
                  "referring_url": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verdict (still_linked, http_status, anchor_now, rel_now, …)"
          },
          "400": {
            "description": "referring_url required / invalid body"
          },
          "500": {
            "description": "Persist failed"
          }
        }
      }
    },
    "/api/domains/{domain}/backlinks/verify-all": {
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "verifyAllDomainBacklinks",
        "summary": "Live-verify all backlinks + recompute authority",
        "description": "One-click LIVE verification: pulls the distinct referring URLs (recon.get_target_backlinks), re-fetches each (does the page still link here?), upserts every verdict to recon.backlink_link_checks, then recomputes recon.domains.live_backlinks_authority_score over ONLY still-linked referrers (recon.refresh_live_backlinks_authority, mig 237). Bounded by limit to fit the 300s budget; for larger domains the score is over the sample.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Max referring URLs to re-fetch (1-500, default 300).",
            "schema": {
              "type": "integer",
              "default": 300,
              "maximum": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Fresh live-verified authority score + counts",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domain": {
                      "type": "string"
                    },
                    "requested_limit": {
                      "type": "integer"
                    },
                    "urls_total": {
                      "type": "integer",
                      "description": "distinct referring URLs considered"
                    },
                    "checked": {
                      "type": "integer",
                      "description": "URLs actually re-fetched"
                    },
                    "live": {
                      "type": "integer",
                      "description": "URLs that still link here"
                    },
                    "live_backlinks_authority_score": {
                      "type": [
                        "number",
                        "null"
                      ],
                      "description": "BLA over still-linked referrers (0-100)"
                    },
                    "verified_refdomains": {
                      "type": "integer"
                    },
                    "checked_urls": {
                      "type": "integer"
                    },
                    "live_urls": {
                      "type": "integer"
                    },
                    "duration_ms": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "domain required"
          },
          "500": {
            "description": "Lookup / score refresh failed"
          }
        }
      }
    },
    "/api/domains/{domain}/backlinks/score": {
      "get": {
        "tags": [
          "Domains"
        ],
        "operationId": "getDomainLiveBacklinkScore",
        "summary": "Read the live-verified backlink authority",
        "description": "Returns the persisted recon.domains.live_backlinks_* (the BLA score over still-linked referrers, plus verified/checked/live counts and when it was last computed). Read-only — does not re-fetch.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Persisted live-backlink authority",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domain": {
                      "type": "string"
                    },
                    "live_backlinks_authority_score": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "verified_refdomains": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    },
                    "checked_urls": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    },
                    "live_urls": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    },
                    "checked_at": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "domain required"
          },
          "500": {
            "description": "Lookup failed"
          }
        }
      },
      "post": {
        "tags": [
          "Domains"
        ],
        "operationId": "rescoreDomainLiveBacklinks",
        "summary": "Recompute live backlink authority (no re-fetch)",
        "description": "Cheap rescore: recomputes recon.domains.live_backlinks_authority_score from whatever verdicts already sit in recon.backlink_link_checks (recon.refresh_live_backlinks_authority) WITHOUT re-fetching any URL. Used after a client-side per-row verify batch; full re-fetch lives in ./verify-all.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Recomputed score + counts",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domain": {
                      "type": "string"
                    },
                    "live_backlinks_authority_score": {
                      "type": [
                        "number",
                        "null"
                      ]
                    },
                    "verified_refdomains": {
                      "type": "integer"
                    },
                    "checked_urls": {
                      "type": "integer"
                    },
                    "live_urls": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "domain required"
          },
          "500": {
            "description": "Score refresh failed"
          }
        }
      }
    },
    "/api/backlinks/validate/tick": {
      "post": {
        "tags": [
          "Backlinks"
        ],
        "operationId": "validateBacklinksTick",
        "summary": "Bulk live-BLA fill tick (highest-BLA buyable prospects)",
        "description": "Bulk fill driver for the live-verified backlink authority (recon.domains.live_backlinks_*, mig 237). Pulls the next batch of highest-historic-BLA buyable domains (recon.live_bla_fill_targets, mig 238 — available or releasing within 3 months, BLA>=min_bla, not validated within cooldown_days, ordered BLA desc), re-fetches each domain's referring URLs, upserts verdicts to recon.backlink_link_checks, and recomputes the live score. Wall-clock budgeted under the 300s limit. Drive from a Coolify scheduled task (e.g. every 5 min). One HyperDX event:tick.live_bla per tick; no SQL audit row.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Max domains to process this tick (1-100, default 12).",
            "schema": {
              "type": "integer",
              "default": 12,
              "maximum": 100
            }
          },
          {
            "name": "min_bla",
            "in": "query",
            "required": false,
            "description": "Historic-BLA floor for candidates (0-100, default 40).",
            "schema": {
              "type": "integer",
              "default": 40,
              "maximum": 100
            }
          },
          {
            "name": "urls",
            "in": "query",
            "required": false,
            "description": "Per-domain referring-URL sample cap (1-500, default 120).",
            "schema": {
              "type": "integer",
              "default": 120,
              "maximum": 500
            }
          },
          {
            "name": "cooldown_days",
            "in": "query",
            "required": false,
            "description": "Skip domains validated within this many days (default 60).",
            "schema": {
              "type": "integer",
              "default": 60
            }
          },
          {
            "name": "triggered_by",
            "in": "query",
            "required": false,
            "description": "Free-form tag recorded on the HyperDX event.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Per-tick counters + per-domain live scores",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "candidates": {
                      "type": "integer"
                    },
                    "processed": {
                      "type": "integer"
                    },
                    "total_checked": {
                      "type": "integer"
                    },
                    "total_live": {
                      "type": "integer"
                    },
                    "scored": {
                      "type": "integer"
                    },
                    "budget_hit": {
                      "type": "boolean"
                    },
                    "duration_ms": {
                      "type": "integer"
                    },
                    "domains": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "domain": {
                            "type": "string"
                          },
                          "bla": {
                            "type": "number",
                            "nullable": true
                          },
                          "live_bla": {
                            "type": "number",
                            "nullable": true
                          },
                          "checked": {
                            "type": "integer"
                          },
                          "live": {
                            "type": "integer"
                          },
                          "error": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Selector or processing error"
          }
        }
      }
    },
    "/api/backlinks/wat/tick": {
      "post": {
        "tags": [
          "Backlinks"
        ],
        "operationId": "watScanTick",
        "summary": "Bounded CommonCrawl WAT scan tick (fills cc_backlinks)",
        "description": "Steady-state filler for recon.cc_backlinks (the per-URL backlink source behind live BLA). Picks a crawl (?crawl, else recon.next_wat_crawl() — newest enabled crawl with work left), fetches its WAT path list, atomically claims the next [start, start+files) index range (recon.claim_wat_files), fetches+parses each WAT file off the prod DB box, upserts .se backlinks, and records progress (recon.record_wat_progress). Wall-clock budgeted (default 75s) to fit a Coolify scheduled task (curl --max-time ~85). Resumable via the recon.cc_wat_runs cursor; the cc_backlinks uniq index makes overlap with the pbox bulk run harmless. One HyperDX event:tick.wat_scan per tick.",
        "parameters": [
          {
            "name": "crawl",
            "in": "query",
            "required": false,
            "description": "CommonCrawl crawl id (e.g. CC-MAIN-2024-10). Omit to auto-pick the newest enabled crawl with work left.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "files",
            "in": "query",
            "required": false,
            "description": "Max WAT files to process this tick (1-50, default 8).",
            "schema": {
              "type": "integer",
              "default": 8,
              "maximum": 50
            }
          },
          {
            "name": "concurrency",
            "in": "query",
            "required": false,
            "description": "Parallel WAT file fetch/parse workers (1-12, default 4). Hides ~150MB-per-file fetch latency.",
            "schema": {
              "type": "integer",
              "default": 4,
              "maximum": 12
            }
          },
          {
            "name": "max_seconds",
            "in": "query",
            "required": false,
            "description": "Wall-clock budget in seconds before the tick stops pulling new files (5-280, default 75).",
            "schema": {
              "type": "integer",
              "default": 75,
              "maximum": 280
            }
          },
          {
            "name": "triggered_by",
            "in": "query",
            "required": false,
            "description": "Free-text tag recorded on the HyperDX event.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tick result with files_done, rows_inserted, budget_hit, and the cursor position."
          }
        }
      }
    }
  }
}
