{
  "openapi": "3.1.0",
  "info": {
    "title": "ShonyLabs Analytics API",
    "version": "1.0.0",
    "summary": "Ingestion, identity, transaction, error-reporting, and read-only analytics endpoints for ShonyLabs Analytics.",
    "description": "ShonyLabs Analytics is a self-hosted-alternative-style, multi-tenant web analytics platform. This spec covers two logically separate APIs served from two hosts: the public/`api_key`-authenticated ingestion API at `api.shonylabs.com` (used by the tracking snippet, a customer's own backend, and server-side integrations), and the `api_key`-authenticated read-only analytics API at `shonylabs.com` (used by the ShonyLabs CLI, MCP server, and any script reading a site's own data). See https://shonylabs.com/docs/api-reference for prose documentation and https://shonylabs.com/docs/cli-and-mcp for the CLI/MCP clients built on the read-only endpoints.",
    "termsOfService": "https://shonylabs.com/tos",
    "contact": {
      "name": "ShonyLabs support",
      "email": "shonylabs@gmail.com",
      "url": "https://shonylabs.com/contact"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://shonylabs.com/tos"
    }
  },
  "externalDocs": {
    "description": "Full prose documentation",
    "url": "https://shonylabs.com/docs/api-reference"
  },
  "servers": [
    { "url": "https://api.shonylabs.com", "description": "Tracking / ingestion API" },
    { "url": "https://shonylabs.com", "description": "Dashboard read-only analytics API" }
  ],
  "tags": [
    { "name": "Ingestion", "description": "Public, unauthenticated event ingestion (authenticated only by a valid public site_id)." },
    { "name": "Identity", "description": "Attach a real account to a visitor from your own server." },
    { "name": "Transactions", "description": "Record and manage purchases/refunds for revenue attribution." },
    { "name": "Errors", "description": "Report server-side application errors, grouped into de-duped issues." },
    { "name": "Analytics (read-only)", "description": "Read a site's own stats, live visitors, and funnel conversion. Used by the CLI and MCP server." }
  ],
  "components": {
    "securitySchemes": {
      "apiKeyBearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "A site's own API key (Settings → API in the dashboard), distinct from the public site_id used by the tracking snippet. Send as `Authorization: Bearer <api_key>`."
      }
    },
    "schemas": {
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "error": { "type": "string", "description": "Human-readable error message." }
        },
        "required": ["error"]
      },
      "TopPage": {
        "type": "object",
        "properties": {
          "path": { "type": "string" },
          "views": { "type": "integer", "minimum": 0 }
        },
        "required": ["path", "views"]
      },
      "LiveVisitor": {
        "type": "object",
        "properties": {
          "visitorId": { "type": "string" },
          "country": { "type": "string", "description": "ISO country code, or \"Unknown\"." },
          "city": { "type": "string" },
          "device": { "type": "string" },
          "path": { "type": "string" },
          "eventName": { "type": "string" },
          "revenueAmountCents": { "type": "integer" },
          "revenueCurrency": { "type": "string" },
          "lastSeenSecondsAgo": { "type": "integer", "minimum": 0 },
          "purchaseLikelihood": { "type": "integer", "minimum": 0, "maximum": 100, "description": "0-100 estimated conversion likelihood." }
        },
        "required": ["visitorId", "country", "path", "eventName", "lastSeenSecondsAgo", "purchaseLikelihood"]
      },
      "FunnelStepResult": {
        "type": "object",
        "properties": {
          "step": { "type": "string" },
          "visitors": { "type": "integer", "minimum": 0 },
          "conversionFromStartPercent": { "type": "number" },
          "conversionFromPreviousPercent": { "type": ["number", "null"] },
          "dropoff": { "type": ["integer", "null"] }
        },
        "required": ["step", "visitors", "conversionFromStartPercent"]
      },
      "Funnel": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "steps": { "type": "array", "items": { "$ref": "#/components/schemas/FunnelStepResult" } }
        },
        "required": ["id", "name", "steps"]
      }
    },
    "parameters": {
      "RangeParam": {
        "name": "range",
        "in": "query",
        "required": false,
        "description": "Time window for the query. Defaults to last7d.",
        "schema": {
          "type": "string",
          "enum": ["today", "yesterday", "last24h", "last7d", "last30d", "last12m", "wtd", "mtd", "ytd", "all"],
          "default": "last7d"
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid `Authorization: Bearer <api_key>` header.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "BadRequest": {
        "description": "Missing or malformed required field.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      },
      "NotFound": {
        "description": "Referenced resource does not exist.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
        }
      }
    }
  },
  "paths": {
    "/event": {
      "servers": [{ "url": "https://api.shonylabs.com" }],
      "post": {
        "operationId": "recordEvent",
        "summary": "Record a pageview or custom goal event",
        "description": "Public endpoint authenticated only by a valid `site_id` in the body, since it is called directly from the browser tracking snippet (also used by the server-sdk middleware and the crawler log shipper for non-JS-executing traffic, with `source: \"server\"`). Always returns 200 on success, including for events silently dropped by an exclusion rule - the tracking snippet should never treat that as a delivery failure.",
        "tags": ["Ingestion"],
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "site_id": { "type": "string", "format": "uuid", "description": "Public site identifier, embedded in the tracking snippet." },
                  "event_name": { "type": "string", "description": "e.g. \"pageview\", or any custom goal name.", "default": "pageview" },
                  "url": { "type": "string", "format": "uri" },
                  "path": { "type": "string" },
                  "referrer": { "type": "string" },
                  "visitor_id": { "type": "string", "description": "Omitted entirely by the cookieless tracker; derived server-side in that case." },
                  "session_id": { "type": "string" },
                  "language": { "type": "string" },
                  "timezone": { "type": "string" },
                  "screen_width": { "type": "integer" },
                  "screen_height": { "type": "integer" },
                  "viewport_width": { "type": "integer" },
                  "viewport_height": { "type": "integer" },
                  "utm_source": { "type": "string" },
                  "utm_medium": { "type": "string" },
                  "utm_campaign": { "type": "string" },
                  "utm_term": { "type": "string" },
                  "utm_content": { "type": "string" },
                  "ref": { "type": "string" },
                  "source_param": { "type": "string" },
                  "via": { "type": "string" },
                  "revenue_amount": { "type": "number", "description": "Only honored for sites with allow_frontend_revenue enabled; otherwise ignored server-side." },
                  "revenue_currency": { "type": "string", "maxLength": 10 },
                  "source": { "type": "string", "enum": ["browser", "server"], "description": "\"server\" marks a report from server-sdk, the crawler log shipper, or another server-to-server reporter - the backend then trusts user_agent/client_ip from the body instead of the request's own headers." },
                  "is_bot": { "type": "boolean" },
                  "custom_data": { "type": "string", "description": "JSON-encoded object of custom event properties." },
                  "user_agent": { "type": "string", "description": "Only trusted when source=\"server\"." },
                  "client_ip": { "type": "string", "description": "Only trusted when source=\"server\"." },
                  "cookieless": { "type": "boolean", "description": "Set by tracker/script-cookieless.js; visitor_id/session_id are derived server-side from a salted IP+UA hash instead." }
                },
                "required": ["site_id"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Event accepted (or intentionally dropped by an exclusion rule - still 200).",
            "content": { "text/plain": { "schema": { "type": "string", "example": "OK" } } }
          },
          "400": { "description": "Invalid or missing site_id.", "content": { "text/plain": { "schema": { "type": "string" } } } },
          "503": { "description": "Ingestion queue full; retry shortly.", "content": { "text/plain": { "schema": { "type": "string" } } } }
        }
      }
    },
    "/v1/identify": {
      "servers": [{ "url": "https://api.shonylabs.com" }],
      "post": {
        "operationId": "identifyVisitor",
        "summary": "Attach a real account to a visitor",
        "description": "Server-to-server counterpart of the browser `window.shonylabs.identify()` call. Identifying the same user_id from multiple devices merges them into one visitor everywhere in the dashboard.",
        "tags": ["Identity"],
        "security": [{ "apiKeyBearer": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "user_id": { "type": "string", "maxLength": 255, "description": "Required. Identifying the same user_id from multiple devices merges them into one visitor." },
                  "visitor_id": { "type": "string", "maxLength": 255, "description": "Defaults to `server:<user_id>` when omitted." },
                  "name": { "type": "string", "maxLength": 255 },
                  "image": { "type": "string", "maxLength": 250, "description": "Avatar URL." },
                  "gender": { "type": "string", "maxLength": 50 }
                },
                "required": ["user_id"],
                "additionalProperties": { "description": "Up to 10 additional custom properties; lowercase key names, markup-stripped values." }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Identity recorded.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "success": { "type": "boolean" }, "visitor_id": { "type": "string" } },
                  "required": ["success", "visitor_id"]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/transactions": {
      "servers": [{ "url": "https://api.shonylabs.com" }],
      "post": {
        "operationId": "recordTransaction",
        "summary": "Record a purchase or refund",
        "description": "Idempotent per (site, transaction_id). Pass `refunded: true` to net a previously-recorded transaction_id's revenue back out instead of recording a new sale. Requires shonylabs_visitor_id to already have at least one tracked (non-bot) event on this site.",
        "tags": ["Transactions"],
        "security": [{ "apiKeyBearer": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "transaction_id": { "type": "string", "maxLength": 255, "description": "Idempotency key - retried webhook deliveries record the sale once." },
                  "shonylabs_visitor_id": { "type": "string", "maxLength": 255, "description": "Read from the first-party shonylabs_visitor_id cookie the tracking snippet sets." },
                  "amount": { "type": "number", "minimum": 0, "description": "Required unless refunded is true. e.g. 49.00." },
                  "currency": { "type": "string", "maxLength": 10, "example": "USD" },
                  "customer_email": { "type": "string", "maxLength": 255 },
                  "email": { "type": "string", "maxLength": 255, "description": "Alias for customer_email." },
                  "customer_name": { "type": "string", "maxLength": 255 },
                  "name": { "type": "string", "maxLength": 255, "description": "Alias for customer_name." },
                  "customer_id": { "type": "string", "maxLength": 255 },
                  "renewal": { "type": "boolean" },
                  "is_free_trial": { "type": "boolean" },
                  "metadata": { "type": "object", "description": "Flat JSON object, up to 20 keys, 64-character keys, 500-character values.", "additionalProperties": { "type": "string" } },
                  "timestamp": { "type": "string", "format": "date-time", "description": "Defaults to request time." },
                  "refunded": { "type": "boolean", "description": "Nets the original transaction_id's revenue back out instead of recording a new sale." }
                },
                "required": ["transaction_id", "shonylabs_visitor_id"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Payment recorded, refund recorded, or already-processed (idempotent replay).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "message": { "type": "string" }, "transaction_id": { "type": "string" } },
                  "required": ["message", "transaction_id"]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "delete": {
        "operationId": "deleteTransactions",
        "summary": "Permanently delete one or more recorded payments",
        "description": "Unlike a refund, the transaction(s) and their revenue attribution are deleted outright rather than netted out. Provide exactly one of the query parameter groups below.",
        "tags": ["Transactions"],
        "security": [{ "apiKeyBearer": [] }],
        "parameters": [
          { "name": "transaction_id", "in": "query", "required": false, "description": "Deletes that one payment.", "schema": { "type": "string" } },
          { "name": "tx_id", "in": "query", "required": false, "description": "Alias for transaction_id.", "schema": { "type": "string" } },
          { "name": "shonylabs_visitor_id", "in": "query", "required": false, "description": "Deletes every payment recorded for that visitor.", "schema": { "type": "string" } },
          { "name": "start", "in": "query", "required": false, "description": "Start of a date range (ISO 8601); must be paired with end.", "schema": { "type": "string", "format": "date-time" } },
          { "name": "end", "in": "query", "required": false, "description": "End of a date range (ISO 8601); must be paired with start.", "schema": { "type": "string", "format": "date-time" } }
        ],
        "responses": {
          "200": {
            "description": "One entry per deleted payment (empty array when the visitor/date-range filter matched nothing).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": { "type": "string", "enum": ["success"] },
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "transaction_id": { "type": "string" },
                          "message": { "type": "string" },
                          "shonylabs_visitor_id": { "type": "string" }
                        }
                      }
                    }
                  },
                  "required": ["status", "data"]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "description": "A transaction_id/tx_id filter matched no transaction.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    },
    "/v1/errors": {
      "servers": [{ "url": "https://api.shonylabs.com" }],
      "post": {
        "operationId": "reportError",
        "summary": "Report a server-side application error",
        "description": "Grouped into a de-duped `error_issues` row by source + status code + a number-collapsed message + path. Returns 200 with `accepted: false` (not an error status) when the site hasn't opted into server-side error tracking, or the status_code falls outside the tracked range - a backend error handler calling this from every response shouldn't treat that as a failed delivery.",
        "tags": ["Errors"],
        "security": [{ "apiKeyBearer": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "message": { "type": "string", "maxLength": 500 },
                  "status_code": { "type": "integer", "description": "Only recorded if it falls within the range configured in Settings → Errors (default 500-599)." },
                  "path": { "type": "string", "maxLength": 500 },
                  "stack": { "type": "string", "maxLength": 4000 },
                  "context": { "type": "object", "description": "Flat JSON object, up to 10 keys, 255-character values.", "additionalProperties": { "type": "string" } },
                  "timestamp": { "type": "string", "format": "date-time" },
                  "shonylabs_visitor_id": { "type": "string", "maxLength": 255, "description": "Read from the first-party shonylabs_visitor_id cookie, if available - links the error to who it happened to." }
                },
                "required": ["message"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted and folded into an issue, or explicitly not accepted (tracking disabled / status_code outside tracked range).",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object",
                      "properties": {
                        "success": { "type": "boolean" },
                        "accepted": { "type": "boolean", "enum": [true] },
                        "issue_id": { "type": "string" },
                        "is_new_issue": { "type": "boolean" }
                      },
                      "required": ["success", "accepted", "issue_id", "is_new_issue"]
                    },
                    {
                      "type": "object",
                      "properties": {
                        "accepted": { "type": "boolean", "enum": [false] },
                        "reason": { "type": "string" }
                      },
                      "required": ["accepted", "reason"]
                    }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/v1/stats": {
      "servers": [{ "url": "https://shonylabs.com" }],
      "get": {
        "operationId": "getStats",
        "summary": "Get visitors, pageviews, bounce rate, and revenue for a range",
        "description": "Read-only analytics endpoint used by the ShonyLabs CLI and MCP server (see https://shonylabs.com/docs/cli-and-mcp).",
        "tags": ["Analytics (read-only)"],
        "security": [{ "apiKeyBearer": [] }],
        "parameters": [{ "$ref": "#/components/parameters/RangeParam" }],
        "responses": {
          "200": {
            "description": "Stats for the requested range.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site": {
                      "type": "object",
                      "properties": { "id": { "type": "string", "format": "uuid" }, "domain": { "type": "string" } },
                      "required": ["id", "domain"]
                    },
                    "range": { "type": "string" },
                    "period": {
                      "type": "object",
                      "properties": { "start": { "type": "string", "format": "date-time" }, "end": { "type": "string", "format": "date-time" } },
                      "required": ["start", "end"]
                    },
                    "visitors": { "type": "integer", "minimum": 0 },
                    "pageviews": { "type": "integer", "minimum": 0 },
                    "bounceRatePercent": { "type": "number" },
                    "avgSessionSeconds": { "type": "number" },
                    "revenueCents": { "type": "integer" },
                    "conversions": { "type": "integer", "minimum": 0 },
                    "revenuePerVisitorCents": { "type": "integer" },
                    "topPages": { "type": "array", "items": { "$ref": "#/components/schemas/TopPage" } }
                  },
                  "required": ["site", "range", "period", "visitors", "pageviews", "bounceRatePercent", "avgSessionSeconds", "revenueCents", "conversions", "revenuePerVisitorCents", "topPages"]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/live": {
      "servers": [{ "url": "https://shonylabs.com" }],
      "get": {
        "operationId": "getLiveVisitors",
        "summary": "Get who is currently on the site",
        "description": "Raw live-visitor snapshot with no name/avatar masking - unlike the public embed route, a caller presenting this site's own api_key has already proven the same trust level as the site owner. Used by the ShonyLabs CLI and MCP server.",
        "tags": ["Analytics (read-only)"],
        "security": [{ "apiKeyBearer": [] }],
        "responses": {
          "200": {
            "description": "Current live visitors.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "totalOnline": { "type": "integer", "minimum": 0 },
                    "visitors": { "type": "array", "items": { "$ref": "#/components/schemas/LiveVisitor" } }
                  },
                  "required": ["totalOnline", "visitors"]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/v1/funnels": {
      "servers": [{ "url": "https://shonylabs.com" }],
      "get": {
        "operationId": "getFunnels",
        "summary": "Get every configured funnel's conversion for a range",
        "description": "Read-only analytics endpoint used by the ShonyLabs CLI and MCP server.",
        "tags": ["Analytics (read-only)"],
        "security": [{ "apiKeyBearer": [] }],
        "parameters": [{ "$ref": "#/components/parameters/RangeParam" }],
        "responses": {
          "200": {
            "description": "Funnels for the requested range (empty array if none are configured for this site).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "range": { "type": "string" },
                    "period": {
                      "type": "object",
                      "properties": { "start": { "type": "string", "format": "date-time" }, "end": { "type": "string", "format": "date-time" } },
                      "required": ["start", "end"]
                    },
                    "funnels": { "type": "array", "items": { "$ref": "#/components/schemas/Funnel" } }
                  },
                  "required": ["range", "period", "funnels"]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    }
  }
}
