DEV Community

Cover image for Detecting Brushing Orders With Convergence Scoring, Not Single Rules
Jarrod Wright for Chargebacks911

Posted on

Detecting Brushing Orders With Convergence Scoring, Not Single Rules

Brushing is review manipulation that runs on real transactions. Someone places a cheap order to a real address, often with a name that isn't theirs, so a package ships and a tracking number exists. That tracking number turns a fake order into a "verified purchase."

If you run a storefront, your checkout can be the place where those orders get created. They are low value, they fulfill cleanly, and nobody contacts support. Then fraud-coded disputes arrive weeks later, because some of the cards were stolen.

This article covers the engineering side: how to turn weak, ambiguous signals into a scorer that catches brushing patterns without blocking gift buyers and new customers. For the merchant and policy side, including vendor liability and what to do once you know your position, see Brushing A Junk Package Is a Merchant Problem, Not Just a Consumer Curiosity.

Why single-signal rules fail here

The usual brushing indicators are:

  • One shipping address shared by unrelated customer names
  • An account created minutes before its first and only order
  • Order value sitting at the lowest amount that qualifies for something, such as free shipping or a promo
  • An address plus payment-instrument pair that has appeared before
  • No post-purchase contact: no support tickets, no tracking page views, no return requests

Each of these fires on legitimate traffic all the time. Gift orders ship to someone else's address. Roommates and families share addresses. Every new customer has a new account. A rule like if account_age < 1h: decline will cost more in false declines than brushing ever would.

The useful property is convergence. One order that hits four or five of these signals at once looks much more like an order placed to get a tracking number. Your system should score each signal, combine the scores, and scale the response to what is at stake.

Data flow

Here is the pipeline at a high level:

checkout event
   │
   ▼
normalize (address, name, payment fingerprint, email)
   │
   ▼
entity graph lookup ── address ↔ names ↔ payment fingerprints ↔ accounts
   │
   ▼
real-time signals (computable at checkout)
   │
   ▼
score + tier ──► allow / allow+watch / hold for review
   │
   ▼
retrospective sweep (T+N days): post-purchase signals, cluster detection
   │
   ▼
re-score, flag clusters, feed evidence file
Enter fullscreen mode Exit fullscreen mode

The split between real-time and retrospective matters. Some of the strongest signals do not exist yet at checkout time.

Step 1: Normalize before you compare

If you skip this step, "shared address" detection is nearly useless. 123 Main St Apt 4, 123 MAIN STREET #4, and 123 Main St., Unit 4 need to collapse to one key.

In practice:

  • Use an address validation or standardization service where you can, such as the USPS standardization format for US addresses. Otherwise, at minimum, uppercase the string, expand or contract street suffixes consistently, and strip punctuation.
  • Keep the unit as its own field. Two names at 123 Main St in different units are not a signal. Two names at the same unit might be.
  • Hash the normalized address into an address_key.
  • For payment, use the fingerprint your processor gives you. Many gateways expose a stable card fingerprint so you never touch the PAN. Never build this from raw card data yourself.
  • Normalize names loosely: lowercase, strip honorifics, and treat J Smith and John Smith as possibly the same person. You are trying to detect unrelated names, so lean toward false merges rather than false splits.

Step 2: Maintain an entity graph

You don't need a graph database to start. A few relational tables with rolling windows are enough:

-- edges observed per order
CREATE TABLE order_entities (
  order_id        TEXT PRIMARY KEY,
  account_id      TEXT,
  address_key     TEXT,
  name_key        TEXT,
  payment_fp      TEXT,
  created_at      TIMESTAMPTZ,
  order_total     NUMERIC
);

CREATE INDEX ON order_entities (address_key, created_at);
CREATE INDEX ON order_entities (payment_fp, created_at);
Enter fullscreen mode Exit fullscreen mode

The query that does most of the work is "distinct names at this address in the last N days":

SELECT COUNT(DISTINCT name_key)
FROM order_entities
WHERE address_key = $1
  AND created_at > now() - interval '30 days';
Enter fullscreen mode Exit fullscreen mode

Run the same query for payment fingerprints per address, and addresses per fingerprint. A single card shipping to many addresses under many names is a different pattern from a household, and it is worth a separate signal.

Step 3: Score the signals

Here is a minimal scorer in Python. The weights are placeholders. You will tune them against your own labeled disputes.

from dataclasses import dataclass

@dataclass
class Signals:
    distinct_names_at_address: int   # 30-day window
    account_age_minutes: float
    order_total: float
    qualifying_threshold: float      # e.g. free-shipping minimum
    addr_payment_pair_seen: int      # prior orders with same pair
    names_on_payment_fp: int         # 30-day window

