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 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 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.

# 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:

{
  "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:

{"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:

{
  "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

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

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)

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.

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