# Beam Analytics API

Query your workspace's analytics from scripts and agents. Base URL: `https://beam.experiment.sh`.

## Start with only an API credential

Use **Copy agent setup** in API Keys to give an agent the secret and API base together. A bare opaque key does not identify its host. The public entry point is `GET https://beam.experiment.sh/api/v1`; it returns authentication instructions and endpoint links. Then read authenticated metadata and its `analysis_guide` and `recommended_periods`.

The [analysis playbook](/docs/analysis.md) gives a complete first-report workflow and explains measurement limitations. It is also embedded in metadata, so agents can discover it without browsing the UI.

## Create an API key

Open [Settings → API Keys](/dashboard/settings?settings_selector=API+Keys) in the workspace you want to query. Only the workspace owner can create and revoke keys. Choose a name, then copy the key: it is shown only once. Keys are read-only, apply to all domains in that workspace, and cannot manage other keys. Keys never expire; they stay valid until you revoke them. Revocation takes effect on subsequent requests.

Store the secret in an environment variable called `BEAM_API_KEY`. Send it in the `Authorization: Bearer` header, never in a URL. The public website tracking token is not an API key.

## Agent workflow: discover, then query

No website names or filter fields need to be hardcoded:

1. Call `GET /api/v1/metadata` to identify the workspace and read supported reports, dimensions, metrics, filter operators, date rules, limits, and endpoint links.
2. Call `GET /api/v1/websites` to list observed domains. Follow `pagination.next_offset` until it is null.
3. Choose a domain and call `POST /api/v1/values` to discover actual values for any dimension, optionally restricted by that domain and a date range.
4. Call `POST /api/v1/coverage` to inspect retained coverage and missing identifiers.
5. Call `POST /api/v1/compare` for an equal-period comparison, then `/api/v1/query` for time-series and segment investigation. Follow pagination for breakdowns.

All data endpoints require the same API key; only the API entry point and static docs are public. Metadata reveals no tracking tokens or backend credentials. Website and value discovery are restricted to the key's workspace.

```bash
# Identify the workspace and learn the API capabilities.
curl --fail-with-body https://beam.experiment.sh/api/v1/metadata \
  -H "Authorization: Bearer $BEAM_API_KEY"

# Discover websites; no site name is required.
curl --fail-with-body 'https://beam.experiment.sh/api/v1/websites?limit=100' \
  -H "Authorization: Bearer $BEAM_API_KEY"

# Discover pages for a domain returned by /websites.
curl --fail-with-body https://beam.experiment.sh/api/v1/values \
  -H "Authorization: Bearer $BEAM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"field":"page","filters":[{"field":"domain","operator":"is","value":"telemetry.sh"}]}'
```

## GET /api/v1/metadata

No query parameters. Returns `version`, `workspace` (`id`, `name`), key access scope, `reports`, `metrics`, `dimensions`, `filter_operators`, `filter_semantics`, `dates`, `limits`, `pagination`, and `links`, plus `server_time`, `recommended_periods` for completed UTC days, and the embedded `analysis_guide`.

Each dimension includes its API `id`, dashboard `label`, value `type`, nullability, whether it is filterable and usable in breakdowns, supported operators, and a value-discovery endpoint. Use IDs in API requests; labels such as "Page" are for display. Each operator states whether it requires a value. Each metric includes its type, unit, and definition. Metadata is the authoritative capability catalog and does not require the analytics backend to respond.

## GET /api/v1/websites

Lists distinct nonempty domains observed in retained workspace events, alphabetically. This is an observed-traffic inventory, not a registry of configured websites: a site with no retained events will not appear. No `from` means all retained history; `to` defaults to now. Optional `from` is inclusive, `to` exclusive. Unlike analytics reports and filter values, this inventory has no 366-day range cap.

Optional query parameters: `from`, `to`, `search` (literal, case-sensitive domain substring), `limit` (1–1000, default 100), and `offset` (default 0). Repeated or unknown parameters are rejected.

