Share links and embedding

A share link shows selected parts of one project's dashboard to people without an X3 account – a client, a board, a public "open stats" page. It is read-only and never contains personal data: no leads, no visits, no journeys, no contact details, no IP addresses. Small numbers are coarsened so nobody can be singled out:

  • counts from 1 to 4 (visits, visitors, leads, pageviews) are shown as "< 5";
  • rates and costs that would reveal such a count (conversion rate of 3 visits, cost per lead of 2 leads) are shown as "–";
  • table rows below 5 visits are merged into one "Other" row;
  • the day chart is hidden while the range has fewer than 5 visits, and a single day with 1 to 4 visitors, visits or leads arrives as null (the page then shows that metric without a day chart);
  • Search Console queries below 5 impressions are left out.

In the dashboard

Settings › Teilen & Berichte › Teilen & Einbetten (project admins). "Neuer Link" opens a wizard: sections → default range (and whether visitors may switch it) → password and expiry → embedding → name. The last step shows the link, the iframe code and – for signed embeds – the signing secret once; copy them right away. The table lists every link with state (active, expires soon, expired, revoked), sections, protection, allowed domains, views in total and in the last 30 days and the last view; it can be sorted, filtered, searched, its columns adjusted and exported as CSV. Per row: edit, new signing secret, revoke (final) and delete.

Visitors open /share/<token>: the project name and link name, the range switch, every shared section with its own empty state ("Search Console is not connected", "Too few visits for a chart") and, for password links, a password form first. A signed-in X3 user sees exactly the same public view.

Create and manage

POST /v1/projects/{project}/shares
{
  "label": "Monatsbericht Kunde",
  "sections": ["kpis", "timeseries", "sources", "pages"],
  "defaultRange": "30d",
  "rangeSelectable": true,
  "password": "ein-langes-passwort",
  "expiresAt": "2027-01-31T23:00:00Z",
  "embed": { "enabled": true, "allowedOrigins": ["https://kunde.example"], "requireSignedToken": false }
}
  • Managing shares needs project:admin; every change is written to the audit log (share.created, share.updated with the names of the changed fields, share.revoked, share.secret_rotated, share.deleted) – never the password, the token or the signing secret.
  • The answer contains the full link (url), an iframe snippet (embedSnippet) and – with requireSignedToken – the signing secret (signingSecret). They are shown once; X3 stores only a hash of the token and the secret encrypted. Send an Idempotency-Key header to make a retried create return the same link instead of a second one.
  • Sections: kpis, timeseries, sources, campaigns, pages, countries, devices, goals, search (Search Console) and annotations (title and day of each annotation, also drawn into the shared chart).
  • Ranges a visitor can pick: 7d, 30d, 90d, this_month, last_month. With rangeSelectable: false the link always shows defaultRange.
  • Embedding needs at least one exact origin (https://kunde.example, no wildcards, http only for localhost); requireSignedToken needs embedding enabled.
  • At most 50 links per project that are not revoked (conflict beyond).
  • PATCH /v1/projects/{project}/shares/{id} changes label, sections, ranges, password (null removes it; a new password ends every unlocked session), expiry (null removes it; a later expiry re-activates an expired link) or embedding. Revoked links cannot be changed (conflict).
  • POST /v1/projects/{project}/shares/{id}/revoke ends the link immediately and for good.
  • POST /v1/projects/{project}/shares/{id}/rotate-secret returns a new signing secret (once); tokens signed with the old one stop working. When you switch requireSignedToken on through PATCH, call it to get the secret.
  • DELETE /v1/projects/{project}/shares/{id} removes the link with its view counter.
  • GET /v1/projects/{project}/shares lists all links with views, views30d, lastViewedAt and state – the token only as tokenPrefix.

MCP: shares_list, shares_create, shares_update and shares_revoke (scope project:admin) do the same as the REST routes.

Public endpoints

The share token in the path is the credential. Unknown, revoked and expired tokens and links of deleted projects all answer the same 404.

EndpointPurpose
GET /v1/shares/{token}Project name, sections, ranges, whether a password is needed, embed rules – while locked only the lock, locale, ranges and embed rules (name, label, sections, time zone, currency and expiry are null/empty)
POST /v1/shares/{token}/unlock{ "password": "…" } → a session for the x-3x-share-session header (12 hours); wrong passwords answer share_locked; more than 10 attempts per hour and visitor, 100 per hour and link (all visitors) or 30 calls per minute answer rate_limited; links without a password answer conflict
GET /v1/shares/{token}/data?section=kpis&preset=30dAggregate data of one section; the link's first section counts the view – at most once per visitor and 30 minutes

Password-protected links answer share_locked without a valid session. Data requests are limited per link and visitor (rate_limited with Retry-After) – a busy visitor never blocks the link for others; above 120 views per minute and link the data is still served, just not counted. Answers are cached for a minute. An empty section carries empty (a sentence in German and English) and emptyKind: no_data, too_few, connector_missing or connector_expired.

A link can also point at one session recording instead of dashboard sections – created from the recording („Link teilen“, see recordings). It uses the same token format, password unlock, revoke and view counter, but has no sections, no ranges and no embedding.

EndpointPurpose
POST /v1/projects/{project}/recordings/{id}/shares{ "expiresInDays": 1 | 7 | 30 | null, "password"?: "…", "label"?: "…" } (default 7 days) → share, url, token (shown once), keptUntil; recordings:read + project:admin; preset_locked under the health preset
GET /v1/projects/{project}/recording-shares?recording={id}Links with label, expiry, password flag, active/revoked, views, lastViewedAt
POST /v1/projects/{project}/recording-shares/{id}/revokeEnd a link immediately (final)
GET /v1/shares/{token}/recordingPublic: the recording's meta, markers (kind and label) and a 60-second signed stage link; counts a view (once per visitor and 30 minutes) and writes recording.view (channel share) to the audit log

GET /v1/shares/{token} answers kind: "recording" for these links. The page plays on the stage origin (e.g. replay.x3growth.io) like in the dashboard; nothing about the visitor (session, visitor hash, attribution, country, leads, conversions) is ever part of the answer.

Embedding

<iframe src="https://<dashboard>/share/<token>?embed=1" title="Monatsbericht" width="100%" height="900" style="border:0" loading="lazy"></iframe>

?embed=1 renders the compact layout. The dashboard serves the share page with Content-Security-Policy: frame-ancestors 'self' <allowed origins>; without embedding it sends frame-ancestors 'none' and X-Frame-Options: DENY, so browsers refuse to show it inside any other page. Unlocked password sessions are kept in a partitioned, httpOnly cookie, so a password link also works inside an iframe.

With requireSignedToken every data request needs ?st=<exp>.<sig>, created on your server for each page view: exp is the expiry in Unix seconds (at most 7 days ahead), sig the base64url HMAC-SHA256 of <share id>.<exp> with the share's signing secret. Requests without a valid, unexpired token answer embed_not_allowed (with reason: missing, malformed, expired, too_long or bad_signature) – the copied link alone shows nothing, which suits internal portals.

import { createHmac } from "node:crypto";

export function embedToken(shareId: string, secret: string, ttlSeconds = 3600): string {
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
  const sig = createHmac("sha256", secret).update(`${shareId}.${exp}`).digest("base64url");
  return `${exp}.${sig}`;
}