DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

Catching affiliate self-referrals before you pay out (Stripe)

Once your affiliate program pays real money, someone will refer themselves. They sign up through their own link, or route a friend they coach through it, collect the commission, and on a recurring product they can churn right after the first payout. If you credit commission the moment an invoice is paid, you just funded a discount to yourself.

The good news: Stripe already hands you most of the signals to catch this. You do not need a fraud vendor to start, you need to look at the data you are storing anyway.

The strongest signal: payment method fingerprint

Every card in Stripe has a fingerprint that stays stable across customers. If the affiliate's own subscription and the customer they referred were paid with the same physical card, that is not a coincidence.

async function sharesPaymentMethod(affiliateCustomerId, referredCustomerId) {
  const [a, b] = await Promise.all([
    stripe.paymentMethods.list({ customer: affiliateCustomerId, type: 'card', limit: 20 }),
    stripe.paymentMethods.list({ customer: referredCustomerId, type: 'card', limit: 20 }),
  ]);
  const aPrints = new Set(a.data.map(pm => pm.card.fingerprint));
  return b.data.some(pm => aPrints.has(pm.card.fingerprint));
}
Enter fullscreen mode Exit fullscreen mode

A match here is close to a smoking gun. Flag it, do not pay it.

Cheaper signals worth stacking

Fingerprint is the best one, but a few more catch the sloppier cases:

  • Email overlap. Same address, obvious plus-addressing (me+ref@), or the same domain on a personal-email product. Normalize before comparing.
  • Billing address match between the two customers.
  • Radar / IP. If you use Stripe Radar, the charge carries risk signals and an IP. Same IP for referrer and referred is worth a flag.
  • Churn inside the window. The referred subscription cancels or refunds right after the commission-eligible invoice. This is the classic grab-and-run.

None of these alone should auto-ban. Stacked, they sort the obvious abuse from the false positives.

Where to run the check

Do it at commission time, not signup. On invoice.paid, before you write the credit:

// case 'invoice.paid'
const affiliateId = invoice.customer_metadata?.affiliate_id;
if (affiliateId) {
  const affiliateCustomer = await lookupAffiliateCustomer(affiliateId);
  const selfReferral =
    affiliateCustomer &&
    await sharesPaymentMethod(affiliateCustomer.stripe_id, invoice.customer);

  if (selfReferral) {
    await ledger.flagForReview({ affiliate_id: affiliateId, invoice: invoice.id, reason: 'card_fingerprint_match' });
  } else {
    await ledger.credit({ affiliate_id: affiliateId, amount: commissionOn(invoice.amount_paid), idempotency_key: invoice.id });
  }
}
Enter fullscreen mode Exit fullscreen mode

The pragmatic policy that stops most of the loss: hold the commission until the referred subscription clears its refund window, and route fingerprint matches to a human instead of a payout. You lose nothing legitimate by waiting a few weeks, and you stop paying people to refer themselves.

We handle this inside Referralful (affiliate software built on Stripe, which I help build), so the fingerprint and churn checks run before a payout is ever approved. Even if you build your own, the payment method fingerprint is the single highest-value check to add first.

Top comments (17)

Collapse
 
vollos profile image
Pon

Fingerprint is the right first check — high precision, but low recall. It nails the lazy self-referrer paying with one card and misses anyone who drops a Privacy.com or Revolut virtual number on the referred account, which is a thirty-second move now. The harder-to-fake signal is downstream: a real referred customer uses the product, while a self-referral tends to be a dormant account that exists only to clear the commission window. You've got churn-inside-the-window for the grab-and-run; usage-inside-the-window catches the patient ones who let the sub ride. Either way, holding payout till the refund window clears is the policy that does the heavy lifting.

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

Both right, and the virtual-card point is the one I underweighted in the post. Privacy.com or a Revolut virtual number turns fingerprinting into a speed bump rather than a gate.

On usage-inside-the-window, worth being honest that it prices rather than detects. Faking usage is a couple of logins and an API call, so a determined self-referrer clears it in minutes. Still worth having, but the honest framing is that you are making the fraud cost more than the commission, not catching it.

