David Lewin David Lewin
SEO, Traffic & Automation

Building an affiliate program on Stripe subscriptions: server-side tracking vs FirstPromoter

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

The hard part of an affiliate program on Stripe is linking the click, which happens on your site, to the payments, which happen in Stripe days or months later. Store the referral code in a first-party cookie, then pass it to Stripe Checkout in three places: client_reference_id, the session metadata and subscription_data.metadata. Stripe copies the subscription metadata onto every invoice, so a webhook endpoint can attribute each customer even when events arrive out of order, then book commissions on invoice.paid and reverse them on charge.refunded. Our tested in-house version does that in about 150 lines of Python with SQLite. What it leaves out is most of the work: affiliate sign-up, a portal, payouts, tax forms and fraud review. FirstPromoter does all of that by connecting to your Stripe account, from $49 a month; build in-house only if your commission rules don't fit a tool.

A SaaS company or agency that bills through Stripe can start an affiliate program in an afternoon: give partners a link, pay them a share of what their referrals pay. Then the questions start. Which customer came from which partner? What happens when that customer upgrades, gets a refund, or comes back through another partner's link? Who pays the affiliates, in which currency, and with what tax paperwork? This guide builds the tracking part yourself, on Stripe's webhooks, so you can see exactly what it takes, then compares it with handing the whole program to FirstPromoter.

How this guide was made

We tested the in-house ledger with Stripe-shaped events, signed with Stripe's signature scheme. The signatures were also checked with the official stripe Python library (version 16.0.0), so the verification matches Stripe's. We didn't send events from a real Stripe account, and the Checkout snippet in section 3 wasn't run against Stripe. The FirstPromoter signup call was tested against a mock of its API. How FirstPromoter matches Stripe customers comes from its help center; plans and prices come from its pricing page, checked in October 2026.

1. The short answer

In-house on Stripe webhooksFirstPromoter
Tracking the clickYour own cookie and Checkout metadataIts script sets a tracking cookie; you pass its ID to Checkout or its API
Payments, upgrades, refundsYour webhook endpoint and ledgerRead from your connected Stripe account
Commission rulesAnything you can codeRecurring, lifetime, tiered, multi-level, double-sided rewards
Affiliate sign-up and portalYou build itIncluded
PayoutsYou run them (PayPal, Wise, bank)Mass payouts; one-click PayPal from the Business plan
CostEngineering time and upkeep$49 to $149 a month, by affiliate revenue

2. What each side runs

What you build and run: in-house vs FirstPromoter
What you build and run: in-house vs FirstPromoterIn-house: referral click / FirstPromoter: referral click → Stripe Checkout / Stripe Checkout → Your webhook endpoint / FirstPromoter's Stripe connection → Your ledger / FirstPromoter campaigns → Payouts, tax forms, portal / Affiliate portal and payoutsIn-house: referral click: your site stores the code in a first-party cookieIn-house: referral clickyour site stores the code ina first-party cookieFirstPromoter: referral click: FirstPromoter's script stores its tracking IDFirstPromoter: referralclickFirstPromoter's script storesits tracking IDStripe Checkout: you pass the code as client_reference_id and in subscription metadataStripe Checkoutyou pass the code asclient_reference_id and insubscription metadataStripe Checkout: you pass the tracking ID as client_reference_idStripe Checkoutyou pass the tracking ID asclient_reference_idYour webhook endpoint: signatures, duplicates, event order, retriesYour webhook endpointsignatures, duplicates, eventorder, retriesFirstPromoter's Stripe connection: payments, refunds, upgrades, cancellationsFirstPromoter's Stripeconnectionpayments, refunds, upgrades,cancellationsYour ledger: rates, windows, holds, refund reversalsYour ledgerrates, windows, holds, refundreversalsFirstPromoter campaigns: rates, windows and holds set in a dashboardFirstPromoter campaignsrates, windows and holds setin a dashboardPayouts, tax forms, portal: you build them or do them by handPayouts, tax forms, portalyou build them or do them byhandAffiliate portal and payouts: included in the planAffiliate portal andpayoutsincluded in the plan
Both columns start the same way: a referral ID has to reach Stripe Checkout. After that, the left column is code and processes you own; the right column is configuration.