Each row contains `domain`, `pageviews`, `visits`, and ISO UTC `first_seen` / `last_seen` timestamps within the selected range. Empty or null domains are omitted. `meta` includes `workspace_id`, `from` (null for all history), `to`, `timezone`, `source: "observed_events"`, `search`, `limit`, and `offset`.

Illustrative response:

```json
{
  "data": [{"domain":"telemetry.sh","pageviews":120,"visits":80,
    "first_seen":"2026-09-01T10:00:00.000Z","last_seen":"2026-09-08T10:00:00.000Z"}],
  "pagination":{"limit":100,"offset":0,"has_more":false,"next_offset":null},
  "meta":{"workspace_id":"your-workspace-id","from":null,"to":"2026-09-08T12:00:00.000Z",
    "timezone":"UTC","source":"observed_events","search":null,"limit":100,"offset":0}
}
```

## POST /api/v1/values

Discovers the observed values for a required `field` from metadata. Send a JSON object containing `field`, optional `from`, `to`, `filters`, `search`, `limit`, and `offset`. Unknown fields are rejected. `search` is a literal case-sensitive substring of the selected field; it does not count toward the 20-filter limit. Other filters are combined with AND.

Defaults to midnight UTC 28 days ago through now, with a maximum 366-day range. Use explicit dates to inspect older activity. Rows contain `value`, `pageviews`, and `visits`; values are sorted alphabetically, with null first. Empty strings and nulls are included if observed (unless excluded by a search or filter). To query a discovered null, use `is_not_set`; to query an empty string, use `is` with `value: ""`.

`meta` contains `workspace_id`, `field`, `from`, `to`, `timezone`, `search`, `limit`, and `offset`. Responses include the pagination object described below. A missing value means it was not observed in the selected range and filters, not that it is permanently unsupported.

## Pagination

Websites, values, and breakdown reports all accept `limit` and `offset` and return:

```json
{"pagination":{"limit":100,"offset":0,"has_more":true,"next_offset":100}}
```

Use `next_offset` for the next request; stop when it is null. The server fetches one extra row to determine `has_more`, so there is no silent top-N truncation. Keep the same filters, search, dimension/field, and limit. Copy `meta.from` and `meta.to` from the first response into subsequent requests (omit a null `from` for websites) to freeze the requested time range. Results are not a transactional snapshot: late-arriving events can still change counts or ordering. There is no pagination for summary or time-series reports.

## POST /api/v1/query

Send a JSON object. Unknown top-level fields are rejected. The key determines the workspace; you cannot supply a workspace ID, tracking token, SQL, or table name.

| Field | Meaning |
| --- | --- |
| `report` | `timeseries` (default), `summary`, or `breakdown` |
| `from` | Inclusive start, `YYYY-MM-DD` or UTC ISO timestamp such as `2026-09-01T00:00:00Z`; defaults to midnight UTC 28 days ago |
| `to` | Exclusive end in the same format; defaults to now. A date alone means midnight at the start of that date |
| `granularity` | `hour`, `day` (default), `week`, or `month`; used for time series |
| `dimension` | Required for breakdowns; one of the dimensions below |
| `filters` | Up to 20 filters combined with AND; defaults to no filters |
| `limit` | Breakdown page size, 1–1000; defaults to 100; rejected for other reports |
| `offset` | Nonnegative breakdown offset; defaults to 0; rejected for other reports |
| `fill` | Time series only: `omit` (default) or `zero` to return every requested bucket |
| `order_by` | Breakdown only: `pageviews` (default), `visits`, `bounce_rate`, or `avg_session_sec` |
| `order` | Breakdown only: `desc` (default) or `asc`; ties are ordered by value ascending, nulls first |
| `min_visits` | Breakdown only: nonnegative minimum matching sessions per returned group, default 0 |

Ranges must be in the past, with `from < to`, and span at most 366 days. Hourly time series are limited to 31 days. Weekly buckets start on Monday; monthly buckets start on the first day. Time series omit empty buckets by default. With `fill: "zero"`, absent event buckets have zero metrics and `observed: false`; populated buckets have `observed: true`. Zero filling does not guarantee that tracking was operational. Breakdowns are paginated and ordered by the requested metric, then value ascending with nulls first.

