Skip to Content
MarketplaceFraud Prevention

Fraud Prevention

CardNexus has a layered Trust & Safety system that captures behavioral signals at checkout, computes risk indicators across the order lifecycle, and surfaces them to the admin team for review. This page covers how it all works — from raw data collection through to Discord alerts.

Architecture Overview

The fraud prevention system has four layers that build on each other:

Data Collection

Transaction: Buyer Context

When a buyer initiates checkout, we capture device and location data from the request and store it on the transaction:

FieldSourcePurpose
buyerContext.ipx-forwarded-for / cf-connecting-ip headerIP comparison with seller
buyerContext.countryVercel’s x-vercel-ip-country headerGeo-mismatch detection
buyerContext.userAgentuser-agent headerDevice fingerprinting
buyerContext.acceptLanguageaccept-language headerLocale mismatch signal

Transaction: Stripe Risk Data

After payment succeeds, the checkout.session.completed webhook populates the stripeRisk object from the Stripe charge:

FieldStripe SourcePurpose
stripeRisk.riskLevelcharge.outcome.risk_levelML-based fraud score (“normal” / “elevated”)
stripeRisk.riskScorecharge.outcome.risk_scoreGranular 0-100 risk ranking
stripeRisk.outcomecharge.outcome.*Authorization result + seller message
stripeRisk.cardCheckscharge.payment_method_details.card.checksCVC, AVS, postal code verification
stripeRisk.cardCountrycharge.payment_method_details.card.countryCard issuing country
stripeRisk.cardFingerprintcharge.payment_method_details.card.fingerprintCross-account card detection

Stripe blocks highest risk transactions outright (score ≥ 75). Those never produce a completed checkout session, so they don’t need indicators. We only see normal and elevated risk levels.

User Profile

We track the most recent device snapshot on the user profile, updated on high-value actions (checkout, listing creation, login):

  • lastIp — most recent IP address
  • lastUserAgent — most recent browser user agent
  • lastCountry — most recent detected country

These are lightweight fields for quick buyer-seller comparison at order assessment time.

Risk Indicators

Each risk indicator has a code, severity (info / warning / critical), a human-readable message, optional data, and a detectedAt timestamp.

Severity Levels

LevelMeaningAdmin Impact
InfoContextual signal, not inherently suspiciousNoted, no urgency
WarningElevated risk, worth investigatingSurfaces in “needs review” queue
CriticalStrong fraud signal, investigate immediatelyDiscord alert + needs review

The order risk level is the highest severity across all its indicators. The transaction risk level is the highest across all its orders.

Full Indicator Catalog

Order Creation Detectors

These run when a transaction completes (all orders in the transaction assessed together):

CodeSeverityTriggerThreshold
HIGH_VALUEWarningOrder subtotal exceeds threshold> $250 (HIGH_VALUE_THRESHOLD_CENTS = 25000)
FIRST_PURCHASEInfoBuyer has 0 completed purchases—
NEW_BUYERWarningBuyer account created recently< 7 days (NEW_BUYER_AGE_DAYS = 7)
SAME_IPCriticalBuyer checkout IP matches seller’s last known IPExact match
SAME_SUBNETWarningBuyer and seller share /24 subnetSame first 3 octets
SAME_CARDCriticalCard fingerprint seen on seller’s past purchasesFingerprint match
RECENT_LISTINGInfoListing created shortly before purchase< 24h (RECENT_LISTING_HOURS = 24)
INSTANT_LISTINGInfoListing created just before purchase< 1h (INSTANT_LISTING_HOURS = 1)
ELEVATED_RISKWarningStripe flagged elevated riskstripeRisk.riskLevel === "elevated"
CARD_COUNTRY_MISMATCHInfoCard issuing country ≠ delivery countryCountry code comparison
IP_COUNTRY_MISMATCHWarningBuyer IP country ≠ delivery countryCountry code comparison
NEW_SELLERInfoSeller has few completed sales< 5 sales (NEW_SELLER_MIN_SALES = 5)
MULTIPLE_ORDERS_SAME_BUYERInfoRepeated buyer-seller pair≥ 3 orders in 30 days (REPEAT_PAIR_WINDOW_DAYS = 30)

