Step 1: Understand Fraud Pattern Detection Fundamentals

Fraud in payment systems follows predictable patterns. Understanding these patterns is the first step toward effective detection and prevention.

Common Fraud Patterns in Stripe Transactions

1. Card Testing (Carding)

Fraudsters use automated bots to test stolen card numbers by making small purchases. Key indicators include:

2. Velocity Abuse

Abnormally high transaction frequency indicating fraudulent activity:

3. Geographic Anomalies

Mismatches in geographic data suggesting fraudulent transactions:

4. Amount Pattern Irregularities

Unusual transaction amounts that deviate from normal patterns:

Expected Outcome

After completing this step, you should be able to list 3-5 specific fraud patterns most relevant to your business model and understand the key indicators for each pattern.

Step 2: Access and Export Your Stripe Transaction Data

To analyze fraud patterns effectively, you need comprehensive transaction data. Stripe provides multiple methods for accessing this information.

Method 1: Using the Stripe Dashboard

  1. Log into your Stripe Dashboard at dashboard.stripe.com
  2. Navigate to Payments in the left sidebar
  3. Click the Export button in the top right
  4. Select your date range (recommend starting with the last 90 days)
  5. Choose Detailed export to include all available fields
  6. Select CSV format and click Export

Method 2: Using the Stripe API

For programmatic access, use the Stripe API. The script below needs a few things to be true. Stripe stores amounts in the currency's smallest unit, so the code converts them (whole units for zero-decimal currencies such as JPY, as described in Stripe's currencies documentation). It exports the billing and shipping countries that Step 4 needs. And the buyer's IP address and the country looked up from it are not fields on a Stripe charge: the script reads them from charge metadata, which exists only if your own checkout writes it there. Without those two columns, skip the IP-based checks.

import stripe
import pandas as pd
from datetime import datetime, timedelta

stripe.api_key = 'sk_test_your_key_here'

