Skip to content
Vitops
GuidesComponentsPackagesThemeReferenceChangelog

Start here

VitopsInstallationYour design systemYour config

Live preview

Theme previewAnimation libraryIconsPatterns

Components

OverviewCSS classesWeb componentsAstro componentsBricks elements

Packages

@getvitops/cli@getvitops/astro@getvitops/vite@getvitops/generator@getvitops/core@getvitops/utils@getvitops/emdash@getvitops/create

Reference

Config referenceOutput formatsColour systemType & space scalesComponent patternsIconsConsent gateConversion trackingSearchLegal documentsCSS class vocabularyBricks elements

Releases

Changelog

Conversion tracking

How an ad click becomes a notified conversion: the `_ac` cookie, consent-demanding capture, the pure notification planner, and the Cloudflare email channel.

Generated page. Rendered from this project’sdesign-system.json by @getvitops/generator — the same bundlevitops docs prints against your own config. Don’t edit it by hand.

Where each piece livesThe capture demands consentThe event is the abstractionThe email channelDisclosure

A visitor arrives on an ad carrying a click ID; <Tracking /> captures it into the first-party _ac cookie; when they later submit a form or tap a tel: link, createConversionRoute() reads the cookie back and notifies whoever the config names.

{
  "site": {
    "tracking": { "enabled": true, "category": "marketing" },
    "notifications": { "email": "leads@example.com" }
  }
}

A bare address is shorthand for { provider: "cloudflare", to }. The recipient otherwise falls back to the primary location’s email, and the sender to noreply@<domains.canonical>.

Where each piece lives

Layer Module Why there
Attribution vocabulary + cookie @getvitops/utils/tracking Needed on both sides of the wire
Plan / render / send @getvitops/utils/notify Pure planner, I/O sender
Capture script, <Tracking />, route factory @getvitops/astro Beside the analytics components

Both utils entries are separate subpaths because they are the only modules that run in a Worker rather than at build time. Keeping them off the package index is what stops a conversion endpoint pulling sharp into its bundle. Neither may use a Node builtin.

Import them from @getvitops/astro if that is your only direct dependency. @getvitops/astro/tracking re-exports everything in @getvitops/utils/tracking (plus TRACKING_ENDPOINT), so the flow works with one install — under strict pnpm, app code cannot resolve a transitive dependency, and @getvitops/utils would otherwise have to be added by hand. Do not reach for the same symbols on @getvitops/astro’s index instead: that entry pulls the integration and its Node builtins, which is the import that drags sharp toward a Worker bundle. @getvitops/astro/tracking and @getvitops/astro/routes are both clean.

⚠️ The route you mount must answer /api/track (TRACKING_ENDPOINT) — the capture script beacons tel: conversions there, and a route at any other path means every call conversion 404s silently. The integration warns at build when tracking is on and no such route exists.

The capture demands consent

_ac is a 90-day identifier tying a visitor to an ad, so it waits on marketing (override with site.tracking.category).

⚠️ The script calls require(), not granted() — require() is what raises the banner. A passive granted() here is a permanent no-op: nothing else on a page demands marketing, so it is never offered, never granted, and _ac is never written — silently, on every gated site. The integration adds marketing to the offered categories when tracking is on, so there is a row for the category the script will ask about.

Only an arrival that carried something asks. The demand is guarded on the URL actually holding a click ID or UTM, so an organic visitor — who has nothing to attribute — is never interrupted. That is demand-driven consent applied to attribution.

The capture is synchronous; only the write waits. Reading the query string is not storage and needs no permission; keeping it does. The click ID is in the URL only on the landing page, so deferring the read would lose it outright.

The marker element carries data-consent but deliberately not data-vitops-tag: the scan never tries to “activate” it (it is ungated by design), while the revoke path — which queries [data-consent="…"] — still finds it and clears _ac.

The event is the abstraction

ConversionEvent is the fact; how it reads belongs to the channel. That is what lets an SMS channel render 160 characters from the same event an email renders in full.

The plan is pure and says why anything is skipped. planNotifications touches no network and no binding, so a misconfigured site can be told exactly why no notification will arrive — a silently unsent conversion notification is indistinguishable from no conversion.

The email channel

Cloudflare Email Sending’s current binding — structured env.EMAIL.send({ to, from, subject, html, text }), not the legacy EmailMessage plus hand-built MIME. The binding is passed in, never imported, so utils takes no Cloudflare dependency.

Only transient codes are retried. E_SENDER_NOT_VERIFIED and friends are surfaced verbatim, because nothing here can check whether the sending domain was onboarded — run:

wrangler email sending enable <domain>

A generic “send failed” would hide the one thing worth knowing.

Only email is implemented. sms and persist are a planned seam (NotificationsConfig plus a sender with sendEmail’s signature); one channel is not enough to know what the abstraction should be.

Disclosure

_ac is disclosed by the generated cookie notice as a first-party cookie. It has to be stated explicitly: no provider table would ever name a first-party cookie, so a site running attribution alongside a cookieless analytics provider would otherwise be described as setting no cookies at all. See legal.md.

Built with the design system it documents.

@getvitops on npm