{
  "openapi": "3.1.0",
  "info": {
    "title": "ISMS Directory public API",
    "version": "1.0.0",
    "summary": "Catalog search, market page-interest, tools dump, and homepage Ask AI.",
    "description": "ISMS Directory is a curated directory of ISO 27001, SOC 2, and other GRC services. These HTTP APIs are already public. They do not write the catalog, change ranking, or expose admin or Stripe endpoints.\n\nPrefer GET /api/v1/search for exhaustive multi-filter vendor matches. GET /api/search is the unversioned alias (same handler, no sunset scheduled). GET /api/v1/market and GET /api/market are first-party directory page interest on /grc-services/* paths (not national adoption, not market share). GET /api/tools.json is the full public dump. POST /api/ai-search is the homepage Ask AI chat (short NL recommendations, rate-limited, may 503 when the corpus gate fails).\n\nVersioning: stable REST uses a major version in the URL path, starting at /api/v1/. Backward-compatible fields may be added inside v1. Breaking changes require /api/v2/. Before removing a stable version we publish a migration note and send RFC 9745 Deprecation and Sunset headers, with at least 90 days before removal. No sunset is scheduled today.\n\nRate limits: catalog and market GET send RateLimit-Policy \"catalog\";q=120;w=60 and RateLimit \"catalog\";r=120;t=60. A 429, if issued, includes Retry-After. Developer portal: https://ismsdirectory.com/developers\n\nUnknown /api and /api/* paths return HTTP 404 application/json with error.code NOT_FOUND, a message, and a hint pointing at this spec and /llms.txt. Wrong methods on GET-only catalog search return HTTP 405 application/json with Allow: GET, HEAD, OPTIONS. Market allows GET and OPTIONS; other methods return HTTP 405 JSON with Allow: GET, OPTIONS (HEAD is 405 so interest mint does not run). Agents must not expect HTML error pages on /api.\n\nHTML routes also negotiate Markdown: send Accept: text/markdown. Missing pages stay HTTP 404. Site index: https://ismsdirectory.com/llms.txt.",
    "contact": {
      "name": "ISMS Directory",
      "url": "https://ismsdirectory.com/contact"
    },
    "license": {
      "name": "Proprietary"
    },
    "externalDocs": {
      "description": "Deprecation and sunset policy (RFC 9745 Deprecation and Sunset headers, 90 days before removal). No sunset is scheduled.",
      "url": "https://ismsdirectory.com/deprecation.md"
    }
  },
  "servers": [
    {
      "url": "https://ismsdirectory.com",
      "description": "Production (unversioned aliases /api/search and /api/market remain)"
    }
  ],
  "tags": [
    {
      "name": "catalog",
      "description": "Public vendor catalog. No auth. Results are unranked (stable name order)."
    },
    {
      "name": "market",
      "description": "First-party directory page-interest stats. No auth. Not market share."
    },
    {
      "name": "ask-ai",
      "description": "Homepage Ask AI. Not a catalog dump. Rate-limited."
    }
  ],
  "security": [],
  "paths": {
    "/api/search": {
      "get": {
        "operationId": "searchCatalog",
        "tags": ["catalog"],
        "summary": "Filter the public vendor catalog",
        "description": "Exact multi-dimension tag filters. Frameworks are ANDed when repeated. Region means service coverage, not headquarters. Results are unranked (name_asc). Provide at least one of framework, region, service_type, or a q that maps to those dimensions. Unknown aliases return HTTP 422 with suggestions. Public vendor fields only (no email).",
        "parameters": [
          {
            "name": "framework",
            "in": "query",
            "required": false,
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" }, "maxItems": 10 },
            "description": "Repeatable. Example: iso-27001, dora."
          },
          {
            "name": "region",
            "in": "query",
            "required": false,
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" }, "maxItems": 10 },
            "description": "Repeatable. Service coverage, not HQ. Example: germany, united-states."
          },
          {
            "name": "service_type",
            "in": "query",
            "required": false,
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" }, "maxItems": 10 },
            "description": "Repeatable. Aliases: service_types, type, types. Example: consulting, software."
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "maxLength": 500 },
            "description": "Optional natural language. Longest alias hits become structured filters. Residual tokens do not filter."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 25 }
          }
        ],
        "responses": {
          "200": {
            "description": "Matches",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CatalogSearchSuccess" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "422": { "$ref": "#/components/responses/UnknownFilter" }
        }
      }
    },
    "/api/v1/search": {
      "get": {
        "operationId": "searchCatalogV1",
        "tags": ["catalog"],
        "summary": "Filter the public vendor catalog (v1)",
        "description": "Versioned path for searchCatalog. Same handler as GET /api/search. Use this path for new integrations.",
        "parameters": [
          {
            "name": "framework",
            "in": "query",
            "required": false,
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" }, "maxItems": 10 }
          },
          {
            "name": "region",
            "in": "query",
            "required": false,
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" }, "maxItems": 10 }
          },
          {
            "name": "service_type",
            "in": "query",
            "required": false,
            "style": "form",
            "explode": true,
            "schema": { "type": "array", "items": { "type": "string" }, "maxItems": 10 }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "maxLength": 500 }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 25 }
          }
        ],
        "responses": {
          "200": {
            "description": "Matches",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CatalogSearchSuccess" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "422": { "$ref": "#/components/responses/UnknownFilter" }
        }
      }
    },
    "/api/market": {
      "get": {
        "operationId": "getMarket",
        "tags": ["market"],
        "summary": "Directory page-interest stats",
        "description": "Point lookup (framework and/or region) or ranking (dimension=frameworks|regions|combinations). Metric = first-party page-view events on /grc-services/* pages. Region means which directory page path was viewed, not visitor geolocation. Growth and rank are withheld below the volume threshold. CORS: GET and OPTIONS, *.",
        "parameters": [
          {
            "name": "framework",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "Point mode. Must not be repeated."
          },
          {
            "name": "region",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "Point mode. Must not be repeated."
          },
          {
            "name": "dimension",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": ["frameworks", "regions", "combinations"]
            },
            "description": "Ranking mode. Mutually exclusive with a point lookup."
          },
          {
            "name": "window",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["30d", "90d"], "default": "30d" }
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "Alias for window (30 or 90, with or without a trailing d)."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 25 },
            "description": "Ranking mode only."
          }
        ],
        "responses": {
          "200": {
            "description": "Point or ranking envelope. Always includes metric definition, limitations, period bounds, and citations to /trends/methodology.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MarketSuccess" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "422": { "$ref": "#/components/responses/UnknownFilter" },
          "503": { "$ref": "#/components/responses/UpstreamUnavailable" },
          "504": { "$ref": "#/components/responses/UpstreamTimeout" }
        }
      },
      "options": {
        "operationId": "optionsMarket",
        "tags": ["market"],
        "summary": "CORS preflight",
        "description": "CORS preflight for GET /api/market. Allow-Origin *, Allow-Methods GET and OPTIONS.",
        "responses": {
          "204": { "description": "No content" }
        }
      }
    },
    "/api/v1/market": {
      "get": {
        "operationId": "getMarketV1",
        "tags": ["market"],
        "summary": "Directory page-interest stats (v1)",
        "description": "Versioned path for getMarket. Same handler as GET /api/market. Use this path for new integrations.",
        "responses": {
          "200": {
            "description": "Point or ranking envelope",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MarketSuccess" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidInput" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "422": { "$ref": "#/components/responses/UnknownFilter" },
          "503": { "$ref": "#/components/responses/UpstreamUnavailable" },
          "504": { "$ref": "#/components/responses/UpstreamTimeout" }
        }
      }
    },
    "/api/tools.json": {
      "get": {
        "operationId": "listToolsDump",
        "tags": ["catalog"],
        "summary": "Full public tools dump",
        "description": "Static JSON of every listed service (public fields only). Prefer GET /api/search for filtered lookup.",
        "responses": {
          "200": {
            "description": "Dump",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ToolsDump" }
              }
            }
          }
        }
      }
    },
    "/api/ai-search": {
      "post": {
        "operationId": "askAiSearch",
        "tags": ["ask-ai"],
        "summary": "Homepage Ask AI (NL vendor recommendations)",
        "description": "Not catalog search. Returns short natural-language recommendations as SSE (event: meta, delta, done, or error). Rate-limited per IP and session. May return 503 when the server is disabled, unconfigured, or the corpus integrity gate fails. Prefer GET /api/search for exhaustive matches.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AiSearchRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "SSE stream",
            "content": {
              "text/event-stream": {
                "schema": { "type": "string" }
              }
            }
          },
          "400": {
            "description": "bad_json, bad_message, or bad_token",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AiSearchError" }
              }
            }
          },
          "413": {
            "description": "too_large",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AiSearchError" }
              }
            }
          },
          "422": {
            "description": "refused by input moderation",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AiSearchError" }
              }
            }
          },
          "429": {
            "description": "rate_limited, conversation_limit, or capacity",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AiSearchError" }
              }
            }
          },
          "503": {
            "description": "disabled, not_configured, corpus_integrity, unavailable, or moderation_unavailable",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AiSearchError" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "PublicVendor": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name",
          "url",
          "briefSummary",
          "description",
          "serviceType",
          "regionCovered",
          "complianceFrameworkExpertise",
          "problemsTheySolve",
          "industrySpecialization",
          "targetClientsSize",
          "supportedLanguages",
          "detailsPage"
        ],
        "properties": {
          "name": { "type": "string" },
          "url": { "type": "string" },
          "briefSummary": { "type": "string" },
          "description": { "type": "string" },
          "serviceType": { "type": "array", "items": { "type": "string" } },
          "regionCovered": { "type": "array", "items": { "type": "string" } },
          "complianceFrameworkExpertise": {
            "type": "array",
            "items": { "type": "string" }
          },
          "problemsTheySolve": { "type": "array", "items": { "type": "string" } },
          "industrySpecialization": {
            "type": "array",
            "items": { "type": "string" }
          },
          "targetClientsSize": { "type": "array", "items": { "type": "string" } },
          "supportedLanguages": { "type": "array", "items": { "type": "string" } },
          "detailsPage": { "type": "string" },
          "matched": {
            "type": "object",
            "properties": {
              "serviceTypes": { "type": "array", "items": { "type": "string" } },
              "frameworks": { "type": "array", "items": { "type": "string" } },
              "regions": { "type": "array", "items": { "type": "string" } }
            }
          }
        }
      },
      "CatalogSearchSuccess": {
        "type": "object",
        "required": [
          "ok",
          "schemaVersion",
          "normalized",
          "warnings",
          "unparsedQueryTerms",
          "catalog",
          "ranking",
          "count",
          "results"
        ],
        "properties": {
          "ok": { "type": "boolean", "const": true },
          "schemaVersion": { "type": "string" },
          "normalized": {
            "type": "object",
            "properties": {
              "frameworks": { "type": "array", "items": { "type": "string" } },
              "regions": { "type": "array", "items": { "type": "string" } },
              "serviceTypes": { "type": "array", "items": { "type": "string" } },
              "regionMeaning": { "type": "string", "const": "service_coverage" }
            }
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "code": { "type": "string" },
                "message": { "type": "string" }
              }
            }
          },
          "unparsedQueryTerms": { "type": "array", "items": { "type": "string" } },
          "catalog": {
            "type": "object",
            "properties": {
              "source": { "type": "string", "enum": ["live", "static"] },
              "fallback": { "type": "boolean" }
            }
          },
          "ranking": {
            "type": "object",
            "properties": {
              "method": { "type": "string", "const": "name_asc" },
              "rawVendorCountsExposed": { "type": "boolean", "const": false }
            }
          },
          "count": { "type": "integer" },
          "results": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/PublicVendor" }
          }
        }
      },
      "ApiError": {
        "type": "object",
        "required": ["ok", "error"],
        "properties": {
          "ok": { "type": "boolean", "const": false },
          "error": {
            "type": "object",
            "required": ["code", "message", "hint"],
            "properties": {
              "code": { "type": "string" },
              "message": { "type": "string" },
              "hint": {
                "type": "string",
                "description": "How to recover: OpenAPI URL, listed aliases, or the GET /api/search example."
              },
              "unknown": { "type": "array", "items": { "type": "object" } }
            }
          }
        }
      },
      "MarketSuccess": {
        "type": "object",
        "required": ["ok", "schemaVersion", "limitations", "citations", "window"],
        "properties": {
          "ok": { "type": "boolean", "const": true },
          "schemaVersion": { "type": "string" },
          "methodologyVersion": { "type": "string" },
          "metric": { "type": "object", "additionalProperties": true },
          "interestIncludeEnabled": { "type": "boolean" },
          "period": { "type": "object", "additionalProperties": true },
          "source": { "type": ["object", "null"], "additionalProperties": true },
          "limitations": {
            "type": "array",
            "items": { "type": "string" }
          },
          "citations": { "type": "object", "additionalProperties": { "type": "string" } },
          "related": { "type": "object", "additionalProperties": { "type": "string" } },
          "window": { "type": "string" },
          "query": { "type": "object", "additionalProperties": true },
          "confidence": { "type": "object", "additionalProperties": true },
          "result": { "type": "object", "additionalProperties": true },
          "results": { "type": "array", "items": { "type": "object" } },
          "warnings": { "type": "array", "items": { "type": "object" } }
        }
      },
      "ToolsDump": {
        "type": "object",
        "required": ["directory", "lastUpdated", "count", "services"],
        "properties": {
          "directory": { "type": "string" },
          "lastUpdated": { "type": "string" },
          "count": { "type": "integer" },
          "services": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/PublicVendor" }
          }
        }
      },
      "AiSearchRequest": {
        "type": "object",
        "required": ["message"],
        "properties": {
          "message": { "type": "string", "minLength": 1, "maxLength": 1200 },
          "token": {
            "type": "string",
            "description": "HMAC-signed conversation token from a previous turn. Omit to start a session."
          },
          "locale": {
            "type": "string",
            "pattern": "^[a-z]{2}$",
            "default": "en"
          }
        }
      },
      "AiSearchError": {
        "type": "object",
        "required": ["ok", "code"],
        "properties": {
          "ok": { "type": "boolean", "const": false },
          "code": { "type": "string" }
        }
      }
    },
    "responses": {
      "InvalidInput": {
        "description": "Malformed query (limit, unknown param, empty filters, repeated market param).",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" }
          }
        }
      },
      "UnknownFilter": {
        "description": "Unknown alias. Body includes suggestions under error.unknown.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" }
          }
        }
      },
      "NotFound": {
        "description": "No API route matches this path. JSON body with error.code NOT_FOUND and a hint.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "Wrong HTTP method. JSON body with error.code METHOD_NOT_ALLOWED, a hint, and an Allow header.",
        "headers": {
          "Allow": {
            "schema": { "type": "string", "examples": ["GET"] }
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" }
          }
        }
      },
      "UpstreamUnavailable": {
        "description": "Market upstream unavailable or not configured.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" }
          }
        }
      },
      "UpstreamTimeout": {
        "description": "Market trends upstream timed out.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" }
          }
        }
      }
    }
  }
}