Status Transition Detectors

These run when an order reaches a fund-releasable status (completed, resolved_no_refund, resolved_partial_refund):

CodeSeverityTriggerThreshold
INSTANT_COMPLETIONCriticalOrder reached fund-releasable state very quickly< 24h (INSTANT_COMPLETION_HOURS = 24)
FAST_COMPLETIONWarningOrder reached fund-releasable state quickly< 3 days (FAST_COMPLETION_DAYS = 3) but ≥ 24h

FAST_COMPLETION does not double-fire alongside INSTANT_COMPLETION — if the order completed in < 24h, only INSTANT_COMPLETION fires.

Tracking Detectors

These run when the order.delivered event fires (tracking confirms delivery):

CodeSeverityTriggerThreshold
FAST_DELIVERYWarningTracking shows delivery suspiciously soon after order creation< 24h (FAST_DELIVERY_HOURS = 24)
TRACKING_PREDATES_ORDERCriticalTracking delivery date is before order creationdeliveredAt < createdAt

Background Job Detectors

CodeSeverityTriggerThreshold
TRACKING_UNRECOGNIZEDWarningNo tracking updates after shipping3 days (TRACKING_UNRECOGNIZED_DAYS = 3)

Assessment Flow

When Assessment Runs

Risk assessment is triggered by domain events — not called synchronously from API handlers:

EventHandlerDetectors Run
transaction.completedassessTransactionOrdersAll 13 order creation detectors
order.completedassessStatusTransitionINSTANT_COMPLETION, FAST_COMPLETION
order.dispute_resolved (no/partial refund)assessStatusTransitionINSTANT_COMPLETION, FAST_COMPLETION
order.deliveredassessTrackingDeliveryFAST_DELIVERY, TRACKING_PREDATES_ORDER

Batch Context Fetching

The assessTransactionOrders method is optimized for multi-order transactions. It batch-fetches:

  1. All orders in the transaction
  2. All distinct sellers (with createdAt, lastIp, lastCountry)
  3. The buyer (with createdAt)
  4. Buyer’s completed purchase count
  5. Per-seller: completed sales count, card fingerprints from past purchases
  6. Per-order: earliest listing creation date, recent order count for the buyer-seller pair

This data is assembled into an OrderCreationContext object passed to each detector — detectors themselves never hit the database.

Catch-Up Cron

A background job runs every 15 minutes to catch any orders that were missed by the real-time subscriber (e.g., due to temporary failures or deployments):

// Finds orders older than 10 minutes that were never assessed const unprocessed = await OrderModel.find({ riskAssessedAt: null, createdAt: { $lt: tenMinutesAgo }, })

It groups the unprocessed orders by transaction and runs assessTransactionOrders for each, with a try/catch per transaction so one failure doesn’t block others.

The 10-minute delay prevents the cron from racing with the real-time transaction.completed subscriber on newly created orders.

Indicator Persistence

When detectors produce indicators, persistIndicators handles storage:

  • With indicators: Appends to Order.riskIndicators via $push, computes the new riskLevel (highest severity), sets riskUpdatedAt and riskAssessedAt, then emits an order.risk_updated event.
  • Without indicators: Still sets riskAssessedAt so the catch-up cron knows this order was processed.

After all orders in a transaction are assessed, the transaction’s riskLevel is updated to the highest across all its orders.

Fund Release Safeguards

Beyond detection, the system also enforces a hard minimum hold period on funds:

payoutEligibleAt = max(calculated, createdAt + 7 days)

No matter how fast an order progresses through its lifecycle, funds cannot be released before 7 calendar days from order creation (FUND_RELEASE_HARD_MINIMUM_DAYS = 7). This prevents rapid fund extraction through colluding accounts.

