# Tinylytics API

The API gives you everything the dashboard has, plus the ability to send data in. Read your analytics into a report of your own, record hits and events from a backend where JavaScript never runs, create kudos, or automate the parts of your workflow that currently involve you opening a browser.

Keys come in two kinds and the distinction is worth getting right up front: read-only keys (`tly-ro-…`) can call every `GET` endpoint, and full-access keys (`tly-fa-…`) are required for anything that writes. Reach for read-only unless you genuinely need to write.

If you are connecting an AI client, the [MCP server](/docs/mcp) at `https://tinylytics.app/mcp` wraps these same endpoints and uses these same keys. If an agent needs to start a trial for a human owner, that flow is in [instructions for agents](/docs/agent_signup).

<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
User-Agent: your-agent
```

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

HTTP API calls need a User-Agent. Cloudflare returns 1010 without one.

### 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" \
  -H "User-Agent: your-agent"
```

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/agent_visits`
  - `POST /sites/:id/events`
  - `POST /sites/:id/events/batch`
  - `POST /sites/:id/kudos`
  - `DELETE /sites/:id/kudos/:kudo_uid`
  - `POST /sites/:id/signals`
  - `POST /groups`
  - `PATCH /groups/:id`
  - `PUT /groups/:id`
  - `DELETE /groups/:id`

### Access rules

- Any account with a valid API key can use core API endpoints.
- API keys stay personal. Omit `account_id` to act on the key owner's personal account.
- Pass `account_id` (the team owner's user id) to act on an active team account you belong to. List available accounts with `GET /accounts`.
- Team viewers can read the owner's sites and groups. Team admins can create, update, and delete the owner's sites. Team owners can also create, update, and delete the owner's groups.
- Premium endpoints require an active subscription on the selected account owner:
  - `GET /sites/:id/insights`
  - `GET /sites/:id/signals`
  - `POST /sites/:id/signals`
  - `GET /sites/:id/uptime`
  - `GET /sites/:id/content`
- Group endpoints require an Ultra subscription:
  - `GET /groups`
  - `GET /groups/:id`
  - `POST /groups`
  - `PATCH /groups/:id`
  - `PUT /groups/:id`
  - `DELETE /groups/:id`
- Signals endpoints also require insights to be enabled on the site.
- 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`.
- Optional `account_id` selects a team account. Send it as a query parameter or in the JSON body. Omit it for the personal account.
- 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` (`signals`, `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`, `date`
  - `interval` one of `day`, `week`, `month` when `group_by=date` (defaults to `day`)

<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> | `/accounts` | List personal and accessible team accounts |
| <span class="http-method http-post">POST</span> | `/agent_signups` | Start signup for a human owner |
| <span class="http-method http-get">GET</span> | `/agent_signups/:id` | Poll agent signup status |
| <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-post">POST</span> | `/sites` | Create one site <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-put">PATCH</span> | `/sites/:id` | Update one site <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-put">PUT</span> | `/sites/:id` | Update one site <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-delete">DELETE</span> | `/sites/:id` | Delete one site <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-put">PATCH</span> | `/sites/:id/features` | Update site monitoring features <span class="access-badge access-full">Full Access</span> |
| <span class="http-method http-put">PUT</span> | `/sites/:id/features` | Update site monitoring features <span class="access-badge access-full">Full Access</span> |
| <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-get">GET</span> | `/sites/:id/agent_visits` | AI assistant and crawler visits |
| <span class="http-method http-post">POST</span> | `/sites/:id/agent_visits` | Record one AI crawler visit <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/signals` | Owner-submitted context signals <span class="access-badge access-subscription">Subscription</span> |
| <span class="http-method http-post">POST</span> | `/sites/:id/signals` | Submit one context signal <span class="access-badge access-full">Full Access</span> <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 class="http-method http-get">GET</span> | `/groups` | List site groups <span class="access-badge access-subscription">Ultra</span> |
| <span class="http-method http-get">GET</span> | `/groups/:id` | Get one site group <span class="access-badge access-subscription">Ultra</span> |
| <span class="http-method http-post">POST</span> | `/groups` | Create one site group <span class="access-badge access-full">Full Access</span> <span class="access-badge access-subscription">Ultra</span> |
| <span class="http-method http-put">PATCH</span> | `/groups/:id` | Update one site group <span class="access-badge access-full">Full Access</span> <span class="access-badge access-subscription">Ultra</span> |
| <span class="http-method http-put">PUT</span> | `/groups/:id` | Update one site group <span class="access-badge access-full">Full Access</span> <span class="access-badge access-subscription">Ultra</span> |
| <span class="http-method http-delete">DELETE</span> | `/groups/:id` | Delete one site group <span class="access-badge access-full">Full Access</span> <span class="access-badge access-subscription">Ultra</span> |

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

