# Tinylytics API

Use the Tinylytics API to read analytics, send hits, events, and kudos from your backend, and automate reporting.

<span id="quick-start"></span>
## 1. Quick Start

**Base URL**

`https://tinylytics.app/api/v1`

**Header format**

```bash
Authorization: Bearer tly-ro-your-api-key
Accept: application/json
```

Use `tly-fa-...` (Full Access) for write endpoints.

### Test your key in 30 seconds

```bash
curl "https://tinylytics.app/api/v1/me" \
  -H "Authorization: Bearer tly-ro-your-api-key" \
  -H "Accept: application/json"
```

If valid, you get your account payload with HTTP `200`.

### Discovery and schema

You can bootstrap clients without reading prose docs first:

```bash
curl "https://tinylytics.app/api/v1"
curl "https://tinylytics.app/api/v1/openapi.json"
```

<span id="authentication-and-access"></span>
## 2. Authentication and Access

- Auth scheme: `Bearer` token.
- Key location: Account Settings → API Access.
- Read-only keys (`tly-ro-...`) can call all `GET` endpoints.
- Full-access keys (`tly-fa-...`) are required for:
  - `POST /sites/:id/hits`
  - `POST /sites/:id/hits/batch`
  - `POST /sites/:id/events`
  - `POST /sites/:id/events/batch`
  - `POST /sites/:id/kudos`
  - `DELETE /sites/:id/kudos/:kudo_uid`

### Access rules

- Any account with a valid API key can use core API endpoints.
- Premium endpoints require an active subscription:
  - `GET /sites/:id/insights`
  - `GET /sites/:id/uptime`
  - `GET /sites/:id/content`
- Revoked or invalid keys return `401`.
- Write endpoint with read-only key returns `403`.

<span id="request-conventions"></span>
## 3. Request Conventions

- Dates use `YYYY-MM-DD`.
- Date range limit for analytics endpoints: max `730` days.
- Date boundaries for analytics endpoints default to `UTC`.
- Optional timezone mode:
  - `time_zone=utc` (default) uses UTC day boundaries.
  - `time_zone=user` uses your account timezone day boundaries.
- Pagination:
  - `page` default varies by endpoint
  - `per_page` max `1000` (`hits`, `kudos`, `leaderboard`), `50` (`user_journeys`, `insights`), `100` (`uptime`)
- Hits filtering:
  - `country` exact match
  - `path` exact match
  - `referrer` partial match
- Kudos filtering:
  - `path` exact match
- Grouped hits:
  - `grouped=true`
  - `group_by` one of `path`, `country`, `referrer`, `browser_name`, `platform_name`, `source`

<span id="endpoint-directory"></span>
## 4. Endpoint Directory

| Method | Endpoint | Purpose |
|--------|----------|---------|
| <span class="http-method http-get">GET</span> | `/` | Public API discovery metadata |
| <span class="http-method http-get">GET</span> | `/openapi.json` | OpenAPI 3.1 schema for API v1 |
| <span class="http-method http-get">GET</span> | `/me` | Validate API key and return account info |
| <span class="http-method http-get">GET</span> | `/sites` | List accessible sites |
| <span class="http-method http-get">GET</span> | `/sites/:id` | Get one site |
| <span class="http-method http-get">GET</span> | `/sites/:id/hits` | Raw or grouped analytics hits |
| <span class="http-method http-post">POST</span> | `/sites/:id/hits` | Create one hit <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-post">POST</span> | `/sites/:id/hits/batch` | Create many hits in one request <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-post">POST</span> | `/sites/:id/events` | Create one event <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-post">POST</span> | `/sites/:id/events/batch` | Create many events in one request <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-get">GET</span> | `/sites/:id/kudos` | Read kudos records |
| <span class="http-method http-post">POST</span> | `/sites/:id/kudos` | Create one kudo <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-delete">DELETE</span> | `/sites/:id/kudos/:kudo_uid` | Delete one kudo by UID <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-get">GET</span> | `/sites/:id/leaderboard` | All-time path leaderboard |
| <span class="http-method http-get">GET</span> | `/sites/:id/user_journeys` | Visitor journey analysis |
| <span class="http-method http-get">GET</span> | `/sites/:id/insights` | AI insights for the site <span class="access-badge access-subscription">Subscription</span> |
| <span class="http-method http-get">GET</span> | `/sites/:id/uptime` | Uptime + SSL/domain status <span class="access-badge access-subscription">Subscription</span> |
| <span class="http-method http-get">GET</span> | `/sites/:id/content` | Content monitoring status and issues <span class="access-badge access-subscription">Subscription</span> |

