{
  "openapi": "3.1.0",
  "info": {
    "title": "PainSignal API",
    "version": "1.0.0",
    "summary": "Real, unsolved business problems described by the people who have them.",
    "description": "A free, public, read-only API over PainSignal's corpus of classified business problems and the buildable opportunities derived from them.\n\nNo authentication is required for any endpoint. An optional free API key raises rate limits and lifts the row cap on bulk export — it does not unlock any data.\n\nEvery response uses the envelope `{ data, meta, error }`. On success `error` is null; on failure `data` is null and `error` is a human-readable string. Bulk export is the one exception and returns raw CSV or JSONL.",
    "contact": {
      "url": "https://painsignal.net/contact"
    },
    "termsOfService": "https://painsignal.net/terms",
    "license": {
      "name": "Free to use with attribution",
      "url": "https://painsignal.net/terms"
    }
  },
  "servers": [
    {
      "url": "https://painsignal.net"
    }
  ],
  "tags": [
    {
      "name": "Data"
    },
    {
      "name": "Taxonomy"
    },
    {
      "name": "Analytics"
    },
    {
      "name": "Search"
    },
    {
      "name": "Bulk"
    },
    {
      "name": "Tools"
    }
  ],
  "paths": {
    "/api/v1/signals": {
      "get": {
        "operationId": "listProblems",
        "summary": "List problems",
        "description": "Individual reported problems, each with an AI-generated app brief describing what could be built.\n\nThis is the finest-grained record type. One row is one person describing one problem. `keyword` runs full-text search over the problem statement, industry, category, gap description and app-brief title; results are then ordered by relevance rather than by `sort`.",
        "tags": [
          "Data"
        ],
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-indexed page number.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows per page.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "keyword",
            "in": "query",
            "required": false,
            "description": "Full-text search. Supports quoted phrases and -negation. Max 200 characters.",
            "schema": {
              "type": "string"
            },
            "example": "scheduling double booking"
          },
          {
            "name": "industry",
            "in": "query",
            "required": false,
            "description": "Exact industry name as returned by /api/v1/industries. Case-sensitive.",
            "schema": {
              "type": "string"
            },
            "example": "Plumbing"
          },
          {
            "name": "problem_category",
            "in": "query",
            "required": false,
            "description": "Exact category name as returned by /api/v1/categories.",
            "schema": {
              "type": "string"
            },
            "example": "Scheduling & Booking"
          },
          {
            "name": "min_severity",
            "in": "query",
            "required": false,
            "description": "Only problems rated at least this severe.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 5
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Ordering. Ignored when `keyword` is present.",
            "schema": {
              "type": "string",
              "enum": [
                "created_at",
                "severity",
                "vote_score",
                "industry"
              ],
              "default": "created_at"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "id": "6f2e9c1a-0b44-4d8e-9a77-17e5f0aa2b31",
                      "slug": "shiftsync-rn-17e5f0aa",
                      "painStatement": "Scheduling staff across three locations means rebuilding the same rota in a spreadsheet every week, and shift swaps get lost in group texts.",
                      "industry": "Health, Wellness, and Beauty",
                      "jobRole": "Owner",
                      "problemCategory": "Staffing & Scheduling",
                      "severityScore": 4,
                      "gapDescription": "No affordable tool handles multi-location rotas with a swap-request workflow.",
                      "willingnessToPay": "implied",
                      "existingSolutionsMentioned": [
                        "Excel",
                        "WhatsApp"
                      ],
                      "solutionTitle": "ShiftSync",
                      "solutionDescription": "Multi-location staff scheduling with self-serve shift swaps.",
                      "solutionFeatures": [
                        "Drag-and-drop rota builder",
                        "Swap requests with manager approval"
                      ],
                      "solutionTargetUsers": "Owners of 2-10 location service businesses",
                      "solutionRevenueModel": "Per-location monthly subscription",
                      "confidenceScore": 0.88,
                      "voteScore": 3,
                      "clusterId": "b1c0d3e4-5f6a-4b7c-8d9e-0a1b2c3d4e5f",
                      "createdAt": "2026-06-14T09:12:44.031Z"
                    }
                  ],
                  "meta": {
                    "page": 1,
                    "limit": 20,
                    "total": 23367
                  },
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/signals/{id}": {
      "get": {
        "operationId": "getProblem",
        "summary": "Get one problem",
        "description": "A single problem by UUID or slug, including its full app brief.",
        "tags": [
          "Data"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The problem's UUID or its URL slug.",
            "schema": {
              "type": "string"
            },
            "example": "shiftsync-rn-17e5f0aa"
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": {
                    "id": "6f2e9c1a-0b44-4d8e-9a77-17e5f0aa2b31",
                    "painStatement": "…"
                  },
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/clusters": {
      "get": {
        "operationId": "listOpportunities",
        "summary": "List opportunities",
        "description": "Problems grouped into buildable product themes, with build complexity, revenue potential and competition analysis.\n\nAn opportunity is a cluster of semantically similar problems. Clusters with two or more members and a generated app brief are the ones surfaced as app ideas; the rest are reachable but not promoted. `build_complexity` and `revenue_potential` are 1-5 LLM judgements, present only on analysed clusters.",
        "tags": [
          "Data"
        ],
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-indexed page number.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows per page.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "industry",
            "in": "query",
            "required": false,
            "description": "Exact industry name as returned by /api/v1/industries. Case-sensitive.",
            "schema": {
              "type": "string"
            },
            "example": "Plumbing"
          },
          {
            "name": "problem_category",
            "in": "query",
            "required": false,
            "description": "Exact category name.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Ordering.",
            "schema": {
              "type": "string",
              "enum": [
                "score",
                "signal_count",
                "severity",
                "newest",
                "votes"
              ],
              "default": "score"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "id": "b1c0d3e4-5f6a-4b7c-8d9e-0a1b2c3d4e5f",
                      "slug": "multi-location-staff-scheduling-b1c0d3e4",
                      "title": "Multi-location staff scheduling",
                      "description": "Owners of small multi-site service businesses rebuild rotas by hand every week.",
                      "primaryIndustry": "Health, Wellness, and Beauty",
                      "problemCategory": "Staffing & Scheduling",
                      "signalCount": 14,
                      "avgSeverity": 3.9,
                      "opportunityScore": 71,
                      "buildComplexity": 3,
                      "revenuePotential": 4,
                      "competitionLevel": "medium",
                      "trendDirection": "rising",
                      "appBriefTitle": "ShiftSync",
                      "industriesAffected": {
                        "Health, Wellness, and Beauty": 9,
                        "Restaurants": 5
                      }
                    }
                  ],
                  "meta": {
                    "page": 1,
                    "limit": 20,
                    "total": 11033
                  },
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/clusters/{id}": {
      "get": {
        "operationId": "getOpportunity",
        "summary": "Get one opportunity",
        "description": "A single opportunity by UUID or slug, including its member problems.",
        "tags": [
          "Data"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The opportunity's UUID or its URL slug.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": {
                    "id": "b1c0d3e4-…",
                    "signals": []
                  },
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/industries": {
      "get": {
        "operationId": "listIndustries",
        "summary": "List industries",
        "description": "The industry taxonomy with problem counts.\n\nThe taxonomy is not pre-built. An LLM assigns an industry per problem and a nightly job merges near-duplicates, so this list grows and consolidates over time. Treat the names as data, not as a fixed enum.",
        "tags": [
          "Taxonomy"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "id": "…",
                      "name": "Plumbing",
                      "displayName": "Plumbing",
                      "signalCount": 968,
                      "clusterCount": 421
                    }
                  ],
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/categories": {
      "get": {
        "operationId": "listCategories",
        "summary": "List problem categories",
        "description": "The problem-category taxonomy with counts.",
        "tags": [
          "Taxonomy"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "id": "…",
                      "name": "Scheduling & Booking",
                      "signalCount": 263
                    }
                  ],
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/trends": {
      "get": {
        "operationId": "listTrends",
        "summary": "Trending opportunities",
        "description": "Opportunities whose report volume is accelerating.",
        "tags": [
          "Analytics"
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows to return.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "id": "…",
                      "title": "…",
                      "trendDirection": "rising",
                      "trendVelocity": 2.4
                    }
                  ],
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/analytics": {
      "get": {
        "operationId": "getAnalytics",
        "summary": "Market analytics",
        "description": "Corpus-wide aggregates: totals, severity, willingness-to-pay economics and industry/category league tables.\n\nEverything the /analytics dashboard renders, as JSON. Cached for an hour.",
        "tags": [
          "Analytics"
        ],
        "parameters": [
          {
            "name": "include",
            "in": "query",
            "required": false,
            "description": "Comma-separated sections. Omit for all of them.",
            "schema": {
              "type": "string",
              "enum": [
                "overview",
                "industries",
                "categories",
                "wtp",
                "matrix",
                "shift"
              ]
            },
            "example": "overview,industries"
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": {
                    "overview": {
                      "problems": 23367,
                      "opportunities": 11033,
                      "industries": 87,
                      "categories": 415,
                      "avgSeverity": 3.14,
                      "wtpExplicit": 867,
                      "withNamedIncumbent": 10339
                    },
                    "industries": [
                      {
                        "name": "Automotive Repair",
                        "slug": "automotive-repair",
                        "problems": 1757,
                        "avgSeverity": 3.22,
                        "wtpExplicitPct": 4
                      }
                    ]
                  },
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/analytics/timeseries": {
      "get": {
        "operationId": "getTimeseries",
        "summary": "Problem volume over time",
        "description": "Report volume bucketed by month, quarter or year, optionally filtered by industry or category.\n\nThe time axis is when the problem was REPORTED (the source post's date), not when it was classified — so this describes the market, not our ingestion schedule. The most recent bucket is always partial and should be dropped when drawing a trend line.",
        "tags": [
          "Analytics"
        ],
        "parameters": [
          {
            "name": "bucket",
            "in": "query",
            "required": false,
            "description": "Bucket size.",
            "schema": {
              "type": "string",
              "enum": [
                "month",
                "quarter",
                "year"
              ],
              "default": "month"
            }
          },
          {
            "name": "industry",
            "in": "query",
            "required": false,
            "description": "Exact industry name as returned by /api/v1/industries. Case-sensitive.",
            "schema": {
              "type": "string"
            },
            "example": "Plumbing"
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Exact category name.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "description": "First year to include.",
            "schema": {
              "type": "integer",
              "minimum": 2000,
              "default": 2015
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "period": "2026-01-01",
                      "problems": 1842,
                      "avgSeverity": 3.11
                    },
                    {
                      "period": "2026-02-01",
                      "problems": 2104,
                      "avgSeverity": 3.18
                    }
                  ],
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/analytics/tools": {
      "get": {
        "operationId": "getToolGaps",
        "summary": "Tool Gap Index",
        "description": "Incumbent tools ranked by how often people describe them failing, with the industries where each hurts most.\n\nBuilt from the tool each worker named when describing what they currently use. Raw names are normalised — QuickBooks Online, QBO and QuickBooks Desktop fold into one entry. Pass `slug` for the full detail of one tool, including the aggregated software gaps and manual workarounds people described.",
        "tags": [
          "Analytics"
        ],
        "parameters": [
          {
            "name": "slug",
            "in": "query",
            "required": false,
            "description": "Return full detail for one tool instead of the index.",
            "schema": {
              "type": "string"
            },
            "example": "quickbooks"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows in the index.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 250,
              "default": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "name": "QuickBooks",
                      "slug": "quickbooks",
                      "kind": "product",
                      "mentions": 493,
                      "avgSeverity": 3.22,
                      "wtpExplicit": 31,
                      "topIndustries": [
                        {
                          "name": "Accounting & Bookkeeping",
                          "count": 214
                        }
                      ]
                    }
                  ],
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/search": {
      "get": {
        "operationId": "search",
        "summary": "Semantic search",
        "description": "Meaning-based search over problems, using vector similarity rather than keyword matching.\n\nUse this when you want problems that are conceptually like your query even if they share no words with it — 'customers keep no-showing' will surface appointment-reminder problems that never use the word 'no-show'. Each call costs an embedding computation, so it is rate-limited far more tightly than the keyword search on /api/v1/signals. For exact term matching, prefer `keyword` there.",
        "tags": [
          "Search"
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Natural-language query. 3-200 characters.",
            "schema": {
              "type": "string"
            },
            "example": "customers keep forgetting their appointments"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Results to return.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "id": "…",
                      "pain_statement": "Patients no-show and we only find out when …",
                      "industry": "Dental",
                      "similarity": 0.81
                    }
                  ],
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/export": {
      "get": {
        "operationId": "export",
        "summary": "Bulk export",
        "description": "Stream the corpus as CSV or JSONL for offline analysis, filtered the same way as the list endpoints.\n\nAnonymous callers get up to 1,000 rows per request. A free API key removes that cap, so a full-corpus pull is one request. The response streams, so it starts immediately and never holds the whole result set in memory.",
        "tags": [
          "Bulk"
        ],
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Which record type to export.",
            "schema": {
              "type": "string",
              "enum": [
                "problems",
                "opportunities"
              ],
              "default": "problems"
            }
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "description": "Output format.",
            "schema": {
              "type": "string",
              "enum": [
                "csv",
                "jsonl"
              ],
              "default": "csv"
            }
          },
          {
            "name": "industry",
            "in": "query",
            "required": false,
            "description": "Exact industry name as returned by /api/v1/industries. Case-sensitive.",
            "schema": {
              "type": "string"
            },
            "example": "Plumbing"
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Exact category name.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "min_severity",
            "in": "query",
            "required": false,
            "description": "Only rows at least this severe.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 5
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Max rows. Capped at 1,000 without an API key, 50,000 with one.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                },
                "example": "id,slug,pain_statement,industry,job_role,problem_category,severity_score,…"
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/spec-builder": {
      "post": {
        "operationId": "specBuilder",
        "summary": "Build spec generator",
        "description": "Turn a problem or opportunity into a build-ready technical spec: user stories, data model, API surface, stack and a phased MVP.\n\nBody takes either `{ id, type }` — where `id` is a problem or opportunity slug or UUID and `type` is `problem` or `opportunity` — or `{ idea }` for free text. Specs built from a stored record are grounded in the actual reported gaps and workarounds; free-text specs are grounded only in what you wrote, and the model is instructed to flag its own assumptions. This is a live LLM generation, hence the hourly limit.",
        "tags": [
          "Tools"
        ],
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "Problem or opportunity slug/UUID. Mutually exclusive with `idea`.",
                    "example": "multi-location-staff-scheduling-b1c0d3e4"
                  },
                  "type": {
                    "type": "string",
                    "description": "Which record `id` refers to.",
                    "enum": [
                      "problem",
                      "opportunity"
                    ],
                    "default": "opportunity"
                  },
                  "idea": {
                    "type": "string",
                    "description": "Free-text description, 20–2000 characters. Used when `id` is absent."
                  }
                },
                "required": []
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": {
                    "spec": {
                      "summary": "Multi-location rota management with self-serve shift swaps.",
                      "mvp_scope": {
                        "estimate": "3 weeks",
                        "in": [
                          "Drag-and-drop rota builder"
                        ]
                      },
                      "data_model": [
                        {
                          "entity": "shift",
                          "fields": [
                            "id uuid",
                            "location_id uuid",
                            "starts_at timestamptz"
                          ]
                        }
                      ]
                    },
                    "source": {
                      "title": "ShiftSync",
                      "url": "https://painsignal.net/opportunity/…"
                    },
                    "model": "deepseek-v4-pro"
                  },
                  "meta": null,
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/match": {
      "post": {
        "operationId": "match",
        "summary": "Match opportunities to your skills",
        "description": "Rank opportunities by semantic similarity to a free-text description of what you can build.\n\nBody takes `{ skills, limit?, maxComplexity? }`. Matches against opportunity centroids rather than individual problems, and only returns clusters with two or more independent reports — so a result is a theme, not one person's bad day. Describe the problem domain rather than your stack: the corpus is indexed by what is broken in someone's job, and nobody complaining about their rota mentions React. One embedding per call, no LLM generation.",
        "tags": [
          "Tools"
        ],
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "skills": {
                    "type": "string",
                    "description": "What you can build and what interests you. At least 15 characters.",
                    "example": "Next.js and Postgres, interested in scheduling and logistics"
                  },
                  "limit": {
                    "type": "integer",
                    "description": "Results to return.",
                    "minimum": 1,
                    "maximum": 30,
                    "default": 12
                  },
                  "maxComplexity": {
                    "type": "integer",
                    "description": "Only opportunities at or below this build complexity. Restricts results to analysed clusters, which are a subset of the corpus.",
                    "minimum": 1,
                    "maximum": 5
                  }
                },
                "required": [
                  "skills"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                },
                "example": {
                  "data": [
                    {
                      "id": "b1c0d3e4-…",
                      "title": "ShiftSync",
                      "industry": "Health, Wellness, and Beauty",
                      "signalCount": 14,
                      "avgSeverity": 3.9,
                      "buildComplexity": 3,
                      "competitionLevel": "medium",
                      "similarity": 0.72,
                      "url": "https://painsignal.net/opportunity/multi-location-staff-scheduling-b1c0d3e4"
                    }
                  ],
                  "meta": {
                    "page": 1,
                    "limit": 12,
                    "total": 12
                  },
                  "error": null
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Retry-After and X-RateLimit-* headers are set on every response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Envelope"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Optional. Free, self-issued at /developers/keys. Raises rate limits only."
      },
      "BearerToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "The same API key, sent as `Authorization: Bearer <key>`."
      }
    },
    "schemas": {
      "Envelope": {
        "type": "object",
        "required": [
          "data",
          "meta",
          "error"
        ],
        "properties": {
          "data": {
            "description": "The payload, or null on error."
          },
          "meta": {
            "type": [
              "object",
              "null"
            ],
            "description": "Pagination, when the endpoint is paginated.",
            "properties": {
              "page": {
                "type": "integer"
              },
              "limit": {
                "type": "integer"
              },
              "total": {
                "type": "integer"
              }
            }
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable message, or null on success."
          }
        }
      }
    }
  },
  "security": [
    {},
    {
      "ApiKeyHeader": []
    },
    {
      "BearerToken": []
    }
  ]
}