{
  "openapi": "3.1.0",
  "info": {
    "title": "omnichannel.cx API",
    "version": "1.0.0",
    "description": "Public API surface of omnichannel.cx - a customer experience and service operations consultancy. These endpoints let agents submit contact enquiries and interact with site services programmatically.",
    "contact": {
      "name": "omnichannel",
      "url": "https://omnichannel.cx/contact"
    },
    "license": {
      "name": "Proprietary"
    },
    "x-llms-txt": "https://omnichannel.cx/llms.txt"
  },
  "servers": [
    {
      "url": "https://omnichannel.cx",
      "description": "Production"
    }
  ],
  "tags": [
    { "name": "contact", "description": "Contact form submission" },
    { "name": "dashboard", "description": "Internal dashboard endpoints" },
    { "name": "discovery", "description": "Machine-readable site metadata" }
  ],
  "paths": {
    "/api/contact": {
      "post": {
        "operationId": "submitContactForm",
        "tags": ["contact"],
        "summary": "Submit a contact enquiry",
        "description": "Sends a contact form submission to the omnichannel team by email. The recipient address is never exposed. Rate limited to 5 requests per hour per IP. Agents should include a descriptive message so the enquiry can be routed correctly.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ContactRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Enquiry accepted and queued for delivery.",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": { "ok": { "type": "boolean", "example": true } }
                }
              }
            }
          },
          "400": {
            "description": "Validation failed.",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "405": {
            "description": "Method not allowed.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "403": {
            "description": "Turnstile human verification missing or failed.",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "429": {
            "description": "Rate limit exceeded (5 per hour per IP).",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "502": {
            "description": "Email delivery failed upstream.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "503": {
            "description": "Email service not configured.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          }
        }
      }
    },
    "/api/dashboard/verify": {
      "post": {
        "operationId": "verifyDashboardPin",
        "tags": ["dashboard"],
        "summary": "Verify dashboard access PIN",
        "description": "Verifies the PIN required to view the private analytics dashboard. Rate limited to 20 requests per minute per IP.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["pin"],
                "properties": {
                  "pin": { "type": "string", "description": "Dashboard PIN" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "PIN verified.",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "valid": { "type": "boolean", "example": true } }
                }
              }
            }
          },
          "401": {
            "description": "Invalid PIN.",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "valid": { "type": "boolean", "example": false },
                    "code": { "type": "string", "example": "INVALID_PIN" }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded.",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          }
        }
      }
    },
    "/api/posthog/query": {
      "post": {
        "operationId": "queryPosthogAnalytics",
        "tags": ["dashboard"],
        "summary": "Run a PostHog analytics query (internal)",
        "description": "Server-side proxy to the PostHog project query API used by the private dashboard. Requires server-configured credentials; not intended for public use. Rate limited to 20 requests per minute per IP.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "PostHog HogQL/insight query payload",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Query result passthrough from PostHog.",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": {
                "schema": { "type": "object", "additionalProperties": true }
              }
            }
          },
          "404": {
            "description": "Path not allowed.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "429": {
            "description": "Rate limit exceeded.",
            "headers": { "$ref": "#/components/headers/RateLimitHeaders" },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          }
        }
      }
    },
    "/openapi.json": {
      "get": {
        "operationId": "getOpenApiSpec",
        "tags": ["discovery"],
        "summary": "This OpenAPI specification",
        "responses": {
          "200": {
            "description": "The OpenAPI 3.1 specification for this API.",
            "content": {
              "application/json": { "schema": { "type": "object", "additionalProperties": true } }
            }
          }
        }
      }
    },
    "/llms.txt": {
      "get": {
        "operationId": "getLlmsTxt",
        "tags": ["discovery"],
        "summary": "LLM-oriented site index",
        "description": "Markdown index of site content, when-to-use guidance, and developer resources.",
        "responses": {
          "200": {
            "description": "llms.txt contents.",
            "content": {
              "text/plain": { "schema": { "type": "string" } }
            }
          }
        }
      }
    },
    "/sitemap.xml": {
      "get": {
        "operationId": "getSitemap",
        "tags": ["discovery"],
        "summary": "XML sitemap",
        "responses": {
          "200": {
            "description": "XML sitemap of all indexable URLs.",
            "content": {
              "application/xml": { "schema": { "type": "string" } }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ContactRequest": {
        "type": "object",
        "required": ["name", "email", "message", "turnstileToken"],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 200,
            "description": "Sender's full name"
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 254,
            "description": "Reply-to email address - the primary way we contact the inquirer"
          },
          "phone": {
            "type": "string",
            "maxLength": 25,
            "pattern": "^[+\\d][\\d\\s\\-().]{5,24}$",
            "description": "Optional phone number as an alternative contact method"
          },
          "company": {
            "type": "string",
            "maxLength": 200,
            "description": "Optional company name"
          },
          "topic": {
            "type": "string",
            "maxLength": 120,
            "description": "Enquiry topic",
            "enum": [
              "General enquiry",
              "Maturity Review & AI readiness",
              "CX strategy & service design",
              "Operational performance",
              "AI & automation with human control",
              "Partnerships"
            ],
            "default": "General enquiry"
          },
          "message": {
            "type": "string",
            "minLength": 10,
            "maxLength": 4000,
            "description": "Enquiry body"
          },
          "website": {
            "type": "string",
            "description": "Honeypot anti-spam field. Must be submitted empty; non-empty submissions are silently dropped.",
            "maxLength": 0
          },
          "turnstileToken": {
            "type": "string",
            "description": "Cloudflare Turnstile token (cf-turnstile-response) proving a human submitted the form. Obtainable only by solving the widget on /contact - bots cannot mint tokens. Agents should direct their human operator to the form.",
            "minLength": 1
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable error message"
          },
          "code": {
            "type": "string",
            "description": "Machine-readable error code",
            "enum": [
              "VALIDATION_ERROR",
              "INVALID_JSON",
              "INVALID_BODY",
              "METHOD_NOT_ALLOWED",
              "NOT_FOUND",
              "RATE_LIMITED",
              "TURNSTILE_FAILED",
              "TURNSTILE_NOT_CONFIGURED",
              "EMAIL_NOT_CONFIGURED",
              "EMAIL_SEND_FAILED",
              "CREDENTIALS_NOT_CONFIGURED",
              "PIN_NOT_CONFIGURED",
              "INVALID_PIN",
              "PATH_NOT_ALLOWED",
              "UPSTREAM_FETCH_FAILED",
              "ASSET_FETCH_FAILED",
              "SERIALIZATION_ERROR"
            ]
          },
          "hint": {
            "type": "string",
            "description": "Suggested resolution for the caller"
          }
        }
      }
    },
    "headers": {
      "RateLimitHeaders": {
        "RateLimit-Limit": {
          "schema": { "type": "integer" },
          "description": "Requests allowed in the current window"
        },
        "RateLimit-Remaining": {
          "schema": { "type": "integer" },
          "description": "Requests remaining in the current window"
        },
        "RateLimit-Reset": {
          "schema": { "type": "integer" },
          "description": "Unix timestamp when the window resets"
        },
        "Retry-After": {
          "schema": { "type": "integer" },
          "description": "Seconds to wait before retrying (429 responses only)"
        }
      }
    }
  }
}
