Skip to content

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):

utm_source  utm_medium  utm_campaign  utm_content  utm_term
gclid  fbclid  msclkid

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

  1. Every cross-app action carries a fitting attribution. Any link or handler that sends a visitor from kwilo.ai to app.kwilo.ai MUST forward the external attribution (appendAttribution) AND append a fitting internal_utm_* describing the surface and CTA. No bare /signup links from the marketing site. Reuse the existing CTA_NAMES / CTA_LOCATIONS taxonomy for the internal values, do not invent ad-hoc strings.

  2. Attribution lives on the user, defined once. Last-touch external UTM are flat columns; first-touch is the first_utm_params JSONB column; internal UTM are flat columns; referrer is folded into first_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 (the demo_requests table predates the mixin and should adopt it).

  3. 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.

  4. External and internal never clobber each other. A site CTA sets internal_utm_*; it does not touch utm_source etc. 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:

  1. apps/site/src/lib/attribution.ts ATTRIBUTION_PARAM_KEYS
  2. apps/web shared reader key list (imported from one place)
  3. apps/backend/src/core/marketing_params.py
  4. AttributionTouch schema field
  5. 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.