Quick technical writeup. Building Zenovay, a web analytics tool. One of our main features is revenue attribution: which marketing channel actually produced this stripe payment.
Sounds simple. The clean version is:
The actual implementation has at least 6 problems the clean version hides.
Problem 1: visitor identity persistence without cookies.
We use a first party session id stored in localStorage. Stable across the same browser, lost across devices. The mental model is 'session = browser instance', not 'user'. This means cross device journeys are partially attributed at best. We accepted this tradeoff for the privacy story.
Problem 2: the stripe customer object often has no link back to the website session
Stripe knows the customer email and the payment metadata. It does not know the session id unless we put it there. So we have to inject the session id into stripe metadata at checkout creation time. This requires the customer to have integrated our js sdk with their stripe checkout flow. The integration is one line, but it's a load bearing line.
Problem 3: webhook race conditions.
A payment fires 'payment_intent.succeeded' before 'checkout.session.completed' in some cases. If you naively attribute on the first event, you miss the metadata that arrived on the second. We queue and dedupe by payment_intent_id with a 60 second debounce.
Problem 4: refunds and disputes.
If a payment is refunded, do you reverse the attribution? We chose yes. Means we send a 'negative' event downstream that subtracts from the channel's attribution. This is obvious in retrospect but the implementation needs idempotency keys per refund event.
Problem 5: subscriptions.
Recurring revenue attribution is the worst part. Do you attribute the entire mrr to the original session forever? Just the first payment? Some decaying function? We picked 'first payment fully attributed, recurring goes to a separate ltv bucket'. Argueable. Our customers asked for both views.
Problem 6: timezone handling for daily revenue reports.
Stripe gives utc. Customers want their local timezone. We push timezone normalisation to the edge of the data path, not the query path, because doing it at query time killed dashboard performance on month long ranges.
Stack wise the whole pipeline runs on cloudflare workers with d1 + r2 for the long term store. The webhook handler is a separate worker that validates the stripe signature, dedupes, writes to a queue, and lets the analytics worker consume.
What i wish i had known before building this:
Tool is at zenovay.com. Happy to dig into specifics if anyone is building similar attribution.