<span id="endpoint-reference"></span>
## 5. Endpoint Reference

<span id="account-and-sites"></span>
### Account and Sites

<span id="get-me"></span>
#### <span class="http-method http-get">GET</span> `/me`

Returns current user + current API key metadata.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| None | Yes | This endpoint does not accept query or body properties. |

```bash
curl "https://tinylytics.app/api/v1/me" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

```json
{
  "id": 123,
  "email": "user@example.com",
  "is_subscribed": true,
  "created_at": "2025-06-01T12:00:00Z",
  "api_key": {
    "name": "CLI integration",
    "access_type": "read_only",
    "last_used_at": "2026-02-12T10:00:00Z"
  }
}
```

---

<span id="list-sites"></span>
#### <span class="http-method http-get">GET</span> `/sites`

Lists your sites with lifetime counters.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| None | Yes | This endpoint does not accept query or body properties. |

```bash
curl "https://tinylytics.app/api/v1/sites" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

```json
{
  "sites": [
    {
      "id": 456,
      "uid": "abc123",
      "url": "https://example.com",
      "label": "My Blog",
      "lifetime_hits": 12340,
      "lifetime_unique_hits": 8920,
      "lifetime_kudos": 87,
      "active": true,
      "public": false,
      "created_at": "2025-06-01T12:00:00Z",
      "updated_at": "2026-02-14T09:30:00Z"
    }
  ]
}
```

---

<span id="get-site"></span>
#### <span class="http-method http-get">GET</span> `/sites/:id`

Returns one site by numeric ID.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. Use the `id` returned from `GET /sites`. |

```bash
curl "https://tinylytics.app/api/v1/sites/456" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

```json
{
  "id": 456,
  "uid": "abc123",
  "url": "https://example.com",
  "label": "My Blog",
  "lifetime_hits": 12340,
  "lifetime_unique_hits": 8920,
  "lifetime_kudos": 87,
  "active": true,
  "public": false,
  "created_at": "2025-06-01T12:00:00Z",
  "updated_at": "2026-02-14T09:30:00Z"
}
```

<span id="analytics-endpoints"></span>
### Analytics Endpoints

<span id="get-hits"></span>
#### <span class="http-method http-get">GET</span> `/sites/:id/hits`

Read detailed hits or grouped analytics.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `start_date` | No | Range start (`YYYY-MM-DD`). Defaults to 30 days ago in the selected timezone mode. |
| `end_date` | No | Range end (`YYYY-MM-DD`). Defaults to today in the selected timezone mode. |
| `time_zone` | No | Date-boundary mode: `utc` (default) or `user` (use account timezone). |
| `country` | No | Filter by exact 2-letter country code. |
| `path` | No | Filter by exact path (for example `/pricing`). |
| `referrer` | No | Case-insensitive partial match on referrer. |
| `grouped` | No | Set to `true` to return grouped/aggregated results. |
| `group_by` | No | One of `path`, `country`, `referrer`, `browser_name`, `platform_name`, `source`. |
| `page` | No | Page number. |
| `per_page` | No | Page size, max `1000`. |

```bash
curl "https://tinylytics.app/api/v1/sites/456/hits?grouped=true&group_by=path" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

