DEV Community

Zekariyas Berihun
Zekariyas Berihun

Posted on • Originally published at refearnapp.com

Self-Hosted Affiliate Tracking Software for SaaS: Own Your Data, Own Your Growth

You're building a SaaS. You want affiliates. You sign up for a third-party platform, paste in an embed snippet, and your program is live in 20 minutes.

Six months later you decide to switch tools — better pricing, a feature you need, or the platform gets acquired and the product direction changes. You export your CSV. And then you realize what you've actually lost.

The CSV has affiliate names, emails, and a payout history. What it doesn't have: the actual tracking infrastructure. The referral links are dead. The cookie logic lived on their servers. The conversion attribution pipeline is gone. You don't just migrate data — you rebuild from scratch. Every affiliate needs a new link. Every integration needs to be rewired. The "switch" that should take a weekend takes a month.

This is the core problem with third-party affiliate platforms that nobody writes about. It's not just the monthly fee. It's that switching costs compound invisibly until the day you try to leave.


The Real Cost of Third-Party Affiliate Platforms

Your Data Is Theirs Until You Leave

When you run your affiliate program on a closed-source platform, your conversion events, click logs, affiliate relationships, and commission history all live in their database schema. You get access through their UI and, if you're lucky, a reasonably complete API.

The moment you stop paying, access is revoked. The CSV they give you is a snapshot — flat, denormalized, stripped of relational context. You lose:

  • Click-level attribution data — which specific link, which campaign, which landing page variant drove each conversion
  • Time-series granularity — daily click and conversion curves that let you spot seasonal patterns or campaign spikes
  • Custom metadata — any extra fields you attached to affiliate signups or conversion events
  • The tracking layer itself — the referral link format, the cookie domain, the JavaScript snippet. All of it belongs to them. You can export the ledger. You cannot export the engine.

Switching Means Starting Over

Here's what a migration from a third-party affiliate platform actually looks like in practice:

  1. Referral links break immediately. Every affiliate has been sharing yourplatform.com/ref/affiliate-slug style links that route through the platform's redirect infrastructure. Those links die the moment you cancel. Your affiliates are now sending traffic to dead URLs.
  2. Attribution history is orphaned. Even if you have the CSV, your new system has no way to resolve historical conversions back to the correct affiliate unless you manually re-import and remap every record. Most platforms export commission totals, not the raw conversion events that produced them.
  3. Affiliate trust takes a hit. You have to email every active affiliate, explain the migration, give them new links, and ask them to update everything they've published — blog posts, YouTube descriptions, newsletters. Some won't. Those referrals are permanently lost.
  4. You rebuild the integration anyway. The Stripe webhook handler, the checkout metadata, the cookie capture logic — you write all of it fresh regardless of which new platform you move to. The only question is whether you're writing it to feed another third-party system or your own.

The Lock-In Is Structural, Not Accidental

This isn't a bug in how these platforms work. It's the business model. The harder you are to leave, the more pricing power they have over you. A platform that makes migration easy is a platform that competes on merit every renewal cycle. Most don't want that.

Self-hosted affiliate tracking software eliminates this dynamic entirely. Your data is in your database, in your schema, accessible to your queries. Your tracking logic is in your codebase. Switching tools means updating a config, not rebuilding an affiliate program.


How Affiliate Tracking Actually Works Under the Hood

Before you self-host anything, you need a clear mental model of what "affiliate tracking" actually consists of. It's three distinct components:

1. Attribution — Capturing the Referral

When a visitor arrives via an affiliate link (yourapp.com?ref=affiliate-slug), something needs to capture that ref parameter and persist it until conversion. The standard approach: read the query param, write it to a cookie with a 30–90 day expiration.

// Simplified attribution logic — runs when a visitor lands
function captureAffiliateRef(searchParams: URLSearchParams): void {
  const ref = searchParams.get('ref');
  if (!ref || !isValidSlug(ref)) return;

  // Only set on first touch — first-click attribution
  if (document.cookie.includes('affiliate_ref=')) return;

  const maxAge = 60 * 60 * 24 * 30; // 30 days
  document.cookie = `affiliate_ref=${encodeURIComponent(ref)}; max-age=${maxAge}; path=/; SameSite=Lax`;
}

