Skip to content

Where did that lead come from? Attribution that respects a no

My Base44 contact form saves where each lead came from with the message itself. Visitors who accepted analytics send the full trail: landing page, visits, first touch. Everyone else sends only that page load's referrer and campaign tags. Here's every field, the inbox card, lead ratings and how won deals feed back.

Aatman (AJ) Jain9 min readUpdated 4 Oct 2026
A wooden signpost with three blank arrows, one covered by washi tape like a privacy curtain, an envelope at its foot and a closed-eye sticker, on sky blue paper.
A wooden signpost with three blank arrows, one covered by washi tape like a privacy curtain, an envelope at its foot and a closed-eye sticker, on sky blue paper.

To see where a lead came from without tracking people who said no, save the attribution with the message itself and let consent decide how much goes in. On my Base44 site, a visitor who accepted analytics sends their whole trail with their message, and everyone else sends only what that page load already knew: the referrer and the campaign tags.

The question my inbox couldn't answer

Every message in my admin inbox had a name, an email, the page it came from and a browser. But when someone wrote in wanting an app built, I had no idea how they'd found me. A Google search? A LinkedIn post? A link someone dropped in Discord?

That matters more than it sounds. I've only got so many hours, and I want to spend them where the good conversations start.

The lazy fix was to track everyone the moment they press send. But I'd already built a cookie banner where Reject all actually means reject. Quietly tracking the people who clicked Reject all would have turned that banner straight back into decoration.

So here's the idea this whole build hangs on. A postmark, not a tail. A letter's postmark tells you where it was posted. It doesn't follow you home. Without consent, a message gets a postmark. With consent, it gets the travel diary.

Two shapes, one field

When someone presses send, the form calls getAttribution(). It returns one of two shapes, saved on the message.

The whole flow. Both lanes end in the same row, which is why the report later works for everyone.

Accepted analytics: the travel diary

  • Which session and visitor sent it (random IDs)
  • The page this visit landed on
  • Where this visit came from, plus the UTM tags
  • How they first found the site, and when
  • Visits so far, days since the first one, pages this visit

Said no: the postmark

  • The referrer this page load arrived with
  • Its category and network, like social and linkedin
  • The UTM tags on the address
  • No analytics storage read, nothing written
  • Gone from memory when the tab closes

Here's every field, and when it's saved:

FieldSavedWhat it is
ref_category, ref_networkalwaysthe channel and network, like social and linkedin
utmalwayssource, medium, campaign, content and term from the link
referreronly on a nothe referring address, cut to its origin and path
landingwith a yesthe first page of this visit
sid, vidwith a yesrandom IDs that point at the visit in analytics
first_touchwith a yeswhen, from where and on which page they first came
visitswith a yeshow many visits, this one included
days_since_firstwith a yeswhole days since that first visit
pages_this_sessionwith a yespages viewed on the visit that sent the message

Spot the odd one out? With consent, the message skips referrer. It doesn't need it: the full referrer already sits on the analytics session, and sid points straight at it.

On the Base44 side, it's two fields in the entity schema. An object field can define its properties. I didn't, on purpose, because attribution has two shapes:

jsonc
// base44/entities/ContactMessage.jsonc (the two new fields, descriptions trimmed)
"attribution": { "type": "object" },
"lead_quality": { "type": "string", "enum": ["unrated", "good", "bad", "won"], "default": "unrated" }

The entity's rules didn't change. It's a letterbox: anyone can create a message, and only an admin can read, update or delete one, attribution included. My row-level security glossary entry covers how those rules work.

This is the real function, trimmed:

js
// src/lib/analytics/attribution.js (trimmed)
export function getAttribution() {
  try {
    if (hasConsent('analytics') && localStorage.getItem(OFF_KEY) !== '1') {
      const v = read('localStorage', 'aj:visitor');
      const s = read('sessionStorage', 'aj:session');
      if (v?.vid && s?.sid) {
        return { sid: s.sid, vid: v.vid, landing: s.landing /* ...and 7 more */ };
      }
    }
    // No consent (or no session yet): this page load only. No analytics storage.
    const now = utmOf(window.location.search);
    const utm = hasUtm(now) ? now : utmOf(ENTRY.search);
    const { referrer, ref_category, ref_network } = classify(document.referrer, utm);
    return { referrer, ref_category, ref_network, utm };
  } catch {
    return null;
  }
}

