diff --git a/docs/renderer/consent-aware-attribution.md b/docs/renderer/consent-aware-attribution.md new file mode 100644 index 00000000..29ed62c6 --- /dev/null +++ b/docs/renderer/consent-aware-attribution.md @@ -0,0 +1,145 @@ +# Consent-Aware Attribution + +A recipe for capturing marketing attribution (`utm_*`, `gclid`, `fbclid`) from a form's URL and getting it to your +backend, without sending it anywhere until the visitor has consented. + +## What this is and isn't + +This is a host-side recipe, not a Formeo feature. Formeo has no concept of consent, attribution, or analytics, and +it sends nothing to anywhere on its own — every network call and every consent check below is code your app +provides. Nothing here names or requires a particular analytics vendor, tag manager, or CRM; adapt the allowlist and +the transport (Option A or Option B) to whatever your backend expects. + +Consent and retention are the host application's responsibility. Formeo only renders a `
`, reads +`renderer.userData`, and (optionally) holds attribution values in hidden fields you add to the form — it doesn't +decide whether capturing attribution is allowed, and it doesn't store or transmit anything by itself. + +## Consent first + +Read attribution parameters only after your own consent check passes. Formeo has no opinion on how consent is +tracked — a cookie banner, a consent management platform, a stored preference — so treat this as a stub you replace +with your app's real check: + +```javascript +// Provided by your app / consent management tooling. Formeo does not implement this. +function hasAttributionConsent() { + // e.g. return window.__consent?.marketing === true + return false +} +``` + +Nothing below should run before `hasAttributionConsent()` returns `true`. + +## An allowlist + +Read attribution only from an explicit allowlist of query parameters, never the whole query string. Keep values +non-empty and cap their length so a malformed or hostile URL can't stuff arbitrary data into your backend: + +```javascript +const ATTRIBUTION_ALLOWLIST = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'] +const MAX_ATTRIBUTION_VALUE_LENGTH = 200 + +function readAttribution(search = location.search) { + const params = new URLSearchParams(search) + const attribution = {} + for (const key of ATTRIBUTION_ALLOWLIST) { + const value = params.get(key) + if (value) { + attribution[key] = value.slice(0, MAX_ATTRIBUTION_VALUE_LENGTH) + } + } + return attribution +} +``` + +Call this only after `hasAttributionConsent()` is `true`: + +```javascript +const attribution = hasAttributionConsent() ? readAttribution() : {} +``` + +There are two ways to get `attribution` to your backend: add it at submit time (Option A), or store it in the +rendered form as hidden fields (Option B). Option A needs no form changes; Option B keeps attribution alongside the +rest of the submission if your backend only looks at one payload. + +## Option A: add it at submit + +Attach the attribution object to the payload yourself in `events.onSubmit` — no changes to the form's fields are +needed: + +```javascript +const renderer = new FormeoRenderer({ + renderContainer: '#formeo-renderer', + formData, + events: { + onSubmit: ({ event, userData }) => { + event.preventDefault() + const attribution = hasAttributionConsent() ? readAttribution() : {} + fetch('/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...userData, attribution }), + }) + }, + }, +}) +renderer.render() +``` + +`events.onSubmit` fires with `{ event, form, userData }` — `userData` is already `renderer.userData` read from the +form at submit time, so nothing else needs computing here. + +## Option B: store it in the form + +If your backend reads a single flat form POST, add a [Hidden field](../controls/README.md) for each allowlisted key +you want to capture, and give each one a `name` matching the allowlist exactly (the Hidden control has no default +`name` — set `attrs.name` on the field, either in the editor's Attributes panel or directly in the field's +`formData`): + +```javascript +fields: { + 'attribution-utm-source': { + id: 'attribution-utm-source', + tag: 'input', + attrs: { type: 'hidden', name: 'utm_source', value: '' }, + config: { label: 'utm_source', hideLabel: true }, + }, + // ...one per allowlisted key you want to capture +} +``` + +Fill them in `events.onSubmit`, not right after `render()`. Most consent banners are accepted after the page loads, +so a check at render time would see no consent and leave the fields empty. The submit handler runs before the browser +builds the POST body, so values set there go out with the native submit. + +Only fill fields the rendered form actually has. This keeps attribution scoped to fields the form author deliberately +added. It's also independent of how `renderer.userData`'s setter treats a key with no matching field: the setter +currently skips it and logs one console warning, but don't rely on that warning, and don't rely on it throwing, since +it never throws: + +```javascript +const renderer = new FormeoRenderer({ + renderContainer: '#formeo-renderer', + formData, + events: { + onSubmit: ({ form }) => { + if (!hasAttributionConsent()) return + const present = key => Boolean(form.elements[key]) + const attribution = readAttribution() + renderer.userData = Object.fromEntries(Object.entries(attribution).filter(([key]) => present(key))) + }, + }, +}) +renderer.render() +``` + +Filtering with `present()` before assigning means every key is guaranteed to match a field, so the assignment fills +exactly those hidden fields and nothing else. The filled hidden fields then go out with the rest of the form, whether +it's a native POST or your own `fetch(form.action, { method: 'POST', body: new FormData(form) })` in the same handler. + +## Retention and deletion + +How long attribution values are kept, where they're stored, and how a visitor's data is deleted on request are the +host application's policy — Formeo has no storage of its own to configure. Whatever you choose, don't keep more +than the allowlist above needs: attribution values only need to answer "where did this submission come from," not +build a profile of the visitor. diff --git a/docs/renderer/renderer.md b/docs/renderer/renderer.md index ae183e4b..22235db2 100644 --- a/docs/renderer/renderer.md +++ b/docs/renderer/renderer.md @@ -489,6 +489,12 @@ new FormeoRenderer({ Give the upload field a `name` attribute in the editor to control the key your server receives. +### Consent-aware attribution + +Capturing marketing attribution (`utm_source`, `gclid`, and similar) from the URL and adding it to `userData`, only +after the host app's own consent check passes, is a host-side recipe — see +[Consent-Aware Attribution](consent-aware-attribution.md). + ## Multi-page forms Each **stage** in `formData` is one page. Pass the `pagination` option to show them one at a time as tabs or a wizard, instead of all at once in a single ``. Navigation (tabs, the wizard's Previous/Next bar and step list, and page headings) only applies with **2 or more stages** — a single-stage form always renders as before, with no navigation added, except that a Submit button is still appended below it when `submit: true` (see [Options](#options) below).