Grouped by `path` returns `views` (+ `unique_views` when enabled). Other groupings return `hit_count`.

To evaluate `start_date` and `end_date` in your account timezone, add `time_zone=user`:

```bash
curl "https://tinylytics.app/api/v1/sites/456/hits?start_date=2026-02-13&end_date=2026-02-13&time_zone=user" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

**Response (ungrouped)**

```json
{
  "hits": [
    {
      "id": 789,
      "url": "https://example.com/pricing",
      "path": "/pricing",
      "referrer": "https://google.com",
      "country": "US",
      "browser_name": "Safari",
      "platform_name": "macOS",
      "is_mobile": false,
      "source": "google",
      "created_at": "2026-02-13T14:22:00Z"
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 100,
    "total_count": 1,
    "total_pages": 1
  },
  "filters": {
    "start_date": "2026-02-13",
    "end_date": "2026-02-13",
    "time_zone": "user",
    "country": null,
    "path": null,
    "referrer": null,
    "grouped": false
  }
}
```

**Response (grouped by `path`)**

```json
{
  "grouped_hits": [
    {
      "path": "/pricing",
      "views": 142,
      "unique_views": 98
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 100,
    "total_count": 1,
    "total_pages": 1
  },
  "filters": {
    "start_date": "2026-02-13",
    "end_date": "2026-02-13",
    "time_zone": "user",
    "country": null,
    "path": null,
    "referrer": null,
    "grouped": true,
    "group_by": "path"
  }
}
```

`unique_views` is only included when unique hit tracking is enabled for the site. Other `group_by` values (`country`, `referrer`, `browser_name`, `platform_name`, `source`) return `hit_count` instead of `views`/`unique_views`.

---

<span id="create-hit"></span>
#### <span class="http-method http-post">POST</span> `/sites/:id/hits` <span class="access-badge access-full">Full Access</span>

Create one hit.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `path` | Yes | Path to track. Leading slash is auto-added if missing. |
| `country` | No | 2-letter uppercase country code (for example `US`, `PL`, `XX`). If provided, this value takes precedence. |
| `ip_address` | No | IPv4/IPv6 address used to resolve country via local lookup first, then IPinfo Lite API as fallback when `country` is not provided. Raw IP is not stored in hits. |
| `url` | No | Full page URL. Defaults to `site.url + path`. |
| `referrer` | No | Referrer URL. |
| `user_agent` | No | User agent string. |
| `visitor_id` | No | Stable visitor identifier used for dedupe/journey grouping. |
| `source` | No | Source override. If missing, Tinylytics may infer from URL parameters. |

**Payload rules**

- Body must be a single JSON object
- Required fields: `path`
- `country` must be 2-letter uppercase when provided (example: `US`, `PL`, `XX`)
- Country resolution order: provided `country` → local lookup from `ip_address` → IPinfo `country_code` API fallback → `XX`
- `path` is normalized to begin with `/`

```bash
curl -X POST "https://tinylytics.app/api/v1/sites/456/hits" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "path": "/pricing",
    "ip_address": "8.8.8.8",
    "visitor_id": "user-123"
  }'
```

**Response (`201` created)**

```json
{
  "status": "created",
  "hit": {
    "id": 789,
    "url": "https://example.com/pricing",
    "path": "/pricing",
    "referrer": null,
    "country": "US",
    "browser_name": null,
    "platform_name": null,
    "is_mobile": false,
    "source": null,
    "unique_hash": "a1b2c3",
    "visitor_hash": "d4e5f6",
    "created_at": "2026-02-14T10:00:00Z"
  }
}
```

**Response (`202` ignored)**

```json
{
  "status": "ignored",
  "reason": "Path matches ignore rule"
}
```

**Response (`422` error)**

```json
{
  "status": "error",
  "errors": ["Path can't be blank"]
}
```

---

<span id="create-hits-batch"></span>
#### <span class="http-method http-post">POST</span> `/sites/:id/hits/batch` <span class="access-badge access-full">Full Access</span>

Create many hits in one request.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `[]` | Yes | Top-level array of hit objects. |
| `[].path` | Yes | Path to track. Leading slash is auto-added if missing. |
| `[].country` | No | 2-letter uppercase country code. If provided, this value takes precedence. |
| `[].ip_address` | No | IPv4/IPv6 address used to resolve country via local lookup first, then IPinfo Lite API as fallback when `[].country` is not provided. Raw IP is not stored in hits. |
| `[].url` | No | Full page URL. |
| `[].referrer` | No | Referrer URL. |
| `[].user_agent` | No | User agent string. |
| `[].visitor_id` | No | Stable visitor identifier used for dedupe/journey grouping. |
| `[].source` | No | Source override. |

**Payload rules**

- Body must be a top-level JSON array
- Each row follows the same field rules as single hit creation.
- Per row country resolution order: provided `country` → local lookup from `ip_address` → IPinfo `country_code` API fallback → `XX`
- Batch is partial-success: one bad row does not fail the whole request.

```bash
curl -X POST "https://tinylytics.app/api/v1/sites/456/hits/batch" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -d '[
    { "path": "/valid", "country": "PL" },
    { "path": "/from-ip", "ip_address": "8.8.8.8" },
    { "path": "/fallback-xx", "ip_address": "999.999.999.999" }
  ]'