Which is why your last line is the actual answer. The payout hold does not need to detect anything. It converts the play from get paid and churn into fund the subscription yourself for N months, and that flips the economics with no detection involved at all.

One thing I would stretch there: size the hold to the dispute window rather than the refund window. Card disputes can arrive months after the charge, well past a typical 30-day refund policy, and these schemes fairly often exit through a chargeback instead of a refund request precisely because it avoids talking to you. A hold sized to your refund policy leaves exactly that gap open.

Collapse
 
vollos profile image
Pon

Yeah, sizing to the dispute window changes the shape of it — a chargeback landing months later eats a refund-sized hold entirely. Then the practical problem becomes walking from a dispute back to the exact credit, since the dispute arrives on the charge while the commission was written against the invoice. Does Referralful reverse automatically when charge.dispute.created fires, or does that route to a human the way a fingerprint match does?

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

Automatic for the dispute, human for the fingerprint, and that split is deliberate. charge.dispute.created is a fact. A fingerprint match is a guess. Facts reverse themselves; heuristics get a person, because a false positive there means accusing an honest affiliate of fraud over a shared household IP.

On walking from the dispute back to the credit, the fix sits upstream of the lookup: store the charge id on the commission row when you write it, not just the invoice id. Then a dispute maps back in one hop. If you keep only the invoice reference you are doing a reverse lookup at precisely the moment you want the least ambiguity, and how a charge exposes its invoice is not consistent enough across API versions to lean on.

The other half is that the reversal has to be idempotent and order-independent. A refund and a dispute can both land against the same charge, and you do not want to claw back twice for one loss.

Thread Thread
 
vollos profile image
Pon

Automatic for facts, human for guesses. And putting the charge id on the commission row at write time rather than deriving it later is the part I'd carry into anything I build.

Coming at that same row from the other side: when an affiliate opens their own dashboard, are they reading that row, or a narrower projection of it? Once it carries the charge id, the answer decides how much of the merchant's side rides along with it.

Reading other founders' schemas for exactly that, which rows reach which account, is what Vollos is. Referralful's is welcome any time, and I don't charge for the first read.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

A narrower projection, deliberately. An affiliate sees that a conversion happened, when it happened, what they are owed and what state it is in. Nothing that identifies the charge or the customer goes across. A charge id buys them nothing, and the moment an affiliate can see one you get a ticket asking who the customer behind it is.

The bit I would push on your framing though: build that projection from an explicit allowlist of fields, never by stripping the ones that look sensitive. A denylist is correct on the day you write it and starts leaking silently the next time somebody adds a column. That is the actual mechanism by which merchant-side data rides along. It is almost never a bad decision at design time, it is a schema that grew afterwards.

I am not going to hand the schema over, but this is the fourth useful round in a row, so I would rather keep doing it here where other people can read it.

Thread Thread
 
vollos profile image
Pon

Taking the correction - allowlist is right, and a denylist is a bet that nobody ever adds a column. The version I run into most isn't the schema growing though, it's a second read path showing up later that never goes through the projection: an export, a webhook body, an internal endpoint that gets reused for the affiliate view. An allowlist only protects the path it was written on. So I'm curious where yours lives, in the code that builds the response, or down in the database so the extra column can't come back no matter who asks.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

Down in the database, and your framing is exactly why. An allowlist in the response builder only protects the path it was written on, and your three examples all get written by someone who does not know the projection exists. So the control has to sit where they cannot route around it, which is the query itself.

Concretely: a view exposing only the affiliate-visible columns, and the affiliate-facing role holding a grant on that view and no grant at all on the base table. The view on its own is not enough, because a service connecting with a role that can still read the base table will eventually read it. The grant is the part doing the actual work.

The cost is that widening what an affiliate can see becomes a migration instead of a one-line change. On this particular surface I think that is the right trade, because "somebody widened the affiliate payload in a hotfix" is precisely the failure you are describing.

Thread Thread
 