3. The core problem: tying a Stripe customer to a referral

The referral happens on your marketing site. The money happens in Stripe, often days later, sometimes on another device, and then again every month. Stripe knows nothing about your affiliates, so the referral code has to be carried into Stripe at the one moment both are present: checkout.

  1. Keep the code in a first-party cookie. When someone arrives with ?ref=ANNA, set ref=ANNA; Max-Age=7776000; Path=/; Secure; SameSite=Lax (90 days). Don't overwrite an existing cookie if you want first-click attribution. Validate the format; the cookie is user input.
  2. Pass it to Checkout in three places. client_reference_id and the session's metadata reach your checkout.session.completed handler. subscription_data.metadata is stored on the subscription, and Stripe copies it onto every invoice that subscription creates.
  3. On the webhook, store customer → affiliate. From then on, every invoice and refund for that customer ID can be credited without looking at cookies again.
import re, stripe

REF = re.compile(r'^[A-Za-z0-9_-]{1,40}$')

def create_checkout(cookies, price_id):
    ref = cookies.get('ref', '')
    extra = {'client_reference_id': ref, 'metadata': {'ref': ref},
             'subscription_data': {'metadata': {'ref': ref}}} if REF.match(ref) else {}
    return stripe.checkout.Session.create(
        mode='subscription',
        line_items=[{'price': price_id, 'quantity': 1}],
        success_url='https://app.example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
        cancel_url='https://app.example.com/pricing',
        **extra)

Why the subscription metadata, when the session already has the code? Stripe doesn't deliver events in order. For a new subscription, invoice.paid can reach you before checkout.session.completed. If attribution only happens on the checkout event, the first invoice finds no affiliate and the first commission is lost. With the code on the subscription, the invoice carries it: under parent.subscription_details.metadata in API versions from 2025-03-31, and subscription_details.metadata before that. The ledger below reads both.

If your product has a free sign-up before any payment, store the code on your user record at sign-up and write it to the Stripe customer's metadata when you create the customer. Customer metadata takes up to 50 keys, with keys up to 40 characters and values up to 500.

4. The in-house ledger

One file, standard library only, SQLite. It handles three Stripe events: checkout.session.completed (attribution), invoice.paid (commission) and charge.refunded (reversal), and prints what's due to each affiliate.

"""In-house affiliate tracking on Stripe subscriptions: a webhook endpoint that ties each Stripe customer to the affiliate
who referred them and keeps a commission ledger. Standard library only, SQLite.

  STRIPE_WEBHOOK_SECRET=whsec_... python affiliate_ledger.py serve      # POST /stripe/webhook on :8080
  python affiliate_ledger.py payouts                                     # what is due, per affiliate and currency

Stripe events to send: checkout.session.completed, invoice.paid, charge.refunded.
"""
import hashlib, hmac, json, os, sqlite3, sys, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

RATE = 0.30                # 30% of what the customer pays, excluding tax
WINDOW = 365 * 86400       # for the first 12 months after the referral
HOLD = 30 * 86400          # payable after 30 days, so refunds inside that time cancel it before you pay
MIN_PAYOUT = 5000          # in minor units (cents): 50.00
TOLERANCE = 300            # seconds; Stripe's own libraries use the same default for signature timestamps
DB = os.environ.get('LEDGER_DB', 'affiliates.sqlite3')


