|
| 1 | +# Consent-Aware Attribution |
| 2 | + |
| 3 | +A recipe for capturing marketing attribution (`utm_*`, `gclid`, `fbclid`) from a form's URL and getting it to your |
| 4 | +backend, without sending it anywhere until the visitor has consented. |
| 5 | + |
| 6 | +## What this is and isn't |
| 7 | + |
| 8 | +This is a host-side recipe, not a Formeo feature. Formeo has no concept of consent, attribution, or analytics, and |
| 9 | +it sends nothing to anywhere on its own — every network call and every consent check below is code your app |
| 10 | +provides. Nothing here names or requires a particular analytics vendor, tag manager, or CRM; adapt the allowlist and |
| 11 | +the transport (Option A or Option B) to whatever your backend expects. |
| 12 | + |
| 13 | +Consent and retention are the host application's responsibility. Formeo only renders a `<form>`, reads |
| 14 | +`renderer.userData`, and (optionally) holds attribution values in hidden fields you add to the form — it doesn't |
| 15 | +decide whether capturing attribution is allowed, and it doesn't store or transmit anything by itself. |
| 16 | + |
| 17 | +## Consent first |
| 18 | + |
| 19 | +Read attribution parameters only after your own consent check passes. Formeo has no opinion on how consent is |
| 20 | +tracked — a cookie banner, a consent management platform, a stored preference — so treat this as a stub you replace |
| 21 | +with your app's real check: |
| 22 | + |
| 23 | +```javascript |
| 24 | +// Provided by your app / consent management tooling. Formeo does not implement this. |
| 25 | +function hasAttributionConsent() { |
| 26 | + // e.g. return window.__consent?.marketing === true |
| 27 | + return false |
| 28 | +} |
| 29 | +``` |
| 30 | + |
| 31 | +Nothing below should run before `hasAttributionConsent()` returns `true`. |
| 32 | + |
| 33 | +## An allowlist |
| 34 | + |
| 35 | +Read attribution only from an explicit allowlist of query parameters, never the whole query string. Keep values |
| 36 | +non-empty and cap their length so a malformed or hostile URL can't stuff arbitrary data into your backend: |
| 37 | + |
| 38 | +```javascript |
| 39 | +const ATTRIBUTION_ALLOWLIST = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'] |
| 40 | +const MAX_ATTRIBUTION_VALUE_LENGTH = 200 |
| 41 | + |
| 42 | +function readAttribution(search = location.search) { |
| 43 | + const params = new URLSearchParams(search) |
| 44 | + const attribution = {} |
| 45 | + for (const key of ATTRIBUTION_ALLOWLIST) { |
| 46 | + const value = params.get(key) |
| 47 | + if (value) { |
| 48 | + attribution[key] = value.slice(0, MAX_ATTRIBUTION_VALUE_LENGTH) |
| 49 | + } |
| 50 | + } |
| 51 | + return attribution |
| 52 | +} |
| 53 | +``` |
| 54 | + |
| 55 | +Call this only after `hasAttributionConsent()` is `true`: |
| 56 | + |
| 57 | +```javascript |
| 58 | +const attribution = hasAttributionConsent() ? readAttribution() : {} |
| 59 | +``` |
| 60 | + |
| 61 | +There are two ways to get `attribution` to your backend: add it at submit time (Option A), or store it in the |
| 62 | +rendered form as hidden fields (Option B). Option A needs no form changes; Option B keeps attribution alongside the |
| 63 | +rest of the submission if your backend only looks at one payload. |
| 64 | + |
| 65 | +## Option A: add it at submit |
| 66 | + |
| 67 | +Attach the attribution object to the payload yourself in `events.onSubmit` — no changes to the form's fields are |
| 68 | +needed: |
| 69 | + |
| 70 | +```javascript |
| 71 | +const renderer = new FormeoRenderer({ |
| 72 | + renderContainer: '#formeo-renderer', |
| 73 | + formData, |
| 74 | + events: { |
| 75 | + onSubmit: ({ event, userData }) => { |
| 76 | + event.preventDefault() |
| 77 | + const attribution = hasAttributionConsent() ? readAttribution() : {} |
| 78 | + fetch('/submit', { |
| 79 | + method: 'POST', |
| 80 | + headers: { 'Content-Type': 'application/json' }, |
| 81 | + body: JSON.stringify({ ...userData, attribution }), |
| 82 | + }) |
| 83 | + }, |
| 84 | + }, |
| 85 | +}) |
| 86 | +renderer.render() |
| 87 | +``` |
| 88 | + |
| 89 | +`events.onSubmit` fires with `{ event, form, userData }` — `userData` is already `renderer.userData` read from the |
| 90 | +form at submit time, so nothing else needs computing here. |
| 91 | + |
| 92 | +## Option B: store it in the form |
| 93 | + |
| 94 | +If your backend reads a single flat form POST, add a [Hidden field](../controls/README.md) for each allowlisted key |
| 95 | +you want to capture, and give each one a `name` matching the allowlist exactly (the Hidden control has no default |
| 96 | +`name` — set `attrs.name` on the field, either in the editor's Attributes panel or directly in the field's |
| 97 | +`formData`): |
| 98 | + |
| 99 | +```javascript |
| 100 | +fields: { |
| 101 | + 'attribution-utm-source': { |
| 102 | + id: 'attribution-utm-source', |
| 103 | + tag: 'input', |
| 104 | + attrs: { type: 'hidden', name: 'utm_source', value: '' }, |
| 105 | + config: { label: 'utm_source', hideLabel: true }, |
| 106 | + }, |
| 107 | + // ...one per allowlisted key you want to capture |
| 108 | +} |
| 109 | +``` |
| 110 | + |
| 111 | +Fill them in `events.onSubmit`, not right after `render()`. Most consent banners are accepted after the page loads, |
| 112 | +so a check at render time would see no consent and leave the fields empty. The submit handler runs before the browser |
| 113 | +builds the POST body, so values set there go out with the native submit. |
| 114 | + |
| 115 | +Only fill fields the rendered form actually has. This keeps attribution scoped to fields the form author deliberately |
| 116 | +added. It's also independent of how `renderer.userData`'s setter treats a key with no matching field: the setter |
| 117 | +currently skips it and logs one console warning, but don't rely on that warning, and don't rely on it throwing, since |
| 118 | +it never throws: |
| 119 | + |
| 120 | +```javascript |
| 121 | +const renderer = new FormeoRenderer({ |
| 122 | + renderContainer: '#formeo-renderer', |
| 123 | + formData, |
| 124 | + events: { |
| 125 | + onSubmit: ({ form }) => { |
| 126 | + if (!hasAttributionConsent()) return |
| 127 | + const present = key => Boolean(form.elements[key]) |
| 128 | + const attribution = readAttribution() |
| 129 | + renderer.userData = Object.fromEntries(Object.entries(attribution).filter(([key]) => present(key))) |
| 130 | + }, |
| 131 | + }, |
| 132 | +}) |
| 133 | +renderer.render() |
| 134 | +``` |
| 135 | + |
| 136 | +Filtering with `present()` before assigning means every key is guaranteed to match a field, so the assignment fills |
| 137 | +exactly those hidden fields and nothing else. The filled hidden fields then go out with the rest of the form, whether |
| 138 | +it's a native POST or your own `fetch(form.action, { method: 'POST', body: new FormData(form) })` in the same handler. |
| 139 | + |
| 140 | +## Retention and deletion |
| 141 | + |
| 142 | +How long attribution values are kept, where they're stored, and how a visitor's data is deleted on request are the |
| 143 | +host application's policy — Formeo has no storage of its own to configure. Whatever you choose, don't keep more |
| 144 | +than the allowlist above needs: attribution values only need to answer "where did this submission come from," not |
| 145 | +build a profile of the visitor. |
0 commit comments