# Currencies whose Stripe amount is already in whole units (no cents).
# Check https://docs.stripe.com/currencies for the full zero-decimal list.
ZERO_DECIMAL = {'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
                'pyg', 'rwf', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'}

def to_major_units(amount, currency):
    return amount if currency in ZERO_DECIMAL else amount / 100

def get(obj, *path):
    """Walk attributes, returning None if any link is missing."""
    for p in path:
        obj = getattr(obj, p, None) if obj is not None else None
    return obj

# Retrieve charges from the last 90 days (auto_paging_iter handles pagination)
ninety_days_ago = int((datetime.now() - timedelta(days=90)).timestamp())
charges = stripe.Charge.list(limit=100, created={'gte': ninety_days_ago})

rows = []
for charge in charges.auto_paging_iter():
    card = get(charge, 'payment_method_details', 'card')
    meta = charge.metadata or {}
    rows.append({
        'id': charge.id,
        'amount': to_major_units(charge.amount, charge.currency),
        'currency': charge.currency,
        'created': datetime.fromtimestamp(charge.created),
        'customer': charge.customer,
        'email': get(charge, 'billing_details', 'email'),
        'status': charge.status,
        'risk_score': get(charge, 'outcome', 'risk_score'),   # Radar for Fraud Teams only
        'card_country': get(card, 'country'),
        'card_fingerprint': get(card, 'fingerprint'),
        'cvc_check': get(card, 'checks', 'cvc_check'),
        'billing_country': get(charge, 'billing_details', 'address', 'country'),
        'shipping_country': get(charge, 'shipping', 'address', 'country'),
        # Stripe does not put the buyer's IP on the Charge. These two columns exist only
        # if your checkout writes them to metadata (IP and a country looked up from it).
        'ip': meta.get('ip_address'),
        'ip_country': meta.get('ip_country'),
    })

df = pd.DataFrame(rows)
df.to_csv('stripe_transactions.csv', index=False)
print(f"Exported {len(df)} transactions")
print(f"File saved: stripe_transactions.csv")
print(f"Columns: {len(df.columns)}")
print(f"Currencies: {', '.join(sorted(df['currency'].unique()))}")
print(f"Date range: {df['created'].min():%Y-%m-%d} to {df['created'].max():%Y-%m-%d}")

Expected Output

You should now have a CSV file containing your transaction data with all relevant fields. On the synthetic data set a successful export prints the following; on your own account the counts and dates will be yours:

Exported 564 transactions
File saved: stripe_transactions.csv
Columns: 15
Currencies: jpy, usd
Date range: 2026-07-28 to 2026-10-23

Verification

Open your exported file and verify it contains:

Step 3: Analyze Transaction Velocity Patterns

Velocity analysis identifies unusually rapid transaction sequences that often indicate fraud. Legitimate customers rarely make multiple purchases within minutes, while fraudsters testing cards or exploiting accounts do.

Calculate Velocity Metrics

Use this Python script to identify velocity-based patterns. It counts, for every charge, how many charges the same card (or IP, or email) made in the trailing window, separately for each currency so amounts are never added across currencies:

import pandas as pd

REQUIRED = ['id', 'amount', 'currency', 'created', 'card_fingerprint', 'ip', 'email']
df = pd.read_csv('stripe_transactions.csv')
missing = [c for c in REQUIRED if c not in df.columns]
assert not missing, f"Re-run the export: missing columns {missing}"
df['created'] = pd.to_datetime(df['created'])
df = df.sort_values('created').reset_index(drop=True)

def calculate_velocity(df, group_by, window_minutes):
    """For every transaction, count the transactions with the same group value
    (and the same currency) in the trailing window, the transaction itself included."""
    cols = ['group_type', 'group_value', 'transaction_id', 'timestamp',
            'velocity', 'time_window', 'currency', 'total_amount']
    parts = []
    for (value, currency), g in df.dropna(subset=[group_by]).groupby([group_by, 'currency']):
        r = g.set_index('created')['amount'].rolling(f'{window_minutes}min')
        parts.append(pd.DataFrame({
            'group_type': group_by, 'group_value': value,
            'transaction_id': g['id'].values, 'timestamp': g['created'].values,
            'velocity': r.count().astype(int).values,
            'time_window': window_minutes, 'currency': currency,
            'total_amount': r.sum().values}))
    # An explicit column schema keeps the result usable when nothing qualifies.
    return pd.concat(parts, ignore_index=True) if parts else pd.DataFrame(columns=cols)

velocity_by_card = calculate_velocity(df, 'card_fingerprint', 60)
velocity_by_ip = calculate_velocity(df, 'ip', 60)
velocity_by_email = calculate_velocity(df, 'email', 120)

# Illustrative review rules, not tested fraud cut-offs: calibrate them in Step 5
flag_card = velocity_by_card[velocity_by_card['velocity'] >= 3]
flag_ip = velocity_by_ip[velocity_by_ip['velocity'] >= 5]

print(f"Card fingerprints with 3+ charges in 60 min: {flag_card['group_value'].nunique()}")
print(f"IP addresses with 5+ charges in 60 min: {flag_ip['group_value'].nunique()}")
print()
print("Worst IP windows:")
print(flag_ip.sort_values('velocity', ascending=False).head(3)
      [['group_value', 'velocity', 'currency', 'total_amount']].to_string(index=False))

# Attach each charge's card-velocity so later steps can use it, and save
df = df.merge(velocity_by_card[['transaction_id', 'velocity']]
              .rename(columns={'transaction_id': 'id', 'velocity': 'velocity_60min'}),
              on='id', how='left')
df['velocity_60min'] = df['velocity_60min'].fillna(1).astype(int)
df.to_csv('stripe_transactions_enriched.csv', index=False)
flag_card.to_csv('velocity_fraud_flags.csv', index=False)

Expected Output

Card fingerprints with 3+ charges in 60 min: 3
IP addresses with 5+ charges in 60 min: 3

Worst IP windows:
 group_value  velocity currency  total_amount
203.0.113.10         7      usd          43.0
203.0.113.11         7      usd          43.0
203.0.113.12         7      usd          43.0

Interpreting Velocity Results

When reviewing velocity patterns, these starting thresholds are illustrative review rules, not tested fraud cut-offs. The right numbers depend on your business (a card used for several subscriptions looks very different from a one-off retail card). Calibrate them by comparing the flagged charges with your confirmed fraud and your legitimate repeat purchases:

If you later have enough confirmed fraud labels, a supervised classifier such as AdaBoost can learn which of these signals matter for your business; see the AdaBoost guide. Until you have labels, treat the counts above as review signals.

Step 4: Identify Geographic Anomalies

Geographic mismatches are strong fraud indicators. Fraudsters often use stolen cards from one country while operating from another, creating detectable geographic inconsistencies.

Analyze Geographic Patterns

import pandas as pd

df = pd.read_csv('stripe_transactions_enriched.csv')
needed = ['card_country', 'ip_country', 'billing_country', 'shipping_country']
missing = [c for c in needed if c not in df.columns]
assert not missing, f"Re-run the Step 2 export: missing columns {missing}"

high_risk_combinations = [
    {'card_country': 'US', 'ip_country': 'NG'},  # examples only: build yours from your own disputes
    {'card_country': 'GB', 'ip_country': 'RU'},
    {'card_country': 'CA', 'ip_country': 'RO'},
]

def check_geographic_risk(row):
    risk_score = 0
    risk_factors = []

    if pd.notna(row['card_country']) and pd.notna(row['ip_country']):
        if row['card_country'] != row['ip_country']:
            risk_score += 2
            risk_factors.append('card_ip_mismatch')

    if pd.notna(row['billing_country']) and pd.notna(row['shipping_country']):
        if row['billing_country'] != row['shipping_country']:
            risk_score += 1
            risk_factors.append('billing_shipping_mismatch')

    for combo in high_risk_combinations:
        if (row['card_country'] == combo['card_country'] and
                row['ip_country'] == combo['ip_country']):
            risk_score += 5
            risk_factors.append('high_risk_country_combo')

    countries = {row['card_country'], row['ip_country'], row['billing_country']}
    if len([c for c in countries if pd.notna(c)]) >= 3:
        risk_score += 3
        risk_factors.append('multiple_country_mismatch')

    return pd.Series({'geo_risk_score': risk_score,
                      'geo_risk_factors': ', '.join(risk_factors)})

df[['geo_risk_score', 'geo_risk_factors']] = df.apply(check_geographic_risk, axis=1)

high_geo_risk = df[df['geo_risk_score'] >= 3].sort_values(
    ['geo_risk_score', 'id'], ascending=[False, True])

print(f"Transactions with geographic risk factors: {len(high_geo_risk)}")
print()
print("Top geographic risk patterns:")
print(high_geo_risk[['id', 'amount', 'card_country', 'ip_country',
                     'geo_risk_score', 'geo_risk_factors']].head(5).to_string(index=False))

df.to_csv('stripe_transactions_enriched.csv', index=False)
high_geo_risk.to_csv('geographic_fraud_flags.csv', index=False)

Expected Output

Transactions with geographic risk factors: 40

Top geographic risk patterns:
      id  amount card_country ip_country  geo_risk_score                                                     geo_risk_factors
ch_00314   96.22           US         NG               8 card_ip_mismatch, billing_shipping_mismatch, high_risk_country_combo
ch_00014   58.51           US         NG               7                            card_ip_mismatch, high_risk_country_combo
ch_00026   79.31           US         NG               7                            card_ip_mismatch, high_risk_country_combo
ch_00034  148.90           US         NG               7                            card_ip_mismatch, high_risk_country_combo
ch_00044   40.45           US         NG               7                            card_ip_mismatch, high_risk_country_combo

Verification

Review the flagged transactions and verify:

Step 5: Review Amount and Behavior Patterns

Transaction amounts and purchasing behaviors often reveal fraud. Fraudsters typically exhibit different spending patterns than legitimate customers.

Detect Amount Anomalies

import pandas as pd
import numpy as np

df = pd.read_csv('stripe_transactions_enriched.csv')
df['created'] = pd.to_datetime(df['created'])

# Amounts in different currencies cannot be averaged together: use the main currency
main_currency = df['currency'].value_counts().index[0]
df = df[df['currency'] == main_currency].sort_values('created').copy()
print(f"Analysing {len(df)} charges in {main_currency.upper()}")

mean_amount = df['amount'].mean()
std_amount = df['amount'].std()
median_amount = df['amount'].median()

print(f"Mean: {mean_amount:.2f}")
print(f"Median: {median_amount:.2f}")
print(f"Std Dev: {std_amount:.2f}")

# Amount z-score (how many standard deviations from the mean)
df['amount_zscore'] = ((df['amount'] - mean_amount) / std_amount).abs()
amount_outliers = df[df['amount_zscore'] > 3]
print(f"\nAmount outliers (>3 std dev): {len(amount_outliers)}")

# Round amounts are only a weak hint: legitimate prices are often round too
df['is_round_amount'] = df['amount'].isin([10, 25, 50, 100, 200, 250, 500, 1000])
print(f"Round-number transactions: {int(df['is_round_amount'].sum())}")

df['customer_transaction_number'] = df.groupby('customer').cumcount() + 1
first_transaction_high_value = df[
    (df['customer_transaction_number'] == 1) &
    (df['amount'] > mean_amount + 2 * std_amount)
]
print(f"First-time transactions with unusually high amounts: {len(first_transaction_high_value)}")

behavior_flags = pd.concat([
    amount_outliers[['id', 'customer', 'amount', 'amount_zscore']].assign(flag_type='amount_outlier'),
    first_transaction_high_value[['id', 'customer', 'amount']].assign(flag_type='first_transaction_high_value')
])
behavior_flags.to_csv('behavior_fraud_flags.csv', index=False)
print(f"\nBehavioral flags saved: {len(behavior_flags)} rows")

df.to_csv('stripe_transactions_enriched.csv', index=False)

Expected Output

Analysing 562 charges in USD
Mean: 82.56
Median: 72.63
Std Dev: 48.40

Amount outliers (>3 std dev): 10
Round-number transactions: 3
First-time transactions with unusually high amounts: 14

Behavioral flags saved: 24 rows

Key Behavioral Indicators

Step 6: Combine the Signals into a Review Score

Manual analysis is valuable for understanding patterns. A scoring script turns the signals from Steps 3 to 5 into one review order. Run it on a schedule you control if you want it repeated; nothing here watches your Stripe account on its own.

Create a Review Score

import pandas as pd

def calculate_fraud_score(transaction):
    """
    Review-priority score combining the checks above (illustrative weights, not
    fitted to fraud labels). Returns 0-100: higher = look at it sooner
    """
    score = 0
    risk_factors = []

    # Velocity risk (0-25 points)
    if transaction.get('velocity_60min', 0) >= 5:
        score += 25
        risk_factors.append('extreme_velocity')
    elif transaction.get('velocity_60min', 0) >= 3:
        score += 15
        risk_factors.append('high_velocity')

    # Geographic risk (0-30 points)
    if transaction.get('geo_risk_score', 0) >= 5:
        score += 30
        risk_factors.append('high_geo_risk')
    elif transaction.get('geo_risk_score', 0) >= 3:
        score += 15
        risk_factors.append('medium_geo_risk')

    # Amount risk (0-20 points)
    if transaction.get('amount_zscore', 0) > 3:
        score += 20
        risk_factors.append('amount_outlier')
    elif transaction.get('amount_zscore', 0) > 2:
        score += 10
        risk_factors.append('high_amount')

    # Behavioral risk (0-25 points)
    if transaction.get('customer_transaction_number', 0) == 1:
        if transaction.get('amount', 0) > 500:
            score += 25
            risk_factors.append('high_first_purchase')
        elif transaction.get('amount', 0) > 200:
            score += 15
            risk_factors.append('elevated_first_purchase')

    # Additional risk factors (bonus points)
    if transaction.get('cvc_check') == 'fail':
        score += 10
        risk_factors.append('cvc_fail')

    if transaction.get('is_round_amount'):
        score += 5
        risk_factors.append('round_amount')

    # Cap at 100
    score = min(score, 100)

    return {
        'fraud_score': score,
        'risk_factors': ', '.join(risk_factors),
        'risk_level': 'HIGH' if score >= 50 else 'MEDIUM' if score >= 30 else 'LOW'
    }

# Apply to the charges enriched in Steps 3 to 5 (the main currency)
df = pd.read_csv('stripe_transactions_enriched.csv')
fraud_scores = df.apply(calculate_fraud_score, axis=1, result_type='expand')
df = pd.concat([df, fraud_scores], axis=1)

# Summary by risk level
print("Fraud Risk Summary:")
print(df['risk_level'].value_counts())
print(f"\nHigh-risk transactions requiring review: {len(df[df['risk_level'] == 'HIGH'])}")

# Save prioritized review list
high_risk = df[df['risk_level'] == 'HIGH'].sort_values('fraud_score', ascending=False)
high_risk[['id', 'customer', 'amount', 'fraud_score', 'risk_factors']].to_csv(
    'fraud_review_queue.csv',
    index=False
)

print("\nTop 5 highest-risk transactions:")
print(high_risk[['id', 'amount', 'fraud_score', 'risk_factors']].head())

Expected Output

Fraud Risk Summary:
risk_level
LOW       518
MEDIUM     40
HIGH        4
Name: count, dtype: int64

High-risk transactions requiring review: 4

Top 5 highest-risk transactions:
           id  amount  fraud_score                            risk_factors
236  ch_00562    5.00           55  high_velocity, high_geo_risk, cvc_fail
373  ch_00555    5.00           55  high_velocity, high_geo_risk, cvc_fail
518  ch_00548    5.00           55  high_velocity, high_geo_risk, cvc_fail
155  ch_00200  239.61           50           high_geo_risk, amount_outlier

The weights and cut-offs (50 and 30) are illustrative and were chosen only so this synthetic data set produces a short review list. Every flagged charge here is either a planted pattern or a chance outlier or mismatch; on real data you must check the flags against confirmed outcomes before trusting them. The script scores the main currency only, because the amount checks in Step 5 cannot mix currencies.

Integrate with Stripe Radar

Custom Radar rules and manual review need a Radar plan that includes them (Radar for Fraud Teams, now Radar Plus or Pro). If yours does, configure rules based on your findings:

  1. Navigate to Radar → Rules in your Stripe Dashboard
  2. Create custom rules based on your risk thresholds:
    • Block if velocity exceeds 5 transactions per hour from same card
    • Review if card country differs from IP country
    • Block specific high-risk country combinations
    • Review first-time transactions over $500

For a broader view of how analysis pipelines can be organised, see AI-first data analysis pipelines.