Portreeve
explainer · 7 Sept 202611 min read

Promo code abuse: why single-use codes don't stop it

Promo code abuse is a multi-account problem: why single-use codes, per-email limits and card gates fail, and how to link the accounts behind the farming.

One referrer on your dashboard has invited forty-one people. Thirty-nine of them signed up inside the same nine hours, none has logged in since, and all forty-one credits are already applied. That is promo code abuse, and the mistake most teams make in the first hour is treating it as a coupon problem they can fix in their billing config.

The attacker is solving a different problem. Their whole method is making accounts cheap enough that one person can be forty-one customers. Every control that keys on the account (single-use codes, one per email, new customers only) is measuring the thing they manufacture for free.

Where the money goes in referral abuse

Three shapes, with three different cost structures.

The self-referral loop. One person on both sides. They sign up, get their referral link, open a fresh browser profile, sign up again through their own link, and collect both halves. On a $20-give-$20-get program, each pair costs you $40 and produces one account that will never convert. The loop is tight and repeatable, which is why it usually shows up as a single referrer with an implausible invite count rather than as diffuse noise.

The referral ring. A Discord or Telegram group where people trade links so everyone's referral counter goes up. Every participant is a distinct real person on distinct hardware, which makes this harder to catch than self-referral and cheaper to catch than it looks: rings show up as reciprocity. A referred B, B referred A, and neither ever paid.

Rings scale further than founders expect. In May 2021 the U.S. Attorney's office in Massachusetts charged nineteen people over a scheme to open fraudulent driver accounts at rideshare and delivery companies using more than 2,000 stolen identities, alleging they obtained referral bonuses "by creating fraudulent driver accounts for the sole purpose of referring other fraudulent driver accounts." The referral program was the payout mechanism. The fake accounts were only the machinery to reach it.

The leaked code. You gave a podcast, an influencer, or one enterprise account an "exclusive" 30% code. Within a day it is in a coupon extension's database and being auto-applied at every checkout. Honey describes itself as a tool that "automatically searches for and tests available coupon codes at checkout on 30,000+ popular sites". A merchant posting as TrueGrit in the Shopify community thread on blocking those extensions described the merchant-side consequence: "Whether you like it or not, the Honey app will extract, store and share any coupon used by your customers who have the Honey extension running on their browser." One customer redeems your private code with the extension installed, and the code is now in front of everyone.

That third shape is a distribution failure rather than fraud, and no detection layer will fix it. The answer is unique codes per recipient plus a redemption cap, applied at the moment you hand the code out. The rest of this post is about the first two.

Single-use per what, exactly

"Just make the codes single-use" is the first suggestion in every thread, and it dissolves on contact with the question of what the code is single per.

Single per code is what Stripe's max_redemptions gives you. Stripe's own documentation is explicit that redemption limits "cap how many times a coupon can be applied across all customers," and that with max_redemptions=50 the coupon "can only be applied 50 times total: once each to 50 different customers, 50 times to the same customer, or any combination until the limit is reached." It is a budget cap. It stops the bleeding at a fixed dollar amount and does nothing about who did the bleeding.

Single per email is the one people actually mean, and it has been broken for as long as Gmail has supported plus-addressing. In a 2018 Hacker News thread, zbentley described the asymmetry: with a $5 first-time credit and a fast signup flow, "it's very easy for those people to sign up for multiple accounts and get multiple $5 credits."

The ten-minute version of five new customers, run by one person on one laptop:

  1. New Chrome profile, or just an incognito window if you only fingerprint on cookies.
  2. A mailbox from any disposable-mail service, or [email protected] if you normalize nothing.
  3. Signup through the referral link from account one.
  4. A fresh virtual card number if checkout requires one.
  5. Repeat.

Nothing in that list is a technique. It is four keystrokes and a browser menu. If your control keys on the email, you are asking the attacker to change the cheapest thing they own. We wrote separately about why disposable email domains are the wrong list to fight on; the short version is that the supply of domains is unbounded and the big domains you would have to block have real customers behind them.

What Stripe's first-time check does and does not do

restrictions.first_time_transaction looks like the control you want. Stripe describes it as restricting the coupon "to customers who have no prior transaction history on your platform", and it is stricter than most people assume: it disqualifies a customer who "Initiated a PaymentIntent, even if the payment never completed" or who subscribed to a trial that later canceled.

Read that back with the object model in mind. The subject of the sentence is a Stripe Customer. The check asks whether this Customer has history, and your signup flow creates a new Customer for every account. A farmer running their fifth account is a first-time customer by construction, because the object that carries the history was born ninety seconds ago.

This is not a Stripe defect. The billing layer holds payment history, not identity, and "is this the same person" was never a question it was built to answer. The mistake is reading a per-object flag as a per-person guarantee and then not looking again.

