Revenue attribution for SaaS: trace each payment to a channel
Revenue attribution joins a visit, a sign-up, a billing customer and a payment so a SaaS founder can see which marketing channel brings paying customers.
Revenue attribution for a SaaS is the practice of tying every payment your billing provider records to the marketing channel that first brought the person who paid. Not the click, not the sign-up, the money. You can then open a table with one row per channel and read what Google Ads customers paid you this month and what Product Hunt customers paid you, each after refunds and processor fees.
The problem is that nothing in a normal founder's stack does this join. Google Ads and Meta Ads count their own clicks and conversions. Web analytics counts visits, sessions and goals. Stripe, Razorpay or Paddle counts money, with a customer id and an email and nothing about where that customer came from. Each tool is accurate about what it measures, and none can answer the question that decides next month's budget: which marketing channel brings paying customers.
So founders guess. They look at the channel with the most sign-ups and assume it also has the most customers, or they export three CSVs into a spreadsheet once a quarter. This guide explains what a real join needs, why first-touch attribution is the right default for a small SaaS, how to set it up with ProductLense in an afternoon, and how to read the numbers.
What revenue attribution has to join
Four records exist in four places. Attribution links them in order.
- A visit. Someone lands on your site. The useful fields are the source, medium and campaign from the URL's UTM parameters, the referrer, and the landing page. Web analytics has this record and nothing else.
- A sign-up. The visitor creates an account and gets a user id in your database. Your own system now knows who they are, but your analytics tool only knows a visitor.
- A customer in billing. Your billing provider creates its own customer record, with its own id, an email, and sometimes metadata you can set.
- A payment. A charge lands against that customer. Later, a refund or a chargeback may follow, and the provider keeps a fee on each one.
ProductLense resolves these records in one chain, and the chain is documented in how payments are attributed:
payment → customer → identity → visitor → first touchEvery payment from a connector carries the provider's customer. ProductLense then looks for visitors linked to that customer, in this order, and stops at the first source that finds one with a first touch:
- The customer's user id, matched to the user id your site sent with
identify. Connectors read it from the customer's metadata underpl_user_id,user_id,userIdoruid, or from the provider's own field, such as Polar's external id. - The customer's email, matched case-insensitively to the
emailtrait sent withidentify, or to a user id that is that email. - A ProductLense visitor id stored on the customer under
pl_visitor_idorvisitor_id.
Each visitor's first pageview fixes its first touch: channel, utm_source, utm_medium, utm_campaign, referrer domain, landing path and country. That record is kept for the life of the site, after raw events expire, so a payment months later can still be attributed. When one user has several visitors, from a laptop and a phone or from different days, the earliest first touch wins.
The answer is resolved when the payment is written and stored on the payment row: channel, source, medium, campaign, referrer domain, landing path, the visitor id used, and the model, first_touch. Revenue reports never re-run attribution. That is how you trace a payment back to a campaign without guessing. If a link is missing, the payment is stored without a channel rather than assigned to the wrong one.
First touch is the right default for a founder
ProductLense reports first-touch attribution only. Every payment is credited to the channel, source, medium and campaign of the paying customer's first recorded visit. There is no last-touch or multi-touch model in v1. Last touch and linear are planned, computed by re-resolving the same payments, and no date is set.
That is a deliberate choice, and the reasoning is in how we compute profit per channel. For a small SaaS, the expensive question is which channel to put more money or time into, and that is a question about where customers start. Three things make first touch fit:
- Few payments. A SaaS with a few hundred paying customers does not have the volume for a multi-touch model to be more than noise. Splitting one payment across four channels produces fractions nobody can check.
- Long consideration. People read a blog post, come back from a newsletter, sign up weeks later and pay a month after that. The first visit is the one that cost you something to earn.
- You can check it by hand. Open the customer, open their first visit, read the landing URL. When a number decides a budget, that matters more than a cleverer model.
First touch has a known limit. It under-credits retargeting and closing channels. A retargeting campaign only works on people another channel already brought, so under first touch its row shows spend and little revenue. The same goes for a newsletter that nudges a trial user to pay. That is not a bug, it is the question being asked. An ad someone saw but did not click leaves no trace on your site either, so there is no view-through attribution.
Set it up in an afternoon
The setup is one script, one identify call, one billing connection and a habit of tagging links. The provider-specific walkthroughs are Stripe revenue attribution and Razorpay conversion tracking. The other providers are listed on the integrations page.
1. Install the script
Put the script in the <head> of every page. The first line is an optional queue stub so you can call identify before the deferred script loads.
<script>
window.productlense =
window.productlense ||
function () {
(window.productlense.q = window.productlense.q || []).push(arguments);
};
</script>
<script defer data-site="SITE_ID" src="https://api.productlense.app/a.js"></script>The site id is a UUID in the site's settings. Deploy, open your site, and your visit shows on the Live page within a few seconds.
2. Call identify after sign-up and login
This is the step that makes the join possible. After sign-up and after every login, link the visitor to your own user id and send the email as a trait:
window.productlense('identify', 'user_123', { email: '[email protected]' });The id is the stable id of the user in your database: a UUID, a numeric id as a string, or an auth provider id. It is kept in sessionStorage, so call identify again when a signed-in user opens a new tab. Repeated calls are harmless, and $identify events do not count toward your plan's monthly events.
Send the email too. A payment whose billing customer has the same email is matched to this visitor even when the customer carries no user id. The id survives an email change, and the email covers customers where you cannot set metadata.
3. Put the same id on the billing customer
The strongest link between a payment and a visitor is your own user id on the billing customer. Where it goes depends on the provider:
| Provider | Where to put the user id |
|---|---|
| Stripe | Customer metadata pl_user_id |
| Paddle | Customer custom data pl_user_id |
| Razorpay | Customer or payment notes pl_user_id |
| Polar | Customer external id |
| Payment API | customer.user_id |
Each provider page under connect billing says where its metadata lives.
4. Connect the billing provider
In the dashboard, open Settings → Billing connections, choose the provider, paste a read-only key, and click Connect and import history. ProductLense makes one authenticated read call to check the credentials, then imports the last 12 months of customers, subscriptions, payments and refunds.
The connection card shows a webhook URL in the shape https://api.productlense.app/api/webhooks/<provider>/<connection-id>. Add it in your provider so new payments arrive within seconds. Every night at 03:00 UTC ProductLense reconciles each connection from its last sync minus 48 hours, so a missed webhook is picked up the next day. ProductLense never writes to your billing account and never moves money.
Every plan includes one billing connection per site, including Free, and Growth and Scale allow any number. ProductLense connects to fourteen billing providers and a payment API, and the Free plan covers up to 10,000 events a month. See pricing.
5. Tag your ad links with UTM parameters
Attribution reads utm_source, utm_medium and utm_campaign from the landing URL. A Google Ads link might carry utm_source=google&utm_medium=cpc&utm_campaign=brand_search. A visit is paid when the URL has a gclid, fbclid or msclkid value, or when utm_medium is cpc, ppc, paid, paidsocial or display. A link tagged utm_medium=email is email. Without tags, the channel comes from the referrer.
Source and medium are stored in lower case. Campaign is matched exactly, case included, so Brand_Search and brand_search are different campaigns. Pick one naming scheme, because step 6 depends on it.
6. Add ad spend
Profit per channel needs the cost side, and it is on the Growth and Scale plans. On the Profit page under Add spend, enter rows by hand or upload a CSV whose first line is exactly:
date,channel,source,medium,campaign,amount,currency
2026-10-01,paid,google,cpc,brand_search,42.50,USDEach row is one day of spend, and a manual row that covers several days is spread evenly across them. Spend joins revenue on exactly the same four values: channel, source, medium and campaign. If they differ by a single character, the spend becomes its own row with no revenue and the campaign's revenue a row with no spend.
Ad spend comes in by CSV import or rows you type today. The Meta Ads and Google Ads connections go live once each platform finishes its app review. Both platforms export daily campaign spend as CSV, so reshape the export to the seven columns above.
Read the numbers: revenue, net revenue, profit, CAC, ROAS, payback
Every money number in ProductLense uses one definition, the same in the dashboard, the API, Ask and the docs. All amounts are in USD at the payment date's European Central Bank rate, and only payments with status succeeded count. The definitions are from how ProductLense counts.
| Name | Definition |
|---|---|
| Revenue | Charges − refunds − chargebacks, before processor fees |
| Fees | Processor and merchant-of-record fees on those charges, minus fees returned on refunds |
| Net revenue | Revenue − Fees |
| Ad spend | Ad spend rows for the range, prorated by day |
| Profit | Revenue − Fees − Ad spend |
| Customers | Distinct customers with a first succeeded charge in the range (new customers) |
| CAC | Ad spend ÷ customers, for the same channel and range. Null when customers = 0 |
| ROAS | Revenue ÷ Ad spend. Null when spend = 0 |
| Payback | CAC ÷ average first-month net revenue per new customer |
A few things follow from these definitions.
Refunds reduce the channel's revenue. A refund in November for an October sale reduces November's revenue for that channel, which matches your cash, not your bookings.
Fees come from the provider. For Stripe that is the balance transaction fee. For Razorpay it is the fee on each payment, GST included. For a merchant of record such as Paddle it is the grand total minus your earnings, so net revenue equals your payout. Where a provider does not report fees, a connection can carry an estimate, a percentage plus a fixed amount, applied only to charges that arrive with a zero fee, and the dashboard shows what share of fees is estimated.
Profit per channel is a strict formula. Profit = Revenue − Fees − Ad spend for the same channel and period, as defined in profit per channel. Revenue counts payments made in the period by any customer the key ever brought, so a subscription bought last year keeps adding to its channel every month. Keys with spend and no revenue appear with zero revenue and negative profit, so a campaign that brought nobody is never hidden. Rows without spend show no CAC, ROAS or payback.
ROAS above 1.0 is not profit. ROAS uses revenue before fees.
The worked example from the docs, for one Google Ads campaign in one month:
revenue 2,407.00
fees 152.00
ad spend 1,518.00
new customers 31
CAC 1,518.00 ÷ 31 = 48.97
ROAS 2,407.00 ÷ 1,518.00 = 1.59
first-month net revenue 899.00
per new customer 899.00 ÷ 31 = 29.00
payback 48.97 ÷ 29.00 = 1.69 months
profit 2,407 − 152 − 1,518 = 737.00Payback is the number of months it takes a new customer to earn back what it cost to acquire them, assuming they keep paying the same. It is empty when CAC is null or first-month net revenue is 0.
Profit per channel subtracts ad spend and payment fees only. Salaries, tools, hosting and content costs are not in ProductLense, so a positive organic search row means the channel paid for its own fees, not that it was free to produce.
What goes wrong, and what ProductLense does about it
These are the cases that break a naive join, and what ProductLense does for each.
| Case | What ProductLense does |
|---|---|
| A customer paid with a different email than they signed up with | The user id is checked first, so if pl_user_id is on the billing customer the email never matters. Without an id, ProductLense looks for an email match, then for a visitor id in the customer metadata. If none of these find a visitor, the payment is stored as unattributed. |
| A visitor converted months after the first visit | The first touch is kept for the life of the site, after raw events expire. identify joins every visitor that used the same id, and the earliest first touch among them wins, so the visit from months ago is the one credited. |
| A payment arrived before the identify call | The payment is stored without attribution. A job runs every day at 04:00 UTC, takes up to 500 unattributed payments from the last 90 days, and resolves them again. Once the identify call links the user, the next run fills in the channel. |
| A payment with no visitor at all | Revenue that cannot be linked to a visitor still counts toward totals, MRR and LTV, and appears in channel tables under an empty key. Common causes are customers who never visited the tracked site, sites installed after the customer's first visit, and customers with no user id and no matching email. |
| Two people on one account | A visitor who identifies joins every visitor that used the same id. If two people share one user id, their visitors count as one user and the earliest first touch among them wins, which is the right answer for one paying account: the payment is attributed through that one id. |
| Refunds and chargebacks | A charge nets to amount − fee, and a refund or chargeback nets to −(amount + fee). Paddle, Dodo Payments, and Lemon Squeezy for tax return their cut on a refund, so a full refund disappears from net revenue as if it never happened. Stripe disputes are recorded as chargebacks with the dispute fee. |
| Currency | Amounts keep their original currency and are converted to USD at the ECB reference rate for the payment's date. A day without a rate uses the nearest earlier day, up to 10 days back. Reports are in USD. |
Pending and failed payments are stored and left out of every figure. A referrer from a checkout page is treated as no referrer, so a visitor returning from a Stripe or Razorpay checkout is never reclassified as a referral.
How this differs from Google Analytics, ad-platform conversions and spreadsheets
Google Analytics counts sessions and conversions, not payments. A conversion value is whatever you typed, not what the customer was charged, and it does not change when the customer refunds or the processor takes its fee.
Ad-platform conversions are counted by the platform that sold you the click, by its own rules. The platform does not see refunds, fees, or the customers who came from channels it does not run.
Spreadsheets can do the join once. Export visitors, sign-ups and charges, match on email, subtract spend. The result is correct for the afternoon you built it and stale the next day. Nothing retries a payment that arrived before the sign-up, and nothing notices a refund.
ProductLense stores the attribution answer on the payment row when it is written, keeps the first touch for the life of the site, retries unattributed payments daily, reconciles each billing connection nightly, and computes every money number from one table with one set of definitions. The same figures are available in the dashboard, through Ask, and through a read API and an MCP server from the Starter plan. The revenue attribution and profit per channel pages show the dashboard side.
Questions founders ask
Do I need identify, or is the billing connection enough?
You need identify. Without it, ProductLense can count visitors but cannot tell which channel a paying customer came from, because nothing links the billing customer to a visitor. The connection alone gives you revenue totals, MRR and LTV, with every payment under an empty channel key. One identify call after sign-up and login, with the user's email in the traits, makes per-channel revenue possible.
What happens to a payment that arrives before the customer is identified?
It is stored without attribution and tried again. Every day at 04:00 UTC, ProductLense takes up to 500 unattributed payments from the last 90 days and re-runs the chain. Once your site sends the identify call that links the user, the next run fills in the channel. A checkout webhook landing before the post-signup page loads is the usual cause.
Which channel gets credit when a customer visited from several places?
The first one. ProductLense uses first-touch attribution only, so the channel, source, medium and campaign of the customer's earliest recorded visit are stored on every payment they make. When one user has several visitors, from a phone and a laptop or from different days, the earliest first touch among them wins. Last touch and linear models are planned, with no date set.
Does revenue attribution work without cookies?
Yes. An anonymous visitor is one browser on one site on one UTC day, hashed from the site id, IP address, user agent and a daily salt, and the IP is never stored. That daily id would split a person across days, but identify joins every visitor that used the same user id, and the earliest first touch is kept for the life of the site.
Can I attribute app store or invoice revenue, not only card payments?
Yes, through two routes. RevenueCat is one of the fourteen billing providers ProductLense connects, and it reads App Store and Google Play purchases and subscriptions. For invoices paid by bank transfer or a provider that is not listed, the Payment API accepts one authenticated POST per payment with a customer id, email, user id and fee, and attribution runs the same chain. The key needs the Scale plan.
Why does my retargeting campaign show spend and almost no revenue?
Because first-touch attribution credits the channel that started the relationship, and retargeting by definition reaches people another channel already brought. Its row shows the spend you entered and only the revenue from customers whose very first visit came through a retargeting click. Read that row as a cost belonging to the channels that fed it.