<span id="agent-signup"></span>
### Agent signup

Unauthenticated. Starts signup for a human owner. The 14-day trial starts when they activate. The response never includes API keys or passwords. After the owner activates, connect over [MCP](/docs/mcp). Full walkthrough: [instructions for agents](/docs/agent_signup).

<span id="post-agent-signups"></span>
#### <span class="http-method http-post">POST</span> `/agent_signups`

| Property | Required | Description |
|----------|----------|-------------|
| email | Yes | Owner email. |
| tos_accepted | Yes | Must be `true`. |
| agent_name | No | Display name of the agent. |
| agent_client | No | Client identifier, such as `cursor`. |
| site_url | No | Suggested first site. Not created automatically. |
| intended_plan | No | Hint only: `zen`, `pro`, or `ultra`. |

Returns `201` with `pending_owner`, or `200` with `existing_account` if the email already has an account.

<span id="get-agent-signups"></span>
#### <span class="http-method http-get">GET</span> `/agent_signups/:id`

Authorize with `X-Agent-Signup-Token`. `token` as a query parameter also works. Poll until `owner_activated`, then connect to `/mcp`.

<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" \
  -H "User-Agent: your-agent"
```

```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"
  },
  "accounts": [
    {
      "id": 123,
      "name": "Personal",
      "kind": "personal",
      "role": "owner",
      "is_subscribed": true,
      "is_plus": false
    },
    {
      "id": 456,
      "name": "Tiny Team",
      "kind": "team",
      "role": "viewer",
      "is_subscribed": true,
      "is_plus": true
    }
  ]
}
```

---

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

Lists the personal account and every active team account available to this API key.

**Accepted properties**

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

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

```json
{
  "accounts": [
    {
      "id": 123,
      "name": "Personal",
      "kind": "personal",
      "role": "owner",
      "is_subscribed": true,
      "is_plus": false
    },
    {
      "id": 456,
      "name": "Tiny Team",
      "kind": "team",
      "role": "admin",
      "is_subscribed": true,
      "is_plus": true
    }
  ]
}
```

Use a team `id` as `account_id` on later requests.

---

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

Lists your sites with lifetime counters. Pass `start_date` or `end_date` to add `period_hits` and `period_unique_hits` for that window. Sites with no hits in the window still appear, with `0`. `period_unique_hits` is `null` when unique hit tracking is off for that site. Lifetime counters stay as they are. Pass `time_zone=user` if you also group hits by week in the account timezone.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `account_id` | No | Team account owner id. Omit for the personal account. |
| `start_date` | No | Range start (`YYYY-MM-DD`). When set, each site includes period counters. |
| `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). |

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

```bash
curl "https://tinylytics.app/api/v1/sites?account_id=456" \
  -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"
    }
  ]
}
```

With a date range:

```bash
curl "https://tinylytics.app/api/v1/sites?start_date=2026-03-01&end_date=2026-03-07" \
  -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,
      "period_hits": 84,
      "period_unique_hits": 61,
      "created_at": "2025-06-01T12:00:00Z",
      "updated_at": "2026-02-14T09:30:00Z"
    }
  ],
  "filters": {
    "start_date": "2026-03-01",
    "end_date": "2026-03-07",
    "time_zone": "utc"
  }
}
```

---

<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="create-site"></span>
#### <span class="http-method http-post">POST</span> `/sites`