```

```json
{
  "created_count": 3,
  "ignored_count": 0,
  "error_count": 0,
  "results": [
    { "index": 0, "status": "created" },
    { "index": 1, "status": "created" },
    { "index": 2, "status": "created" }
  ]
}
```

---

<span id="create-event"></span>
#### <span class="http-method http-post">POST</span> `/sites/:id/events` <span class="access-badge access-full">Full Access</span>

Create one event.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `event` | Yes | Event name in `category.action` format. |
| `value` | No | Optional event value stored as `event_properties["value"]`. |
| `path` | No | Optional path context. Leading slash is auto-added if missing. |
| `country` | No | 2-letter uppercase country code (for example `US`, `PL`, `XX`). If provided, this value takes precedence. |
| `ip_address` | No | IPv4/IPv6 address used to resolve country via local lookup first, then IPinfo Lite API as fallback when `country` is not provided. Raw IP is not stored in events. |
| `url` | No | Full page URL. Defaults to `site.url + path` when `path` is provided. |
| `referrer` | No | Referrer URL. |
| `user_agent` | No | User agent string. |
| `visitor_id` | No | Stable visitor identifier used for event identity/grouping hashes. |
| `source` | No | Source override. If missing, Tinylytics may infer from URL parameters. |

**Payload rules**

- Body must be a single JSON object
- Required fields: `event`
- `event` must use `category.action` format
- `country` must be 2-letter uppercase when provided (example: `US`, `PL`, `XX`)
- Country resolution order: provided `country` → local lookup from `ip_address` → IPinfo `country_code` API fallback → `XX`
- `path` is optional and normalized to begin with `/` when present

```bash
curl -X POST "https://tinylytics.app/api/v1/sites/456/events" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "event": "signup.started",
    "value": "pricing",
    "ip_address": "8.8.8.8",
    "visitor_id": "user-123"
  }'
