Guide/Affiliate Tracking Setup

Affiliate Tracking Setup

Copy-paste affiliate click, signup, and Stripe payment tracking into your SaaS app.

Use this guide in the SaaS app that receives referred users. You will copy one server helper, add one browser setup snippet, then call the helper from signup and Stripe payment code.

Connect Stripe

Open the organization workspace, go to Affiliation, then click Connect Stripe. Stripe asks the organization owner to authorize Connect access and sends them back to the Affiliation page.

Stripe must be connected before verified payment tracking can use stripeInvoiceId, stripePaymentIntentId, or stripeChargeId.

Create An API Key

Open Settings > API Keys, create a server key, and store it in the target app backend.

GROWTH_API_KEY="org_api_key"
GROWTH_API_URL="https://your-growth-app.com"

Never expose the API key in browser code. Browser code can store referral cookies, but API calls should be made by your backend.

How Attribution Works

Keep these values stable across the visitor's journey:

ValueWhere it comes fromWhy it matters
refPartner code in the landing URLLets Growth find the affiliate when the visitor first lands.
visitorIdAnonymous browser id generated by your appConnects click and signup before the user has an account.
clickIdReturned by /api/v1/affiliate/clickStrongest link between the click event and the signup.
customerKeyStripe customer id or internal user idConnects signup, payments, and manual recovery.

For reliable attribution, the target landing page must be able to read the partner code. Prefer links like:

https://your-app.com/pricing?ref=PARTNER_CODE

If you use Growth redirect links like /r/PARTNER_CODE, configure each partner target URL so the final page still receives ?ref=PARTNER_CODE.

Create src/lib/affistripe.ts

Create this file in the app you want to track. This file is server-only because it uses the organization API key.

const apiUrl = process.env.GROWTH_API_URL?.replace(/\/$/, "");
const apiKey = process.env.GROWTH_API_KEY;

type MetadataValue = string | number | boolean | null | undefined;

type AffiStripeMetadata = Record<string, MetadataValue>;

export type AffiStripeAttribution = {
  ref?: string | null;
  clickId?: string | null;
  visitorId?: string | null;
  landingUrl?: string | null;
  referrer?: string | null;
  userAgent?: string | null;
  metadata?: AffiStripeMetadata;
};

const compact = (payload: Record<string, unknown>) =>
  Object.fromEntries(
    Object.entries(payload).filter(([, value]) => {
      if (value === undefined || value === null) return false;
      return typeof value !== "string" || value.trim().length > 0;
    }),
  );

const cleanMetadata = (metadata: AffiStripeMetadata | undefined) => {
  if (!metadata) return undefined;

  const entries = Object.entries(metadata).flatMap(([key, value]) => {
    if (value === undefined || value === null) return [];
    return [[key, String(value)]];
  });

  return entries.length > 0 ? Object.fromEntries(entries) : undefined;
};

async function postGrowth<TResponse>(
  path: string,
  payload: Record<string, unknown>,
) {
  if (!apiUrl || !apiKey) {
    throw new Error("Missing GROWTH_API_URL or GROWTH_API_KEY");
  }

  const response = await fetch(`${apiUrl}${path}`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-api-key": apiKey,
    },
    body: JSON.stringify(compact(payload)),
  });

  if (!response.ok) {
    throw new Error(await response.text());
  }

  return (await response.json()) as TResponse;
}

export async function trackAffiStripeClick(input: AffiStripeAttribution) {
  if (!input.ref) return null;

  return postGrowth<{ clickId?: string; matched?: boolean; targetUrl?: string }>(
    "/api/v1/affiliate/click",
    {
      ref: input.ref,
      visitorId: input.visitorId,
      landingUrl: input.landingUrl,
      referrer: input.referrer,
      userAgent: input.userAgent,
      metadata: cleanMetadata(input.metadata),
    },
  );
}

