Attribution and UTM¶
How Kwilo captures, carries, and persists marketing attribution from the first
ad click through signup. Modeled on Instaffo's MarketingParams /
User::WithMarketingParams (a separate Rails app), adapted to the
site/web/backend split.
Read this before touching anything that builds a cross-app link, adds a signup path, or stores acquisition data. The model is a contract across three layers; changing one side without the others silently loses attribution.
The model¶
Two namespaces, kept separate on purpose:
- External attribution — what brought the visitor to the site. Standard UTM plus ad-network click IDs. Captured dual-touch (first + last).
- Internal attribution — what on-site action converted them. Tags the cross-app handoff (site CTA to app signup) so an organic visitor who clicks "Generate" is not recorded as blank-source. Never overwrites external UTM.
Dual-touch matters: first-touch answers "which channel acquired them" (ROI), last-touch answers "which click closed them" (conversion lift). Keep both.
Keys¶
External, captured on every arrival (the single frontend source of truth is
apps/site/src/lib/attribution.ts ATTRIBUTION_PARAM_KEYS):
Internal, appended on every site to app action:
internal_utm_source the page or surface e.g. individuals
internal_utm_medium the CTA location e.g. individuals_hero (CTA_LOCATIONS)
internal_utm_campaign the CTA name or action e.g. generate (CTA_NAMES)
Plus referrer and landing_url (context, not a UTM).
Add a key only deliberately, in all three places at once (see "Adding a key").
The rules¶
-
Every cross-app action carries a fitting attribution. Any link or handler that sends a visitor from
kwilo.aitoapp.kwilo.aiMUST forward the external attribution (appendAttribution) AND append a fittinginternal_utm_*describing the surface and CTA. No bare/signuplinks from the marketing site. Reuse the existingCTA_NAMES/CTA_LOCATIONStaxonomy for the internal values, do not invent ad-hoc strings. -
Attribution lives on the user, defined once. Last-touch external UTM are flat columns; first-touch is the
first_utm_paramsJSONB column; internal UTM are flat columns;referreris folded intofirst_utm_params. All of it comes from one SQLAlchemy mixin (MarketingParamsMixin). Any new conversion surface (a second signup flow, a lead form) reuses the mixin. Never hand-roll attribution columns on a new table again (thedemo_requeststable predates the mixin and should adopt it). -
Three key lists stay in lock-step. The site const, the web reader, and the backend const must list the same keys. They are mirrors, not independent definitions.
-
External and internal never clobber each other. A site CTA sets
internal_utm_*; it does not touchutm_sourceetc. The external source stays whatever the ad/referrer set.
Where each piece lives¶
| Layer | File | Role |
|---|---|---|
| Site capture | apps/site/src/lib/attribution.ts |
Reads URL params, writes the kwilo_utm cookie (.kwilo.ai scope, dual-touch), ATTRIBUTION_PARAM_KEYS source of truth, appendAttribution / buildAttributionPayload |
| Site handoff | apps/site/src/pages/Individuals/signupUrl.ts + useSignupHandler.ts |
Builds the signup URL with intent + external UTM + internal_utm_* |
| Web reader | apps/web/src/features/signup/attribution.ts |
Reads the same kwilo_utm cookie, builds the AttributionPayload sent on register |
| Web parse | apps/web/src/features/signup/intent.ts |
Parses attribution off the /signup URL (URL first, cookie fallback) |
| Web send | SignupPage/components/{EmailTab,PhoneTab,GoogleContinueButton}.tsx |
Attaches attribution to the register payload / Google intent JSON |
| Backend keys | apps/backend/src/core/marketing_params.py |
Backend mirror of the key lists, is_accepted |
| Backend model | apps/backend/src/models/mixins/marketing_params.py |
MarketingParamsMixin: columns + first_utm_params JSONB + attribution_click_id offline-conversion helper |
| Backend schema | apps/backend/src/schemas/attribution.py |
AttributionTouch / AttributionPayload (shared; demo_request re-exports) |
| Backend persist | apps/backend/src/services/attribution.py |
apply_attribution(model, payload) — the single writer, shared by email + Google paths |
Data flow¶
ad click (utm_* in URL)
-> apps/site captureAttribution() freezes first-touch, updates last-touch in kwilo_utm cookie (.kwilo.ai)
-> visitor clicks a site CTA
buildSignupUrl appends: intent (as/mode/topic/tier) + external UTM (appendAttribution) + internal_utm_* (from the CTA)
-> app.kwilo.ai/signup
intent.ts parses URL (cookie fallback); attribution.ts reads kwilo_utm
-> register payload (email/phone) or Google intent JSON carries AttributionPayload {first,last,internal_utm,referrer,landing_url}
-> backend register endpoint -> apply_attribution(user, payload)
last-touch + internal_utm -> flat columns; first-touch + referrer -> first_utm_params JSONB
-> persisted on users; exposed via GET /auth/me
The cross-domain cookie scope (.kwilo.ai) is what lets attribution survive the
kwilo.ai to app.kwilo.ai hop. Local dev uses .kwilo.local; standalone dev
sets no domain. Do not narrow the scope.
Adding a key¶
A new attribution key (say a new ad network's click ID) touches, in one change:
apps/site/src/lib/attribution.tsATTRIBUTION_PARAM_KEYSapps/webshared reader key list (imported from one place)apps/backend/src/core/marketing_params.pyAttributionTouchschema field- A migration adding the column to
users(nullable, additive, no backfill)
If you add it in fewer than all five, the key is captured somewhere and dropped somewhere else. That is the failure mode this doc exists to prevent.
Migration safety¶
Attribution columns are always nullable, additive, no backfill, no NOT NULL. Staging and prod share one Cloud SQL instance (isolated at DB level), so a migration runs against both. Existing rows carry NULLs until the user next signs up via a tracked URL.
Reference¶
Instaffo's app: lib/marketing_params.rb (the central constant) and
app/models/concerns/user/with_marketing_params.rb (the on-user concern with
the offline-conversion click-id rule). Kwilo mirrors the shape, not the Ruby.