ScenarioWithout safeguardWith safeguard
Normal order (ship day 1, deliver day 2, complete day 9)Day 12 (complete + 3)Day 12 (unchanged)
Fast cycle (all in day 1)Day 4 (complete + 3)Day 7 (hard floor)

Admins can also extend the hold period on suspicious orders via the admin panel (reason required for audit trail). There is no action to shorten — we never accelerate release below the minimums.

See Fund Management for the full fund release flow.

Admin UI

Order List

The order list page has risk-related columns and filters:

  • Risk Level column — colored badge (info/warning/critical)
  • Risk Level filter — filter by severity
  • Review Status filter — needs_review / reviewed / all

Order Detail: Risk Indicators Card

When an order has risk indicators, a dedicated card appears showing:

  • Overall risk level badge with color-coded border (amber for warning, red for critical)
  • Each indicator with severity icon, code, message, and detection timestamp
  • Tooltips with exact timestamps on hover
  • “Mark as Reviewed” / “Unmark Reviewed” button

Review Workflow

The review workflow is timestamp-based — no complex state machine:

FieldPurpose
riskReviewedAtWhen an admin last reviewed this order
riskUpdatedAtWhen the last risk indicator was added
  • Needs review = has indicators AND (riskReviewedAt is null OR riskUpdatedAt > riskReviewedAt)
  • Reviewed = riskReviewedAt >= riskUpdatedAt

This means if an order is reviewed at WARNING level and a CRITICAL indicator fires later, it surfaces again as “needs review” automatically.

Discord Alerts

Critical indicators trigger real-time Discord notifications via the existing webhook integration. The DiscordRiskAlertHandler subscribes to order.risk_updated events and sends an alert embed when any of these codes are detected:

  • SAME_IP — buyer and seller share an IP
  • SAME_CARD — buyer used seller’s card
  • TRACKING_PREDATES_ORDER — recycled tracking number
  • INSTANT_COMPLETION — order completed in under 24 hours

The alert includes the order number (linked to admin panel), risk level, critical indicator codes, and buyer/seller usernames.

Stripe Radar Configuration

The system relies on Stripe Radar being configured with these rules (Stripe Dashboard > Radar > Rules):

#RuleType
1Block if CVC verification failsBlock (built-in)
2Block if postal code verification failsBlock (built-in)
3Request 3D Secure if :risk_score: >= 653DS (custom)
4Block if :risk_level: = highestBlock (default)

EU transactions already require 3DS under PSD2/SCA. The rule for score ≥ 65 targets NA transactions specifically.

Code Locations

ComponentLocation
Risk indicator codes & severity typespackages/core/api-dtos/src/lib/risk/risk-indicators.ts
Threshold constantspackages/backend/features/orders/src/risk/constants.ts
Order creation detectorspackages/backend/features/orders/src/risk/detectors/order-creation.detectors.ts
Status transition detectorspackages/backend/features/orders/src/risk/detectors/status-transition.detectors.ts
Tracking detectorspackages/backend/features/orders/src/risk/detectors/tracking.detectors.ts
Detector context typespackages/backend/features/orders/src/risk/detectors/types.ts
Risk assessment servicepackages/backend/features/orders/src/risk/risk-assessment.service.ts
Event subscriberpackages/backend/features/orders/src/risk/risk-assessment.subscriber.ts
Discord alert handlerpackages/backend/features/discord-admin-notifications/src/handlers/risk-alert.handler.ts
Domain events (OrderRiskUpdatedEvent)packages/core/events/src/domain-events.ts
Order model (risk fields)packages/core/db/src/mongo/marketplace/order.model.ts
Transaction model (buyerContext, stripeRisk)packages/core/db/src/mongo/marketplace/transaction.model.ts
Fund release safeguardspackages/backend/domains/order/src/order.rules.ts
Admin risk indicators cardapps/admin/src/components/marketplace/risk-indicators-card.tsx
Last updated on