Creates a site owned by the selected account. Omit `account_id` to create on the key owner's personal account. Team admins and owners can pass `account_id` to create a site owned by the team owner.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `url` | Yes | Site URL. Must start with `http://` or `https://`. |
| `label` | No | Display label. |
| `display_root_path` | No | Optional public path prefix for display URLs. |
| `public` or `is_public` | No | Whether the site stats page is public. |
| `active` | No | Whether the site accepts analytics ingestion. Defaults to `true`. |
| `allow_from_any_domain` | No | When `true`, the embed accepts hits from any hostname (not only the configured site URL). Defaults to `false`. |
| `account_id` | No | Team account owner id. The new site belongs to that owner. |

Requires a full-access API key (`tly-fa-*`) and owner or team-admin access on the selected account. Team owners can also write groups.

```bash
curl -X POST "https://tinylytics.app/api/v1/sites" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "url": "https://example.com",
    "label": "My Blog",
    "public": false
  }'
```

```json
{
  "site": {
    "id": 456,
    "uid": "abc123",
    "url": "https://example.com",
    "label": "My Blog",
    "display_root_path": null,
    "lifetime_hits": 0,
    "lifetime_unique_hits": 0,
    "lifetime_kudos": 0,
    "active": true,
    "public": false,
    "created_at": "2026-02-14T09:30:00Z",
    "updated_at": "2026-02-14T09:30:00Z"
  }
}
```

---

<span id="update-site"></span>
#### <span class="http-method http-put">PATCH</span> `/sites/:id`

Updates one owned site. Accepts the same body fields as `POST /sites`, except `url` is optional on update.

Requires a full-access API key (`tly-fa-*`).

```bash
curl -X PATCH "https://tinylytics.app/api/v1/sites/456" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "label": "Renamed Blog",
    "display_root_path": "/blog",
    "public": true,
    "active": true
  }'
```

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

---

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

Soft-deletes one owned site immediately, hides it from future `GET /sites` responses, and queues background cleanup.

This action is irreversible from the API client's perspective.

Requires a full-access API key (`tly-fa-*`).

**Accepted properties**

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

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

Returns `204 No Content` on success.

<span id="groups"></span>
### Groups

Group endpoints let Ultra subscribers bundle sites for combined analytics dashboards. Mutations require a full-access API key.

<span id="list-groups"></span>
#### <span class="http-method http-get">GET</span> `/groups` <span class="access-badge access-subscription">Ultra</span>

Lists your site groups with member site summaries.

**Accepted properties**

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

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

```json
{
  "groups": [
    {
      "id": 12,
      "name": "Blog Portfolio",
      "description": "All personal blogs",
      "icon": "rocket",
      "accent_color": "#4F46E5",
      "pinned": true,
      "default_chart_view": "separate",
      "position": 1,
      "site_ids": [456, 789],
      "sites": [
        { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog" },
        { "id": 789, "uid": "def456", "url": "https://notes.example.com", "label": "Notes" }
      ],
      "created_at": "2025-11-30T18:58:32Z",
      "updated_at": "2026-02-14T09:30:00Z"
    }
  ]
}
```

---

<span id="get-group"></span>
#### <span class="http-method http-get">GET</span> `/groups/:id` <span class="access-badge access-subscription">Ultra</span>

Returns one group by numeric ID.

**Accepted properties**

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

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

---

<span id="create-group"></span>
#### <span class="http-method http-post">POST</span> `/groups` <span class="access-badge access-full">Full Access</span> <span class="access-badge access-subscription">Ultra</span>

Creates a group on the selected account. Omit `account_id` to create on the key owner's personal account. Team owners can pass `account_id` to create a group on the team account.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `name` | Yes | Group name. |
| `description` | No | Optional description. |
| `icon` | No | Icon key used by the dashboard UI. |
| `accent_color` | No | Hex color such as `#4F46E5`. |
| `pinned` | No | Whether the group is pinned in the dashboard. |
| `default_chart_view` | No | One of `separate` or `combined`. Defaults to `separate`. |
| `site_ids` | No | Array of site numeric IDs owned by the selected account. Unknown or foreign site IDs are ignored. |
| `account_id` | No | Team account owner id. The new group belongs to that owner. |

Requires a full-access API key (`tly-fa-*`) and personal or team-owner access on the selected account.

