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:
| Value | Where it comes from | Why it matters |
|---|---|---|
ref | Partner code in the landing URL | Lets Growth find the affiliate when the visitor first lands. |
visitorId | Anonymous browser id generated by your app | Connects click and signup before the user has an account. |
clickId | Returned by /api/v1/affiliate/click | Strongest link between the click event and the signup. |
customerKey | Stripe customer id or internal user id | Connects 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_CODEIf 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:
| Framework | Typical location |
|---|---|
| Next.js App Router | app/api/affistripe/click/route.ts |
| TanStack Start | src/routes/api/affistripe/click.ts |
| Express/Fastify | Add 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,
}),
});