function isValidSlug(slug: string): boolean {
  return /^[a-zA-Z0-9][a-zA-Z0-9-]{1,39}$/.test(slug);
}
Enter fullscreen mode Exit fullscreen mode

First-click attribution (shown above) gives credit to the first affiliate who drove the visit. The cookie isn't overwritten if it already exists. This is the most common and fair model for SaaS affiliate programs.

2. Conversion — Linking the Payment to the Referral

When the user completes a paid action, you read the cookie and attach the affiliate reference to the payment event. With Stripe, this means passing it as session metadata at checkout creation, then reading it back in the webhook.

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20',
});

export async function POST(request: NextRequest): Promise<NextResponse> {
  // Read the affiliate cookie from the incoming request
  const affiliateRef = request.cookies.get('affiliate_ref')?.value ?? null;

  const sessionParams: Stripe.Checkout.SessionCreateParams = {
    mode: 'subscription',
    line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  };

  if (affiliateRef) {
    // Attach to session — readable in checkout.session.completed webhook
    sessionParams.metadata = { affiliate_ref: affiliateRef };

    // Attach to subscription — readable on every invoice for recurring commissions
    sessionParams.subscription_data = {
      metadata: { affiliate_ref: affiliateRef },
    };
  }

  const session = await stripe.checkout.sessions.create(sessionParams);
  return NextResponse.json({ url: session.url });
}
Enter fullscreen mode Exit fullscreen mode

Why attach to both session and subscription?
The checkout.session.completed event fires once. But if you're paying recurring commissions (e.g., 20% of every renewal), you need the ref on the subscription object so it's available on every invoice.payment_succeeded event going forward.

3. Confirmation — Webhook-Driven Commission Recording

Never trust the client-side success page for conversion confirmation. Users close tabs, skip redirects, or hit back. The Stripe webhook is the authoritative signal.

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20',
});

