Arron Bennett, Founder of Bennett Financials
Most small SaaS teams can tell you which channel brings the most signups. Very few can tell you which channel brings the most revenue. That gap is what marketing attribution is for, and you don't need an attribution platform to close it. You need two exports and a join.
Signups are easy to count, so they end up running the budget. But a signup isn't revenue. Some channels bring lots of people who never pay. Others bring a few who pay a lot.
This post builds a first-touch attribution report in pandas for a hypothetical bootstrapped job board for travel nurses, where staffing agencies pay a monthly subscription to post jobs. It's at $39,000 of MRR, with four acquisition channels and no attribution at all. Every figure is example data.
Key takeaways
- Tag every signup with its first-touch UTM source and store it on the account, not just in your analytics tool.
- Join signups to paid invoices by account, and judge channels on revenue in a fixed window, not on signups.
- In this example, Google Ads brings the most signups and the least revenue per dollar spent. A newsletter sponsorship brings the fewest signups and the most.
- Count untagged signups. If a tenth of your accounts have no source, fix tracking before you move budget.
What is marketing attribution for a small SaaS team?
Marketing attribution is working out which acquisition channel gets the credit for each customer, so you can compare channels on what they bring in. For a small team, first-touch attribution is enough to start: record the UTM source on the first visit that led to a signup, keep it on the account, and later join it to revenue.
There are fancier models: last touch, linear, time decay. They answer questions a team at $39,000 of MRR usually isn't asking yet. The question here is simpler: of the money we spend on each channel, which one comes back as paying customers?
UTM parameters are the plumbing. Google's guide to campaign URLs says to always set utm_source, utm_medium and utm_campaign. For this report, utm_source is the one that matters, and it has to land in your database, not just in an analytics dashboard.
How do you capture the first-touch UTM source?
Read the UTM parameters when a visitor first lands, store them in a first-party cookie or local storage, and write them onto the account record when they sign up. Don't overwrite them on later visits. Then every account carries its first-touch source, and you can join it to billing data whenever you like.
A few rules that save a lot of cleanup later:
- Use one spelling per channel. "linkedin", "LinkedIn" and "li" will show up as three channels.
- Tag every link you control: ads, sponsorships, emails, social posts.
- Leave untagged signups as blank, not "direct". You want to see how many you're missing.
This short isn't about UTM tags. It's about diagnosing marketing with the wrong numbers: if you judge cost per acquisition and lifetime value before pricing and cost of revenue are settled, every marketing metric moves under you. Attribution has the same trap. Judge channels on signups and you're reading the wrong number.
How do you tie signups to revenue in pandas?
Export signups with their account ID, signup date and first-touch source. Export paid invoices with account ID, paid date and amount. Merge them on account ID, keep only invoices paid within 90 days of signup, and group by source. Add each channel's spend for the same period, and you can compare channels on revenue, not signups.
The 90-day window matters. Without it, older channels look better simply because their customers have had longer to pay. With it, every channel gets the same time to prove itself.
The sample data is inline so you can paste it and run it. You'll need pandas.
import io import pandas as pd signups = pd.read_csv(io.StringIO("""account_id,signup_date,utm_source a101,2026-07-03,google_ads a102,2026-07-08,linkedin a103,2026-07-11,google_ads a104,2026-07-15,newsletter a105,2026-07-19,google_ads a106,2026-07-22,seo a107,2026-07-28, a108,2026-08-02,google_ads a109,2026-08-05,seo a110,2026-08-09,linkedin a111,2026-08-14,google_ads a112,2026-08-18,newsletter a113,2026-08-21,linkedin a114,2026-08-27,google_ads a115,2026-08-30,seo a116,2026-09-02, a117,2026-09-05,google_ads a118,2026-09-09,newsletter a119,2026-09-12,linkedin a120,2026-09-16,google_ads a121,2026-09-20,seo """), parse_dates=["signup_date"]) invoices = pd.read_csv(io.StringIO("""account_id,paid_date,amount a103,2026-07-25,149 a104,2026-07-29,499 a103,2026-08-25,149 a104,2026-08-29,499 a106,2026-08-05,299 a107,2026-08-11,299 a110,2026-08-23,299 a103,2026-09-25,149 a104,2026-09-29,499 a106,2026-09-05,299 a107,2026-09-11,299 a110,2026-09-23,299 a114,2026-09-10,149 a115,2026-09-13,149 a118,2026-09-23,299 a104,2026-10-29,499 a106,2026-10-05,299 a107,2026-10-11,299 a110,2026-10-23,299 a114,2026-10-10,149 a115,2026-10-13,149 a118,2026-10-23,299 """), parse_dates=["paid_date"]) spend = {"google_ads": 4500, "linkedin": 2700, "newsletter": 1800, "seo": 1200} # Jul-Sep signups["utm_source"] = signups["utm_source"].fillna("(no utm)") paid = invoices.merge(signups, on="account_id") paid = paid[paid["paid_date"] <= paid["signup_date"] + pd.Timedelta(days=90)] by = signups.groupby("utm_source").size().to_frame("signups") by["paying"] = paid.groupby("utm_source")["account_id"].nunique() by["revenue"] = paid.groupby("utm_source")["amount"].sum() by = by.fillna(0).sort_values("revenue", ascending=False, kind="stable") print(f"{'channel':<12}{'signups':>8}{'paying':>8}{'rev_90d':>9}{'spend':>8}{'cost/payer':>12}{'rev/$':>7}") for ch, r in by.iterrows(): sp = spend.get(ch, 0) cpp = f"{sp / r.paying:,.0f}" if sp and r.paying else "-" rps = f"{r.revenue / sp:.2f}" if sp else "-" print(f"{ch:<12}{r.signups:>8.0f}{r.paying:>8.0f}{r.revenue:>9,.0f}{sp:>8,}{cpp:>12}{rps:>7}") untagged = (signups["utm_source"] == "(no utm)").mean() print(f"\ntotal: {len(signups)} signups, {paid['account_id'].nunique()} paying, " f"{paid['amount'].sum():,} in first-90-day revenue, {untagged:.0%} of signups untagged") Output:
channel signups paying rev_90d spend cost/payer rev/$ newsletter 3 2 2,095 1,800 900 1.16 seo 4 2 1,195 1,200 600 1.00 (no utm) 2 1 897 0 - - linkedin 4 1 897 2,700 2,700 0.33 google_ads 8 2 745 4,500 2,250 0.17 total: 21 signups, 8 paying, 5,829 in first-90-day revenue, 10% of signups untagged Revenue here is invoices paid in each account's first 90 days. Cost per payer is the channel's spend for the quarter divided by the accounts from that channel that paid at least once. Notice one invoice is missing from the totals: an account's fourth payment landed on day 106, outside the window.
What does the attribution report say about this business?
Google Ads brings the most signups and the least revenue for its spend. It produced 8 of the 21 signups but only $745 of first-90-day revenue on $4,500 of spend, 17 cents per dollar. The newsletter sponsorship produced 3 signups and $2,095 on $1,800, $1.16 per dollar. Ranked by signups, the budget runs backwards.
The detail behind it is common. Google Ads pulls in individual recruiters on the cheapest plan, and many never post a job. The newsletter reaches agency owners who buy the bigger plans. SEO sits in the middle: $1,195 on $1,200 of content spend, and that content keeps working next quarter.
Two more things jump out. LinkedIn costs $2,700 per paying account, the highest of the four. And 10% of signups have no UTM source at all, one of which became an $897 customer. Until that bucket shrinks, every channel's numbers are understated.
In total, the quarter's $10,200 of spend is about $3,400 a month, about 8.7% of MRR, and it brought 8 paying accounts. If you want context on how much to spend in the first place, I've collected research on marketing spend as a share of revenue.
What should you change in the budget?
Move money slowly, toward the channels that bring revenue per dollar, and test before you cut. Shift part of the Google Ads budget to a second newsletter sponsorship for one quarter, keep SEO going, and fix the untagged signups. Then rerun the same report on the next quarter's cohort before moving more.
A few cautions before you act on a report like this:
- Small samples lie. A handful of paying accounts per channel is a signal to test, not proof. Run it every quarter and look for the pattern holding.
- First touch isn't the whole story. A customer might find you on Google and decide after a newsletter. First touch credits Google. That's fine for a start; just know it.
- Revenue isn't profit. If one channel brings customers with much higher hosting or support costs, look at gross margin at the company level too.
- Watch retention by channel. Join the same table to cancellations later, and you'll see which channels bring customers who stay.
What should you do on Monday?
Check that every signup in your database has a first-touch source field. If it doesn't, add one this week and start capturing it. Then export last quarter's signups and paid invoices, paste them into the script, add each channel's spend, and read the table. Write down one budget test for next quarter.
FAQ
What is first-touch attribution?
It gives full credit for a customer to the first channel that brought them to your site. It's simple, stable and good enough for a small team comparing a few channels.
Do I need an attribution tool for this?
Not at the start. If you store the UTM source on the account and can export invoices, a short script does the job. Tools help once you have many channels and campaigns.
How long should the revenue window be?
Long enough for a typical customer to pay a few times. Ninety days works for monthly plans. Use the same window for every channel so the comparison is fair.



