Referral and promo-code abuse: the email signals that help
· 6 min read · Disposable email
Referral and promo-code abuse is one person pretending to be several, so the email signals that help are the ones that link accounts together, not the ones that judge a single address. Normalize every address so Gmail dot and plus-tag variants collapse to one inbox, look for several referees sharing an obscure custom domain with no website, spot templated local parts like alex.t1, alex.t2, alex.t3, and only then add the per-address checks for disposable domains and machine-generated usernames. Then score the referrer across all their referees before you pay anything.
The per-address checks most teams already have catch the clumsy cases. The linking checks catch the ones that cost you money.
Why single-address checks miss most of it
A signup filter asks "is this address bad?". A self-referrer's addresses usually aren't, individually:
alex.taylor+r1@gmail.comis a working Gmail address.a.lextaylor@gmail.comis the same working Gmail address.alex@taylor-deals.net,sam@taylor-deals.netandjo@taylor-deals.netare three real mailboxes on a domain the abuser registered for a few dollars, possibly with a catch-all so any local part works.
Each passes a disposable check. Each passes a randomness check. Together, they're obviously one person. The question that catches abuse is "do these referees look like they come from the same place as each other and as the referrer?".
Signal 1: normalized address collisions
Gmail ignores dots in the local part. Google's own help page is titled "Dots don't matter in Gmail addresses". Separately, many providers deliver user+anything@ to user@, a convention called subaddressing. RFC 5233 describes it and notes that the separator character and the parsing rules are site- and implementation-specific, so + is common but not universal.
Build a normalized key and compare it across the referrer and all their referees:
function normalizedKey(email: string): string {
const [local, domain] = email.trim().toLowerCase().split('@');
let user = local.split('+')[0];
if (domain === 'gmail.com') user = user.replace(/\./g, '');
return `${user}@${domain}`;
}
Only remove dots for Gmail. At most other providers, j.smith and jsmith are different people. The provider-by-provider details are in Gmail dots, plus addressing and duplicate accounts.
A referee whose key matches the referrer's key is a self-referral, full stop. Two referees sharing a key with each other is almost as clear.
Signal 2: clusters on a cheap custom domain
The next step up for an abuser is their own domain. It costs little, gives unlimited addresses, and doesn't look like Gmail. What gives it away is the combination:
- several referees from one referrer share the same domain,
- the domain isn't a known provider or a recognizable company,
- and it often has mail records but no website.
That last one is easy to check with DNS. A domain that can receive email but serves nothing on the apex or www is unusual for a real business. Signup domains with no website covers how to read that signal and its false positives. Some are also set up to accept mail for any local part, which catch-all email domains explains.
Three colleagues at acme-logistics.com referring each other is fine and probably great for you. Three "colleagues" at a domain with no website, all referred by the same person within an hour, is not.
Signal 3: templated local parts
People creating accounts by hand reuse patterns. alex.t1, alex.t2, alex.t3. promo.jan, promo.feb. Strip trailing digits and short suffixes, then count how many referees share a stem:
function stem(email: string): string {
const local = email.toLowerCase().split('@')[0].split('+')[0].replace(/[._-]/g, '');
return local.replace(/\d+$/, '');
}
Three or more referees with the same stem from one referrer is worth a hold.
Signal 4: the per-address checks
Now add the checks you probably already have:
- Disposable domains. An abuser who doesn't care about receiving mail later will use a throwaway inbox. The domain lists are public and cover several thousand services.
- Machine-generated local parts. Scripted abuse produces gibberish usernames at Gmail or Outlook. A randomness score on the local part catches the obvious ones.
- Privacy relays. Apple Hide My Email, Firefox Relay and similar services make a new, real address in a click. Don't treat a relay as fraud by itself, but don't use it as a uniqueness key either. Count relay referees and weigh them like plus-addresses.
Score the referrer, not each referee
Put the signals together at the level that matters: the person collecting the reward. This function takes a referrer and their referees, looks up each unique domain once with pingutil (fast mode plus DNS), and returns a decision. Lookups are sequential to stay well inside the 60-requests-per-minute key limit; cache domain results in your own store if you run this often.
type Decision = 'pay' | 'hold_for_review' | 'deny';
const RELAY_HOSTS = ['privaterelay.appleid.com', 'mozmail.com', 'duck.com'];
const isRelay = (email: string) => {
const host = email.toLowerCase().split('@')[1] ?? '';
return RELAY_HOSTS.some((r) => host === r || host.endsWith(`.${r}`));
};
async function lookup(input: string) {
const res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input, skip_enrichment: true, check_dns: true }),
});
if (!res.ok) throw new Error(`lookup failed: ${res.status}`);
return res.json();
}
export async function assessReferrer(referrerEmail: string, refereeEmails: string[]) {
const reasons: string[] = [];
const referrerKey = normalizedKey(referrerEmail);
const keys = refereeEmails.map(normalizedKey);
// Signal 1: collisions with the referrer or between referees
if (keys.includes(referrerKey)) reasons.push('self_referral_same_inbox');
if (new Set(keys).size < keys.length) reasons.push('duplicate_inbox_among_referees');
// Signal 3: templated local parts
const stems = new Map<string, number>();
for (const e of refereeEmails) stems.set(stem(e), (stems.get(stem(e)) ?? 0) + 1);
if ([...stems.values()].some((n) => n >= 3)) reasons.push('templated_local_parts');
// Signals 2 and 4: per-address verdicts and per-domain clustering
let spamCount = 0;
let relayCount = 0;
const domainCounts = new Map<string, { n: number; noWebsite: boolean; category: string }>();
for (const email of refereeEmails) {
if (isRelay(email)) { relayCount++; continue; }
const r = await lookup(email);
if (r.verdict.is_spam) spamCount++;
const d = domainCounts.get(r.domain) ?? {
n: 0,
noWebsite: r.domain_signals ? !r.domain_signals.has_website : false,
category: r.category,
};
d.n++;
domainCounts.set(r.domain, d);
}
for (const [domain, d] of domainCounts) {
if (d.category === 'company' && d.n >= 3 && d.noWebsite) {
reasons.push(`cluster_on_${domain}_without_website`);
}
}
if (spamCount > 0) reasons.push(`${spamCount}_spam_verdicts`);
if (relayCount >= 3) reasons.push('many_relay_referees');
let decision: Decision = 'pay';
if (reasons.includes('self_referral_same_inbox') || spamCount >= 2) decision = 'deny';
else if (reasons.length > 0) decision = 'hold_for_review';
return { decision, reasons };
}
The thresholds are guesses you should tune on your own data. The structure matters more than the numbers: hard evidence (same inbox, multiple spam verdicts) denies, softer evidence holds for a human, and nothing pays automatically just because each address passed a filter on its own. domain_signals only appears when you send check_dns: true, and it's informational, so the "no website" rule above is a policy you own. Field details are in the API reference.
Design the program so email signals aren't your only defense
Email signals link accounts. They can't tell you whether a linked account is worth paying for. A few program-design choices do more than any filter:
- Pay on a costly event, not on signup. Reward the referrer when the referee pays an invoice or stays active for a few weeks. Fake accounts rarely clear that bar, and the delay gives your review queue time to work.
- Cap rewards per referrer per period. Real advocates rarely refer dozens of people in a week. A cap turns a big loss into a small one while you investigate.
- Make promo codes single-use per payment method, not per email. An email address is the cheapest identity you can ask for. A card is not. If your processor exposes a payment-method fingerprint, key one-time offers on it.
- Give the referee the reward too, and make it product credit. Credit you can only spend on your product is worth less to a farmer than cash, and it can't be withdrawn.
- Review holds quickly. A real advocate whose reward sits in limbo for a month stops referring. Keep the queue short and explain holds.
These signals overlap with general signup risk scoring. If you're building one score for every new account rather than a referral-specific check, building a simple email risk score from free signals is the broader version. For one-person-many-accounts on the trial side rather than the referral side, see stopping free-trial abuse.
Start with normalized-key collisions. It's ten lines of code, it needs no third-party call, and it catches the most common form of self-referral on day one.
Questions people ask
- How do people abuse referral programs?
- The common pattern is self-referral: one person creates several accounts with different email addresses, refers them from a main account, and collects the reward for each. Promo-code abuse works the same way, with new accounts used to redeem a one-per-customer code repeatedly.
- Can I stop referral abuse by blocking disposable emails?
- It helps, but only against the laziest abusers. Plus-addresses, Gmail dot variants, privacy relays and cheap custom domains all produce real, working inboxes. You need to compare referees against each other and against the referrer, not just check each address alone.
- When should a referral reward be paid?
- After the referred account does something that costs a fake account money or real effort, such as paying its first invoice or staying active for a few weeks. Rewarding on signup alone invites abuse.