```bash
curl -X POST "https://tinylytics.app/api/v1/groups" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "name": "Blog Portfolio",
    "description": "All personal blogs",
    "accent_color": "#4F46E5",
    "default_chart_view": "separate",
    "site_ids": [456, 789]
  }'
```

```json
{
  "group": {
    "id": 12,
    "name": "Blog Portfolio",
    "description": "All personal blogs",
    "icon": null,
    "accent_color": "#4F46E5",
    "pinned": false,
    "default_chart_view": "separate",
    "position": 1,
    "site_ids": [456, 789],
    "sites": [
      { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog" },
      { "id": 789, "uid": "def456", "url": "https://notes.example.com", "label": "Notes" }
    ],
    "created_at": "2026-02-14T09:30:00Z",
    "updated_at": "2026-02-14T09:30:00Z"
  }
}
```

---

<span id="update-group"></span>
#### <span class="http-method http-put">PATCH</span> `/groups/:id` <span class="access-badge access-full">Full Access</span> <span class="access-badge access-subscription">Ultra</span>

Updates one owned group. Send only the fields you want to change. Include `site_ids` to replace group membership with the provided owned site IDs.

Requires a full-access API key (`tly-fa-*`).

```bash
curl -X PATCH "https://tinylytics.app/api/v1/groups/12" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "name": "Renamed Portfolio",
    "pinned": true,
    "site_ids": [456]
  }'
```

---

<span id="delete-group"></span>
#### <span class="http-method http-delete">DELETE</span> `/groups/:id` <span class="access-badge access-full">Full Access</span> <span class="access-badge access-subscription">Ultra</span>

Deletes one owned group. Member sites are not deleted.

Requires a full-access API key (`tly-fa-*`).

```bash
curl -X DELETE "https://tinylytics.app/api/v1/groups/12" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Accept: application/json"
```

Returns `204 No Content` on success.

<span id="update-site-features"></span>
#### <span class="http-method http-put">PATCH</span> `/sites/:id/features` <span class="access-badge access-full">Full Access</span>

Enable or disable monitoring features for one owned site. Send only the features you want to change.

Requires a full-access API key (`tly-fa-*`).

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `content_monitoring` | No | Enable or disable content monitoring. Requires a paid plan. Enabling also turns on content monitoring emails. |
| `content_monitoring_emails` | No | Enable or disable email alerts for content monitoring issues. Requires content monitoring to be enabled before turning emails on. |
| `uptime` | No | Enable or resume uptime monitoring, or pause it when set to `false`. Pausing does not delete the monitor. |

At least one supported feature property must be present in the request body.

```bash
curl -X PATCH "https://tinylytics.app/api/v1/sites/456/features" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "content_monitoring": true,
    "uptime": true
  }'
```

```json
{
  "site": {
    "id": 456,
    "uid": "abc123",
    "url": "https://example.com",
    "label": "Example Blog",
    "display_root_path": "/",
    "lifetime_hits": 12340,
    "lifetime_unique_hits": 8920,
    "lifetime_kudos": 87,
    "active": true,
    "public": true,
    "created_at": "2025-06-01T12:00:00Z",
    "updated_at": "2026-02-14T10:15:00Z"
  },
  "features": {
    "content_monitoring": true,
    "content_monitoring_emails": true,
    "uptime": true
  }
}
```

To keep content monitoring on while turning off email alerts:

```bash
curl -X PATCH "https://tinylytics.app/api/v1/sites/456/features" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{ "content_monitoring_emails": false }'
```

To pause uptime monitoring without deleting the monitor:

```bash
curl -X PATCH "https://tinylytics.app/api/v1/sites/456/features" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{ "uptime": false }'
```

<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`, `date`. |
| `interval` | No | Date bucket size when `group_by=date`. One of `day` (default), `week`, `month`. Week start follows your account start-of-week setting. |
| `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"
```

```bash
curl "https://tinylytics.app/api/v1/sites/456/hits?grouped=true&group_by=date&interval=week&start_date=2026-03-01&end_date=2026-03-29" \
  -H "Authorization: Bearer tly-ro-your-api-key"
