# Email reports

Clicktag emails daily, weekly or monthly stats digests: set workspace defaults once for every site, give any site its own settings (up to 25 recipients) or switch it off, and get one workspace digest across all sites, plus [API alerts](#api-alerts) that email when an API fails, slows down or goes quiet, checked every minute.

Base URL: `https://clicktag.io`. [Download OpenAPI](/openapi.json) or [read this page as Markdown](/docs/email-reports.md).

## How the layers work

| Layer             | Covers                                         | Wins when                                                  |
| ----------------- | ---------------------------------------------- | ---------------------------------------------------------- |
| Workspace defaults | Every verified site set to **Workspace default** | The site's mode is `inherit` (new sites start here)        |
| Site settings     | One site, up to 25 recipients                  | The site's mode is `custom`                                |
| Workspace digest  | One email per recipient across all your sites   | Always, independent of the site modes                      |

A site on Custom never picks up default changes; a site on Workspace default always does. A site set to Off sends nothing, and unverified sites never send.

## Access

Every route below except unsubscribe needs the owner's Firebase ID bearer token or a management key (`read` for GET, `manage` for changes); test sends need the Firebase token. The unsubscribe endpoints are public: the token in the link is the capability.

| Method | Route                                     | Result                                          |
| ------ | ----------------------------------------- | ----------------------------------------------- |
| GET    | `/api/reports/defaults`                   | Workspace defaults and which sites use them     |
| PUT    | `/api/reports/defaults`                   | Save workspace defaults                         |
| GET    | `/api/reports/workspace`                  | Workspace digest settings                       |
| PUT    | `/api/reports/workspace`                  | Save workspace digest settings                  |
| POST   | `/api/reports/workspace/test`             | Email the digest to yourself now                |
| GET    | `/api/reports/workspace/preview`          | Render the digest as HTML                       |
| PATCH  | `/api/sites/{hostname}`                   | Set `reports_mode` to `inherit`, `custom` or `off` |
| GET    | `/api/sites/{hostname}/reports`           | Mode, site subscriptions and what the site sends |
| POST   | `/api/sites/{hostname}/reports`           | Add a recipient (`201`)                         |
| PATCH  | `/api/sites/{hostname}/reports/{id}`      | Update frequency, schedule, sections            |
| DELETE | `/api/sites/{hostname}/reports/{id}`      | Remove a recipient                              |
| POST   | `/api/sites/{hostname}/reports/{id}/test` | Queue a test email for the current window       |
| GET    | `/api/sites/{hostname}/reports/preview`   | Render the email as HTML                        |
| GET    | `/api/reports/unsubscribe/{token}`        | Confirmation page (HTML)                        |
| POST   | `/api/reports/unsubscribe/{token}`        | One-click unsubscribe (HTML)                    |

## Cadence and windows

| Frequency | Sends                | Covers                    |
| --------- | -------------------- | ------------------------- |
| `daily`   | every day            | yesterday (local)         |
| `weekly`  | Mondays              | previous Monday to Sunday |
| `monthly` | the 1st of the month | previous calendar month   |

`send_hour` is a local wall-clock hour (0-23) in the subscription's IANA `timezone`; daylight-saving changes follow the clock, not a fixed UTC offset, so a send hour that a spring-forward change skips sends when the clocks jump, and one that a fall-back change repeats sends once. Due reports are queued at 7 minutes past each hour. Every report covers the last *fully elapsed* period, so a weekly report mailed Monday 09:00 Europe/Amsterdam always covers the Monday-Sunday that just ended in that timezone.

Timezones are stored in canonical form, so `europe/amsterdam` comes back as `Europe/Amsterdam`; fixed offsets such as `+02:00` are refused with `400` `invalid timezone`.

Changes compare with the period before of the same kind: the day before, the Monday to Sunday before, or the whole previous calendar month. A top row's change uses its own count in that period, even when it wasn't a top row then.

## Sections

| Key         | Default   | Contents                                              |
| ----------- | --------- | ----------------------------------------------------- |
| `overview`  | always on | Totals, change vs previous period, chart, live count  |
| `pages`     | on        | Top 5 pages with change vs previous period            |
| `referrers` | on        | Top 5 referrers with change                           |
| `countries` | off       | Top 5 countries with change                           |
| `events`    | off       | Top 5 events with change                              |
| `alerts`    | on        | Spike/dip callout for abnormal days                   |
| `api`       | on        | Requests, error rates, p95, top and failing endpoints |

The alert section flags the most extreme day in the period whose pageviews land more than 2.5 standard deviations and more than 50% away from the trailing 28-day mean, and names the top 3 referrers of a spike day. It needs at least 14 days of history before the period, so new sites get no callout for about two weeks.

The `api` section shows request volume and p95 latency with change, 5xx and 4xx rates, the top 5 endpoints and the endpoints with the most 5xx responses. It only appears when the site received API requests in the period or the one before; a site with API traffic but no pageviews in either period gets it as the headline instead of empty web totals.

## Workspace defaults

`PUT /api/reports/defaults` accepts any subset of `enabled`, `recipients` (up to 10), `frequency`, `timezone`, `send_hour` and `sections`. Changes apply on save to every site on Workspace default; each recipient gets one email per site. `GET` also returns `sites` (how many sites inherit, which are on Custom or Off), `stalled` (default recipients that failed five times in a row on an inheriting site) and `dropped` (addresses removed by an unsubscribe or a hard bounce since the last save). A save clears `dropped` and resets the failure counts of default and digest recipients, so stalled ones are retried.

```bash
curl -X PUT "$TT_HOST/api/reports/defaults" \
  -H "Authorization: Bearer $TT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true,"recipients":["team@company.com"],"frequency":"weekly","timezone":"Europe/Amsterdam","send_hour":9}'
```

Send back the `updated_at` you read to avoid overwriting someone else's change: if the settings changed in between, the save returns `409`. The defaults and the digest share one version, so save one before editing the other. `POST /api/sites/{hostname}/reports` on a site that isn't set to Custom returns `409` with `switch this site to custom reports first`.

## Per-site settings

`PATCH /api/sites/{hostname}` with `{"reports_mode":"custom"}` gives the site its own recipients; the first switch to Custom copies the current default recipients, schedule and sections into the site, so nothing changes until you edit them. Switching away from Custom keeps the site's own recipients for when you switch back. `GET /api/sites/{hostname}/reports` returns `mode`, the site's `subscriptions` and `effective`: who the site mails right now and when, or why it sends nothing.

`POST /api/sites/{hostname}/reports` accepts `email`, `frequency`, and optional `timezone` (default `UTC`), `send_hour` (default `9`) and `sections`. The site must be verified (`403`) and set to Custom (`409`). One subscription per site, email and frequency; duplicates return `409`. At most 25 subscriptions per site, paused ones included and an address on two frequencies counting twice; the next one returns `429` with `too many recipients for this site`. Addresses you add are active right away, without a confirmation email.

```bash
curl -X POST "$TT_HOST/api/sites/$TT_HOSTNAME/reports" \
  -H "Authorization: Bearer $TT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"teammate@company.com","frequency":"weekly","timezone":"Europe/Amsterdam","send_hour":9}'
```

`PATCH` accepts any subset of `frequency`, `timezone`, `send_hour`, `enabled`, `sections`. Setting `enabled` to `false` pauses without deleting. Setting it back to `true` resets `fail_count` and needs a verified site. Moving an address to a frequency it already has returns `409`.

`POST .../test` queues a real email to that subscription's address for the current last-full-period window and returns `202` with the window it used, or `502` when the queue is unavailable. It sends at most once per 30 seconds per subscription (`429` otherwise), and a paused subscription returns `409`. Test sends never consume or block the scheduled window; a delivered test resets `fail_count` and sets `last_sent_at`.

`GET .../preview?frequency=weekly&tz=Europe/Amsterdam&sections=pages,referrers,alerts` renders the email HTML inline; omit `sections` to see everything on. `overview` is always included, unknown section keys are ignored, `frequency` defaults to `weekly` and an unknown `tz` falls back to UTC.

## Workspace digest

One email per recipient with total visitors and pageviews against the previous period, a row per site with visitors in either period, the biggest movers (up to three each way among sites with at least 50 visitors in either period), the quiet sites, and one requests and 5xx line per API site. It follows the same cadence and windows as site reports.

`PUT /api/reports/workspace` accepts `enabled`, `recipients` (up to 10), `frequency`, `timezone`, `send_hour` and `updated_at`, with the same rules as the defaults. `POST /api/reports/workspace/test` emails the digest for the last full period to you, the signed-in owner, at most once per 30 seconds (`429` otherwise), and returns the address and window. It needs at least one digest recipient and a verified site (`400` otherwise), though it only mails you. `GET /api/reports/workspace/preview?frequency=weekly&tz=Europe/Amsterdam` renders it as HTML.

## Delivery and unsubscribe

Mail is sent from `reports@totallytics.com` over a Postmark broadcast stream with `List-Unsubscribe` and RFC 8058 one-click headers. Unsubscribing from a site's own subscription sets `enabled` to `false` and keeps history; re-enable from the site's settings. Unsubscribing from a default or digest email removes that address from that workspace list right away (a default address stops for every inheriting site); it shows up under `dropped` until the next save.

A permanent failure (an invalid address, or one Postmark marks inactive, for example after a hard bounce) disables a site's own subscription immediately. On a default or digest email, an inactive address is removed from the workspace list and an invalid one counts as a failure. Transient failures retry three times, then the send is marked failed and `fail_count` rises. Recipients with five consecutive failures stop being scheduled until re-enabled or saved again.

## API alerts

Per-site rules on API traffic, checked every minute. All routes need the owner's Firebase ID token or a management key (`read` for GET, `manage` for changes), except `POST .../test`, which needs the Firebase token. Adding, changing and testing rules also needs a verified site; listing and deleting don't.

| Method | Route                                    | Result                         |
| ------ | ---------------------------------------- | ------------------------------ |
| GET    | `/api/sites/{hostname}/alerts`           | List rules                     |
| POST   | `/api/sites/{hostname}/alerts`           | Add a rule (`201`)             |
| PATCH  | `/api/sites/{hostname}/alerts/{id}`      | Pause or resume with `enabled` |
| DELETE | `/api/sites/{hostname}/alerts/{id}`      | Remove a rule                  |
| POST   | `/api/sites/{hostname}/alerts/{id}/test` | Email a sample alert now       |

| Metric       | Fires when                                                                    | `threshold`             |
| ------------ | ----------------------------------------------------------------------------- | ----------------------- |
| `error_rate` | the share of 5xx responses in the window is above the threshold               | percent, 0 to below 100 |
| `p95_ms`     | p95 latency in the window is above the threshold                              | ms, below 600000        |
| `silence`    | the window has no requests while the same window yesterday had `min_requests` | none                    |

`window_minutes` is 5, 15 or 60, ending a minute back so in-flight batches land first. Set `method` and `route` (the template shown in the API view, such as `/v1/orders/:id`) to watch one endpoint; leave both out for the whole API. Windows with fewer than `min_requests` requests (default 20) never fire a rule, and a firing rule resolves in them with a "too few requests" email. A `p95_ms` window without latency buckets keeps the current state. `recipients` defaults to the signed-in owner's email; a management key has none, so it must send `recipients` (`400` `recipients is required when using a management key` otherwise). Up to 10 recipients per rule and 20 rules per site, after which adding one returns `429`.

```bash
curl -X POST "$TT_HOST/api/sites/$TT_HOSTNAME/alerts" \
  -H "Authorization: Bearer $TT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"metric":"error_rate","threshold":2,"window_minutes":15,"method":"POST","route":"/v1/orders"}'
```

A rule is `ok` or `firing`. Crossing the threshold sends one alert and marks it `firing`; nothing more is sent until it recovers, which sends one resolved email. An alert stays firing or resolved for at least 10 minutes, so a value hovering at the threshold can't flap. `last_value` holds the value behind the latest change. Pausing resets the rule to `ok`. `POST .../test` emails a sample only to you, the signed-in owner, once per 30 seconds per rule, and leaves the state alone.

Alerts come from `alerts@totallytics.com` on a transactional stream with a link to the site's API view. Every firing and resolved email carries a one-click unsubscribe link (and `List-Unsubscribe` headers) for its own recipient: `GET /api/alerts/unsubscribe/{id}/{token}` shows a confirm page and `POST` removes that address from the rule, or pauses the rule when it was the last one. Report unsubscribes don't affect alerts.
