TinyTrail API Documentation

API reference for TinyTrail — privacy-focused, cookie-less website analytics engine.

Base URL: http://localhost:8888 (default)


Table of Contents


Authentication

Protected endpoints require an API key passed via the X-API-KEY header.

X-API-KEY: your-api-key-here

Two roles exist:

Role Access
admin Full system access (accounts, users, all websites, aggregation)
user Scoped to own account (websites and reports within that account)

Cross-account access by a user role returns 404 NOT_FOUND (not 403) to prevent resource enumeration.


Response Format

All endpoints use a consistent JSON envelope (except /health).

Success:

{
  "status": "success",
  "data": { ... }
}

Error:

{
  "status": "error",
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable description"
  }
}

Error Codes

HTTP Status Code Description
400 INVALID_PAYLOAD Malformed JSON or missing/invalid fields
401 UNAUTHORIZED Missing or invalid X-API-KEY header
403 FORBIDDEN Valid API key but insufficient permissions
404 NOT_FOUND Resource does not exist (or access denied)
429 RATE_LIMITED Rate limit exceeded
500 INTERNAL_ERROR Unexpected server error

Rate Limiting

Scope Limit Key
POST /api/v1/collect 10 requests per 10 seconds IP
All protected routes 60 requests per 60 seconds API key

Rate-limited responses include a Retry-After header (seconds).


Endpoints

Health

GET /health

Health check for load balancers and uptime monitoring. No authentication required.

Response (200):

{
  "status": "ok",
  "db": "connected",
  "version": "0.1.0"
}

Response (503):

{
  "status": "degraded",
  "db": "disconnected"
}

Note: This endpoint does not use the standard response envelope.


Event Collection

OPTIONS /api/v1/collect

CORS preflight handler.


POST /api/v1/collect

Public event ingestion endpoint. No authentication required.

Accepts either a single event object or an array of events (batch mode, up to 100 events per request). A batch counts as a single request against the rate limiter.

Request Body (single event):

Field Type Required Description
website_id string Yes UUID of the target website
event_type string Yes Event type (see below)
url string Yes Full page URL including query string
path string Yes URL pathname
referrer string No Referrer URL
screen_width number No Viewport width in pixels
screen_height number No Viewport height in pixels
custom_event_name string No Name for custom events. Required when event_type is custom.
user_uuid string No Optional user identifier for logged-in users
utm_source string No UTM source parameter
utm_medium string No UTM medium parameter
utm_campaign string No UTM campaign parameter

Request Body (batch): A JSON array of event objects (each using the schema above). Maximum 100 events per request.

Default event types: page_view, close_tab, scroll_25, scroll_50, scroll_75, outbound_link, custom

Example (single event):

{
  "website_id": "550e8400-e29b-41d4-a716-446655440000",
  "event_type": "page_view",
  "url": "https://example.com/blog/post-1?utm_source=twitter",
  "path": "/blog/post-1",
  "referrer": "https://t.co/abc123",
  "screen_width": 1920,
  "screen_height": 1080
}

Example (batch):

[
  {
    "website_id": "550e8400-e29b-41d4-a716-446655440000",
    "event_type": "page_view",
    "url": "https://example.com/",
    "path": "/"
  },
  {
    "website_id": "550e8400-e29b-41d4-a716-446655440000",
    "event_type": "custom",
    "url": "https://example.com/pricing",
    "path": "/pricing",
    "custom_event_name": "signup_complete"
  }
]

Responses (single event):

Status Description
202 {"status":"accepted"} — Event stored successfully
204 Silent drop (invalid website_id)
400 INVALID_PAYLOAD — Missing required fields
429 RATE_LIMITED — Rate limit exceeded

Responses (batch):

Status Description
200 Batch processed — see body for per-item results
400 INVALID_PAYLOAD — Empty array or batch larger than 100 events
429 RATE_LIMITED — Rate limit exceeded

Batch response body:

{
  "status": "success",
  "data": {
    "received": 2,
    "accepted": 1,
    "dropped": 1,
    "errors": 0,
    "results": [
      { "status": "accepted" },
      { "status": "dropped" }
    ]
  }
}

The results array preserves the order of the input events. Per-item status values:

A batch with mixed accepted/dropped/error items still returns HTTP 200; inspect results for per-event outcomes.

Server-side processing pipeline:

  1. Validate website_id exists (silent 204 drop if invalid)
  2. Extract IP and User-Agent from request headers
  3. Parse User-Agent → browser_family, os_family, device_type
  4. Parse UTM parameters from url query string
  5. Classify referrer → referrer_channel
  6. Generate ip_hash = HMAC-SHA256(DailySalt, IP + UA + WebsiteID)
  7. Check IP against website.excluded_ips → set is_excluded
  8. Run bot detection → set is_bot
  9. Match path against website.content_groups patterns
  10. Store custom_event_name for custom events
  11. Store event row

Accounts

All account endpoints require admin role.

GET /api/v1/accounts

List all accounts.

Response (200):

{
  "status": "success",
  "data": [
    {
      "id": "uuid",
      "name": "Acme Corp",
      "createdAt": "2026-01-15T10:30:00.000Z"
    }
  ]
}

POST /api/v1/accounts

Create a new account.

Request Body:

Field Type Required Description
name string Yes Account name

Response (201):

{
  "status": "success",
  "data": {
    "id": "uuid",
    "name": "Acme Corp",
    "createdAt": "2026-01-15T10:30:00.000Z"
  }
}

Errors:

Status Code Cause
400 INVALID_PAYLOAD Missing name field

DELETE /api/v1/accounts/:id

Delete an account and all associated data (users, websites, events, aggregated data).

Path Parameters:

Param Type Description
id UUID Account ID

Response (200): Deleted account object.

Errors:

Status Code Cause
400 INVALID_PAYLOAD Invalid UUID format
404 NOT_FOUND Account does not exist

Users

All user endpoints require admin role.

GET /api/v1/users

List all users (API key field excluded from response).

Response (200):

{
  "status": "success",
  "data": [
    {
      "id": "uuid",
      "accountId": "uuid",
      "email": "user@example.com",
      "role": "user",
      "createdAt": "2026-01-15T10:30:00.000Z"
    }
  ]
}

POST /api/v1/users