```

Grouped by `path` returns `views` (+ `unique_views` when enabled). Other groupings return `hit_count`. `group_by=date` returns a series of `{ date, hit_count }` rows, including empty buckets. Every successful response includes `summary` over the filtered range, not the current page.

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
  },
  "summary": {
    "total_hits": 1,
    "unique_visitors": 1,
    "distinct_paths": 1
  }
}
```

**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"
  },
  "summary": {
    "total_hits": 142,
    "unique_visitors": 98,
    "distinct_paths": 1
  }
}
```

`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`, `date`) return `hit_count` instead of `views`/`unique_views`.

**Response (grouped by `date`)**

```json
{
  "grouped_hits": [
    { "date": "2026-03-02", "hit_count": 40 },
    { "date": "2026-03-09", "hit_count": 0 },
    { "date": "2026-03-16", "hit_count": 22 }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 100,
    "total_count": 3,
    "total_pages": 1
  },
  "filters": {
    "start_date": "2026-03-01",
    "end_date": "2026-03-22",
    "time_zone": "utc",
    "country": null,
    "path": null,
    "referrer": null,
    "grouped": true,
    "group_by": "date",
    "interval": "week"
  },
  "summary": {
    "total_hits": 62,
    "unique_visitors": 41,
    "distinct_paths": 8
  }
}
```

`summary.total_hits` is the filtered hit count. For grouped results, `pagination.total_count` is the number of groups. `group_by=date` returns the full series in one page (empty buckets included), so `per_page` does not slice days or weeks.

---

<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.",
      "context_signals_count": 1,
      "context_signals": [
        {
          "id": 12,
          "occurred_at": "2026-02-12T09:00:00Z",
          "title": "Newsletter #42 sent",
          "description": "Featured the Rails post that is now breaking out.",
          "category": "newsletter",
          "metadata": {
            "path": "/blog/rails-post"
          }
        }
      ]
    }
  ],
  "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 auto-detected signal snapshots for that report, including headline, summary, score, detection time, window, and a small payload excerpt.
- `context_signals_count`: how many owner-submitted signals were included when the insight was generated.
- `context_signals`: the owner-submitted signals included in that insight.
- `traffic_patterns`, `best_content`, and `recommendations`: the fuller AI explanation for the week.

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

Returns owner-submitted context signals for a site. Requires insights to be enabled.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `id` (URL path) | Yes | Site numeric ID. |
| `start_date` | No | Start date (`YYYY-MM-DD`). Defaults to 30 days ago. |
| `end_date` | No | End date (`YYYY-MM-DD`). Defaults to today. |
| `category` | No | Filter by category (`newsletter`, `social`, `launch`, `maintenance`, `content`, `podcast`, `other`). |
| `page` | No | Page number. |
| `per_page` | No | Page size, max `100`. |

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

<span id="create-signal"></span>
#### <span class="http-method http-post">POST</span> `/sites/:id/signals` <span class="access-badge access-full">Full Access</span> <span class="access-badge access-subscription">Subscription</span>

Submit one owner context signal. Use this to tell Tinylytics about newsletters, launches, social posts, maintenance windows, and other events that help explain traffic changes in Insights and traffic spike analysis.

**Accepted properties**

| Property | Required | Description |
|----------|----------|-------------|
| `signal.occurred_at` | Yes | When the event happened (ISO8601 datetime). |
| `signal.title` | Yes | Short headline, max 200 characters. |
| `signal.description` | No | Longer context, max 5000 characters. |
| `signal.category` | No | One of `newsletter`, `social`, `launch`, `maintenance`, `content`, `podcast`, `other`. |
| `signal.metadata` | No | Optional JSON object (for example `url`, `path`, `campaign`, `tags`). |
| `signal.external_id` | No | Idempotency key unique per site. Reusing it updates the existing signal. |

```bash
curl -X POST "https://tinylytics.app/api/v1/sites/456/signals" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "signal": {
      "occurred_at": "2026-02-12T09:00:00Z",
      "title": "Newsletter #42 sent",
      "description": "Featured the Rails post that is now breaking out.",
      "category": "newsletter",
      "metadata": { "path": "/blog/rails-post" },
      "external_id": "newsletter-42"
    }
  }'
```

<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": "OK (200)",
    "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_enabled": 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`.