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:
| Field | Source | Purpose |
|---|---|---|
buyerContext.ip | x-forwarded-for / cf-connecting-ip header | IP comparison with seller |
buyerContext.country | Vercel’s x-vercel-ip-country header | Geo-mismatch detection |
buyerContext.userAgent | user-agent header | Device fingerprinting |
buyerContext.acceptLanguage | accept-language header | Locale mismatch signal |
Transaction: Stripe Risk Data
After payment succeeds, the checkout.session.completed webhook populates the stripeRisk object from the Stripe charge:
| Field | Stripe Source | Purpose |
|---|---|---|
stripeRisk.riskLevel | charge.outcome.risk_level | ML-based fraud score (“normal” / “elevated”) |
stripeRisk.riskScore | charge.outcome.risk_score | Granular 0-100 risk ranking |
stripeRisk.outcome | charge.outcome.* | Authorization result + seller message |
stripeRisk.cardChecks | charge.payment_method_details.card.checks | CVC, AVS, postal code verification |
stripeRisk.cardCountry | charge.payment_method_details.card.country | Card issuing country |
stripeRisk.cardFingerprint | charge.payment_method_details.card.fingerprint | Cross-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 addresslastUserAgent— most recent browser user agentlastCountry— 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
| Level | Meaning | Admin Impact |
|---|---|---|
| Info | Contextual signal, not inherently suspicious | Noted, no urgency |
| Warning | Elevated risk, worth investigating | Surfaces in “needs review” queue |
| Critical | Strong fraud signal, investigate immediately | Discord 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):
| Code | Severity | Trigger | Threshold |
|---|---|---|---|
HIGH_VALUE | Warning | Order subtotal exceeds threshold | > $250 (HIGH_VALUE_THRESHOLD_CENTS = 25000) |
FIRST_PURCHASE | Info | Buyer has 0 completed purchases | — |
NEW_BUYER | Warning | Buyer account created recently | < 7 days (NEW_BUYER_AGE_DAYS = 7) |
SAME_IP | Critical | Buyer checkout IP matches seller’s last known IP | Exact match |
SAME_SUBNET | Warning | Buyer and seller share /24 subnet | Same first 3 octets |
SAME_CARD | Critical | Card fingerprint seen on seller’s past purchases | Fingerprint match |
RECENT_LISTING | Info | Listing created shortly before purchase | < 24h (RECENT_LISTING_HOURS = 24) |
INSTANT_LISTING | Info | Listing created just before purchase | < 1h (INSTANT_LISTING_HOURS = 1) |
ELEVATED_RISK | Warning | Stripe flagged elevated risk | stripeRisk.riskLevel === "elevated" |
CARD_COUNTRY_MISMATCH | Info | Card issuing country ≠delivery country | Country code comparison |
IP_COUNTRY_MISMATCH | Warning | Buyer IP country ≠delivery country | Country code comparison |
NEW_SELLER | Info | Seller has few completed sales | < 5 sales (NEW_SELLER_MIN_SALES = 5) |
MULTIPLE_ORDERS_SAME_BUYER | Info | Repeated 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):
| Code | Severity | Trigger | Threshold |
|---|---|---|---|
INSTANT_COMPLETION | Critical | Order reached fund-releasable state very quickly | < 24h (INSTANT_COMPLETION_HOURS = 24) |
FAST_COMPLETION | Warning | Order 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):
| Code | Severity | Trigger | Threshold |
|---|---|---|---|
FAST_DELIVERY | Warning | Tracking shows delivery suspiciously soon after order creation | < 24h (FAST_DELIVERY_HOURS = 24) |
TRACKING_PREDATES_ORDER | Critical | Tracking delivery date is before order creation | deliveredAt < createdAt |
Background Job Detectors
| Code | Severity | Trigger | Threshold |
|---|---|---|---|
TRACKING_UNRECOGNIZED | Warning | No tracking updates after shipping | 3 days (TRACKING_UNRECOGNIZED_DAYS = 3) |
Assessment Flow
When Assessment Runs
Risk assessment is triggered by domain events — not called synchronously from API handlers:
| Event | Handler | Detectors Run |
|---|---|---|
transaction.completed | assessTransactionOrders | All 13 order creation detectors |
order.completed | assessStatusTransition | INSTANT_COMPLETION, FAST_COMPLETION |
order.dispute_resolved (no/partial refund) | assessStatusTransition | INSTANT_COMPLETION, FAST_COMPLETION |
order.delivered | assessTrackingDelivery | FAST_DELIVERY, TRACKING_PREDATES_ORDER |
Batch Context Fetching
The assessTransactionOrders method is optimized for multi-order transactions. It batch-fetches:
- All orders in the transaction
- All distinct sellers (with
createdAt,lastIp,lastCountry) - The buyer (with
createdAt) - Buyer’s completed purchase count
- Per-seller: completed sales count, card fingerprints from past purchases
- 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.riskIndicatorsvia$push, computes the newriskLevel(highest severity), setsriskUpdatedAtandriskAssessedAt, then emits anorder.risk_updatedevent. - Without indicators: Still sets
riskAssessedAtso 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.
| Scenario | Without safeguard | With 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:
| Field | Purpose |
|---|---|
riskReviewedAt | When an admin last reviewed this order |
riskUpdatedAt | When the last risk indicator was added |
- Needs review = has indicators AND (
riskReviewedAtis null ORriskUpdatedAt > 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 IPSAME_CARD— buyer used seller’s cardTRACKING_PREDATES_ORDER— recycled tracking numberINSTANT_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):
| # | Rule | Type |
|---|---|---|
| 1 | Block if CVC verification fails | Block (built-in) |
| 2 | Block if postal code verification fails | Block (built-in) |
| 3 | Request 3D Secure if :risk_score: >= 65 | 3DS (custom) |
| 4 | Block if :risk_level: = highest | Block (default) |
EU transactions already require 3DS under PSD2/SCA. The rule for score ≥ 65 targets NA transactions specifically.