Three details make the short shape work.

ENTRY is the address the page load started on, saved in a plain variable when the app boots. My site is a single-page app, so by the time someone reaches /contact, the UTM tags have gone from the address bar. The variable remembers them. It never touches storage, and it's gone when the tab closes.

document.referrer stays put for the whole page load, because moving around a single-page app isn't a new page load. And browsers already trim it. MDN says (opens in a new tab) the default policy, strict-origin-when-cross-origin, sends "the origin (only)" for cross-origin requests. So a visit from LinkedIn usually arrives as just https://www.linkedin.com/, not the post someone was reading. My cleanUrl() drops query strings and fragments anyway, just in case.

classify() turns that into a category and a network. A utm_source naming an AI tool wins over the referrer, and with no referrer at all, the UTM tags decide. No referrer and no tags means direct.

One edge case I'm fine with: accept analytics and send straight away, and the collector (it loads in an idle moment) may not have started yet. You get the short shape. When in doubt, it keeps less.

fields saved, with consent and without
10 → 4
analytics keys in storage after a no
0
gzipped attribution code, fetched only on send
1.7 KB

The test run

I didn't want to write this from memory, so I ran the production build locally with Playwright, every Base44 API call mocked so nothing left my machine. Same visit, twice: in from LinkedIn with UTM tags, landing on /work/hattle, then /about, then /contact, then send. The consent run also had an earlier visit from Google on 11 October (I faked the clock). Here's what the form actually sent.

Said no:

json
{
  "referrer": "https://www.linkedin.com/",
  "ref_category": "social",
  "ref_network": "linkedin",
  "utm": { "source": "linkedin", "medium": "social", "campaign": "lead-attribution", "content": null, "term": null }
}

Accepted:

json
{
  "sid": "b1c27631-20a7-407a-83a5-2e6c2c209a08",
  "vid": "dd4ea383-7730-49fa-b604-49e244cf29ce",
  "landing": "/work/hattle",
  "ref_category": "social",
  "ref_network": "linkedin",
  "utm": { "source": "linkedin", "medium": "social", "campaign": "lead-attribution", "content": null, "term": null },
  "first_touch": { "at": "2026-10-11T19:12:00.000Z", "ref_category": "search", "ref_network": "google", "utm_source": null, "utm_campaign": null, "landing": "/services" },
  "visits": 2,
  "days_since_first": 7,
  "pages_this_session": 3
}

After the no, the only thing in local storage was aj:consent, the choice itself. No aj:visitor, no aj:session. Both postmarks say LinkedIn and the same campaign. Only the yes knows they'd found me through Google a week earlier.

