Wire transparency — every outbound call, byte for byte
Published: July 19, 2026 · Updated: August 13, 2026
Outbound connections from the apps are limited to the documented OpenHeaders endpoints below, plus targets the operator configures themselves (IdP issuer for SSO, Git remotes, an audit/SIEM collector, and whatever requests the user’s own rules and workflows make). This document is the published specification of every phone-home payload; if a request is not listed here, the app does not make it. The app is fully functional with all of these unreachable or disabled — see the per-endpoint off switches.
The only usage data that ever leaves is the anonymous telemetry channel of section 5: a typed allowlist of feature-usage counts, structurally incapable of carrying URLs, headers, traffic, or identity, default-on for the desktop app, extension, and CLI with a one-switch opt-out, and hard-off for the daemon, the served web app, and the MCP server. The license system itself remains telemetry-free: license endpoints and telemetry never share identifiers, payloads, or deployments.
The browser extension’s only OpenHeaders-bound calls are that telemetry channel (section 5) and the on-demand release-notes history fetch (section 4); browser store distribution owns its updates.
1. License refresh
Self-serve subscription renewal. Node hosts only (desktop main process and the daemon — never the extension). The agent only ever delivers files: the response is verified offline against the compiled-in Ed25519 trust ring before anything persists; no online validation path exists.
- Endpoint:
POST https://license.openheaders.io/refresh - Request headers:
content-type: application/json - Request body — exactly these three fields, nothing else:
{
"licenseKey": "oh-license.<base64url payload>.<base64url signature>",
"appVersion": "2026.7.10",
"platform": "darwin"
} -
licenseKey— the currently installed license artifact, verbatim. It contains what the license file contains: licensee name/org/email, seat count, validity window, key id, and an opaque subscription reference (subscriptionRef) the control plane stamped at purchase and uses to look the subscription up. Nothing about the deployment (users, workspaces, hostnames, usage) is derivable from it. appVersion— the host’s own version string.platform— the Nodeprocess.platformvalue (darwin,win32,linux).- Response:
200with the fresh signed license artifact as the body.4xxmeans the subscription lapsed; the host stops asking until a different license is installed. Anything else is retried silently on the next cycle. - Cadence: checked every 6 hours (±10 min jitter), but a request is only
actually sent while the installed license is inside its renewal window
(
validUntil − now < 30 days) or grace period. A healthy deployment POSTs here roughly four times a day for the last two weeks of each 45-day file; outside the window, nothing leaves. - Off switches: no license installed → never called.
offline: true(enterprise/air-gapped) licenses → never called. Daemon configlicenseRefresh: false/OH_LICENSE_REFRESH=0→ never called. - Failure is never lockout: refreshes stopping only means the file lapses into its grace period and then free-tier limits on new user creation. Existing users always log in; data is never hostage.
2. Update check
Anonymous check, staging by default, and a restart that is never unprompted: an available update
may download in the background (default on, one switch to off), but a running app is only ever
restarted by an explicit “Update & Restart” click or a quit that happens anyway.
The oh CLI additionally self-updates between invocations on self-managed binary
installs (default on, oh autoupdate off to stop) — it swaps its own binary so the
next run launches the new version, restarting nothing. Who checks: desktop packaged builds on
macOS/Windows and Linux AppImage, the oh CLI (daily cached), and the
ohd daemon only when you run ohd status or opt into its default-off
unattended mode. Dev builds, deb/rpm installs, container images, and npm/brew installs make no
update requests — their owning channel updates them.
- Endpoint: the update feed at
updates.openheaders.io— static pointer files read by electron-updater’s generic provider:GET https://updates.openheaders.io/desktop/stable/latest*.yml(the per-platform update metadata). Only after the user chooses to download does the app fetch the installer artifact itself, from the feed’s owndl/<tag>/path on the same host — the entire update lifecycle reaches exactly one first-party domain. The public github.com/OpenHeaders/open-headers release page hosts a redundant human-browsable copy; the app never contacts it. - Request body: none. These are plain HTTP
GETs of static files; no identifier, license, or machine information is attached beyond what any HTTP client sends (the feed is served by Cloudflare, subject to its hosting logs). - Cadence: at most once a day (±10 min jitter) plus explicit
“Check now” clicks. A found update downloads in the background by default
(
updates.autoDownload, one switch to off) from the same first-party feed; applying it happens only on an explicit Update & Restart or a natural app quit — never mid-session. - Off switch:
updates.check: offin Settings → Updates (alsosecurity-onlyto limit notifications);updates.autoDownload: offstops background downloads while keeping the check. CLI:oh autoupdate offand theOH_NO_UPDATE_CHECKenvironment variable. Daemon: unattended mode is off unlessupdates.autoUpdateis explicitly enabled.
3. Severity manifest
A small static severity manifest published to the update feed on each release —
{ latest, tag, severity, minimumSafeVersion } per app — so a security release
can escalate loudly (red badge, entry banner). Severity is authored by a human before each
release, never derived from anything about your install.
- Endpoint:
GET https://updates.openheaders.io/versions/stable.json - Request body: none — a plain
GETof a static file; the comparison against your running version happens locally. - Cadence: fetched only as part of an update check (section 2) — the same daily schedule and explicit “Check now” clicks; never on its own timer.
- Off switch: the same as the update check —
updates.check: offdisables both. If the manifest is unreachable, the app simply treats severity as unknown.
4. Release notes history
Each app ships its own current release notes inside the build — the What’s New screen
renders them with zero network. The only wire traffic is the optional history underneath:
the desktop app and the extension can list previous releases from the public
changelog feed and fetch an older entry’s notes when you expand it. The CLI’s
oh changelog is fully air-gapped, and the server daemon serves its own bundled
notes to its admin console — the browser never dials the feed.
- Endpoint: the app’s own stream index on the update feed —
GET https://updates.openheaders.io/changelog/desktop.json(desktop) or…/changelog/extension.json(extension) — plus…/changelog/<product>/<version>.jsonfor a single entry when you expand it. All static files on the same first-party feed host as section 2. - Request body: none — plain
GETs of static files; no identifier, version, or machine information is attached. - Cadence: only while you are looking at the What’s New screen — the index when its “Previous releases” section renders, one entry file per release you expand. Never on a timer, never at startup.
- Off switch: none needed — the fetch only happens on that explicit screen, and with the feed unreachable (or offline) the history section simply disappears while the bundled current notes keep rendering. On Firefox the feed host is an optional permission; declining it disables the fetch entirely.
5. Anonymous telemetry
Anonymous usage counting — which features get used, nothing more. The event vocabulary is a typed allowlist compiled into the app: every payload property is a closed union, boolean, or number; free-form strings are banned by a guard test, so URLs, hostnames, header names or values, rule contents, request/response data, and file paths are inexpressible. The in-app inspector (Settings → General → View telemetry events) shows every event of the current session byte for byte, sent or suppressed.
- Endpoint:
POST https://telemetry.openheaders.io/v1/events - Request headers:
content-type: application/json - Request body — a batch envelope, exactly these fields:
{
"schemaVersion": 2,
"host": "extension",
"channel": "chrome-store",
"appVersion": { "year": 2026, "month": 8, "patch": 0 },
"platform": "mac",
"browser": "firefox",
"locale": "en",
"sessionId": "c0ffee00c0ffee00c0ffee00c0ffee00",
"installId": "feedface00feedface00feedface0000",
"sinceInstall": "2-7",
"sessionAge": "1-8h",
"sentAt": 1760000000000,
"events": [
{ "name": "first_run" },
{ "name": "session_start", "rules": "1-5", "workspaces": "0" },
{ "name": "feature_used", "feature": "workflow-editor" },
{ "name": "rule_created", "ruleType": "header", "origin": "editor" },
{ "name": "rule_matched", "ruleType": "header" },
{ "name": "import_run", "source": "postman", "ok": true },
{ "name": "workflow_run", "ok": true },
{ "name": "error_beacon", "code": "ws-connect-failed" },
{ "name": "license_activated", "plan": "individual" },
{ "name": "paywall_hit", "surface": "seat-gate" },
{ "name": "upgrade_cta_shown", "surface": "license-pane" },
{ "name": "upgrade_cta_clicked", "surface": "grace-banner" },
{ "name": "mcp_client_connected", "client": "claude-code" }
]
} -
host— which surface sent the batch, only everdesktop/extension/cli(the daemon, served web app, and MCP server are hard-off and have no vocabulary member). Clients built before 2026.8 carried it onsession_startinstead; the worker accepts both. -
channel,appVersion,platform,browser,locale— the remaining per-process facts, hoisted to the envelope alongsidehost(2026-08 second rev) so every stored row is segmentable without joins. All closed unions or integers:channelis the static distribution fact (which store or package manager the build shipped through — never sniffed from traffic);appVersionis the CalVer version as integers, plus abetaiteration on pre-release builds;platformis omitted where the running OS has no vocabulary member;browserexists only on browser-hosted surfaces;localeis the resolved interface language from the shipped catalog, with anything outside it reported asother— never a raw language tag. Earlier schema-v2 clients carried these onsession_start/first_runinstead; the worker accepts both. -
sessionId— 32 hex chars minted at random per process launch, held in memory only, never persisted. It groups one session’s events and nothing else. -
installId— 32 hex chars minted at random on first run and kept by the host. Random by law: never derived from hardware, the network, an account, or any real-world fact — it identifies the install, not you, and cannot be traced back to either. Turning the telemetry toggle off deletes it — a later re-enable mints a new one unlinkable to history, so the toggle doubles as a reset. -
sinceInstall— how old the install is, only ever as one of five coarse buckets (0,1,2-7,8-30,31+days); precise ages are inexpressible. -
sessionAge— how long this session has been running at the moment the batch is sent, only ever as one of five coarse buckets (0-9m,10-59m,1-8h,8-24h,24h+); precise durations are inexpressible. Added 2026-08; earlier clients omit it. -
events— only the thirteen event shapes above exist, and every field value comes from a closed union checked into the codebase.first_runfires once per install;session_startfires once per session per UTC day (a long-running browser or tray process re-announces itself daily — same event, same fields, no extra data) and carries only the coarse scale-of-use buckets:rules/workspacesuse coarse buckets (0,1,2-5,6-20,21-100,100+— clients built before 2026-08 send the older1-5and21+spans, still accepted), never exact counts.rule_createdcarriesorigin— which in-app affordance created the rule, one ofeditor,quick-editor,empty-state-nudge.rule_matchedfires at most once per rule type per session per UTC day when a rule of that type acts on a request — it carries the rule type and nothing about the request. The four monetization shapes (2026-08) carry one closed-union value each:license_activatedfires only on a license install you perform yourself (background license refreshes never emit) withplanas a coarse bucket (free/individual/team) — never a license id, key, licensee, or seat count;paywall_hitand theupgrade_cta_*pair carry only which in-app spot was involved (seat-gate,license-pane,grace-banner), withupgrade_cta_shownfiring at most once per spot per session per UTC day.mcp_client_connected(2026-08) fires from the desktop app at most once per client per session per UTC day when an AI tool completes the MCPinitializehandshake against the app’s embedded MCP server — it carries only which client family connected (claude-code,claude-desktop,cursor,windsurf,vscode,other); a client not on that list reportsother, never its name, and nothing about the agent’s session, tools, or data is expressible. (The MCP server itself still sends no telemetry — this is the desktop app counting that its MCP surface is in use, and a standalone server deployment counts nothing.) Every other per-process fact rides the envelope. - Response:
202when the envelope validates,4xxotherwise. The client never acts on the status either way — failures are silent, the batch simply rides the next flush, and nothing ever retries aggressively, blocks, or degrades the app. - Storage: the worker validates against the same compiled schema and writes
one Cloudflare Workers Analytics Engine data point per event, carrying only the vocabulary
values, the session id, the install id, and one caller-derived value: the coarse two-letter
country code Cloudflare resolves at its edge. The IP address it derives from is never read
into a data point, and no other request header is either; no third-party analytics SDK or
processor is involved (Cloudflare already hosts the license worker and is the only processor
named in the privacy policy). Stored column positions never change
meaning; a few are shared across event names carrying the same kind of fact (the typed
reason code of
error_beacon/uninstall_reason; the external source id ofimport_run/mcp_client_connected), split by event name when queried. Monthly aggregate snapshots (counts only — ids are aggregated away) are committed to the repository as the long-term metrics ledger. - Uninstall ping (extension only):
GET https://telemetry.openheaders.io/v1/uninstall?i=<installId>&a=<sinceInstall>&c=<channel>is registered as the browser’s uninstall URL — the page the browser opens when the extension is removed. It carries the install id plus two coarse vocabulary values already described above —ais thesinceInstallbucket at registration time andcis the distribution channel — counts one departure, and redirects to the farewell page athttps://openheaders.io/uninstall/, passing only the validateda/cvalues along (the install id never leaves the worker). The worker validates both context values against their closed unions and stores nothing for anything else. It is registered only while the telemetry toggle is on and an install id exists; toggling off clears it (no id, no ping). - Uninstall micro-survey (2026-08): the farewell page offers one optional
“why did you uninstall?” picklist. Tapping an answer submits
GET https://telemetry.openheaders.io/v1/uninstall-reason?r=<reason>&a=<sinceInstall>&c=<channel>— the reason is one of seven fixed values (not-needed,not-working,missing-feature,too-complex,privacy,switching,other) and the coarse context is the same pair the redirect carried. The stored row is anonymous by construction: no install id, no session, nothing joinable back to an install — free-text is inexpressible and an off-list value stores nothing. Skipping the question sends nothing at all. - Download-click beacon (website only, 2026-08): clicking a desktop-installer
link on openheaders.io sends
GET https://telemetry.openheaders.io/v1/download?t=<platform>—tis one ofmac,win,linux(the same closed union the apps report) and nothing else is attached. The stored row is anonymous by the same construction as the micro-survey: no install id, no session, nothing joinable. It counts a click on the website, never a download or an install; the apps themselves never call this route. - Cadence: batched — the host flushes the in-memory queue on an interval and best-effort on quit.
- Off switches: Settings → General → telemetry toggle (extension and
desktop),
OH_TELEMETRY=0env var or config key (CLI). The daemon, served web app, and MCP server never send telemetry and have no toggle to misconfigure. Off means off: the channel goes silent entirely, with no “essential telemetry” residue. - Strict channel separation: no license id, subscription ref, licensee email, seat data, or OIDC subject can appear in an event — the vocabulary has no such fields — and the telemetry worker is a separate deployment sharing no secrets or code paths with the license worker beyond the published schema.
Everything else
Anything else leaving the process is operator-configured, not OpenHeaders-bound: the OIDC issuer you set, the Git remotes you sync, the SIEM collector you point audit streaming at, and the HTTP requests your own rules, sources, and workflows define.