when someone subscribes to your SaaS, you want to know: did they find you on HN? from that reddit post? organic google search? a blog post you wrote?
this guide shows you how to track that using UTM parameters and Stripe metadata. accuracy: 95%+ (vs 60-70% for mobile apps).
how it works:
add UTM parameters to all marketing links, capture UTM when user lands on your site, save to Stripe customer metadata on checkout, query Stripe to see customers by source.
step 1: add UTM parameters:
see UTM parameters guide for complete reference. quick examples: reddit is https://yourapp.com?utm_source=reddit&utm_campaign=launch, twitter is https://yourapp.com?utm_source=twitter&utm_campaign=launch, email is https://yourapp.com?utm_source=email&utm_campaign=weekly.
step 2: capture on landing page:
simple version using cookies:
const params = new URLSearchParams(window.location.search);
const source = params.get('utm_source') || 'direct';
const campaign = params.get('utm_campaign') || 'none';
// save 30 days
document.cookie = `utm_source=${source}; max-age=2592000; path=/`;
document.cookie = `utm_campaign=${campaign}; max-age=2592000; path=/`;why 30 days? user might see ad today, subscribe next week.
step 3: save to Stripe metadata:
when creating Stripe customer:
// read from cookie
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
return parts.length === 2 ? parts.pop().split(';').shift() : null;
}
// on checkout
const source = getCookie('utm_source');
const campaign = getCookie('utm_campaign');
const customer = await stripe.customers.create({
email: 'user@example.com',
metadata: {
utm_source: source || 'direct',
utm_campaign: campaign || 'none',
signup_date: new Date().toISOString(),
}
});step 4: query attribution:
using Stripe API:
const customers = await stripe.customers.list({ limit: 100 });
const bySource = {};
customers.data.forEach(c => {
const source = c.metadata.utm_source || 'unknown';
bySource[source] = (bySource[source] || 0) + 1;
});
// { reddit: 45, twitter: 23, email: 67, google: 12 }using SQL (if syncing Stripe to database):
SELECT
metadata->>'utm_source' as source,
metadata->>'utm_campaign' as campaign,
COUNT(*) as customers,
SUM(mrr_cents) / 100 as total_mrr
FROM stripe_customers
WHERE created_at >= '2024-01-01'
GROUP BY source, campaign
ORDER BY total_mrr DESC;example results: reddit launch brought 45 customers ($2,250 MRR), email weekly brought 23 customers ($1,610 MRR), twitter launch brought 12 customers ($600 MRR).
see customers by source:
once you know customers per source, you can see which channels work. hackernews brought 28 customers (Show HN posts), reddit brought 15 (/r/SideProject, /r/productivity), direct brought 12 (word of mouth, bookmarks), twitter brought 5 (occasional mentions), blog brought 8 (grows over time from SEO).
server-side tracking (better):
client-side cookies can be blocked by ad blockers. server-side bypasses this.
Cloudflare Worker example:
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
// capture server-side
const sessionId = crypto.randomUUID();
const attribution = {
utm_source: url.searchParams.get('utm_source') || 'direct',
utm_campaign: url.searchParams.get('utm_campaign') || 'none',
referrer: request.headers.get('referer') || 'direct',
};
// store in database
await env.DB.prepare(`
INSERT INTO sessions (id, utm_source, utm_campaign, created_at)
VALUES (?, ?, ?, ?)
`).bind(sessionId, attribution.utm_source, attribution.utm_campaign, Date.now()).run();
// set session cookie
const response = await fetch(request);
response.headers.set('Set-Cookie', `session=${sessionId}; Max-Age=2592000`);
return response;
}
}on checkout, lookup session:
const session = getCookie('session');
const attribution = await getSessionAttribution(session);
const customer = await stripe.customers.create({
metadata: attribution
});benefits: bypasses ad blockers, 95%+ accuracy (vs 70% client-side), can't be deleted by user.
common mistakes:
cookie expiry too short: use 30 days, not 1 day. users take time to convert.
not tracking organic: default to utm_source=direct for users without UTM. shows organic vs paid split.
forgetting to save to Stripe: capturing UTM is useless if you don't attach to customer record.
inconsistent naming: use lowercase, hyphens. stick to convention. ✅ utm_source=google-ads. ❌ utm_source=GoogleAds, Google_Ads.
20-minute setup:
add UTM to links (5 min): use UTM guide for format, create tracking URLs for each channel.
capture on site (5 min): add cookie script to landing page, test with ?utm_source=test.
save to Stripe (5 min): add metadata to customer creation, verify in Stripe dashboard.
verify (5 min): create test customer with UTM, check Stripe → customer → metadata, should see utm_source field.
example: privacy email app:
developer runs privacy-focused email app. $50/month subscription.
before tracking: got 20 customers total, no idea where they came from, kept posting everywhere.
after UTM tracking: reddit /r/privacy brought 12 customers (best channel), HN brought 5 customers (high quality, stay long), twitter brought 2 customers (low conversion), blog post brought 1 customer (just published, will grow from SEO).
what changed: focused on reddit privacy community (where target users are), posted on HN every few months with updates, reduced random twitter posts, kept writing blog content (compounds over time).
knowing where customers came from helped focus effort on what works.
summary:
web attribution is simple: add UTM to links (guide), capture in cookie (30 days), save to Stripe metadata, query by source.
accuracy: 95%+ (much better than mobile). cost: free (no third-party tools needed). time: 20 minutes to set up.
see also: UTM parameters guide, attribution index, SaaS unit economics