{
  "openapi": "3.1.0",
  "info": {
    "title": "Vitrus API",
    "version": "1",
    "summary": "Cookie-free analytics where every number carries the query that produced it.",
    "description": "Two surfaces: an unauthenticated ingest endpoint the browser script calls, and a session-authenticated read API. Every read response carries evidence records — the SQL, its parameters and the window — so a client can cite a number rather than assert it.\n\nThere is also a read-only MCP server at POST /mcp for agents that speak the Model Context Protocol. It is read-only on purpose: a record a model can write to stops being a record.",
    "license": {
      "name": "Apache-2.0 (core)",
      "url": "https://www.apache.org/licenses/LICENSE-2.0"
    }
  },
  "servers": [
    {
      "url": "https://app.vitrus.dev/app",
      "description": "Vitrus Cloud"
    },
    {
      "url": "http://localhost:3000",
      "description": "Self-hosted"
    }
  ],
  "tags": [
    {
      "name": "ingest",
      "description": "What the browser script sends. No authentication; quota applies."
    },
    {
      "name": "analytics",
      "description": "Reads. Session cookie required; every site id passes the ACL first."
    },
    {
      "name": "account",
      "description": "Who you are and what you may see."
    }
  ],
  "paths": {
    "/api/collect": {
      "post": {
        "tags": [
          "ingest"
        ],
        "summary": "Record one event",
        "description": "Called by the tracker at /api/d (this path is the same endpoint under its original name). Returns 204 on success, 404 for an unknown site and 429 when the workspace is past its quota grace band — rejected events are counted and shown to the customer.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "site",
                  "type"
                ],
                "properties": {
                  "site": {
                    "type": "string"
                  },
                  "type": {
                    "type": "string",
                    "enum": [
                      "pageview",
                      "event"
                    ]
                  },
                  "name": {
                    "type": "string",
                    "description": "Event name. Required when type is event."
                  },
                  "url": {
                    "type": "string"
                  },
                  "referrer": {
                    "type": "string"
                  },
                  "props": {
                    "type": "object",
                    "description": "Event properties. Never contains a form field's VALUE."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Recorded."
          },
          "404": {
            "description": "Unknown site."
          },
          "429": {
            "description": "Quota exceeded; the event was rejected and counted as rejected."
          }
        }
      }
    },
    "/api/sites": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "The sites an API key can read",
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "{ sites: Site[] }"
          },
          "401": {
            "description": "Missing, unknown or revoked key."
          }
        }
      }
    },
    "/api/stats": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "The full metric bundle for a site and window",
        "parameters": [
          {
            "name": "site",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Site id. Resolved against your access before it reaches any query; a site you cannot read answers 404, byte-identical to one that does not exist."
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 7
            },
            "description": "Window length in days."
          }
        ],
        "responses": {
          "200": {
            "description": "Every metric, each with the query behind it.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "siteId": {
                      "type": "string"
                    },
                    "window": {
                      "type": "object"
                    },
                    "evidence": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "description": "One metric and the query that produced it. `sql` is the statement that ran, not a description of it.",
                        "properties": {
                          "id": {
                            "type": "string",
                            "example": "e1"
                          },
                          "metric": {
                            "type": "string",
                            "example": "visitors.unique"
                          },
                          "label": {
                            "type": "string"
                          },
                          "value": {
                            "type": "number",
                            "nullable": true
                          },
                          "previous": {
                            "type": "number",
                            "nullable": true
                          },
                          "deltaPct": {
                            "type": "number",
                            "nullable": true
                          },
                          "unit": {
                            "type": "string",
                            "enum": [
                              "count",
                              "percent",
                              "seconds",
                              "ratio"
                            ]
                          },
                          "sql": {
                            "type": "string"
                          },
                          "params": {
                            "type": "array",
                            "items": {}
                          },
                          "window": {
                            "type": "object",
                            "properties": {
                              "from": {
                                "type": "integer"
                              },
                              "to": {
                                "type": "integer"
                              },
                              "label": {
                                "type": "string"
                              }
                            }
                          },
                          "rows": {
                            "type": "array",
                            "items": {
                              "type": "object"
                            },
                            "nullable": true
                          }
                        },
                        "required": [
                          "id",
                          "metric",
                          "sql",
                          "params",
                          "window"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such site, or not yours — the two are indistinguishable by design."
          }
        }
      }
    },
    "/api/digest": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "The written summary, with the evidence each sentence rests on",
        "description": "Deterministic. Every line carries the ids of the evidence records that support it. With an LLM configured the prose may be rephrased, never recomputed: a sentence containing a number that is not in the evidence set is dropped and the digest is marked degraded.",
        "parameters": [
          {
            "name": "site",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Site id. Resolved against your access before it reaches any query; a site you cannot read answers 404, byte-identical to one that does not exist."
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 7
            },
            "description": "Window length in days."
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "json",
                "text"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Digest and evidence."
          },
          "404": {
            "description": "No such site."
          }
        }
      }
    },
    "/api/funnel": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "An ordered funnel",
        "description": "Steps are counted IN ORDER: step N must happen after step N−1. Step values are always bound parameters — only the number and type of steps shape the generated SQL.",
        "parameters": [
          {
            "name": "site",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Site id. Resolved against your access before it reaches any query; a site you cannot read answers 404, byte-identical to one that does not exist."
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 7
            },
            "description": "Window length in days."
          },
          {
            "name": "steps",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "JSON array, e.g. [{\"type\":\"page\",\"value\":\"/pricing\"},{\"type\":\"event\",\"value\":\"signup\"}]"
          }
        ],
        "responses": {
          "200": {
            "description": "Funnel, the generated SQL and its parameters."
          }
        }
      }
    },
    "/api/retention": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "Retention cohorts, or the reason there are none",
        "description": "Returns `available: false` with a message and a remedy when the site has no identified users. The visitor id is salted per day, so cross-day retention of anonymous traffic is arithmetically impossible rather than merely disallowed. Call vitrus.identify() to make it computable.",
        "parameters": [
          {
            "name": "site",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Site id. Resolved against your access before it reaches any query; a site you cannot read answers 404, byte-identical to one that does not exist."
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 7
            },
            "description": "Window length in days."
          }
        ],
        "responses": {
          "200": {
            "description": "Cohort matrix, or the reason it cannot be computed."
          }
        }
      }
    },
    "/api/lifecycle": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "User lifecycle: new, came back, lost, paying — counted, never listed",
        "description": "Growth accounting against the previous period of equal length (new, retained, resurrected, churned), quick ratio, one-and-done rate, current plans and plan moves. Needs vitrus.identify(user.id); the id is hashed in the browser and again on the server, so no person is ever returned. `available: false` with a reason and a remedy when nobody is identified or the site is in strict privacy mode.",
        "parameters": [
          {
            "name": "site",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Site id. Resolved against your access before it reaches any query; a site you cannot read answers 404, byte-identical to one that does not exist."
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 7
            },
            "description": "Window length in days."
          }
        ],
        "responses": {
          "200": {
            "description": "Counts, each with its query, or the reason they cannot be computed."
          }
        }
      }
    },
    "/api/batch": {
      "post": {
        "tags": [
          "ingest"
        ],
        "summary": "Up to 50 events of one site in one request (native SDKs)",
        "description": "Body: { events: [...] } — each event as for /api/collect, plus optional `nonce` (a per-device value the SDK regenerates every UTC day; it replaces IP + user-agent in the visitor hash) and `ago` (ms since the event, at most 86400000; the server subtracts it from its own clock). Each event is validated, excluded and counted on its own.",
        "responses": {
          "200": {
            "description": "{ ok, accepted, rejected: [{ index, reason }] }"
          },
          "400": {
            "description": "Not a batch, mixed sites, or more than 50."
          },
          "404": {
            "description": "Unknown site."
          }
        }
      }
    },
    "/api/heatmap": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "Click and scroll heatmap for one page and device class",
        "description": "Built from the site's session recordings (nothing extra is collected): clicked cells as [x share of width, y px, clicks], scroll reach per 100 px, the recorded pages for the picker and the latest masked snapshot to draw on. A sample — recorded visits only. Members only; on the cloud a Pro feature (402 with reason \"plan\" below it).",
        "parameters": [
          {
            "name": "site",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Site id. Resolved against your access before it reaches any query; a site you cannot read answers 404, byte-identical to one that does not exist."
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 7
            },
            "description": "Window length in days."
          },
          {
            "name": "path",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "device",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "desktop",
                "tablet",
                "mobile"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The heatmap and the query that chose the recordings."
          },
          "402": {
            "description": "Not on a plan with heatmaps."
          }
        }
      }
    },
    "/api/people": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "One app's signed-in users, one row each — under a made-up name",
        "description": "Status (returning / one_time / signed_up_only), actions, active days, visits, joined, last active and plan per user, plus counts by status. A row is an identity hash and a stable made-up alias: no email or name is stored, so none can be returned. Members only; a share link gets 401. Filter with segment, plan; sort with last_active, joined, actions, active_days, visits.",
        "parameters": [
          {
            "name": "site",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Site id. Resolved against your access before it reaches any query; a site you cannot read answers 404, byte-identical to one that does not exist."
          }
        ],
        "responses": {
          "200": {
            "description": "Rows, counts by status and the queries."
          },
          "401": {
            "description": "Not a member."
          }
        }
      }
    },
    "/api/portfolio": {
      "get": {
        "tags": [
          "analytics"
        ],
        "summary": "Every site of the API key's workspace, side by side",
        "description": "Visitors (with the previous period), active, new and lost users, signups, became paying, paying now and revenue per currency, plus totals. Counts are added across sites: identities are hashed per site, so the same person on two sites counts twice. Takes Authorization: Bearer vk_…",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 7
            },
            "description": "Window length in days."
          }
        ],
        "responses": {
          "200": {
            "description": "One row per site, totals, and the queries."
          },
          "401": {
            "description": "No or invalid API key."
          }
        }
      }
    },
    "/api/me": {
      "get": {
        "tags": [
          "account"
        ],
        "summary": "The signed-in user, their workspaces and the current quota",
        "responses": {
          "200": {
            "description": "User, workspaces, quota."
          },
          "401": {
            "description": "Not signed in."
          }
        }
      }
    },
    "/api/plans": {
      "get": {
        "tags": [
          "account"
        ],
        "summary": "The plan table",
        "description": "The same table the product enforces quotas from, so pricing pages cannot drift from the app.",
        "responses": {
          "200": {
            "description": "Plans."
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "tags": [
          "analytics"
        ],
        "summary": "Model Context Protocol endpoint (read-only)",
        "description": "JSON-RPC. Tools: list_sites, get_overview, get_ai_traffic, get_web_vitals, get_errors, analyze_funnel, get_retention, get_lifecycle, get_portfolio, get_digest. Every result includes the query and the raw rows.",
        "responses": {
          "200": {
            "description": "JSON-RPC response."
          },
          "401": {
            "description": "Not signed in."
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Evidence": {
        "type": "object",
        "description": "One metric and the query that produced it. `sql` is the statement that ran, not a description of it.",
        "properties": {
          "id": {
            "type": "string",
            "example": "e1"
          },
          "metric": {
            "type": "string",
            "example": "visitors.unique"
          },
          "label": {
            "type": "string"
          },
          "value": {
            "type": "number",
            "nullable": true
          },
          "previous": {
            "type": "number",
            "nullable": true
          },
          "deltaPct": {
            "type": "number",
            "nullable": true
          },
          "unit": {
            "type": "string",
            "enum": [
              "count",
              "percent",
              "seconds",
              "ratio"
            ]
          },
          "sql": {
            "type": "string"
          },
          "params": {
            "type": "array",
            "items": {}
          },
          "window": {
            "type": "object",
            "properties": {
              "from": {
                "type": "integer"
              },
              "to": {
                "type": "integer"
              },
              "label": {
                "type": "string"
              }
            }
          },
          "rows": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "nullable": true
          }
        },
        "required": [
          "id",
          "metric",
          "sql",
          "params",
          "window"
        ]
      }
    },
    "securitySchemes": {
      "session": {
        "type": "apiKey",
        "in": "cookie",
        "name": "vitrus_session",
        "description": "Server-side session, not a JWT: revoking one has to be instant, which a signed token cannot do."
      },
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Workspace API key (vk_…), created under Workspace → API keys. Read-only: accepted by the read endpoints only. Header only — a key in the URL is refused. 600 requests a minute per key."
      }
    }
  },
  "security": [
    {
      "session": []
    },
    {
      "apiKey": []
    }
  ],
  "externalDocs": {
    "url": "https://vitrus.dev/docs",
    "description": "Documentation"
  }
}