export async function trackAffiStripeSignup(input: {
  customerKey: string;
  customerEmail?: string | null;
  amount?: number;
  currency?: string;
  sourceId?: string;
  attribution?: AffiStripeAttribution;
  metadata?: AffiStripeMetadata;
}) {
  return postGrowth("/api/v1/affiliate/signup", {
    customerKey: input.customerKey,
    customerEmail: input.customerEmail,
    amount: input.amount,
    currency: input.currency,
    sourceId: input.sourceId,
    ref: input.attribution?.ref,
    clickId: input.attribution?.clickId,
    visitorId: input.attribution?.visitorId,
    metadata: cleanMetadata(input.metadata),
  });
}

export async function trackAffiStripePayment(input: {
  customerKey: string;
  stripeInvoiceId?: string;
  stripePaymentIntentId?: string;
  stripeChargeId?: string;
  amount?: number;
  currency?: string;
  sourceId?: string;
  metadata?: AffiStripeMetadata;
}) {
  return postGrowth("/api/v1/affiliate/payment", {
    customerKey: input.customerKey,
    stripeInvoiceId: input.stripeInvoiceId,
    stripePaymentIntentId: input.stripePaymentIntentId,
    stripeChargeId: input.stripeChargeId,
    amount: input.amount,
    currency: input.currency,
    sourceId: input.sourceId,
    metadata: cleanMetadata(input.metadata),
  });
}

Add A Click Proxy Route

Create a backend route in your app, for example POST /api/affistripe/click. The browser calls this route, and the route calls Growth with the secret API key.

import { trackAffiStripeClick } from "@/lib/affistripe";

export async function POST(request: Request) {
  const body = (await request.json()) as {
    ref?: string;
    visitorId?: string;
    landingUrl?: string;
  };

  const result = await trackAffiStripeClick({
    ref: body.ref,
    visitorId: body.visitorId,
    landingUrl: body.landingUrl,
    referrer: request.headers.get("referer"),
    userAgent: request.headers.get("user-agent"),
  });

  return Response.json(result ?? { skipped: true });
}

Use the route format for your framework:

FrameworkTypical location
Next.js App Routerapp/api/affistripe/click/route.ts
TanStack Startsrc/routes/api/affistripe/click.ts
Express/FastifyAdd a POST /api/affistripe/click handler

Add This In Your App Setup

Paste this in the root client component, marketing layout, or app bootstrap that runs on landing pages.

import { useEffect } from "react";

const VISITOR_KEY = "affistripe_visitor_id";
const CLICK_KEY = "affistripe_click_id";
const REF_KEY = "affistripe_ref";

function getOrCreateVisitorId() {
  const existing = window.localStorage.getItem(VISITOR_KEY);
  if (existing) return existing;

  const visitorId = crypto.randomUUID();
  window.localStorage.setItem(VISITOR_KEY, visitorId);
  return visitorId;
}

export function AppTrackingSetup() {
  useEffect(() => {
    const ref = new URLSearchParams(window.location.search).get("ref");
    if (!ref) return;

    const visitorId = getOrCreateVisitorId();
    window.localStorage.setItem(REF_KEY, ref);

    void fetch("/api/affistripe/click", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        ref,
        visitorId,
        landingUrl: window.location.href,
      }),
    })
      .then((response) => (response.ok ? response.json() : null))
      .then((data) => {
        if (data?.clickId) {
          window.localStorage.setItem(CLICK_KEY, data.clickId);
        }
      })
      .catch(() => null);
  }, []);

  return null;
}

Then mount it once:

<AppTrackingSetup />

Send Attribution With Signup

When your signup form posts to your backend, include the stored attribution values.

const affiStripe = {
  ref: window.localStorage.getItem("affistripe_ref"),
  clickId: window.localStorage.getItem("affistripe_click_id"),
  visitorId: window.localStorage.getItem("affistripe_visitor_id"),
};

await fetch("/api/signup", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    email,
    password,
    affiStripe,
  }),
});

After the user account is created, send a signup event with one stable customerKey. Use your internal user id or the Stripe customer id.

