DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

Your referral links lose attribution in the redirect chain, not at checkout

A referral link is the shortest piece of infrastructure in your product and the one most likely to lose data silently.

The setup looks trivial. An affiliate gets yoursite.com/?ref=alice. You read ref on page load, stash it somewhere, attach it to the Stripe Checkout Session, and pay out on the webhook. You paste the link into your browser once, see alice in the console, and call attribution done.

Then payouts come in low, an affiliate emails asking why a sale they clearly drove is not showing, and you go looking at the last hop.

The last hop is almost never where it broke

The debugging path is predictable. You check the Checkout Session in the Stripe dashboard, find metadata.ref empty, and start reading your checkout code. Maybe you add logging around the session creation. Maybe you suspect a race between the cookie write and the click on the pricing button.

Meanwhile the value was already gone three hops earlier, before your app rendered a single byte.

The tell is that attribution works perfectly on localhost and fails only in production. Local has no canonical-host redirect, no CDN rule, no locale router, no shortener, no in-app browser. Production has all of them, and several will rebuild your URL from scratch.

Where the query string actually dies

Canonical host redirects. You redirect http to https, or bare domain to www, or the reverse. A lot of redirect rules construct the target by string concatenation and never reattach the query. This is the single most common way a ref param evaporates, and it is invisible because the page still loads fine.

server {
    listen 80;
    server_name example.com;

    # fine: $request_uri is the original path AND query
    return 301 https://www.example.com$request_uri;

    # broken: $uri is the normalized path with no query
    # return 301 https://www.example.com$uri;
}
Enter fullscreen mode Exit fullscreen mode

One character of difference decides whether your affiliate gets paid. The same trap exists in every layer that can redirect: CDN rules, load balancer listeners, framework config.

// next.config.js
module.exports = {
  async redirects() {
    return [
      { source: "/pricing", destination: "/plans", permanent: true },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

Next.js forwards query params on redirects by default, but only if you have not written your own destination with a hardcoded query, and only if the hop is actually handled by Next rather than by the platform sitting in front of it. Check the hop that is actually running, not the one in your repo.

Locale and geo redirects. example.com/?ref=alice becomes example.com/en/. Same mechanism, different layer, and it usually lives in middleware written by whoever set up i18n rather than by whoever set up the affiliate program.

Shorteners and social wrappers. An affiliate runs your link through a shortener, or posts it somewhere that rewrites outbound links. Some wrappers preserve the query and append their own. Some pass the whole thing as a single encoded parameter to an interstitial and reconstruct it. Some strip anything they do not recognize.

In-app browsers. A click inside a mobile app opens a webview with a fresh cookie jar. Whatever you wrote on a previous visit in the real browser does not exist there, and whatever you write inside it may not survive the user tapping "open in Safari" afterward.

Marketing site to app domain. The ref lands on example.com, your signup and checkout live on app.example.com, and the cookie you wrote is scoped to the marketing host. The origin that writes the value is not the origin that later reads it, so at checkout there is nothing to read.

Meta refresh and client-side router redirects. A <meta http-equiv="refresh" content="0; url=/home"> or a router.replace("/dashboard") inside an auth guard rewrites the location without the query. These are especially easy to miss, because your server logs still show the original request with the param present.

The principle

Capture the referral at the earliest possible hop and persist it first-party. Do not carry the query string end to end.

The query string is a transport. It gets one job, which is to hand you a value on first paint, and then it is allowed to disappear. Every hop after that reads from your own storage. If your checkout code still expects to find ?ref= in window.location, you have built a chain where any single link breaks the whole thing.

// runs as early as possible on first paint, on the marketing domain
const REF_COOKIE = "rf_ref";
const MAX_AGE = 60 * 60 * 24 * 60; // 60 days

function captureRef() {
  const ref = new URLSearchParams(window.location.search).get("ref");
  if (!ref) return;
  if (document.cookie.includes(`${REF_COOKIE}=`)) return; // first touch wins

  document.cookie = [
    `${REF_COOKIE}=${encodeURIComponent(ref)}`,
    "path=/",
    "domain=.example.com", // top-level, so app.example.com can read it
    `max-age=${MAX_AGE}`,
    "samesite=lax",
    "secure",
  ].join("; ");
}
Enter fullscreen mode Exit fullscreen mode

Two details matter more than the rest. The cookie domain is the registrable domain with a leading dot, so subdomains share it. And first touch wins here, so a returning visitor who arrives later through a different affiliate does not silently overwrite the credit. Last touch and first touch produce different payouts, and your affiliates will eventually notice which one you picked, so decide deliberately and put it in your program terms.

Then at checkout, read your own storage and hand it to Stripe.

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function createCheckout(req, res) {
  const ref = req.cookies["rf_ref"] ?? null;

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: req.body.priceId, quantity: 1 }],
    success_url: "https://app.example.com/welcome",
    cancel_url: "https://app.example.com/plans",
    metadata: ref ? { ref } : {},
  });

  res.json({ url: session.url });
}
Enter fullscreen mode Exit fullscreen mode

metadata is the only Stripe-specific piece here. It rides along to the webhook, and the value you read there is whatever your own cookie held, not whatever survived the redirect chain.

Verify the paths, do not trust them

Most affiliate programs never write a test for this, which is strange given that the whole revenue share depends on it. The test is short. Walk your own referral link through each real entry path and assert the cookie exists at the end.

http://example.com/?ref=alice
http://www.example.com/?ref=alice
https://example.com/?ref=alice
https://www.example.com/?ref=alice
https://example.com/pricing?ref=alice     # any path with its own redirect rule
https://your-shortener.example/abc123
Enter fullscreen mode Exit fullscreen mode

For each one, follow redirects and check the final Set-Cookie and the final URL. curl -sIL gets you most of the way for the server-side hops. The client-side ones need a real browser, so a short Playwright script that visits each URL and reads document.cookie covers the rest. Do the in-app browser check by hand once, on a real phone, from whichever app your affiliates actually post in.

Run it after any change to redirect config, not just once at launch. Redirect rules get edited by people who have no idea an affiliate program depends on them.

One caveat, because a cookie is not forever

Fixing the redirect chain gets the value into your storage. It does not make that storage permanent: Safari's Intelligent Tracking Prevention caps the lifetime of cookies written through document.cookie, which is a separate problem I wrote about in Your 60-day affiliate cookie is lying on Safari. A cookie set by the server through a Set-Cookie header is treated differently from a script-written one, which is a real argument for capturing the ref server-side during that first request.

The shape that survives both problems: capture early, set it server-side where you can, and write the referral against the user record the moment someone signs up. The cookie only has to survive the gap between the click and the account, and that gap is the part you can actually control.

I work on affiliate software for SaaS companies, and the redirect chain is the first place I would look when a payout number does not match what an affiliate says they drove.

Top comments (0)