Want to feel it? Here's the sandbox from my cookie banner post. It's simplified (no Session recordings row, and its list shows Base44's own page-view stats, not my collector), but the Analytics switch is the same decision. Flip it and you've picked your message's shape.

Interactive demo

Consent lab: a mini cookie banner

A working copy of this site's cookie banner, with Accept all, Reject all and Choose, and switches for Analytics and Embedded content. A live list shows what each choice allows or blocks: Base44's page-view analytics, the base44_analytics_session_id it stores and the Cal.com booking calendar.

A Global Privacy Control switch shows the browser's signal overriding everything to reject. It is a sandbox: it never changes your real choice on this site.

Where it shows up: the inbox card

Every message in my inbox gets a "Where they came from" card. Here are both test messages:

My inbox with test data. Top: accepted analytics. Bottom: said no. Same postmark, very different diary.

The first-touch line only shows when it differs from this visit, so it only speaks up when there's a story. Without consent, the card says so in plain words: "They didn't accept analytics, so only the page's referrer and campaign were kept." The message list gets a short version too, like "via LinkedIn" and a "Good lead" badge.

Rating leads: unrated, good, bad, won

Attribution says where someone came from, not whether the conversation was any good. Only I know that, so I rate it.

lead_quality has four values. In the inbox they're Unrated, Good, Won and Not a fit. The stored value is bad, but I label it "Not a fit", because the person on the other end isn't bad. The project just wasn't right.

Under the pills they're real radio buttons, so arrow keys and screen readers behave. The save is optimistic: the pill changes at once, Base44 catches up, and a failure refreshes it back with an error toast. Pressing Won throws a tiny burst of confetti. Not needed. Kept anyway.

Won deals mark their messages

Rating by hand works, but "Won" really belongs to the pipeline. When I built a real CRM pipeline into my admin, I wired the two together.

When a client moves to Won or Delivered, every inbox message linked to them gets lead_quality: "won". The same happens when I create a client straight into Won, or link a new message to a client who's already won. It only writes messages that aren't won yet, a few at a time, retrying if Base44 says slow down.

One thing I spotted writing this up: Undo on a stage move put the client back, but their messages stayed Won. That's fixed now. Undo puts each message back to the rating it had before the move.

Leads by source: the join

My analytics dashboard has a "Lead quality by source" card. It loads the range's ContactMessage rows (only the fields it needs), leaves spam out, and groups them by channel and network: Google, LinkedIn, Perplexity, direct and so on. A message with no attribution, sent before any of this existed, lands in "Before tracking". The percentage is good or won out of the rated ones, so unrated messages don't drag it down.

The dashboard with generated test data, not real numbers. The right-hand card is the join.

My favourite part: the report only needs the postmark. Grouping by source uses the category, the network and the UTM tags, and both shapes carry all three. The diary is lovely when I'm reading one lead. The report doesn't need it at all.

The left-hand card counts lead events from analytics sessions, so it only sees people who said yes. The right-hand card sees everyone who wrote in. For "which sources send good leads?", that's the honest one.

Is this allowed?

My reasoning, not a ruling. The ICO says (opens in a new tab) the rules apply to "any technology that stores information, or accesses information stored, on a subscriber's or user's 'terminal equipment'." On a no, the attribution code stores nothing, and the only thing it reads is the consent choice itself. The referrer comes with the page request, the tags are in the address they clicked, and both are only saved if they send a message they chose to write.

It's also in my privacy notice, in plain words: the form saves "how you found the site: the site you came from and any campaign tags in the link", plus the extra fields only with a yes. The contact form runs on legitimate interests, because replying is the whole point.

Changing your mind works too. Withdraw analytics consent and aj:visitor and aj:session are deleted on the spot. A message you've already sent keeps its attribution, the same as it keeps your words, and you can ask me to delete the lot.

Steal this for your own Base44 app

  1. Add two fields

    An attribution object with no fixed properties, and a lead_quality enum that defaults to unrated.

  2. Lock the entity

    Anyone can create. Only an admin can read, update or delete.

  3. Remember the entry

    Save the address the page load started on in a plain variable, so UTM tags survive navigation. Memory only.

  4. Build it on send

    Consent and a session? Read your own storage. Otherwise use the referrer and UTM tags. Never throw.

  5. Cap it

    My form drops any attribution over 4,000 characters of JSON. The message still sends.

  6. Rate and join

    Rate leads where you read them, and group the ratings by channel and network.

  7. Say it out loud

    Put it in your privacy notice, both shapes.

Ask me in six months which source sends the best leads. It's early days. But when the answer turns up, it'll come from everyone who wrote in, not just the people who clicked yes. And if you've built this differently, tell me how.

FAQ

You can keep a minimal record of how one visit arrived: the referrer the browser sent and the UTM tags in the link, saved only with a message the person chose to send. My site stores nothing on the device for it, and only reads the consent choice itself. This isn't legal advice, so check your own situation.

What's the difference between first touch and last touch here?

The source on each message is the visit that sent it, the last touch. With analytics consent, the message also carries first_touch: when the visitor first came, from where, and the page they landed on. Without consent there's no first touch, because remembering it would mean storing something.

How does a won deal mark the lead as won?

In my admin, moving a client to Won or Delivered sets lead_quality to won on every inbox message linked to that client. The analytics dashboard then counts the win against the source in each message's attribution.

Their analytics storage, aj:visitor and aj:session, is deleted straight away, and nothing new is collected. Messages they already sent keep their attribution, and they can ask for those to be deleted.

Keep reading

More posts on the same things.

Newsletter

Liked this one?

Get my Friday email: new posts, what I built and the Base44 and AI news worth knowing.

No spam. Unsubscribe whenever.

Got an app in mind?

I build and fix Base44 apps, and I'm looking for a remote internship. Tell me what you're working on.