Tying a stripe payment back to the marketing channel that produced it is a classic problem. The classic solutions all rely on third party cookies or shared identifiers across tools. Neither survives 2026.
I built revenue attribution into Zenovay (cookieless web analytics) and the implementation had more gotchas than expected. Sharing the patterns that ended up holding.
The minimal viable attribution flow
browser analytics worker stripe checkout stripe webhook
| | | |
| sessionId | | |
|------------->| | |
| |
| redirect to checkout with sessionId in metadata |
|------------------------------> |
| payment |
| ---> |
| fires event |
| -----------> |
| read sessionId
| attribute revenue
Step 1, injecting sessionId into stripe metadata
// in the customer's checkout creation code
const sessionId = window.zenovay?.sessionId();
const checkout = await stripe.checkout.sessions.create({
line_items: [...],
mode: 'payment',
metadata: {
zenovay_session_id: sessionId,
zenovay_utm_source: window.zenovay?.attribution()?.utmSource,
zenovay_first_seen: window.zenovay?.attribution()?.firstSeen,
},
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cancel',
});
This is a one line integration for the customer. The js sdk exposes sessionId and attribution helpers. Everything else flows from these few fields landing in stripe metadata.
Step 2, the webhook handler
export async function handleStripeWebhook(req: Request, env: Env): Promise<Response> {
const sig = req.headers.get('stripe-signature') ?? '';
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET);
} catch {
return new Response('invalid signature', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
await attributeRevenue(env, {
sessionId: session.metadata?.zenovay_session_id,
amount: session.amount_total,
currency: session.currency,
stripeEventId: event.id,
utmSource: session.metadata?.zenovay_utm_source,
});
}
if (event.type === 'charge.refunded') {
await reverseAttribution(env, event.data.object.payment_intent as string);
}
return new Response('ok');
}
Step 3, idempotency
Stripe retries webhooks. You will receive the same event multiple times. Always idempotent by event.id:
async function attributeRevenue(env: Env, data: AttributionInput) {
const seen = await env.KV.get(`stripe:event:${data.stripeEventId}`);
if (seen) return;
await env.DB.prepare(
`INSERT INTO revenue_attribution
(session_id, amount, currency, utm_source, stripe_event_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(data.sessionId, data.amount, data.currency, data.utmSource, data.stripeEventId, Date.now())
.run();
await env.KV.put(`stripe:event:${data.stripeEventId}`, '1', { expirationTtl: 86400 * 30 });
}
The five non obvious gotchas
checkout.session.completeddoes not fire for direct charge api usage. If your customers do not all use stripe checkout, you also need to listen topayment_intent.succeededand look up metadata from there.Subscriptions.
checkout.session.completedfires once.invoice.paidfires every cycle. Decide which is your 'attribution event'. We attribute the first, separately track recurring as ltv.metadatais string only. Numbers and booleans become strings. Convert when reading.Stripe metadata has a 500 character limit per value, 50 keys max. You will hit this if you try to dump the entire attribution object. Pick the few fields you really need.
Webhook signature validation must use the raw body, not the parsed json. Most frameworks have to be told this explicitly.
Reversals on refunds
Charge refunded fires charge.refunded. Use the payment_intent field to find the original attribution row and either soft delete it or write a negative attribution event. We chose soft delete with an revoked_at field. Easier to audit.
The data model
Keep it simple. One row per attributed revenue event. Index by session_id and by created_at for the common queries. Roll up nightly into the channel reports.
I build Zenovay. Cookieless web analytics with stripe revenue attribution as the default view, not an addon.
Top comments (0)