export async function POST(request: NextRequest): Promise<NextResponse> {
  const body = await request.text();
  const sig = request.headers.get('stripe-signature');

  if (!sig) return NextResponse.json({ error: 'No signature' }, { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Initial conversion
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    const ref = session.metadata?.affiliate_ref;

    if (ref && session.amount_total) {
      await db.affiliateConversion.create({
        data: {
          affiliateSlug: ref,
          stripeSessionId: session.id,
          stripeCustomerId: session.customer as string,
          amountCents: session.amount_total,
          commissionCents: Math.floor(session.amount_total * 0.2), // 20% commission
          status: 'pending',
          convertedAt: new Date(),
        },
      });
    }
  }

  // Recurring commissions
  if (event.type === 'invoice.payment_succeeded') {
    const invoice = event.data.object as Stripe.Invoice;
    const sub = await stripe.subscriptions.retrieve(invoice.subscription as string);
    const ref = sub.metadata?.affiliate_ref;

    if (ref && invoice.amount_paid) {
      await db.affiliateCommission.create({
        data: {
          affiliateSlug: ref,
          stripeInvoiceId: invoice.id,
          amountCents: invoice.amount_paid,
          commissionCents: Math.floor(invoice.amount_paid * 0.2),
          type: 'recurring',
          status: 'pending',
          paidAt: new Date(),
        },
      });
    }
  }

  return NextResponse.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

The Database Schema You Actually Own

This is what your data looks like when it lives in your own Postgres instance — queryable, JOIN-able, fully yours:

-- Affiliates
CREATE TABLE affiliates (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug            TEXT UNIQUE NOT NULL,        -- The ?ref= value
  name            TEXT NOT NULL,
  email           TEXT UNIQUE NOT NULL,
  commission_rate NUMERIC(5,4) DEFAULT 0.20,  -- 20%
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- One-time conversions
CREATE TABLE affiliate_conversions (
  id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  affiliate_id         UUID NOT NULL REFERENCES affiliates(id),
  stripe_session_id    TEXT UNIQUE NOT NULL,
  stripe_customer_id   TEXT NOT NULL,
  amount_cents         INTEGER NOT NULL,
  commission_cents     INTEGER NOT NULL,
  status               TEXT NOT NULL DEFAULT 'pending', -- pending | paid | reversed
  converted_at         TIMESTAMPTZ NOT NULL,
  paid_at              TIMESTAMPTZ
);

-- Recurring commission events
CREATE TABLE affiliate_commissions (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  affiliate_id     UUID NOT NULL REFERENCES affiliates(id),
  stripe_invoice_id TEXT UNIQUE NOT NULL,
  amount_cents     INTEGER NOT NULL,
  commission_cents INTEGER NOT NULL,
  type             TEXT NOT NULL DEFAULT 'recurring',
  status           TEXT NOT NULL DEFAULT 'pending',
  paid_at          TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_conversions_affiliate ON affiliate_conversions(affiliate_id);
CREATE INDEX idx_commissions_affiliate ON affiliate_commissions(affiliate_id);
CREATE INDEX idx_conversions_status ON affiliate_conversions(status);
Enter fullscreen mode Exit fullscreen mode

When your data is in a schema you control, cross-referencing affiliate performance against churn, LTV, or plan type is a SQL query, not a support ticket.


The Build-vs-Deploy Calculation

Here's the honest breakdown of what building affiliate tracking yourself actually costs:

Component DIY Effort Notes
Cookie capture logic ~1 hour Straightforward, shown above
Stripe checkout integration ~2 hours Shown above
Stripe webhook handler ~2 hours Shown above
Database schema + migrations ~1 hour Shown above
Affiliate-facing portal 80–150 hours The part you don't want to build
Payout management UI 40–80 hours Approve, reject, mark as paid
Email notifications 20–40 hours Commission approved, payout sent

The tracking code is 8 hours of work. The surrounding infrastructure — the portal your affiliates actually log into, the payout queue, the notification emails — is where real time disappears. That's not a reason to use a third-party platform. It's a reason to use open-source self-hosted infrastructure that ships all of it already built.


Where RefearnApp Comes In

RefearnApp is a free, open-source, self-hostable affiliate tracking platform built for exactly this stack — Next.js and Stripe — that gives you the 80–150 hours of infrastructure work without the 80–150 hours.

What it includes out of the box:

  • Affiliate portal — Your affiliates get a real dashboard to track their clicks, conversions, and commission history. You don't build this.
  • Commission engine — Configurable percentage and flat-rate commissions, with recurring attribution for subscription billing built in.
  • Payout tracking — Mark commissions as pending, approved, or paid. Full payout history per affiliate.
  • Zero revenue cut — No percentage rake on commissions. Every dollar goes to your affiliates.
  • Your database, your schema — When you deploy RefearnApp, the data is in your Postgres instance. You can query it, back it up, migrate it, or extend it whenever you want. No CSV exports. No support tickets. No lock-in.

The critical difference from third-party platforms: if you ever need to change tools, extend functionality, or migrate to a different payment processor, you're modifying TypeScript in a repo you own. Not starting over.


Summary

Third-party affiliate platforms don't just cost money. They cost optionality. The moment you decide to switch — and eventually you will — you discover that the real asset wasn't the dashboard or the payout UI. It was the tracking layer underneath: the referral links, the cookie logic, the attribution pipeline. All of it belongs to them.

Self-hosted affiliate tracking software means:

  • Your referral links are on your domain. They don't break when you change tools.
  • Your conversion data is in your database. You query it, you own it, you migrate it.
  • Your tracking logic is in your codebase. You read it, you modify it, you extend it.

- Switching costs drop to near zero. You update config, not rebuild a program.

Conclusion & Next Steps

Take Control of Your Affiliate Data

RefearnApp is fully open-source, self-hostable, and AGPL-3.0 licensed. You can deploy it completely free on your own infrastructure using Coolify or check out our direct paths:

Top comments (0)