vollos profile image
Pon

That's the version I'd want too, and it puts the whole surface on one question: what the affiliate role is granted. The regression that tends to follow is a later migration issuing a blanket grant on all tables in the schema to the app role, which puts the base table back in reach without anyone touching the view.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

yeah, and that migration gets written by someone who never read the thread where we agreed not to do that.

which is why i would put it in CI rather than in a convention. one test asserting the affiliate role has no select on the base table survives a blanket grant, because it fails the build on the day somebody adds one. a line in a runbook does not.

Thread Thread
 
vollos profile image
Pon

That test only bites if it runs against the full migration history. A check scoped to the PR's own diff would still pass a blanket grant merged from an unrelated branch the same week, and it would only surface on the next full rebuild.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

fair, a diff-scoped check has exactly that hole. which is why the assertion shouldn't look at migrations at all: build the db from the full chain, then ask the catalog whether the privilege exists right now, has_table_privilege('affiliate_ro', 'public.customers', 'SELECT') returning false. state is order-independent, so it doesn't matter which branch snuck the grant in or what merged first.

and honestly even that only proves something about the database CI just built. someone can run a manual GRANT in prod that never touches a migration file, so the same query is worth running as a post-deploy check against the real db too.

Thread Thread
 
vollos profile image
Pon

That closes the branch-order gap cleanly. Post-deploy against the live catalog is the one that matters more in practice, since a manual GRANT in prod never touches a migration file or a PR.

Same catalog check, different angle: fixed list of tables, or enumerate the schema each run? A fixed list needs someone to remember to add the next sensitive table to it. Enumerating and asserting affiliate_ro holds nothing beyond an explicit allowlist covers that one automatically.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

enumerate, with an allowlist. a fixed list only covers objects someone already thought about, and the thing you're defending against is the table nobody thought about, so it fails silently and forever while an allowlist fails loudly the moment a new object shows up holding a grant. pull the list from pg_class filtered on relkind rather than pg_tables though, so views and matviews come along, that's where a "read-only" role ends up reading columns you thought were locked. and check nothing arrives via PUBLIC, since that won't show up if you only look at direct grants to affiliate_ro.

Thread Thread
 
vollos profile image
Pon

Those two answers close each other, which I hadn't put together until you said it. has_table_privilege resolves what a role holds through PUBLIC and through membership, so running it across the enumerated list covers that case without a second query. A direct-grant query is the version that has to go hunting for PUBLIC separately.

This one's been worth the length. I came out of it with a sharper checklist than I went in with, so thanks for staying with it.

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

Projection, always, and you have put a finger on the real cost of that advice. The moment the row carries merchant-side identifiers, the internal row and the affiliate-facing row have to stop being the same object.

What the affiliate should read is the commission amount, its status, a stable opaque id for the referred account, and the dates. What they should never read is the charge id, the customer's email, the invoice total, or anything about the payment method.

The invoice total is the one people leave in without thinking about it. An affiliate who can see it can infer your per-customer pricing and your discounting, and affiliate programs are open by design, so that population includes competitors who signed up as affiliates. The dashboard becomes a pricing leak before it ever becomes a privacy problem.

Kind offer, thank you. Not something I want to open up right now, but noted.

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

Answering your dispute question here rather than nested, the reply box would not take it further down the thread.

Automatic for the dispute, human for the fingerprint, and that split is deliberate. charge.dispute.created is a fact. A fingerprint match is a guess. Facts reverse themselves; heuristics get a person, because a false positive there means accusing an honest affiliate of fraud over a shared household IP.

On walking from the dispute back to the credit, the fix sits upstream of the lookup: store the charge id on the commission row when you write it, not just the invoice id. Then a dispute maps back in one hop. If you keep only the invoice reference you are doing a reverse lookup at precisely the moment you want the least ambiguity, and how a charge exposes its invoice is not consistent enough across API versions to lean on.

The other half is that the reversal has to be idempotent and order-independent. A refund and a dispute can both land against the same charge, and you do not want to claw back twice for one loss.