Back to blogs
Fintech / Payments
8 min read
Feb 28, 2025

Fraud Detection: What Every Payment Platform Has to Handle

The fraud detection layer we built at Trulipay—velocity checks, amount limits, device fingerprinting, behavioral scoring, and dispute workflows—and what we learned about managing false positives without blocking legitimate payments.

Fraud DetectionVelocity ChecksFintechSecurityNode.jsRedisRisk Management
Table of contents

Payment fraud doesn't announce itself. It looks like a legitimate transaction until the chargeback arrives—or it looks like fraud and turns out to be a real customer with an unusual purchase pattern. Both errors are expensive. A missed fraud hits the merchant; a false block loses a sale and damages trust.

Here's the fraud detection layer we built at Trulipay, and how we balanced precision against recall.

The Fraud Detection Pipeline

Every payment request passes through our risk engine before the gateway call:

POST /payment
    │
    ▼
Risk Engine
    ├── Velocity checks
    ├── Amount limit checks
    ├── Device fingerprint check
    ├── Behavioral score
    └── Blocklist check
         │
         ├── ALLOW ──────► Gateway charge
         ├── REVIEW ─────► Queue for manual review, hold payment
         └── BLOCK ──────► Reject immediately

The engine returns a risk score (0-100) and a disposition. We tune the ALLOW/REVIEW/BLOCK thresholds per merchant—a merchant selling luxury goods has different risk tolerance than one selling ₹50 items.

Velocity Checks

The most effective single fraud signal is velocity: how many times has this card / email / IP / device been used in the last N minutes?

type VelocityWindow = "1m" | "10m" | "1h" | "24h";
 
const VELOCITY_LIMITS: Record<VelocityWindow, { card: number; ip: number; email: number }> = {
  "1m":  { card: 2,  ip: 5,  email: 3  },
  "10m": { card: 5,  ip: 15, email: 8  },
  "1h":  { card: 10, ip: 30, email: 15 },
  "24h": { card: 20, ip: 60, email: 30 },
};
 
async function checkVelocity(
  payment: PaymentAttempt
): Promise<VelocityResult> {
  const windows: VelocityWindow[] = ["1m", "10m", "1h", "24h"];
  const now = Date.now();
 
  for (const window of windows) {
    const windowMs = parseWindow(window);
    const limits = VELOCITY_LIMITS[window];
 
    const [cardCount, ipCount, emailCount] = await Promise.all([
      redis.zcount(`vel:card:${payment.card_hash}`, now - windowMs, now),
      redis.zcount(`vel:ip:${payment.ip}`, now - windowMs, now),
      redis.zcount(`vel:email:${payment.email_hash}`, now - windowMs, now),
    ]);
 
    if (cardCount >= limits.card) {
      return { triggered: true, reason: `card_velocity_${window}`, count: cardCount };
    }
    if (ipCount >= limits.ip) {
      return { triggered: true, reason: `ip_velocity_${window}`, count: ipCount };
    }
    if (emailCount >= limits.email) {
      return { triggered: true, reason: `email_velocity_${window}`, count: emailCount };
    }
  }
 
  // Record this attempt in velocity windows
  await recordVelocityAttempt(payment);
  return { triggered: false };
}
 
async function recordVelocityAttempt(payment: PaymentAttempt) {
  const now = Date.now();
  const dayMs = 86400 * 1000;
 
  await Promise.all([
    redis.zadd(`vel:card:${payment.card_hash}`, now, `${payment.id}:${now}`),
    redis.zadd(`vel:ip:${payment.ip}`, now, `${payment.id}:${now}`),
    redis.zadd(`vel:email:${payment.email_hash}`, now, `${payment.id}:${now}`),
    redis.expire(`vel:card:${payment.card_hash}`, dayMs / 1000),
    redis.expire(`vel:ip:${payment.ip}`, dayMs / 1000),
    redis.expire(`vel:email:${payment.email_hash}`, dayMs / 1000),
  ]);
}

We use sorted sets with Unix timestamps as scores. ZCOUNT with a time range is O(log N) and returns the count in any window without scanning the full set.

Amount Anomaly Detection

A card that normally buys ₹200-₹500 items suddenly placing a ₹50,000 order is a red flag. We compare the current order amount against the customer's historical average:

async function checkAmountAnomaly(
  customerId: string,
  amount: number
): Promise<AnomalyResult> {
  const history = await db.query(
    `SELECT AVG(amount) AS avg, STDDEV(amount) AS stddev, COUNT(*) AS count
     FROM transactions
     WHERE customer_id = $1
       AND status = 'completed'
       AND created_at > NOW() - INTERVAL '90 days'`,
    [customerId]
  );
 
  const { avg, stddev, count } = history.rows[0];
 
  if (count < 3) return { anomalous: false }; // not enough history
 
  const zScore = (amount - avg) / stddev;
 
  if (zScore > 4) {
    return {
      anomalous: true,
      severity: "high",
      reason: "amount_z_score_exceeded",
      details: { amount, avg, stddev, z_score: zScore },
    };
  }
 
  return { anomalous: false };
}

A z-score above 4 means the amount is more than 4 standard deviations from the customer's mean—statistically very unusual. We send these to REVIEW, not BLOCK, since a customer buying an anniversary gift once a year will trigger this.

Device Fingerprinting

We collect a lightweight device fingerprint on the client side:

// Client-side, runs before payment form submission
async function collectDeviceFingerprint(): Promise<string> {
  const components = [
    navigator.userAgent,
    navigator.language,
    screen.width + "x" + screen.height,
    new Date().getTimezoneOffset(),
    navigator.hardwareConcurrency,
    !!window.sessionStorage,
    !!window.localStorage,
  ];
 
  const fingerprint = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(components.join("|"))
  );
 
  return Array.from(new Uint8Array(fingerprint))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

The fingerprint goes with every payment request. Server-side, we check if this fingerprint was recently used on a different payment method—a signal that someone is testing stolen cards:

async function checkDeviceFingerprint(
  fingerprint: string,
  cardHash: string
): Promise<FingerprintResult> {
  const recentCards = await redis.smembers(`fp:${fingerprint}:cards`);
 
  if (recentCards.length > 3 && !recentCards.includes(cardHash)) {
    return {
      suspicious: true,
      reason: "device_multiple_cards",
      card_count: recentCards.length,
    };
  }
 
  await redis.sadd(`fp:${fingerprint}:cards`, cardHash);
  await redis.expire(`fp:${fingerprint}:cards`, 3600);
  return { suspicious: false };
}

Dispute Workflows

When a customer files a chargeback with their bank, Stripe sends us a charge.dispute.created webhook. Our dispute workflow:

  1. Automatically gather evidence: pull the original transaction record, delivery confirmation (if applicable), customer communication history.
  2. Notify the merchant: they have the most context on whether the transaction was legitimate.
  3. Respond to Stripe within the window: chargebacks have strict response deadlines (7-21 days depending on card network).
async function handleChargebackOpened(disputeEvent: WebhookEvent) {
  const transaction = await getTransactionByExternalId(disputeEvent.externalId);
  const merchant = await getMerchant(transaction.merchant_id);
 
  // Auto-gather evidence
  const evidence = await gatherDisputeEvidence(transaction);
 
  // Create dispute case
  const disputeCase = await db.query(
    `INSERT INTO dispute_cases
     (transaction_id, merchant_id, stripe_dispute_id, deadline, evidence_json, status)
     VALUES ($1, $2, $3, $4, $5, 'open')
     RETURNING id`,
    [
      transaction.id,
      merchant.id,
      disputeEvent.raw.id,
      calculateDisputeDeadline(disputeEvent.raw.evidence_due_by),
      JSON.stringify(evidence),
    ]
  );
 
  await notifyMerchant(merchant.id, "chargeback_opened", {
    amount: transaction.amount,
    deadline: disputeCase.rows[0].deadline,
    case_id: disputeCase.rows[0].id,
  });
}

False Positive Management

Our first two weeks in production had a 4.2% false positive rate—legitimate payments blocked as fraud. The main culprits were:

  1. VPN users triggering IP velocity rules (the same VPN exit node handles thousands of users)
  2. Business accounts making bulk orders that tripped amount anomaly checks
  3. Return customers who'd been inactive for 6+ months looking like new/suspicious users

We addressed this by adding a context layer:

  • Verified merchant customers (authenticated, history > 3 orders) get a lower base risk score.
  • Amount anomaly checks exclude the customer's first 5 purchases.
  • IP velocity limits are doubled for known VPN/proxy ranges (identified via MaxMind) since that's expected behavior, not fraud.

After two months of tuning, the false positive rate dropped to 0.8%.

Key Takeaways

  • Redis sorted sets with timestamp scores are the right data structure for velocity checks—O(log N) range queries, automatic expiry.
  • Z-score anomaly detection catches amount outliers without needing ML. Four standard deviations is a strong signal.
  • Device fingerprinting catches card testing—one device, many cards is the clearest fraud signal after velocity.
  • Dispute deadlines are hard—build automation that gathers evidence and notifies merchants immediately; manual processes miss the window.
  • False positive rate matters as much as fraud catch rate—a 4% block rate on legitimate payments costs more than the fraud it prevents.