def db():
    c = sqlite3.connect(DB, isolation_level=None, timeout=10)
    c.row_factory = sqlite3.Row
    c.executescript('''
      CREATE TABLE IF NOT EXISTS affiliates (code TEXT PRIMARY KEY, email TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1);
      CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, type TEXT, received INTEGER);
      CREATE TABLE IF NOT EXISTS attributions (customer TEXT PRIMARY KEY, code TEXT NOT NULL REFERENCES affiliates,
        attributed_at INTEGER NOT NULL);
      CREATE TABLE IF NOT EXISTS ledger (id INTEGER PRIMARY KEY, code TEXT NOT NULL, customer TEXT NOT NULL,
        source TEXT NOT NULL UNIQUE, amount INTEGER NOT NULL, currency TEXT NOT NULL, available_at INTEGER NOT NULL,
        paid_out INTEGER NOT NULL DEFAULT 0);''')
    return c


def verify(payload: bytes, header: str, secret: str, now=None):
    """Stripe-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "t.payload">[,v1=... while a secret is being rolled]."""
    parts = [p.split('=', 1) for p in header.split(',') if '=' in p]
    t = next((v for k, v in parts if k == 't'), '')
    sigs = [v for k, v in parts if k == 'v1']
    if not t.isdigit() or not sigs or abs((now or time.time()) - int(t)) > TOLERANCE:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b'.' + payload, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)


def attribute(c, customer, code, email, created):
    aff = code and c.execute('SELECT * FROM affiliates WHERE code = ? AND active', (code,)).fetchone()
    if not aff or not customer or (email or '').lower() == aff['email'].lower():
        return False  # unknown code, no customer (one-off guest payment) or the affiliate referring themselves
    # First referral wins: a customer who comes back through another link later stays with the first affiliate.
    c.execute('INSERT OR IGNORE INTO attributions VALUES (?, ?, ?)', (customer, code, created))
    return True


def on_checkout(c, s):
    code = (s.get('metadata') or {}).get('ref') or s.get('client_reference_id')
    ok = attribute(c, s.get('customer'), code, (s.get('customer_details') or {}).get('email'), s['created'])
    return 'attributed' if ok else 'no attribution'


def on_invoice_paid(c, inv):
    # Stripe doesn't order events: the first invoice.paid can arrive before checkout.session.completed. The ref is also
    # on the subscription's metadata, which Stripe copies onto each invoice (parent.subscription_details in API
    # versions from 2025-03-31, subscription_details before), so attribute from there if needed.
    sub = (inv.get('parent') or {}).get('subscription_details') or inv.get('subscription_details') or {}
    attribute(c, inv.get('customer'), (sub.get('metadata') or {}).get('ref'), inv.get('customer_email'), inv['created'])
    a = c.execute('SELECT * FROM attributions WHERE customer = ?', (inv.get('customer'),)).fetchone()
    if not a or inv['amount_paid'] <= 0 or inv['created'] > a['attributed_at'] + WINDOW:
        return 'no commission'
    tax = inv['total'] - inv['total_excluding_tax'] if inv.get('total_excluding_tax') is not None else 0
    amount = round((inv['amount_paid'] - max(tax, 0)) * RATE)
    c.execute('INSERT OR IGNORE INTO ledger (code, customer, source, amount, currency, available_at) VALUES (?,?,?,?,?,?)',
              (a['code'], a['customer'], inv['id'], amount, inv['currency'], inv['created'] + HOLD))
    return f'commission {amount}'


def on_refund(c, ch):
    # amount_refunded is cumulative: a second partial refund reports the total so far, so book only the difference.
    # ponytail: refunds are matched to the customer, not to the invoice (newer Stripe API versions drop Charge.invoice);
    # the cap below stops a refund from reversing more than the customer earned. Match on InvoicePayment if you need exact.
    a = c.execute('SELECT * FROM attributions WHERE customer = ?', (ch.get('customer'),)).fetchone()
    if not a or ch['created'] > a['attributed_at'] + WINDOW:
        return 'no reversal'
    booked = -c.execute("SELECT COALESCE(SUM(amount), 0) FROM ledger WHERE source LIKE ?", (ch['id'] + ':%',)).fetchone()[0]
    earned = c.execute('SELECT COALESCE(SUM(amount), 0) FROM ledger WHERE customer = ? AND currency = ?',
                       (a['customer'], ch['currency'])).fetchone()[0]
    amount = min(round(ch['amount_refunded'] * RATE) - booked, earned)
    if amount <= 0:
        return 'no reversal'
    c.execute('INSERT INTO ledger (code, customer, source, amount, currency, available_at) VALUES (?,?,?,?,?,?)',
              (a['code'], a['customer'], f"{ch['id']}:{ch['amount_refunded']}", -amount, ch['currency'], 0))
    return f'reversal {amount}'