### Dimensions and filters

Dimensions: `page`, `domain`, `referrer`, `browser`, `operating_system`, `country`, `region`, `city`, `device_size`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `title`, `hash`, `continent`, `isp`, `event`.

Each filter has `field` (a dimension) and `operator`. Operators `is`, `is_not`, `contains`, `not_contains`, `starts_with`, and `not_starts_with` require a string `value` of at most 500 characters. Operators `is_set` and `is_not_set` must omit `value`. Extra filter properties are rejected.

Comparisons are case-sensitive. Prefix and contains filters treat `%` and `_` literally. Control characters and backslashes are not supported in values. Negative comparisons exclude nulls. `is_set` means not null, including empty strings; `is_not_set` means null. Multiple filters use AND. Discover current types and operators from metadata, and observed values from `/values`.

All dimensions are raw event attributes. Entry/exit pages, session-duration dimensions, funnels, and raw-event exports are not supported by this read API; they are not advertised in metadata. It does not accept arbitrary SQL or let a key manage users, websites, or keys.

### Reports and metrics

- `summary`: one aggregate row for the range.
- `timeseries`: one row per populated time bucket, sorted by date. `date` is an ISO UTC timestamp. `incomplete` is true when either requested boundary cuts the bucket short, including a partial first bucket. `observed` states whether the backend returned matching events for that bucket.
- `breakdown`: rows contain `value`, `pageviews`, `visits`, `bounce_rate`, and `avg_session_sec`, grouped by the requested dimension. `value` can be null for missing data.

`pageviews` counts matching analytics events, matching Beam's dashboard convention. `visits` counts distinct sessions, not unique people. `bounce_rate` is the percentage of matching sessions with one matching event. `avg_session_sec` is the average difference in seconds between the first and last matching event per session. Filters apply before session aggregation. For time series, sessions are calculated separately within each bucket; visits summed across buckets can exceed the summary's distinct-session count. Numeric values may be returned as JSON numbers or numeric strings by the analytics backend.

Illustrative time-series response:

```json
{
  "data": [
    {"date": "2026-09-01T00:00:00.000Z", "visits": 42, "pageviews": 80,
     "bounce_rate": 35.71, "avg_session_sec": 24.5, "incomplete": false, "observed": true}
  ],
  "meta": {
    "report": "timeseries", "from": "2026-09-01T00:00:00.000Z",
    "to": "2026-09-02T00:00:00.000Z", "timezone": "UTC",
    "granularity": "day", "fill": "omit", "workspace_id": "your-workspace-id"
  }
}
```

### Summary example

```bash
curl --fail-with-body https://beam.experiment.sh/api/v1/query \
  -H "Authorization: Bearer $BEAM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"report":"summary","from":"2026-09-01","to":"2026-09-08"}'
```

### Top pages example

```bash
curl --fail-with-body https://beam.experiment.sh/api/v1/query \
  -H "Authorization: Bearer $BEAM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"report":"breakdown","dimension":"page","limit":20,
       "filters":[{"field":"domain","operator":"is","value":"telemetry.sh"}]}'
```

### Python example: discover websites and query (standard library)

```python
import json
import os
import urllib.parse
import urllib.request

base = "https://beam.experiment.sh"
headers = {"Authorization": f"Bearer {os.environ['BEAM_API_KEY']}"}

def api(path, body=None):
    request = urllib.request.Request(
        base + path,
        data=None if body is None else json.dumps(body).encode(),
        headers={**headers, "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(request, timeout=35) as response:
        return json.load(response)

metadata = api("/api/v1/metadata")
print("Workspace:", metadata["workspace"]["name"])
print("Available fields:", [field["id"] for field in metadata["dimensions"]])

websites = []
params = {"limit": 100}
while True:
    page = api("/api/v1/websites?" + urllib.parse.urlencode(params))
    websites.extend(page["data"])
    offset = page["pagination"]["next_offset"]
    if offset is None:
        break
    params.update(offset=offset, to=page["meta"]["to"])

if websites:
    domain = websites[0]["domain"]
    filters = [{"field": "domain", "operator": "is", "value": domain}]
    pages = api("/api/v1/values", {"field": "page", "filters": filters})
    print("Observed pages (first page):", pages["data"])
    summary = api("/api/v1/query", {"report": "summary", "filters": filters})
    print(domain, summary["data"])
else:
    print("No websites with retained events in this workspace.")
```

