{
  "openapi": "3.1.0",
  "info": {
    "title": "MorusAI public API",
    "version": "1.0.0",
    "summary": "The two public endpoints behind morusai.com's forms, documented so AI agents and integrators can use them directly.",
    "description": "Both endpoints are unauthenticated, same as the forms on the website, and are rate limited per client address. They create real records: a consultation request reaches the MorusAI team, and a website score emails a report to the address given. Only call them with a person's knowledge and consent to be contacted. Every response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy headers, and a 429 adds Retry-After. Every error is JSON ({\"error\": \"...\"}); a wrong method returns a JSON 405 with an Allow header and an unknown /api path a JSON 404. A JSON index of the API is at https://www.morusai.com/api. Versioning: The current API version is v1, served under /api/v1/. The unversioned paths (/api/contact, /api/score) are permanent aliases of v1. Any breaking change will ship as a new version under its own path; the previous version then keeps running for at least 90 days and its responses carry Deprecation and Sunset headers (RFC 9745 and RFC 8594) naming the retirement date. Additive changes (new optional fields or endpoints) are not breaking and need no new version. Privacy policy: https://www.morusai.com/privacy. Human-readable docs: https://www.morusai.com/docs. Machine-readable overview: https://www.morusai.com/llms.txt. An MCP server exposing the same actions as tools is at https://www.morusai.com/api/mcp.",
    "termsOfService": "https://www.morusai.com/terms",
    "contact": {
      "name": "MorusAI",
      "email": "info@morusai.com",
      "url": "https://www.morusai.com/contact"
    },
    "x-api-version": "v1",
    "x-versioning-policy": "The current API version is v1, served under /api/v1/. The unversioned paths (/api/contact, /api/score) are permanent aliases of v1. Any breaking change will ship as a new version under its own path; the previous version then keeps running for at least 90 days and its responses carry Deprecation and Sunset headers (RFC 9745 and RFC 8594) naming the retirement date. Additive changes (new optional fields or endpoints) are not breaking and need no new version."
  },
  "servers": [
    {
      "url": "https://www.morusai.com"
    }
  ],
  "paths": {
    "/api/v1/contact": {
      "post": {
        "operationId": "requestConsultation",
        "summary": "Request a free consultation or demo",
        "description": "Records a consultation request for the MorusAI team, who reply by email. Also used to record a lead before running the Website Grader (source \"Website Grader\") and by partner member pages (sources \"RAE Member Perk\" and \"AMBA Member Perk\"). Rate limit: 60 requests per 10 minutes per client address. The unversioned path /api/contact is a permanent alias.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactRequest"
              },
              "example": {
                "name": "Priya Sharma",
                "email": "priya@example.ca",
                "company": "Example Mortgages",
                "message": "Team of five brokers in Calgary. Interested in a demo next week.",
                "source": "API"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Request recorded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Success"
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            }
          },
          "400": {
            "description": "Name or email missing, a field is not a string or exceeds its length limit, or the email is not valid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              },
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "description": "The request could not be stored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            }
          }
        }
      }
    },
    "/api/v1/score": {
      "post": {
        "operationId": "scoreWebsite",
        "summary": "Score a public website for SEO, GEO and AI readiness",
        "description": "Runs the MorusAI Website Grader against a public website and returns a 0-100 score, a letter grade, every check with evidence and a fix, and the top fixes. Scoring is deterministic; a check is excluded (status \"na\") rather than guessed when it cannot be measured. If a valid email is supplied, the full report is emailed to it. The grader form also records a lead through /api/contact before calling this endpoint; integrators acting for a person should do the same with that person's consent. Typical duration 15 to 60 seconds. Rate limit: 20 requests per 10 minutes per client address. Private and loopback addresses are refused; sites that block automated scans may be scored through Google PageSpeed with reduced coverage, or refused with status 400 if nothing can be measured. The unversioned path /api/score is a permanent alias.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScoreRequest"
              },
              "example": {
                "url": "https://example.ca",
                "audience": "mortgage",
                "name": "Priya Sharma",
                "email": "priya@example.ca"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Scored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScoreResponse"
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            }
          },
          "400": {
            "description": "Missing or invalid URL, or the site could not be measured.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              },
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "description": "The scan failed on the server side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ContactRequest": {
        "type": "object",
        "required": [
          "name",
          "email"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Full name of the person requesting contact."
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 254,
            "description": "Where the MorusAI team replies."
          },
          "phone": {
            "type": "string",
            "maxLength": 40
          },
          "company": {
            "type": "string",
            "maxLength": 160,
            "description": "Brokerage or company name."
          },
          "message": {
            "type": "string",
            "maxLength": 5000,
            "description": "Context: what to discuss, preferred times, team size."
          },
          "source": {
            "type": "string",
            "maxLength": 80,
            "description": "Where the request came from, for the team's records (for example \"API\" or your product name)."
          }
        },
        "description": "Every field must be a string when present. Unknown fields are ignored."
      },
      "ScoreRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "description": "Public website address. \"https://\" is assumed when no scheme is given."
          },
          "audience": {
            "type": "string",
            "enum": [
              "mortgage",
              "real-estate"
            ],
            "default": "mortgage",
            "description": "Tailors the fixes and tips."
          },
          "name": {
            "type": "string",
            "description": "Used to address the emailed report; longer values are truncated to 80 characters."
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "If valid, the full report is emailed here."
          }
        },
        "description": "Unknown fields are ignored."
      },
      "ScoreResponse": {
        "type": "object",
        "required": [
          "report"
        ],
        "properties": {
          "report": {
            "$ref": "#/components/schemas/ScoreReport"
          }
        }
      },
      "ScoreReport": {
        "type": "object",
        "required": [
          "requestedUrl",
          "finalUrl",
          "fetchedAt",
          "audience",
          "overall",
          "grade",
          "categories",
          "topFixes",
          "meta"
        ],
        "properties": {
          "requestedUrl": {
            "type": "string"
          },
          "finalUrl": {
            "type": "string",
            "description": "Address after redirects."
          },
          "fetchedAt": {
            "type": "string",
            "format": "date-time"
          },
          "audience": {
            "type": "string",
            "enum": [
              "mortgage",
              "real-estate"
            ]
          },
          "overall": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Weighted across categories that have at least one scored check."
          },
          "grade": {
            "type": "string",
            "description": "Letter grade derived from overall."
          },
          "categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CategoryScore"
            }
          },
          "topFixes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CheckResult"
            },
            "description": "Highest-impact failing or warning checks."
          },
          "content": {
            "type": "object",
            "properties": {
              "wordCount": {
                "type": "integer"
              },
              "topKeywords": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "meta": {
            "type": "object",
            "description": "Which data sources ran: direct scan or Google PageSpeed fallback, PageSpeed and Chrome UX Report status, whether AI-written tips were added, and whether the report was emailed.",
            "additionalProperties": true
          }
        }
      },
      "CategoryScore": {
        "type": "object",
        "required": [
          "key",
          "label",
          "score",
          "weight",
          "checks",
          "counts"
        ],
        "properties": {
          "key": {
            "type": "string",
            "enum": [
              "seo",
              "geo",
              "aio",
              "trust"
            ]
          },
          "label": {
            "type": "string"
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "weight": {
            "type": "number"
          },
          "checks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CheckResult"
            }
          },
          "counts": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            }
          }
        }
      },
      "CheckResult": {
        "type": "object",
        "required": [
          "id",
          "category",
          "label",
          "status",
          "weight",
          "score",
          "remediation"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Stable id, e.g. \"seo.title\"."
          },
          "category": {
            "type": "string",
            "enum": [
              "seo",
              "geo",
              "aio",
              "trust"
            ]
          },
          "label": {
            "type": "string",
            "description": "Technical label."
          },
          "help": {
            "type": "string",
            "description": "Plain-language explanation."
          },
          "status": {
            "type": "string",
            "enum": [
              "pass",
              "warn",
              "fail",
              "na"
            ],
            "description": "\"na\" means not measurable; excluded from scoring."
          },
          "weight": {
            "type": "number"
          },
          "score": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "evidence": {
            "type": "string",
            "description": "Concrete proof from the fetched page."
          },
          "remediation": {
            "type": "string",
            "description": "Deterministic one-line fix."
          },
          "tip": {
            "type": "string",
            "description": "AI-written, grounded tip, when available."
          }
        }
      },
      "Success": {
        "type": "object",
        "required": [
          "success"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string"
          }
        }
      }
    },
    "headers": {
      "RateLimit-Limit": {
        "description": "Requests allowed per window for this client address.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Remaining": {
        "description": "Requests left in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Reset": {
        "description": "Seconds until the window resets.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Policy": {
        "description": "Policy as \"<limit>;w=<window seconds>\".",
        "schema": {
          "type": "string"
        }
      }
    }
  }
}