import { trackAffiStripeSignup } from "@/lib/affistripe";

await trackAffiStripeSignup({
  customerKey: user.stripeCustomerId ?? user.id,
  customerEmail: user.email,
  attribution: {
    ref: input.affiStripe?.ref,
    clickId: input.affiStripe?.clickId,
    visitorId: input.affiStripe?.visitorId,
  },
  metadata: {
    plan: selectedPlan,
    source: "signup",
  },
});

If you sell with Stripe, the safest customerKey is the Stripe customer id (cus_...). If you use your internal user id instead, store that same id in Stripe metadata so your webhook can send the same customerKey later.

await stripe.checkout.sessions.create({
  mode: "subscription",
  customer: user.stripeCustomerId,
  client_reference_id: user.id,
  metadata: {
    customerKey: user.id,
  },
  subscription_data: {
    metadata: {
      customerKey: user.id,
    },
  },
  line_items: [{ price: "price_123", quantity: 1 }],
  success_url: "https://your-app.com/success",
  cancel_url: "https://your-app.com/cancel",
});

For one-time Checkout payments, Stripe can show buyers as Guest customers in the Dashboard without creating a reusable Customer object. To force a real cus_... customer for future payments, pass an existing customer or set customer_creation: "always" when creating the Checkout Session.

await stripe.checkout.sessions.create({
  mode: "payment",
  customer_creation: "always",
  customer_email: user.email,
  client_reference_id: user.id,
  metadata: {
    customerKey: user.id,
  },
  line_items: [{ price: "price_123", quantity: 1 }],
  success_url: "https://your-app.com/success",
  cancel_url: "https://your-app.com/cancel",
});

Growth's Stripe import also reads successful charges from the connected live account so existing Dashboard guests can appear in Affiliation > Customers even when they were not created as Stripe Customer objects.

If attribution fails, the customer still appears in Affiliation > Customers as unattributed.

Add This In Your Stripe Webhook

For payment-based programs, call the payment endpoint after Stripe confirms the invoice, payment intent, or charge is paid. Prefer invoice.paid with stripeInvoiceId for subscriptions.

import Stripe from "stripe";
import { trackAffiStripePayment } from "@/lib/affistripe";

if (event.type === "invoice.paid") {
  const invoice = event.data.object as Stripe.Invoice;

  const stripeCustomerId =
    typeof invoice.customer === "string"
      ? invoice.customer
      : invoice.customer?.id;
  const customerKey =
    invoice.metadata.customerKey ||
    invoice.subscription_details?.metadata?.customerKey ||
    stripeCustomerId;

  if (customerKey) {
    await trackAffiStripePayment({
      customerKey,
      stripeInvoiceId: invoice.id,
      metadata: {
        source: "stripe_webhook",
      },
    });
  }
}

For one-time payments, use payment_intent.succeeded:

if (event.type === "payment_intent.succeeded") {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;

  const customerKey =
    paymentIntent.metadata.customerKey ||
    (typeof paymentIntent.customer === "string"
      ? paymentIntent.customer
      : paymentIntent.customer?.id);

  if (customerKey) {
    await trackAffiStripePayment({
      customerKey,
      stripePaymentIntentId: paymentIntent.id,
    });
  }
}

Growth reads the Stripe object from the connected organization account and uses Stripe's amount and currency. Re-sending the same Stripe id is idempotent.

Optional: Track A Manual Payment

Use this only when the organization has not connected Stripe or the revenue source is not Stripe.

await trackAffiStripePayment({
  customerKey: user.id,
  amount: 4900,
  currency: "eur",
  sourceId: "invoice_123",
  metadata: {
    source: "manual_invoice",
  },
});

Amounts are in the smallest currency unit. For EUR, 4900 means 49.00 EUR.

Manual Recovery

Open Affiliation > Customers to review signups that arrived without attribution. Click Link, choose the affiliate, and save. For payment-based programs, resend the original payment event after the customer is linked.

Common Mistakes

Getting StartedEmail Marketing Integration