DEV Community

Cover image for Stripe revenue attribution in a cookieless world. The webhook patterns that hold up
Zenovay
Zenovay

Posted on

Stripe revenue attribution in a cookieless world. The webhook patterns that hold up

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
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

The five non obvious gotchas

  1. checkout.session.completed does not fire for direct charge api usage. If your customers do not all use stripe checkout, you also need to listen to payment_intent.succeeded and look up metadata from there.

  2. Subscriptions. checkout.session.completed fires once. invoice.paid fires every cycle. Decide which is your 'attribution event'. We attribute the first, separately track recurring as ltv.

  3. metadata is string only. Numbers and booleans become strings. Convert when reading.

  4. 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.

  5. 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)