HANDLERS = {'checkout.session.completed': on_checkout, 'invoice.paid': on_invoice_paid, 'charge.refunded': on_refund}


def handle(payload: bytes, sig: str, secret: str):
    if not verify(payload, sig, secret):
        return 400, 'bad signature'
    ev = json.loads(payload)
    fn = HANDLERS.get(ev['type'])
    if not fn:
        return 200, 'ignored'
    c = db()
    try:
        c.execute('BEGIN IMMEDIATE')  # one writer at a time; the event row and its effect commit together
        if c.execute('SELECT 1 FROM events WHERE id = ?', (ev['id'],)).fetchone():
            c.execute('ROLLBACK')
            return 200, 'duplicate'   # Stripe delivers at least once and retries for up to 3 days
        c.execute('INSERT INTO events VALUES (?, ?, ?)', (ev['id'], ev['type'], int(time.time())))
        result = fn(c, ev['data']['object'])
        c.execute('COMMIT')
        return 200, result
    except Exception:
        c.execute('ROLLBACK')
        raise  # -> 500, Stripe retries


def payouts(now=None):
    """Balance per affiliate and currency: commissions past their hold, minus every reversal, minus what was paid."""
    now = now or time.time()
    q = '''SELECT code, currency, SUM(amount) AS due FROM ledger WHERE NOT paid_out AND (available_at <= ? OR amount < 0)
           GROUP BY code, currency HAVING due >= ? ORDER BY code'''
    return [dict(r) for r in db().execute(q, (now, MIN_PAYOUT))]


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != '/stripe/webhook':
            return self.reply(404, 'not found')
        body = self.rfile.read(int(self.headers.get('Content-Length', 0)))  # raw bytes: re-serialized JSON won't verify
        try:
            self.reply(*handle(body, self.headers.get('Stripe-Signature', ''), os.environ['STRIPE_WEBHOOK_SECRET']))
        except Exception as e:
            self.reply(500, type(e).__name__)

    def reply(self, code, text):
        self.send_response(code)
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()
        self.wfile.write(text.encode())


if __name__ == '__main__':
    if sys.argv[1:] == ['serve']:
        ThreadingHTTPServer(('0.0.0.0', 8080), Handler).serve_forever()
    elif sys.argv[1:] == ['payouts']:
        for r in payouts():
            print(f"{r['code']:<16} {r['due'] / 100:>10.2f} {r['currency'].upper()}")

The decisions inside it:

  • Verify on the raw bytes. Stripe signs timestamp.body with HMAC-SHA256. Parse the JSON before checking and re-serialize it, and the bytes change and the check fails. Timestamps more than five minutes old are refused, so a captured request can't be replayed later. During a secret rotation Stripe sends two v1 signatures; either may match.
  • Exactly-once from at-least-once. Stripe may deliver an event more than once and retries failures for up to three days. The event ID and its effect are written in one SQLite transaction, so a duplicate is skipped, and a crash halfway leaves nothing, so the retry is processed. The ledger also has a unique key per invoice, for the case where you resend events from the dashboard and they arrive with new IDs.
  • First referral wins. INSERT OR IGNORE on the customer ID: a customer who later clicks another partner's link stays with the first. Change this line if your policy is last-click.
  • Commission on revenue, not tax. 30% of amount_paid minus tax, for 12 months from the referral. A $0 invoice (trial, 100% coupon) earns nothing.
  • A 30-day hold. Each commission becomes payable 30 days after the invoice, so most refunds land before you've paid the affiliate. Reversals count immediately.
  • Refunds are cumulative. amount_refunded on a charge is the total so far, so a second partial refund reports both. The ledger books only the difference, and never reverses more than the customer earned the affiliate.
  • Self-referrals are refused when the buyer's email matches the affiliate's. It's a minimum: people use second addresses, so also review payout lists by hand.