What to check instead lives in your data: not "has this Customer transacted before" but "have I seen this person before, under a different Customer."

Requiring a card is the wrong tax

The next suggestion is to require a card at signup, on the theory that cards are scarce.

They are not. Privacy.com's free Personal plan lets you create up to 12 new cards per month at no cost, and $5 a month doubles that to 24. Revolut and several neobanks issue disposable virtual cards from the app. A farmer working a $20 credit has a supply of distinct card numbers deep enough that the limit stops mattering, at a marginal cost of roughly zero, and every one of those numbers passes the card gate.

Now price the same gate for the person the promotion exists for. A friend sent them a link with $20 attached. They are three fields into a signup for a product they have not evaluated yet, and you have just asked for a card number. Referral traffic is the top of a funnel you already paid for, and a card gate is the most expensive possible place to put friction: it taxes the honest referee heavily and the farmer not at all. The same logic applies to trials, which is why we argue against the card wall in free trial abuse too.

The card is still useful. It is useful after the charge, as a linking key rather than a gate. Stripe returns a card fingerprint that is stable across customers for the same underlying number, and funding type tells you prepaid from credit. Both belong in your identity graph. Neither belongs in front of your signup form.

Link on the things that cost something to change

The unit of enforcement has to be the person. You cannot observe a person, so you approximate one by linking accounts across keys, ranked by what it costs the attacker to get a fresh one.

  • Card fingerprint. Free for a virtual-card user, expensive for someone using a real bank card. Strong when it fires, silent when it does not.
  • Phone. A verifiable mobile number costs real money and real friction to replace at volume. Strong linking key, bad gate.
  • Device fingerprint. A new browser profile is free. A meaningfully different device signature is not, which is why the farmer's five accounts in ten minutes share a fingerprint even though they share nothing else. See device fingerprinting for what goes into the signal.
  • Email shape. Normalize plus-addressing and dots, check deliverability and domain age. Weak alone, useful as corroboration.

IP is not on that list, and that is deliberate. An IP links roommates, an office, a coffee shop, a university, and every mobile user behind the same carrier NAT. Treated as a linking key it manufactures false positives out of shared egress, and it misses the farmer sitting on home broadband entirely. Use it as a velocity counter and a proxy signal. Never as the thing that says two accounts are one person.

This is the tool I built for it. Portreeve links accounts across hashed identity keys (email, device fingerprint, card fingerprint, phone, payer wallet), and confirmed abuse on one account marks the whole linked cluster; IP stays a soft signal that never links accounts on its own. You call it at signup and trial_start and get back allow, review, or block with reason codes. The part that matters for a promotion is review: it never blocks the user, so a suspicious referral proceeds normally, lands in a queue, and if you deny it later a signed webhook reaches your server so you can revoke the credit.

Measure the farm before you block anything

Do not add enforcement until you know the size of the problem, because the honest redemption is the entire point of the promotion and the downside of over-blocking is worse than it looks. The same Hacker News post makes the second-order case: kill a promotion mid-flight and the legitimate users who told their friends to sign up now have "their friends bad-mouthing the site to them."

So compute the farming rate first. Three numbers, from data you already have:

  1. Credit concentration. Group redemptions by referrer. What share of issued credit came from the top 1% of referrers? A healthy program is long-tailed and boring.
  2. Cluster share. Join accounts to shared identity keys, group into connected components, and compute the share of issued credit that landed in clusters of size two or more. This is the number that decides everything.
  3. Cluster conversion. Compare day-30 retention and paid conversion for accounts in clusters against singletons. If clustered accounts convert at a tenth the rate, the clusters are the farm, and you now have a dollar figure for what it costs you monthly.

If cluster share is 3%, leave the promotion alone and revisit next quarter. If it is 40%, you are not running a promotion, you are running a payout program, and the arithmetic on your CAC has been wrong for as long as it has been live.

What to do with a cluster you have convicted

Quietly, and narrowly.

Void the pending credit rather than clawing back settled money, because clawbacks generate support tickets from the honest side of any cluster you got wrong. Decline the next redemption with the generic "this code isn't valid" that a genuinely expired code produces; a specific error message is free training data for whoever is testing your rules. Leave the referrer's other invites alone unless those accounts are in the cluster too, because a real customer with two fake invites is still a real customer.

Then close the loop. Whatever you use to link accounts needs to hear the outcome, so that the next signup on that device or card is scored against a known-bad cluster instead of being re-litigated from scratch. Report the conviction, let the cluster carry it, and read how to handle each verdict before you wire the revoke path.

The promotion survives this. It is meant to buy you customers who tell their friends, and the version that runs after you remove the farm is the version you thought you were running the whole time.

If you want the account-linking part without building the graph yourself, Portreeve's free tier screens 1,000 events a month with no card: create an account.

← Back to all posts