```

**Response (`201` created)**

```json
{
  "status": "created",
  "event": {
    "id": 790,
    "event": "signup.started",
    "value": "pricing",
    "url": null,
    "path": null,
    "referrer": null,
    "country": "US",
    "source": null,
    "unique_hash": "a1b2c3",
    "visitor_hash": "d4e5f6",
    "created_at": "2026-02-14T10:00:00Z"
  }
}
```

**Response (`202` ignored)**

```json
{
  "status": "ignored",
  "reason": "Event matched ignore rules"
}
```

**Response (`422` error)**

```json
{
  "status": "error",
  "errors": ["Event must be 'category.action' format (2+ dot-separated segments)"]
}
```

---

<span id="create-events-batch"></span>
#### <span class="http-method http-post">POST</span> `/sites/:id/events/batch` <span class="access-badge access-full">Full Access</span>

Create many events in one request.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `[]` | Yes | Top-level array of event objects. |
| `[].event` | Yes | Event name in `category.action` format. |
| `[].value` | No | Optional event value stored as `event_properties["value"]`. |
| `[].path` | No | Optional path context. Leading slash is auto-added if missing. |
| `[].country` | No | 2-letter uppercase country code. If provided, this value takes precedence. |
| `[].ip_address` | No | IPv4/IPv6 address used to resolve country via local lookup first, then IPinfo Lite API as fallback when `[].country` is not provided. Raw IP is not stored in events. |
| `[].url` | No | Full page URL. |
| `[].referrer` | No | Referrer URL. |
| `[].user_agent` | No | User agent string. |
| `[].visitor_id` | No | Stable visitor identifier used for event identity/grouping hashes. |
| `[].source` | No | Source override. |

**Payload rules**

- Body must be a top-level JSON array
- Each row follows the same field rules as single event creation
- Per row country resolution order: provided `country` → local lookup from `ip_address` → IPinfo `country_code` API fallback → `XX`
- Batch is partial-success: one bad row does not fail the whole request

```bash
curl -X POST "https://tinylytics.app/api/v1/sites/456/events/batch" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -d '[
    { "event": "signup.started", "value": "pricing", "country": "PL" },
    { "event": "signup.completed", "ip_address": "8.8.8.8" },
    { "event": "signup.cancelled", "ip_address": "999.999.999.999" }
  ]'
```

```json
{
  "created_count": 3,
  "ignored_count": 0,
  "error_count": 0,
  "results": [
    { "index": 0, "status": "created" },
    { "index": 1, "status": "created" },
    { "index": 2, "status": "created" }
  ]
}
```

---

<span id="get-kudos"></span>
#### <span class="http-method http-get">GET</span> `/sites/:id/kudos`

Read detailed Kudos activity.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `start_date` | No | Range start (`YYYY-MM-DD`). Defaults to 30 days ago in the selected timezone mode. |
| `end_date` | No | Range end (`YYYY-MM-DD`). Defaults to today in the selected timezone mode. |
| `time_zone` | No | Date-boundary mode: `utc` (default) or `user` (use account timezone). |
| `path` | No | Filter by exact path (for example `/pricing`). |
| `uid` | No | Filter by exact kudo UID. |
| `page` | No | Page number. |
| `per_page` | No | Page size, max `1000`. |

```bash
curl "https://tinylytics.app/api/v1/sites/456/kudos?start_date=2026-02-01&end_date=2026-02-14" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

```json
{
  "kudos": [
    {
      "id": 321,
      "uid": "pricing-kudo-1",
      "path": "/pricing",
      "created_at": "2026-02-10T08:15:00Z"
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 100,
    "total_count": 1,
    "total_pages": 1
  },
  "filters": {
    "start_date": "2026-02-01",
    "end_date": "2026-02-14",
    "time_zone": "utc",
    "path": null,
    "uid": null
  }
}
```

---

<span id="create-kudo"></span>
#### <span class="http-method http-post">POST</span> `/sites/:id/kudos` <span class="access-badge access-full">Full Access</span>

Create one kudo.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `path` | Yes | Path to track. Leading slash is auto-added if missing. |
| `custom_uid` | No | Custom identifier for the kudo. If omitted, Tinylytics generates one. |

**Payload rules**

- Body must be a single JSON object
- Required fields: `path`
- `path` is normalized to begin with `/`

```bash
curl -X POST "https://tinylytics.app/api/v1/sites/456/kudos" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "path": "/pricing",
    "custom_uid": "pricing-kudo-1"
  }'
```

**Response (`201` created)**