5. What our test showed

All 28 checks passed:

  • Signatures made the way Stripe makes them were accepted by both our check and the official stripe library. A tampered body, a timestamp 10 minutes old and missing or malformed headers were refused; a header with an old and a new signature was accepted.
  • Attribution worked from session metadata and from client_reference_id. Unknown and deactivated codes and an affiliate buying through their own link were refused, and a later click through a second affiliate's link didn't move the customer.
  • A $120 invoice including $20 tax earned $30.00. The same event delivered twice, and the same invoice resent in a new event, were each booked once. A $0 invoice, an invoice 13 months after the referral and a customer nobody referred earned nothing.
  • When the first invoice.paid arrived before checkout.session.completed, the customer was attributed from the subscription metadata, in both the 2025+ and the older invoice shape.
  • A $50 partial refund reversed $15.00; refunding the whole $120 reversed $15.00 more and stopped there, because the tax part was never commissioned.
  • When the handler failed halfway, the event wasn't recorded, so Stripe's retry would be processed.
  • Payouts listed only commissions past the 30-day hold, net of reversals, for affiliates owed at least $50.
  • Over HTTP, a bad signature got 400 and a valid event 200, using the raw request bytes.

6. What the ledger doesn't do

The table of commissions is the small part. A program people join and stay in also needs:

  • Affiliate sign-up and approval, with terms they accept.
  • A portal where affiliates get links, see clicks, signups and earnings, and trust the numbers.
  • Click tracking, for conversion rates per affiliate. The ledger only sees money.
  • Payouts in several currencies and methods, with records of each payment.
  • Tax paperwork. In the US, that means collecting W-9 or W-8BEN forms and issuing 1099-NEC forms to US affiliates paid above the IRS reporting threshold. Other countries have their own rules.
  • Coupon-code attribution, for affiliates who promote a code rather than a link.
  • Disputes. A chargeback should reverse the commission like a refund; the ledger above doesn't listen for charge.dispute.created yet.
  • Fraud review: stolen cards bought through an affiliate link, cookie stuffing, self-referrals with new emails.
  • Emails to affiliates about new referrals and payouts.

Each is ordinary work. Together they are a product, and maintaining one is the real cost of building in-house.

FirstPromoter connects to your Stripe account (Settings, then Integrations) and tracks sales, refunds, upgrades and cancellations from it. Your part is still the link between a visitor and a customer, done one of three ways:

  • Stripe Checkout: pass FirstPromoter's tracking ID as client_reference_id. The ID is in the _fprom_tid cookie its script sets (_fprom_track on accounts created before April 2021), or in window.$FPROM.data.tid in the browser.
  • Sign-up first, payment later: call its tracking API at sign-up with the visitor's tracking ID, the email and a uid. FirstPromoter later matches the Stripe customer by the same email, or by a uid equal to the Stripe customer ID or to an fp_uid value in the customer's metadata.
  • Coupon codes tied to an affiliate, for the cases where no cookie survives.
"""FirstPromoter: tell it about a signup, server-side, so the later Stripe payments match the right affiliate.
Call this when the account is created, with the _fprom_tid cookie the FirstPromoter script set in the visitor's browser
(_fprom_track on accounts created before April 2021)."""
import json, os, urllib.request

FP_API = os.environ.get('FP_API', 'https://api.firstpromoter.com/api/v2')