Create a new user. Returns the generated API key (only time it's shown).

Request Body:

Field Type Required Description
account_id UUID Yes Account to assign the user to
role string Yes "admin" or "user"
email string No Optional label (not used for login)

Response (201):

{
  "status": "success",
  "data": {
    "id": "uuid",
    "accountId": "uuid",
    "email": "user@example.com",
    "role": "user",
    "apiKey": "tt_abc123...",
    "createdAt": "2026-01-15T10:30:00.000Z"
  }
}

Errors:

Status Code Cause
400 INVALID_PAYLOAD Missing role or account_id, invalid role, or account not found

GET /api/v1/users/:id/api-key

Retrieve a user's API key.

Path Parameters:

Param Type Description
id UUID User ID

Response (200):

{
  "status": "success",
  "data": {
    "id": "uuid",
    "apiKey": "tt_abc123..."
  }
}

Errors:

Status Code Cause
400 INVALID_PAYLOAD Invalid UUID format
404 NOT_FOUND User does not exist

DELETE /api/v1/users/:id

Delete a user.

Path Parameters:

Param Type Description
id UUID User ID

Response (200): Deleted user object.

Errors:

Status Code Cause
400 INVALID_PAYLOAD Invalid UUID format
404 NOT_FOUND User does not exist

Websites

Website endpoints require authentication. Non-admin users are scoped to their own account.

GET /api/v1/websites

List websites. Admins see all; users see only their account's websites.

Response (200):

{
  "status": "success",
  "data": [
    {
      "id": "uuid",
      "accountId": "uuid",
      "domain": "example.com",
      "contentGroups": [],
      "excludedIps": [],
      "createdAt": "2026-01-15T10:30:00.000Z"
    }
  ]
}

POST /api/v1/websites

Create a new website.

Request Body:

Field Type Required Description
domain string Yes Domain name (alphanumeric, hyphens, dots; max 255)
content_groups array No Array of {name, pattern} objects (default: [])
excluded_ips array No IP addresses/ranges to exclude (default: [])
account_id UUID No Admin only — assign to specific account

Response (201): Created website object.

Errors:

Status Code Cause
400 INVALID_PAYLOAD Missing/invalid domain, or duplicate domain in account

PATCH /api/v1/websites/:id

Update website settings. At least one field must be provided.

Path Parameters:

Param Type Description
id UUID Website ID

Request Body:

Field Type Required Description
domain string No New domain name
content_groups array No Updated content group definitions
excluded_ips array No Updated IP exclusion list

Response (200): Updated website object.

Errors:

Status Code Cause
400 INVALID_PAYLOAD Invalid ID, invalid domain, or no fields provided
404 NOT_FOUND Website not found or access denied

DELETE /api/v1/websites/:id

Delete a website and all associated events and aggregated data.

Path Parameters:

Param Type Description
id UUID Website ID

Response (200): Deleted website object.

Errors:

Status Code Cause
400 INVALID_PAYLOAD Invalid UUID format
404 NOT_FOUND Website not found or access denied

Admin

POST /api/v1/admin/aggregate

Manually trigger the aggregation pipeline for a specific date.

Request Body:

Field Type Required Description
date string No Target date in YYYY-MM-DD format (default: yesterday UTC)

Response (200):

{
  "status": "success",
  "data": {
    "message": "Aggregation completed for 2026-03-18"
  }
}

Errors:

Status Code Cause
400 INVALID_PAYLOAD Invalid date format

Reports

All report endpoints require authentication and are scoped by account for non-admin users.

Common Query Parameters

Parameter Type Default Description
website_id UUID required Target website
period string 7d Predefined: yesterday, today, 7d, 30d, 90d, 12m, ytd, all
start_date string — Custom start date (YYYY-MM-DD). Overrides period.
end_date string — Custom end date (YYYY-MM-DD). Overrides period.
compare string — previous_period or yoy (year-over-year)
include_bots string false "true" to include bot traffic
content_group string — Filter by content group name
page number 1 Pagination page (min: 1)
per_page number 50 Results per page (max: 500)
format string json json or csv

When format=csv, the response uses Content-Type: text/csv with a Content-Disposition: attachment header.

Common Paginated Response Structure

{
  "status": "success",
  "data": {
    "period": { "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" },
    "page": 1,
    "per_page": 50,
    "rows": [...]
  }
}

Common Errors (all report endpoints):

Status Code Cause
400 INVALID_PAYLOAD Missing website_id or invalid parameters
401 UNAUTHORIZED Missing or invalid API key
404 NOT_FOUND Website not found or no access

GET /api/v1/reports/summary

High-level metrics overview with optional period comparison.

Response (200):

{
  "status": "success",
  "data": {
    "period": { "start": "2026-02-23", "end": "2026-03-01" },
    "comparison": "2026-02-16 to 2026-02-22",
    "metrics": {
      "visits": { "value": 12450, "previous": 11200, "change_pct": 11.16 },
      "unique_pageviews": { "value": 9870, "previous": 8930, "change_pct": 10.53 },
      "pageviews": { "value": 34200, "previous": 30100, "change_pct": 13.62 },
      "bounce_rate": { "value": 42.3, "previous": 44.1, "change_pct": -4.08 },
      "avg_session_duration_sec": { "value": 185, "previous": 172, "change_pct": 7.56 }
    }
  }
}

When compare is not set, each metric contains only value (no previous or change_pct).

CSV columns: metric, value, previous, change_pct


GET /api/v1/reports/pages

Page-level traffic breakdown sorted by pageviews (descending).

Response (200):

{
  "status": "success",
  "data": {
    "period": { "start": "2026-02-23", "end": "2026-03-01" },
    "page": 1,
    "per_page": 50,
    "rows": [
      {
        "path": "/blog/getting-started",
        "contentGroup": "Blog",
        "pageviews": 5420,
        "uniquePageviews": 3210
      }
    ]
  }
}

CSV columns: path, contentGroup, pageviews, uniquePageviews


GET /api/v1/reports/sources

Traffic source analysis with configurable grouping.

Extra Query Parameters:

Parameter Type Default Description
group_by string channel channel, domain, or utm

Response — group_by=channel (200):

{
  "rows": [
    { "channel": "Organic Search", "visits": 4500 },
    { "channel": "Direct", "visits": 3200 },
    { "channel": "Social", "visits": 1800 }
  ]
}

Response — group_by=domain (200):

{
  "rows": [
    { "referrer": "google.com", "visits": 3200 },
    { "referrer": "twitter.com", "visits": 1100 }
  ]
}

Response — group_by=utm (200):

{
  "rows": [
    {
      "utmSource": "twitter",
      "utmMedium": "social",
      "utmCampaign": "launch",
      "visits": 850
    }
  ]
}

Referrer channel classifications:

Channel Rule
Organic Search Referrer matches known search engines (google.*, bing.com, etc.)
Paid Search Search engine + utm_medium contains cpc, ppc, or paid
Social Referrer matches social platforms (t.co, facebook.com, etc.)
Email utm_medium=email or referrer matches email providers
Referral Referrer exists but doesn't match other channels
Direct No referrer

GET /api/v1/reports/devices

Device, browser, and OS breakdown.

Response (200):

{
  "status": "success",
  "data": {
    "period": { "start": "2026-02-23", "end": "2026-03-01" },
    "device_types": [
      { "deviceType": "desktop", "visits": 8500 },
      { "deviceType": "mobile", "visits": 3200 }
    ],
    "browsers": [
      { "browserFamily": "Chrome", "visits": 6400 },
      { "browserFamily": "Safari", "visits": 3100 }
    ],
    "os_families": [
      { "osFamily": "Windows", "visits": 5200 },
      { "osFamily": "macOS", "visits": 3800 }
    ],
    "screen_resolutions": [
      { "bucket": "1025-1440", "visits": 4200 },
      { "bucket": "376-768", "visits": 2800 }
    ]
  }
}

Screen resolution buckets: 320-375, 376-768, 769-1024, 1025-1440, 1441+, unknown

CSV columns: deviceType, visits (only device_types exported)


GET /api/v1/reports/flow

Landing and exit page analysis.

Response (200):

{
  "status": "success",
  "data": {
    "period": { "start": "2026-02-23", "end": "2026-03-01" },
    "landing_pages": [
      { "path": "/", "entries": 5200 },
      { "path": "/blog", "entries": 2100 }
    ],
    "exit_pages": [
      { "path": "/pricing", "exits": 3400 },
      { "path": "/contact", "exits": 1800 }
    ]
  }
}

CSV columns: path, entries (only landing_pages exported)


GET /api/v1/reports/funnel

Multi-step conversion funnel analysis.

Extra Query Parameters:

Parameter Type Required Description
steps[] string (array) Yes Ordered funnel steps (min 2). Each step is a path, event type, or custom event with event: prefix.

Step patterns:

Pattern Matches
/pricing Page view at /pricing
event:user_signup Custom event named user_signup (any path)
event:user_signup:/pricing Custom event named user_signup on /pricing only
event:sign* Custom events matching wildcard (e.g. signup, signout)
event:video_play:/blog/* Custom event video_play on paths under /blog/
page_view Any event with event_type = "page_view"
/blog/* Any path under /blog/

Example request:

GET /api/v1/reports/funnel?website_id=...&steps[]=/pricing&steps[]=event:user_signup&steps[]=/onboarding

Response (200):

{
  "status": "success",
  "data": {
    "period": { "start": "2026-02-23", "end": "2026-03-01" },
    "funnel": [
      { "step": "/pricing", "sessions": 5000, "conversion_rate": 100 },
      { "step": "/signup", "sessions": 1200, "conversion_rate": 24.0 },
      { "step": "/onboarding", "sessions": 900, "conversion_rate": 75.0 }
    ]
  }
}

Conversion rate: First step is always 100%. Subsequent steps: (sessions at step N / sessions at step N-1) * 100, rounded to 2 decimals.

Session logic: Grouped by IP hash with a 30-minute gap threshold.

Errors:

Status Code Cause
400 INVALID_PAYLOAD Fewer than 2 steps provided

GET /api/v1/reports/realtime

Real-time traffic for the last 30 minutes. Queries raw events table directly.

Query Parameters:

Parameter Type Required Description
website_id UUID Yes Target website

Response (200):

{
  "status": "success",
  "data": {
    "active_visitors": 42,
    "top_pages": [
      { "path": "/", "visitors": 18 },
      { "path": "/blog/new-post", "visitors": 12 }
    ],
    "events_by_type": [
      { "eventType": "page_view", "count": 87 },
      { "eventType": "scroll_50", "count": 34 }
    ]
  }
}

GET /api/v1/reports/custom-events

Custom event tracking and analysis.

Extra Query Parameters:

Parameter Type Required Description
event_name string No Filter by custom event name

Response (200):

{
  "status": "success",
  "data": {
    "period": { "start": "2026-02-23", "end": "2026-03-01" },
    "page": 1,
    "per_page": 50,
    "rows": [
      {
        "name": "signup_complete",
        "count": 342,
        "paths": [
          { "path": "/pricing", "count": 210 },
          { "path": "/blog/getting-started", "count": 132 }
        ]
      },
      {
        "name": "video_play",
        "count": 218,
        "paths": [
          { "path": "/", "count": 150 },
          { "path": "/demos", "count": 68 }
        ]
      }
    ]
  }
}

Each row includes a paths array with per-path counts sorted by count descending.

CSV columns: name, count (flat totals only, no path breakdown)


GET /api/v1/reports/timeseries

Daily time-series data for a single metric over the selected period.

Extra Query Parameters:

Parameter Type Default Description
metric string pageviews Metric to plot: visits, pageviews, unique_pageviews, bounce_rate, avg_session_duration, landing_page_entries, exit_page_exits
path string — Filter to a specific page path (e.g. /about). Omit for site-wide totals.

Rate metrics (bounce_rate, avg_session_duration) are averaged per day. Count metrics are summed.

Response (200):

{
  "status": "success",
  "data": {
    "period": { "start": "2026-03-13", "end": "2026-03-19" },
    "metric": "pageviews",
    "path": "*",
    "series": [
      { "date": "2026-03-13", "value": 1200 },
      { "date": "2026-03-14", "value": 1350 },
      { "date": "2026-03-15", "value": 980 },
      { "date": "2026-03-16", "value": 870 },
      { "date": "2026-03-17", "value": 1100 },
      { "date": "2026-03-18", "value": 1420 },
      { "date": "2026-03-19", "value": 1290 }
    ]
  }
}

CSV columns: date, value

Errors:

Status Code Cause
400 INVALID_PAYLOAD Invalid metric value

GET /api/v1/reports/events

Raw event log for a given time range. Queries the events table directly.

Extra Query Parameters:

Parameter Type Required Description
event_type string No Filter by event type (e.g. page_view, custom)
path string No Filter by exact page path (e.g. /blog/post-1)

Response (200):

{
  "status": "success",
  "data": {
    "period": { "start": "2026-03-24", "end": "2026-03-30" },
    "page": 1,
    "per_page": 50,
    "rows": [
      {
        "id": "42",
        "eventType": "page_view",
        "url": "https://example.com/blog/post-1?utm_source=twitter",
        "path": "/blog/post-1",
        "referrer": "https://t.co/abc123",
        "referrerChannel": "Social",
        "utmSource": "twitter",
        "utmMedium": "social",
        "utmCampaign": "launch",
        "browserFamily": "Chrome",
        "osFamily": "macOS",
        "deviceType": "desktop",
        "screenWidth": 1920,
        "screenHeight": 1080,
        "isBot": false,
        "userUuid": null,
        "customEventName": null,
        "createdAt": "2026-03-30T14:22:01.000Z"
      }
    ]
  }
}

CSV columns: id, eventType, url, path, referrer, referrerChannel, utmSource, utmMedium, utmCampaign, browserFamily, osFamily, deviceType, screenWidth, screenHeight, isBot, userUuid, customEventName, createdAt


Static Assets

GET /

This API documentation, rendered as an HTML page (text/html; charset=utf-8). The raw markdown is served as text/markdown at GET /docs/api.md.

GET /js/tracker.js

Client-side tracking snippet (vanilla JavaScript).

Installation:

<script defer data-id="YOUR_WEBSITE_UUID" src="https://your-api.com/js/tracker.js"></script>

User identification:

Optionally associate events with a known user by setting data-uuid on the script tag or calling identify() after login:

<script defer data-id="YOUR_WEBSITE_UUID" data-uuid="USER_UUID" src="https://your-api.com/js/tracker.js"></script>
window.tinytrail.identify("user-abc-123");

The identify() call overrides data-uuid for all subsequent events in the session. The user_uuid field is optional and not used in aggregation.

SPA page views:

// Call after each client-side route change
window.tinytrail.pageview();

Sends a page_view event with the current URL and resets scroll depth tracking.

Custom events API:

window.tinytrail.track("signup_complete");

Auto-tracked events:

Event Trigger
page_view DOMContentLoaded
close_tab visibilitychange → hidden
scroll_25 25% scroll depth
scroll_50 50% scroll depth
scroll_75 75% scroll depth
outbound_link Click on external link

Endpoint Summary

Method Path Auth Rate Limited Description
GET /health No No Health check
OPTIONS /api/v1/collect No No CORS preflight
POST /api/v1/collect No Yes (IP) Event ingestion
GET /api/v1/accounts Admin Yes List accounts
POST /api/v1/accounts Admin Yes Create account
DELETE /api/v1/accounts/:id Admin Yes Delete account
GET /api/v1/users Admin Yes List users
POST /api/v1/users Admin Yes Create user
GET /api/v1/users/:id/api-key Admin Yes Get user API key
DELETE /api/v1/users/:id Admin Yes Delete user
GET /api/v1/websites User+ Yes List websites
POST /api/v1/websites User+ Yes Create website
PATCH /api/v1/websites/:id User+ Yes Update website
DELETE /api/v1/websites/:id User+ Yes Delete website
POST /api/v1/admin/aggregate Admin Yes Trigger aggregation
GET /api/v1/reports/summary User+ Yes Summary metrics
GET /api/v1/reports/pages User+ Yes Page traffic
GET /api/v1/reports/sources User+ Yes Traffic sources
GET /api/v1/reports/devices User+ Yes Device analytics
GET /api/v1/reports/flow User+ Yes Landing/exit pages
GET /api/v1/reports/funnel User+ Yes Funnel analysis
GET /api/v1/reports/realtime User+ Yes Real-time activity
GET /api/v1/reports/custom-events User+ Yes Custom events
GET /api/v1/reports/timeseries User+ Yes Daily metric timeseries
GET /api/v1/reports/events User+ Yes Raw event log
GET / No No API docs (HTML)
GET /docs/api.md No No API docs (markdown)
GET /js/tracker.js No No Tracker script