All API dates and buckets use UTC; input subsecond timestamps are normalized down to whole seconds. The dashboard uses your browser timezone; choose equivalent UTC boundaries when comparing results.

## POST /api/v1/coverage

Accepts `{}` or `{"filters":[...]}`. Uses the same dimension filters as queries, over **all retained matching events**, without a date range. Returns `data` with numeric `events`, `visits`, `days_with_events`, `events_without_session_id`, and `events_without_domain`; nullable ISO `first_seen` / `last_seen`; nullable `elapsed_seconds_since_last_event`; and `ingestion_complete_through: null` (unknown). `meta` includes workspace ID, UTC, generation time, scope, and an interpretation caveat.

An empty successful aggregate means no matching retained events. Missing session IDs are excluded from session metrics while their events remain in pageview counts. First/last observations and event-free days do not establish instrumentation uptime or a retention guarantee. A missing dataset returns 404, not a fabricated empty response.

## POST /api/v1/compare

Accepts optional `from`, `to`, `compare_from`, `compare_to`, and `filters`. Defaults to the latest 28 **completed UTC days** against the preceding 28 days. Omit both comparison boundaries for the previous equal-duration period, or provide both explicitly. Periods must be equal-duration, non-overlapping, in the past, and no longer than 366 days each. Uses the same event filters for both, in one backend query.

Returns `data` keyed by each metric. Each metric has numeric `current`, `previous`, nullable `absolute_change`, nullable `percent_change`, and `absolute_change_unit` (`events`, `sessions`, `percentage_points`, or `seconds`). Percent change is `100 * (current - previous) / previous`; when the baseline is zero it is null. Rate and duration changes are also null when either period has no sessions. `meta.current` and `meta.previous` echo the exact boundaries, alongside workspace ID, UTC, generation time, and a caveat. Check coverage before treating windows as comparable; this endpoint does not establish collection completeness or statistical significance.

```bash
curl --fail-with-body https://beam.experiment.sh/api/v1/compare \
  -H "Authorization: Bearer $BEAM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"filters":[{"field":"domain","operator":"is","value":"telemetry.sh"}]}'
```

## Limits and errors

Each key allows 60 requests per minute shared across every authenticated endpoint (metadata, websites, values, coverage, compare, and query). The request body limit is 16 KB. Requests time out if the analytics backend takes more than 25 seconds. Keys survive application restarts and never expire. You can have up to 20 active keys per workspace.

API errors return `error` and `message` fields (Next.js may return plain text for malformed JSON or oversized bodies).

| HTTP status | Meaning |
| --- | --- |
| 400 | Invalid JSON, range, filter, dimension, or other input |
| 401 | Missing, invalid, or revoked API key |
| 403 | The key's workspace is unavailable |
| 404 | `dataset_unavailable`: no queryable retained dataset was found. Do not interpret this as zero traffic; verify collection with the workspace owner |
| 405 | Use the method in the Allow header: GET for metadata/websites; POST for values/coverage/compare/query |
| 413 | Request body too large |
| 415 | Send Content-Type: application/json |
| 429 | Rate limit reached; wait the number of seconds in Retry-After |
| 502 | Analytics backend unavailable; retry with exponential backoff |

An empty successful result has `data: []` (or zero metrics for a summary). A backend failure is an error, never a fabricated zero result. A missing analytics table returns 404 `dataset_unavailable`; metadata remains accessible.

## Agent resources

- [OpenAPI schema](/openapi.json)
- [Plain Markdown documentation](/docs/beam-api.md)
- [Agent discovery file](/llms.txt)