def fp_track_signup(email, stripe_customer_id, tid, ip=None):
    body = {'email': email, 'uid': stripe_customer_id, 'tid': tid, **({'ip': ip} if ip else {})}
    req = urllib.request.Request(FP_API + '/track/signup', json.dumps(body).encode(), method='POST', headers={
        'Authorization': 'Bearer ' + os.environ['FP_API_KEY'], 'Account-ID': os.environ['FP_ACCOUNT_ID'],
        'Content-Type': 'application/json'})
    try:
        with urllib.request.urlopen(req, timeout=10) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        if e.code in (404, 422):  # 404: no such visitor (no referral, or cookie expired); 422: signup already tracked
            return None
        raise

Rules from FirstPromoter's own troubleshooting list worth knowing before launch: the connected Stripe account and FirstPromoter must both be in live mode or both in test mode; a lead becomes a customer only after a real payment above $0.50, so a 100%-coupon invoice doesn't convert it; and with the Stripe integration on, don't also send sales through the API, or they're counted twice.

8. FirstPromoter plans

PlanMonthlyYearlyAffiliate revenueNotable
Starter$49$490Up to $5,000 a monthUp to 1,000 affiliates, 3 campaigns
Business$99$990Up to $15,000 a monthUnlimited affiliates, custom domain, embedded dashboard, one-click PayPal payouts
Enterprise$149 (or quoted)$1,490Above $15,000 a monthWhite label, SSO, postback URL, managed auto payouts

"Affiliate revenue" is the revenue your affiliates bring in, and you move up a plan when it passes the limit. Every plan includes the Stripe, Paddle, Chargebee, Recurly and Braintree integrations, the admin and tracking APIs with webhooks, recurring and multi-level commissions, fraud detection and a 14-day free trial without a card.

9. Our opinion: build or buy

  • Start with FirstPromoter if you're launching a program. At $49 to $99 a month, the portal, payouts and fraud checks cost less than a week of engineering time, and a program that may not work shouldn't need a project to find out.
  • Build in-house when your rules don't fit any tool (commissions on usage-based billing, revenue shared across products in odd ways), or when the program is large enough that the plan price is real money and you have people to run it.
  • Either way, put the referral code on the Stripe subscription. It's free, it survives event ordering, and if you ever migrate between in-house and a tool, every invoice still says who referred it.
  • Write the policy before the code: rate, window, first or last click, tax, hold period, refunds and disputes, minimum payout. Affiliates will ask, and the code should match what you told them.

Related: verifying payment callbacks for crypto orders, a webhook relay for marketing events and where to host a webhook service.

10. Tools

Top picks

ToolRoleWhy it fits
FirstPromoterAffiliate and referral tracking for subscription billingConnects to Stripe and tracks payments, refunds and upgrades itself; tracking API, affiliate portal, payouts and fraud checks; from $49 a month with a 14-day free trial.

Alternatives: free tools and references

We don't earn anything from these links.

11. Pitfalls

  • Attributing only on checkout.session.completed. Events arrive out of order; put the code on the subscription too.
  • Verifying a parsed body. Frameworks that parse JSON before your handler break the signature check. Read the raw body for the webhook route.
  • Commission on gross. Tax isn't your revenue. Neither is a refunded payment, so hold commissions long enough to see refunds.
  • Treating amount_refunded as the latest refund. It's the running total.
  • Forgetting disputes. A chargeback costs you the payment and a fee; reverse the commission.
  • Trusting the cookie. It's user input: validate its format before it reaches Stripe or SQL.
  • Test and live mixed up. Separate webhook secrets, separate databases, and for FirstPromoter, the same mode on both sides.
Bottom line

Tie each Stripe customer to an affiliate by carrying the referral code from a first-party cookie into Checkout's client_reference_id, metadata and subscription metadata, then book commissions from invoice.paid and reverse them from refunds, with signed, deduplicated webhooks. That part is about 150 lines and we've tested it. The portal, payouts, tax forms and fraud review are the larger job. FirstPromoter does them from $49 a month by reading your Stripe account directly, so start there and build in-house only when your rules outgrow it.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.