def score(s: Signals) -> tuple[float, list[str]]:
    reasons = []
    total = 0.0

    if s.distinct_names_at_address >= 3:
        total += 2.0; reasons.append("address_shared_by_unrelated_names")
    elif s.distinct_names_at_address == 2:
        total += 0.5  # households and gifts are common

    if s.account_age_minutes < 30:
        total += 1.0; reasons.append("account_created_just_before_order")

    # within 5% above the qualifying threshold
    if s.qualifying_threshold and \
       s.qualifying_threshold <= s.order_total <= s.qualifying_threshold * 1.05:
        total += 1.0; reasons.append("lowest_qualifying_value")

    if s.addr_payment_pair_seen >= 2:
        total += 1.5; reasons.append("address_payment_pair_repeated")

    if s.names_on_payment_fp >= 3:
        total += 2.0; reasons.append("one_card_many_names")

    return total, reasons
Enter fullscreen mode Exit fullscreen mode

Returning reasons is not optional. Your reviewers need to see why an order was held, and you will need the same data later if you ever have to explain a pattern to a processor or platform.

Step 4: Tier the response by exposure

Score alone should not decide the outcome. Combine it with what you stand to lose.

Score Order value Signals converging Action
< 2 any 0–1 Allow
2–3.5 low 2 Allow, add to watchlist cluster
2–3.5 high 2 Step-up verification (e.g. 3DS) or manual review
≥ 4 low 3+ Hold for review before fulfillment
≥ 4 high 3+ Hold; review the whole cluster, not just this order

The counterintuitive row is the low-value, high-score order. In most fraud models, cheap orders get waved through. Brushing orders are cheap on purpose, so low value is one of the signals. When it converges with the others, the low price stops being a reason to relax.

Step 5: Run a retrospective sweep

"No post-purchase contact" is one of the most telling brushing signals, and you cannot compute it at checkout. It only becomes meaningful after delivery. Treat it as a batch job:

def retrospective_flags(order, days_since_delivery):
    if days_since_delivery < 14:
        return []
    flags = []
    if (order.tracking_page_views == 0 and
        order.support_tickets == 0 and
        order.marketing_email_opens == 0):
        flags.append("zero_post_purchase_engagement")
    return flags
Enter fullscreen mode Exit fullscreen mode

Then re-score, and more importantly, cluster. Group flagged orders by shared address_key or payment_fp. One silent order is nothing. Twelve silent orders that share two addresses and three cards are a pattern. That lets you proactively review, refund, or report before the disputes show up.

If you sell on a marketplace and can see review data, add one more retrospective check: verified reviews posted before the carrier's earliest plausible delivery date for that order. That gap shouldn't exist with legitimate buyers.

Common mistakes and how to catch them

Comparing raw address strings. Symptom: shared-address counts look suspiciously low. Test by seeding fixtures with known variants of one address and asserting they produce one address_key.

Dropping the unit number. Symptom: every apartment building and office tower lights up. Check your top 20 addresses by distinct-name count. If they are all large residential buildings, your normalization collapsed units.

Ignoring freight forwarders and reshippers. Package-forwarding warehouses legitimately receive orders for thousands of unrelated names. Keep an allowlist of known forwarder addresses and score them separately, or you will hold every international customer.

Label leakage in model training. If you train a model on features that include post-delivery engagement, then deploy it at checkout, it will look excellent offline and do poorly in production. Keep real-time and retrospective feature sets separate, and evaluate each at the point in time it would actually run.

Hardcoding the qualifying threshold. The "lowest qualifying value" moves whenever marketing changes the free-shipping minimum or launches a promo. Read thresholds from the same config the checkout uses, and version them with timestamps so historical scores stay explainable.

Scoring orders, never clusters. Brushing is a volume activity. A per-order view misses the operation. Make cluster review a first-class workflow for analysts.

Not logging the decision. If an order is allowed, held, or refunded, store the score, reasons, threshold versions, and timestamp. If disputes arrive, a dated record showing you detected and acted on a pattern is far more useful than a reconstruction made after the fact.

Testing the scorer

Build synthetic fixtures for at least these cases and assert the tier:

  1. First-time customer, own address, normal basket → Allow
  2. New customer sending a gift to a different address → Allow or watch
  3. Household with two names, one card → Allow
  4. Five names, one unit, two cards, all orders at threshold → Hold
  5. Forwarder address with 200 names → Allow (allowlist path)

Then backtest against historical fraud-coded disputes. Look for precision at the hold tier: of the orders you would have held, how many later disputed or clustered? Optimize that before you chase recall. Every false hold costs a real customer.

Wrapping up

Brushing detection isn't a single clever rule. It's normalization, an entity graph with rolling windows, a scorer that explains itself, response tiers tied to exposure, and a delayed sweep that picks up the signals checkout can't see. The goal is to flag the order that matches everything at once, and let the gift buyer who matches one thing through.

This article was drafted with AI assistance and reviewed by our team.

Top comments (0)