```json
{
  "status": "created",
  "kudo": {
    "id": 321,
    "uid": "pricing-kudo-1",
    "path": "/pricing",
    "created_at": "2026-02-14T10:00:00Z"
  }
}
```

**Response (`202` ignored)**

```json
{
  "status": "ignored",
  "reason": "Path matches ignore rule"
}
```

**Response (`422` error)**

```json
{
  "status": "error",
  "errors": ["Path can't be blank"]
}
```

---

<span id="delete-kudo"></span>
#### <span class="http-method http-delete">DELETE</span> `/sites/:id/kudos/:kudo_uid` <span class="access-badge access-full">Full Access</span>

Delete one kudo by UID.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `kudo_uid` (URL path) | Yes | Kudo UID to delete. |

```bash
curl -X DELETE "https://tinylytics.app/api/v1/sites/456/kudos/pricing-kudo-1" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Accept: application/json"
```

**Response (`200` deleted)**

```json
{
  "status": "deleted",
  "uid": "pricing-kudo-1"
}
```

**Response (`404` not found)**

```json
{
  "error": "Kudo not found"
}
```

---

<span id="leaderboard"></span>
#### <span class="http-method http-get">GET</span> `/sites/:id/leaderboard`

All-time path ranking with caching.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `path` | No | Case-insensitive partial filter for path text. |
| `page` | No | Page number. |
| `per_page` | No | Page size, max `1000`. |

```bash
curl "https://tinylytics.app/api/v1/sites/456/leaderboard?path=blog" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

```json
{
  "leaderboard": [
    {
      "path": "/blog/hello-world",
      "total_hits": 540,
      "unique_hits": 320,
      "percentage": 12.5
    }
  ],
  "site": {
    "id": 456,
    "uid": "abc123",
    "url": "https://example.com",
    "label": "My Blog"
  },
  "pagination": {
    "current_page": 1,
    "per_page": 100,
    "total_count": 1,
    "total_pages": 1
  },
  "cache_info": {
    "cached_at": "2026-02-14T09:00:00Z",
    "expires_at": "2026-02-14T10:00:00Z"
  },
  "filters": {
    "path": "blog"
  }
}
```

---

<span id="user-journeys"></span>
#### <span class="http-method http-get">GET</span> `/sites/:id/user_journeys`

Session-style visitor path analysis with summary metrics.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `start_date` | No | Range start (`YYYY-MM-DD`). Defaults to 30 days ago in the selected timezone mode. |
| `end_date` | No | Range end (`YYYY-MM-DD`). Defaults to today in the selected timezone mode. |
| `time_zone` | No | Date-boundary mode: `utc` (default) or `user` (use account timezone). |
| `page` | No | Page number. |
| `per_page` | No | Page size, max `50`. |

```bash
curl "https://tinylytics.app/api/v1/sites/456/user_journeys?start_date=2026-01-01&end_date=2026-01-31&time_zone=user" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

```json
{
  "user_journeys": [
    {
      "visitor_hash": "v1a2b3",
      "page_count": 4,
      "first_hit": "2026-01-15T10:00:00Z",
      "last_hit": "2026-01-15T10:12:00Z",
      "duration_minutes": 12,
      "pages": [
        { "path": "/" },
        { "path": "/blog" },
        { "path": "/blog/hello-world" },
        { "path": "/pricing" }
      ],
      "entry_page": "/",
      "exit_page": "/pricing",
      "session_duration": 720,
      "referrer": "https://google.com",
      "country": "DE",
      "browser": "Firefox"
    }
  ],
  "summary": {
    "total_visitors": 230,
    "multi_page_visitors": 95,
    "single_page_visitors": 135,
    "bounce_rate": 58.7
  },
  "insights": {
    "top_entry_pages": [
      { "path": "/", "visitors": 120 },
      { "path": "/blog", "visitors": 45 }
    ],
    "top_exit_pages": [
      { "path": "/pricing", "visitors": 60 },
      { "path": "/blog/hello-world", "visitors": 30 }
    ]
  },
  "pagination": {
    "current_page": 1,
    "per_page": 50,
    "total_count": 230,
    "total_pages": 5
  },
  "filters": {
    "start_date": "2026-01-01",
    "end_date": "2026-01-31",
    "time_zone": "user"
  }
}
```

