Tracked Links & Analytics
The analytics module answers “which channel actually converted?” — a facebook group, a newsletter, a specific post — by minting a per-source tracked link, redirecting the visitor through it, and rolling up visits vs. paid conversions per source.
The funnel has three hops:
tracked link (?s=<source>) → your landing page (?s= forwarded) → paywall (e.g. HelloAsso)1. Mint a tracked link
Section titled “1. Mint a tracked link”From /analytics in the dashboard, or via the SDK:
import { PlatformApiClient } from '@viite-ai/platform-sdk';
const client = new PlatformApiClient({ baseUrl, sessionToken });const link = await client.analytics.createLink({ label: 'Summer Fest', targetUrl: 'https://your-event-site.example.com', // your landing page, NOT the paywall directly});targetUrl should be your landing page — the page a social-media click should land on — not the
payment link itself. The landing page is the one that has the “book now” / “ticketing” call to action
linking onward to the paywall.
2. Share a per-source URL
Section titled “2. Share a per-source URL”Each channel gets its own share URL built from the link’s slug:
https://go.viite.ai/<slug>?s=fb-groupAThat short form is what the dashboard’s copy button, the MCP create_tracked_link tool and a
published post’s first comment all hand you. The long form is the same link and keeps working
forever, so anything already shared stays valid:
https://<api host>/api/analytics/l/<slug>?s=fb-groupAA slug is a short opaque key (k3f9qa), not your link’s label — the label is editable and the slug
is not, so a slug derived from it would be a name that silently stops matching what it names. Pass
slug explicitly when you want a readable one.
s (or the utm_source alias) is whatever label you want for that channel — a facebook group name,
newsletter, etc. Every hit records a source-attributed visit, then 302-redirects to targetUrl
with the same ?s= appended — the landing page receives the exact source the visitor arrived
with.
Why a query param and not the Referer header? Referer is unreliable across a landing page we
don’t run infra for: many in-app browsers (Facebook’s especially) strip or rewrite it, it’s governed
by whatever Referrer-Policy the landing page’s own host sets, and it’s inconsistent across a
redirect chain. A query param is explicit and works on any static host.
3. Forward the source from your landing page
Section titled “3. Forward the source from your landing page”Your landing page needs to read ?s= (or utm_source) from its own URL and pass it along to its
outbound paywall link, so the attribution survives that second hop too. A minimal vanilla-JS snippet,
dropped once into your page layout, covers every outbound link to a fixed paywall URL:
<script> const params = new URLSearchParams(location.search); const source = params.get('s') || params.get('utm_source'); if (source) { document.querySelectorAll('a[href^="https://www.helloasso.com"]').forEach((a) => { const url = new URL(a.href); url.searchParams.set('s', source); a.href = url.toString(); }); }</script>Adjust the selector to match your own paywall’s URL prefix.
4. Report a conversion
Section titled “4. Report a conversion”The redirect appends two params to your landing page URL: ?s=<source> (the traffic source) and
?lk=<slug> (which tracked link the visitor came through). When someone converts — signs up, submits
a form, whatever counts for you — post both back:
<script> const params = new URLSearchParams(location.search); const linkSlug = params.get('lk'); if (linkSlug) { fetch('https://platform-api.viite.ai/api/analytics/beacon/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ linkSlug, source: params.get('s'), // Optional: any stable id for this conversion. Makes a retry idempotent // across processes, which the 30-second in-memory dedupe window is not. externalId: newAccountId, }), }); }</script>The beacon needs no authentication and always answers 202 — it sits in front of your signup flow, so
a failure on our side must never surface as an error in yours. It records the conversion against the
org that owns the link, resolved from the slug: there is no way to name an org in the request, so
nobody can write into your funnel by guessing an id.
Same guards as the redirect: crawler user-agents are ignored, and repeats from the same visitor inside 30 seconds count once.
Which CTA produced the signup (placement, playbook)
Section titled “Which CTA produced the signup (placement, playbook)”A signup can also say which button on viite.ai produced it — the placement of the site’s
signup_cta_clicked event (hero, usecase:<slug>, usecase-hub:<slug>, usecase-page:<slug>,
usecase-page-footer:<slug>, …) — and which onboarding playbook it came to start. Both ride the
same attribution as ?lk=/?s=: the signup URL carries ?placement=<placement>&playbook=<slug>, the
dashboard keeps them for the signup, and they reach the signup row two ways:
- On the sign-up request itself (the dashboard’s email form, and viite.ai’s Google One Tap, send
them as the request’s query string). The server then stamps them on the
signed_upevent and on the PostHogsigned_upevent the moment the account exists. - Through the beacon, for a redirect sign-in (Google) that cannot carry a query. Pass
placementand/orplaybookin the body. With nolinkSlugthe beacon fills them on the signup row the server already wrote (platform org only), keyed byexternalId. It never inserts a signup, so it cannot be used to add one to a funnel; with alinkSlugit behaves exactly as above.
A placement is checked against ^[a-z0-9][a-z0-9:_-]{0,79}$ and a playbook against the catalog the
product can start; anything else is dropped, never stored. The same two properties are on the
PostHog signed_up (when the request carried them), playbook_started and activated events.
4b. Or install the pixel once, instead of a link per post
Section titled “4b. Or install the pixel once, instead of a link per post”Steps 1–4 need a tracked link for every place you post, because the slug is doing two jobs at once: it says which campaign and it says whose funnel. The pixel splits those apart, so only the second one needs setting up — once.
From /analytics?tab=pixel, click Create pixel. You get a public site key (pk_…) and two
snippets. Put one on the pages you send traffic to:
<!-- recommended: reads the page URL and the referring site --><script async src="https://platform-api.viite.ai/api/analytics/px/v.js" data-key="pk_…"></script>
<!-- no JavaScript: works in an email or a locked-down page --><img src="https://platform-api.viite.ai/api/analytics/px?k=pk_…" alt="" width="1" height="1" />After that, any URL you share is attributable. ?utm_source=reddit is read as the source; so is
?s=reddit; a Google or Facebook ad click (gclid / fbclid) is read as its network; and a plain
link with no parameters at all is attributed to the site that linked to you, from the referrer. A
visit from one of your own pages to another is not a source — that is navigation, not acquisition.
The site key is public, the way a Meta pixel id is. It ships in the HTML of every page you put it on. It names your workspace and grants no access to it, so there is nothing to protect — but if a snippet ends up somewhere it should not be, Rotate issues a new key and stops the old one reporting. Every page still carrying the old snippet stops being measured until you paste the new one in.
Did it work? The tab tells you when the pixel last recorded a page view. That is the only honest check: a pixel is a 1×1 image that answers OK to everything, on a page we do not control.
What it does and does not do for privacy
Section titled “What it does and does not do for privacy”Visitors are counted with no cookie and nothing stored on their device, so a site carrying this needs no consent banner it did not already need. A visitor is recognised by a daily-rotating, salted, one-way hash of their IP address, browser and your workspace — which means two things worth knowing: the identity expires every night (so there is no multi-day first-touch attribution), and people sharing one connection and one browser are counted as one visitor.
5. Read the report
Section titled “5. Read the report”/analytics shows a per-source table — page views, link visits, signups, signup rate, paid,
conversion, revenue — for a date range and optional link filter (both URL-addressable, so a shared
link reproduces the exact view):
const report = await client.analytics.report({ from, to, slug });// report.sources: Array<{ source, pageViews, reachedPaywall, signedUp, paid, signupRate, paidRate, revenueCents }>Page views and link visits are two different counts, and adding them up double-counts. One person clicking a tracked link onto a page that carries the pixel is recorded in both — once by the redirect, once by the pixel. They are two independent measurements of the same visit, which is why they get two columns and why the signup rate is measured against link visits alone.
Rank channels on signupRate, not on visits. A channel can deliver most of your traffic and none
of your accounts; the visit count alone can’t tell you which one you’re looking at.
Signups by CTA placement and by playbook
Section titled “Signups by CTA placement and by playbook”report.placements and report.playbooks cut the same signups by the viite.ai CTA that produced
them and by the onboarding playbook they came to start:
report.placements; // [{ placement: 'usecase:inbox-triage', signedUp: 2 }, { placement: 'unknown', signedUp: 5 }]report.playbooks; // [{ playbook: 'inbox-triage', signedUp: 2 }, { playbook: 'unknown', signedUp: 5 }]They sit beside sources and totals, which are unchanged; an API that predates them omits the
fields, so read report.placements ?? []. A signup with no value — every signup recorded before this
existed, and any that came through no CTA — is in the unknown bucket rather than dropped, so each list
adds up to totals.signedUp (for the same window). They are counts: the other half of a rate, clicks on
the CTA, is the site’s PostHog signup_cta_clicked{placement}, so rank a placement by signups ÷ clicks
there, never by clicks alone. The same cuts come back from the get_funnel_report tool.
Direct / organic, and why the rate ignores it
Section titled “Direct / organic, and why the rate ignores it”Every signup is recorded, including the ones that arrived through no tracked link — typed the URL, found you in search, clicked a link that dropped the attribution. Those sit in the Direct / organic row, which has no visits by construction and therefore no rate of its own.
totals.signupRate divides only the attributed signups by visits. Counting the direct ones
against a visit that never happened would report a conversion your links did not produce — a rate
whose numerator and denominator describe different populations. totals.signedUp is everyone;
totals.signedUpAttributed is the subset a link can be credited with.
A link that can’t convert is not a broken funnel
Section titled “A link that can’t convert is not a broken funnel”Tracked links shows clicks and signups per link. A link pointing at your own site can convert; a
link pointing at someone else’s — a GitHub repo, a Product Hunt page — cannot, because the ?lk=
the redirect appends is discarded when the visitor lands there. Those links measure reach, and the
panel says so rather than leaving a 0% row to read as something broken.
If you want a link like that to convert, point it at a page you control that forwards the attribution onward (step 3 above).
Claude can read this too, if you’ve added the Claude connector —
create_tracked_link mints the links and get_funnel_report reads the funnel back.
Known limitation: HelloAsso paid events aren’t source-attributed yet
Section titled “Known limitation: HelloAsso paid events aren’t source-attributed yet”Register your org’s HelloAsso webhook (/api/analytics/webhooks/helloasso/:orgId, shown on
/analytics) so completed payments record a paid event. Today that only works with a plain
HelloAsso payment link — HelloAsso’s webhook payload doesn’t echo back arbitrary query params, so
paid events land in an unattributed bucket regardless of step 3 above. Source-attributed paid
requires switching to HelloAsso’s Checkout-Intent API (which sets metadata.source explicitly, and
is echoed back) — a larger, separate integration. Until then, this funnel gives you accurate
visits per source, signups per source (via the beacon in step 4), and landing-page
click-through; only paid-per-source stays aggregate-only. The signup stage exists precisely
because of this gap: it is the first conversion that can be attributed, since the source travels
with the visitor rather than arriving in a third party’s webhook.
