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:
- Call
GET /api/v1/metadatato identify the workspace and read supported reports, dimensions, metrics, filter operators, date rules, limits, and endpoint links. - Call
GET /api/v1/websitesto list observed domains. Followpagination.next_offsetuntil it is null. - Choose a domain and call
POST /api/v1/valuesto discover actual values for any dimension, optionally restricted by that domain and a date range. - Call
POST /api/v1/coverageto inspect retained coverage and missing identifiers. - Call
POST /api/v1/comparefor an equal-period comparison, then/api/v1/queryfor 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.dateis an ISO UTC timestamp.incompleteis true when either requested boundary cuts the bucket short, including a partial first bucket.observedstates whether the backend returned matching events for that bucket.breakdown: rows containvalue,pageviews,visits,bounce_rate, andavg_session_sec, grouped by the requested dimension.valuecan 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.