---

<span id="insights"></span>
#### <span class="http-method http-get">GET</span> `/sites/:id/insights` <span class="access-badge access-subscription">Subscription</span>

Returns generated insights, signal snapshots, and site insight settings.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `page` | No | Page number. |
| `per_page` | No | Page size, max `50`. |

```bash
curl "https://tinylytics.app/api/v1/sites/456/insights" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

```json
{
  "insights": [
    {
      "id": 42,
      "insights_for_date": "2026-02-13",
      "formatted_insights_date": "February 13, 2026",
      "generated_at": "2026-02-14T06:00:00Z",
      "summary": "Traffic was steadier than usual overall, with one blog post and a new referrer doing most of the lifting.",
      "signals": [
        {
          "type": "traffic_change",
          "headline": "Traffic is up 28% this week",
          "summary": "The site picked up 378 hits in the last 7 days, up from 296 the week before.",
          "importance_score": 64,
          "detected_at": "2026-02-14T06:00:00Z",
          "window": {
            "started_at": "2026-02-07T00:00:00Z",
            "ended_at": "2026-02-14T06:00:00Z"
          },
          "payload_excerpt": {
            "direction": "increase",
            "current_hits": 378,
            "previous_hits": 296,
            "absolute_change": 82,
            "change_percentage": 27.7
          }
        }
      ],
      "traffic_patterns": "Wednesday and Thursday were the busiest days, with evenings remaining your strongest hour.",
      "best_content": "Your recent Rails post is getting more attention than usual and is now one of the site's top pages.",
      "recommendations": "Keep an eye on the post that is breaking out, and consider sharing similar content while the momentum is still fresh."
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 50,
    "total_count": 1,
    "total_pages": 1
  },
  "site": {
    "id": 456,
    "uid": "abc123",
    "url": "https://example.com",
    "label": "My Blog",
    "insights_enabled": true,
    "daily_insight_reports_active": true,
    "next_insight_job_scheduled_at": "2026-02-15T06:00:00Z"
  }
}
```

Each insight returns:

- `summary`: the short AI overview of what changed most.
- `signals`: stored signal snapshots for that report, including headline, summary, score, detection time, window, and a small payload excerpt.
- `traffic_patterns`, `best_content`, and `recommendations`: the fuller AI explanation for the week.

<span id="monitoring-endpoints"></span>
### Monitoring Endpoints

<span id="uptime"></span>
#### <span class="http-method http-get">GET</span> `/sites/:id/uptime` <span class="access-badge access-subscription">Subscription</span>

Returns uptime monitor status, SSL/domain details, and downtime history.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `page` | No | Page number for downtime records. |
| `per_page` | No | Page size for downtime records, max `100`. |

```bash
curl "https://tinylytics.app/api/v1/sites/456/uptime" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

If uptime is not enabled for the site, response is `404`.

```json
{
  "monitor": {
    "id": 101,
    "url": "https://example.com",
    "enabled": true,
    "is_down": false,
    "uptime": 99.95,
    "last_check_at": "2026-02-14T09:55:00Z",
    "next_check_at": "2026-02-14T10:00:00Z",
    "last_status_code": 200,
    "last_error_message": null,
    "status_description": "Up",
    "current_check_interval": 300,
    "period": "30d",
    "ssl": {
      "expires_at": "2026-08-01T00:00:00Z",
      "valid": true,
      "expiring_soon": false,
      "expired": false,
      "days_until_expiry": 168
    },
    "domain": {
      "tested_at": "2026-02-14T00:00:00Z",
      "expires_at": "2027-06-01T00:00:00Z",
      "remaining_days": 472,
      "source": "whois",
      "expired": false,
      "expiring_soon": false,
      "days_until_expiry": 472
    },
    "auto_paused": false,
    "created_at": "2025-06-01T12:00:00Z",
    "updated_at": "2026-02-14T09:55:00Z"
  },
  "downtimes": [
    {
      "id": 55,
      "error": "Connection timed out",
      "started_at": "2026-02-10T03:00:00Z",
      "ended_at": "2026-02-10T03:15:00Z",
      "duration": 900,
      "duration_in_words": "15 minutes",
      "partial": false,
      "ongoing": false
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 100,
    "total_count": 1,
    "total_pages": 1
  },
  "summary": {
    "total_downtimes": 3,
    "ongoing_downtimes": 0,
    "recent_downtimes_30_days": 1
  }
}
```

---

<span id="content-monitoring"></span>
#### <span class="http-method http-get">GET</span> `/sites/:id/content` <span class="access-badge access-subscription">Subscription</span>

Returns content monitoring status, issues, ignored issues, and stats.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |

```bash
curl "https://tinylytics.app/api/v1/sites/456/content" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

```json
{
  "site": {
    "id": 456,
    "uid": "abc123",
    "url": "https://example.com",
    "label": "My Blog"
  },
  "monitoring_status": {
    "enabled": true,
    "root_path": "/blog",
    "last_check_at": "2026-02-14T08:00:00Z",
    "is_initial_check": false,
    "is_rechecking": false,
    "has_issues": true,
    "emails_paused": false,
    "emails_paused_until": null
  },
  "issues": {
    "broken_links": [
      {
        "id": 201,
        "url": "https://example.com/old-page",
        "status_code": 404,
        "error_message": "Not Found",
        "issue_type": "broken_link",
        "checked_at": "2026-02-14T08:00:00Z",
        "ignored": false
      }
    ],
    "mixed_content": []
  },
  "ignored_issues": [],
  "ok_links": [],
  "stats": {
    "total_checked": 48,
    "broken_links_count": 1,
    "mixed_content_count": 0,
    "ignored_count": 0,
    "ok_count": 47
  }
}
```

If content monitoring is disabled for the site, response is `403` with:

```json
{
  "error": "Content monitoring is not enabled for this site",
  "content_monitoring_enabled": false
}
```

<span id="common-flows"></span>
## 6. Common Flows

### Build a dashboard

1. `GET /sites`
2. `GET /sites/:id/hits?grouped=true&group_by=path`
3. `GET /sites/:id/leaderboard`

### Add server-side tracking

1. Create full-access key
2. `POST /sites/:id/hits` from your backend
3. `POST /sites/:id/events` for backend interaction tracking
4. `POST /sites/:id/kudos` when users react
5. Verify ingestion with `GET /sites/:id/hits` and your site’s Events/Kudos dashboard views

### Monitor health in one poll cycle

1. `GET /sites/:id/uptime`
2. `GET /sites/:id/content`
3. Alert from `summary`/`stats` fields

<span id="errors-and-status-codes"></span>
## 7. Errors and Status Codes

| Status | Meaning |
|--------|---------|
| `200` | Success |
| `201` | Resource created |
| `202` | Accepted but skipped (for ignored hits, events, or kudos) |
| `400` | Invalid parameter(s) |
| `401` | Missing/invalid/revoked API key |
| `403` | Premium endpoint requires subscription, write access required, or feature disabled |
| `404` | Resource not found |
| `422` | Validation or payload format error |
| `500` | Unexpected server error |

Typical error payload:

```json
{
  "error": "Invalid API key"
}
```

<span id="rate-limits-and-support"></span>
## 8. Rate Limits and Support

Authenticated API requests are rate limited to `1000 requests per hour per API key`.

For implementation help: `hello@tinylytics.app`.