Back to all posts

SaaS / Stripe · October 2026 · 6 min read

Stripe Referral Tracking for SaaS: Step-by-Step Setup Guide

How to wire up referral attribution on any Stripe-based SaaS. The end-to-end pattern — landing → metadata → webhook → commission — plus the four pitfalls that quietly cost you commissions.

If you're building a SaaS on Stripe and want a referral program, you have three options: install a no-code SDK (Rewardful's checkout.js style), call a tracking provider's REST API from your own backend, or build it from scratch against your own database. This guide covers option two — the middle ground — because it gives you 90% of the benefit of a full SaaS tool at 10% of the integration cost, and works against any provider with a sane API.

We'll use AffRef's API for the code examples because that's what we build, but the same pattern applies against Rewardful, FirstPromoter, Tolt, or anyone else that accepts conversion events server-to-server.

The mental model

Referral attribution on a Stripe-based SaaS is three handoffs:

  1. Browser → server. A user lands on your marketing site with ?ref=ABC123. Your frontend captures it and persists it somewhere that survives the trip to Stripe Checkout.
  2. Server → Stripe. At Checkout creation time, your backend reads the persisted code and serialises it into Stripe customer / subscription metadata. From this point Stripe is the source of truth.
  3. Stripe → tracking platform. Your existing Stripe webhook (you have one) reads the metadata and POSTs a conversion to your tracking platform.

Three steps. Each is a few lines of code. The reason most teams overcomplicate this is they try to do attribution client-side with cookies → race conditions with ITP → flaky conversions. Stripe's customer + subscription objects are durable, server-authoritative, and survive every browser-side mess.

Step 1 — Capture the referrer

On every page of your marketing site, run a tiny client-side snippet that reads ?ref= from the URL and stashes it in a first-party cookie:

// Run on every page (or just your landing page if traffic only enters there)
<script>
  (function () {
    var ref = new URL(window.location.href).searchParams.get('ref');
    if (ref) {
      document.cookie = 'affref=' + encodeURIComponent(ref) +
        '; path=/; max-age=' + (60 * 60 * 24 * 30) +
        '; SameSite=Lax';
    }
  })();
</script>

First-party cookie, 30-day expiry, SameSite=Lax. Three properties you want: first-party survives Safari ITP, 30 days covers most signup cycles, Lax lets the cookie cross the Stripe Checkout subdomain.

If your marketing site and app live on different subdomains (e.g. www.yourapp.com and app.yourapp.com), set domain=.yourapp.com in the cookie so it's shared. Otherwise the cookie won't be visible when the user signs up on the app subdomain.

Step 2 — Push the code into Stripe metadata at checkout

When the user actually subscribes, your backend creates the Stripe Checkout Session. Read the cookie server-side and stash the code in metadata:

// Node.js / Express example
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post('/checkout', async (req, res) => {
  // Cookies are server-readable; how you parse depends on your framework.
  const affiliateCode = req.cookies?.affref ?? null;

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: 'price_xxx', quantity: 1 }],
    success_url: `${process.env.APP_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,

    // The important bit — metadata on BOTH session and subscription_data
    metadata: { affiliate_code: affiliateCode ?? '' },
    subscription_data: {
      metadata: { affiliate_code: affiliateCode ?? '' },
    },
  });

  res.redirect(303, session.url);
});

Setting metadata on both session.metadata and subscription_data.metadata is critical. The session and the subscription are separate Stripe objects. You'll read from the subscription in step 3, and if you only set session metadata, you'll wonder why nothing attributes.

Step 3 — Fire the conversion from your Stripe webhook

You already have a Stripe webhook handler. Add a few lines so it fires a conversion when a subscription is created:

// Continuing the Node.js example
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);

  if (event.type === 'customer.subscription.created') {
    const sub = event.data.object;
    const code = sub.metadata.affiliate_code;

    if (code) {
      await fetch('https://app.affref.com/api/v1/conversions', {
        method: 'POST',
        headers: {
          'Authorization': 'Bearer ' + process.env.AFFREF_API_KEY,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          affiliate_code: code,
          order_id: sub.id, // idempotent on this — safe if Stripe retries
          order_total: sub.items.data[0].price.unit_amount / 100,
          currency: sub.currency.toUpperCase(),
        }),
      });
    }
  }

  res.send('ok');
});

A few things this does right:

  • Idempotency. The order_id is the Stripe subscription ID, which is unique. AffRef's conversion endpoint is idempotent on it, so Stripe webhook retries (and Stripe will retry) don't create duplicate conversions.
  • Fire-and-forget. If the AffRef call fails (network blip), Stripe still considers the webhook handled. You don't want to 500 your Stripe webhook over an attribution miss.
  • Source of truth. All the data — amount, currency, customer — comes from Stripe, not from any client-side state that could have been tampered with.

Step 4 — Test the round-trip

  1. Create a test affiliate in your AffRef dashboard. Note their code (e.g. TEST).
  2. Incognito-window your marketing site with ?ref=TEST.
  3. Check DevTools → Application → Cookies for the affref cookie.
  4. Complete a real signup + payment.
  5. Within ~30 seconds, the conversion should appear in your AffRef dashboard attributed to TEST.
  6. Refund the payment in Stripe (we'll come back to refund handling below).

If no conversion appears, debug in this order: (1) was the cookie set? (2) did Stripe Checkout receive the metadata? — check the Subscription object in Stripe Dashboard. (3) did your webhook fire and pull the metadata? — check Stripe Dashboard → Developers → Webhooks → most recent attempt. (4) did your POST to AffRef succeed? — your platform should have an incoming-request log.

The four pitfalls nobody documents

1. Trial conversions vs first-payment conversions

If your SaaS has a free trial, customer.subscription.created fires immediately on signup, before money has changed hands. If you commission affiliates on trial signups, expect ~30% of those to never convert. Better pattern: fire the conversion on invoice.payment_succeeded (first real payment), and add a separate event for cancellations during trial.

2. Refunds and clawbacks

Add a handler for charge.refunded that calls your platform's reverse-commission endpoint. Otherwise you'll pay affiliates on customers who cancelled in the trial period or charged-back. AffRef's webhook handles refunds automatically if you forward Stripe's refund event — but most teams set this up six months in and discover they've been over-paying.

3. Recurring commissions on subscription renewals

You decide whether your affiliate gets a one-time commission on first payment, or recurring commissions on every renewal (typical: first 12 months). For recurring: fire a separate conversion on every invoice.payment_succeeded event after the first. Our commission-rate post has the strategic side of this decision.

4. Plan upgrades / downgrades

If a customer upgrades their plan, Stripe usually amends the existing subscription rather than creating a new one. You won't get a new customer.subscription.created event. Decide upfront: does the affiliate get a commission on the upgrade delta? If yes, listen for customer.subscription.updated and compare old vs new MRR; fire a delta conversion. Most teams skip this for v1.

Why this beats client-side SDKs

  • Survives any browser privacy update. First-party cookie + Stripe metadata + server webhook = zero browser-side moving parts at the point of conversion.
  • Testable. Each step is an HTTP request you can replay locally. No "did the SDK initialize correctly" debugging.
  • Swappable. Switching from Rewardful to AffRef (or vice versa) is two URL changes. No vendor lock.
  • No client bloat. Most affiliate SDKs ship 50-200KB to your users. This is one cookie write.

Where to go from here

If you're building this on Next.js specifically, our Next.js walkthrough has framework-specific code (App Router cookies API, server actions etc). If you're still comparing platforms, our Rewardful alternatives post covers the options. If you want to skip ahead and try AffRef: 7-day free trial. Full API docs here.

Ready to grow your affiliate program?

Start your 7-day free trial. Cancel anytime.

View Plans
Usually replies within an hour
Hey 👋 I'm Aaron, founder of AffRef. Drop your email and your question and I'll get back to you fast.
By chatting you agree to our privacy policy.
End this chat? History on this device clears.