Skip to content

Commit fac4ae2

Browse files
docs(renderer): add a consent-aware attribution recipe (#518)
* docs(renderer): draft a consent-aware attribution recipe Refs #485 * docs(renderer): render the form in the attribution recipe's submit example Refs #485 * docs(renderer): fill attribution fields at submit so late consent counts Refs #485
1 parent df87e86 commit fac4ae2

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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.

‎docs/renderer/renderer.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,12 @@ new FormeoRenderer({
489489

490490
Give the upload field a `name` attribute in the editor to control the key your server receives.
491491

492+
### Consent-aware attribution
493+
494+
Capturing marketing attribution (`utm_source`, `gclid`, and similar) from the URL and adding it to `userData`, only
495+
after the host app's own consent check passes, is a host-side recipe — see
496+
[Consent-Aware Attribution](consent-aware-attribution.md).
497+
492498
## Multi-page forms
493499

494500
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 `<form>`. 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).

0 commit comments

Comments
 (0)