Marketplace fraud is not a single-party problem. When a fraudulent booking happens on a car-rental platform or a gig economy app, both sides of the transaction contribute signals: the buyer who initiated it, the seller who accepted it, and the history they share with others on the platform. Scoring one side in isolation is insufficient. Scoring both in batch is too slow. And maintaining separate real-time pipelines for buyer risk, seller risk, and cross-entity features is the kind of infrastructure tax that kills small trust and safety teams.
This article shows how to build all three feature categories using streaming SQL materialized views -- no dedicated feature platform, no Flink clusters to babysit.
The Marketplace Risk Problem: Why It Is Harder Than Single-Party Fraud
In single-party fraud -- say, a stolen credit card on an e-commerce checkout -- the risk signal is mostly contained to one actor. You score the buyer. You look at velocity, device fingerprint, billing address mismatches. The seller is passive.
In two-sided marketplaces, both parties have agency, and fraud often requires or exploits that symmetry.
Turo-style rental fraud: A fraudster creates a seller account with a desirable car listing at a below-market price. Buyers book the car, pay the deposit, and show up to find no car. The seller's cancellation pattern, listing age, and response-to-accept ratio are all signals -- but so is whether the buyers who booked had suspicious velocity or were new accounts themselves.
Whatnot-style auction manipulation: Shill bidding requires coordination between buyer and seller accounts. An unusually high win rate for a specific buyer on a specific seller's auctions, or a seller who consistently attracts the same cluster of high bidders, is a network-level signal that neither buyer nor seller history alone would surface.
Gig platform scammers: A contractor on a home services marketplace submits fake job completions. Their rating starts clean because they seed their account with a few legitimate jobs. The tell is often a sudden spike in high-value job claims, combined with reviews from accounts that themselves have no other review history.
What these cases share: the risk signal is relational. It lives in the edges between buyers and sellers, not just in the nodes. Real-time scoring means those edges must be computed incrementally, as events arrive, not as a nightly batch job.
For a marketplace at growth stage, fraud isn't just a cost of revenue -- it's a take-rate problem. Every fraudulent transaction that goes through eats into GMV quality and erodes the trust that brings back repeat buyers. Trust and safety isn't a bolt-on function. It's how you protect net revenue retention.
Key Feature Categories
Buyer Features
These capture behavioral signals on the demand side: how often does this buyer cancel, dispute, or bounce between sellers?
- Cancellation velocity (7-day, 30-day)
- Dispute rate (filings per transaction)
- Unique seller count (too many in a short window can signal testing or probing)
- Total spend and average transaction size
- Time since first transaction (account age as a proxy for trust)
- Ratio of completed transactions to initiated transactions
Seller Features
These capture reliability and listing quality on the supply side.
- Listing completion rate (completed bookings divided by accepted bookings)
- Seller-initiated cancellation rate
- Average response time to booking requests
- Average review score (and review count -- low count changes the signal interpretation)
- Listing age and modification frequency (newly altered listings deserve more scrutiny)
- Revenue concentration (is most of their GMV from a single buyer?)
Cross-Entity Interaction Features
These are the features that make marketplace risk modeling genuinely different. They require joining buyer and seller state at scoring time.
- Buyer-seller pair transaction history (have they transacted before? How many times?)
- Seller's average buyer risk score (a seller who consistently attracts risky buyers is a signal)
- Buyer's average seller risk score (a buyer who targets newly listed or low-rated sellers may be probing)
- Co-occurrence clusters (buyers and sellers that repeatedly appear together across the platform)
Network Features
These are harder to compute in real time but worth approximating.
- Review graph density (how many of a seller's reviewers have reviewed each other?)
- New account pairing rate (both buyer and seller being new accounts in the same transaction)
- Referral chain depth (was this account referred by a flagged account?)
Data Sources
A marketplace generates event streams that, properly sourced, cover all of these features.
| Source | Key events |
| Listing service | listing_created, listing_modified, listing_deactivated |
| Booking service | booking_requested, booking_accepted, booking_completed, booking_cancelled |
| Review service | review_submitted, review_flagged |
| Payment service | payment_initiated, payment_captured, payment_disputed, chargeback_filed |
| Cancellation service | cancellation_requested, cancellation_reason |
All of these should flow into Kafka or a similar message broker. From there, a streaming SQL system ingests them and maintains materialized views that update incrementally as new events arrive.
SQL Implementation
The core idea: define your features as materialized views. The streaming system maintains them continuously. At decision time, you query the view -- it's precomputed, so latency is milliseconds, not seconds.
Seller Reliability Features
CREATE MATERIALIZED VIEW seller_reliability AS
SELECT
seller_id,
COUNT(*) AS total_listings_30d,
COUNT(*) FILTER (WHERE outcome = 'completed') AS completed_bookings_30d,
COUNT(*) FILTER (WHERE outcome = 'cancelled_by_seller') AS seller_cancels_30d,
AVG(response_time_minutes) AS avg_response_time,
AVG(review_score) FILTER (WHERE review_score IS NOT NULL) AS avg_rating
FROM marketplace_events
WHERE event_time >= NOW() - INTERVAL '30 days'
GROUP BY seller_id;
This view updates whenever a new event arrives. There is no scheduled job. If a seller cancels five bookings in an hour, the seller_cancels_30d count reflects that within seconds.
Buyer Behavior Features
CREATE MATERIALIZED VIEW buyer_behavior AS
SELECT
buyer_id,
COUNT(*) FILTER (WHERE outcome = 'cancelled_by_buyer') AS buyer_cancels_30d,
COUNT(*) FILTER (WHERE dispute_filed = true) AS disputes_90d,
COUNT(DISTINCT seller_id) AS unique_sellers_30d,
SUM(transaction_value) AS total_spend_30d
FROM marketplace_events
WHERE event_time >= NOW() - INTERVAL '90 days'
GROUP BY buyer_id;
Note the mixed time windows: 30 days for cancellations (shorter behavior window), 90 days for disputes (disputes often lag the transaction). You can use whichever window makes sense for the signal without running multiple pipelines.
Cross-Entity Interaction Features
CREATE MATERIALIZED VIEW buyer_seller_history AS
SELECT
buyer_id,
seller_id,
COUNT(*) AS total_interactions,
COUNT(*) FILTER (WHERE outcome = 'completed') AS completed_together,
MAX(event_time) AS last_interaction_time,
AVG(review_score) FILTER (WHERE review_score IS NOT NULL) AS avg_review_given
FROM marketplace_events
WHERE event_time >= NOW() - INTERVAL '90 days'
GROUP BY buyer_id, seller_id;
This is the view that surfaces repeated buyer-seller pairing. A buyer-seller pair with ten transactions where the buyer always posts a glowing review is either a loyal customer or a shill bidder -- context from the other views disambiguates.
Seller's Buyer Risk Profile
This view aggregates the risk scores of a seller's recent buyers. A seller who consistently attracts buyers with high cancellation rates or dispute histories may be running a scheme that targets vulnerable accounts, or may themselves be attracting fraudsters because their listings are priced to lure them.
CREATE MATERIALIZED VIEW seller_buyer_risk_profile AS
SELECT
me.seller_id,
AVG(bb.buyer_cancels_30d) AS avg_buyer_cancel_rate,
AVG(bb.disputes_90d) AS avg_buyer_dispute_rate,
COUNT(DISTINCT me.buyer_id) AS distinct_buyers_30d,
SUM(CASE WHEN me.event_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) AS new_buyer_interactions_7d
FROM marketplace_events me
JOIN buyer_behavior bb ON me.buyer_id = bb.buyer_id
WHERE me.event_time >= NOW() - INTERVAL '30 days'
GROUP BY me.seller_id;
This is a materialized view that joins another materialized view. The streaming system handles the incremental update propagation -- when a buyer's behavior metrics change, the seller's aggregated profile updates downstream automatically.
Transaction Velocity Check
For decision-time use, a per-seller velocity check over a short window catches burst patterns:
CREATE MATERIALIZED VIEW seller_velocity_1h AS
SELECT
seller_id,
COUNT(*) AS bookings_last_1h,
SUM(transaction_value) AS value_last_1h,
COUNT(DISTINCT buyer_id) AS unique_buyers_last_1h
FROM marketplace_events
WHERE event_time >= NOW() - INTERVAL '1 hour'
GROUP BY seller_id;
One hour is short enough that a sudden spike in bookings stands out before it causes damage. Batch systems cannot catch this in time.
Real-Time Risk Score Assembly at Decision Time
When a booking request arrives, your risk service queries the relevant views and assembles a score. The latency profile looks like this:
- Booking request arrives at risk service (0ms)
- Risk service queries
seller_reliability,buyer_behavior,buyer_seller_history,seller_buyer_risk_profile,seller_velocity_1hfor the relevant IDs (2-10ms, depending on index setup) - Risk service runs a scoring model (logistic regression, gradient boosted tree, or a simple rules engine) on the assembled feature vector (1-5ms)
- Decision returned to booking service (total: under 20ms)
The features are precomputed. The latency comes only from the point lookup, not from feature computation. This is the core advantage of materialized views over on-demand feature computation.
Your scoring model can be as simple as a weighted threshold rule during early stages:
def score_booking_request(seller_id, buyer_id):
seller = query("SELECT * FROM seller_reliability WHERE seller_id = %s", seller_id)
buyer = query("SELECT * FROM buyer_behavior WHERE buyer_id = %s", buyer_id)
pair = query("SELECT * FROM buyer_seller_history WHERE buyer_id = %s AND seller_id = %s", buyer_id, seller_id)
seller_velocity = query("SELECT * FROM seller_velocity_1h WHERE seller_id = %s", seller_id)
risk_score = 0.0
# Seller signals
if seller.seller_cancels_30d > 3:
risk_score += 0.3
if seller.avg_rating is not None and seller.avg_rating < 3.5:
risk_score += 0.2
if seller_velocity.bookings_last_1h > 20:
risk_score += 0.4
# Buyer signals
if buyer.buyer_cancels_30d > 5:
risk_score += 0.25
if buyer.disputes_90d > 2:
risk_score += 0.35
# Cross-entity signals
if pair is None:
risk_score += 0.1 # no shared history, small uplift
if pair and pair.total_interactions > 10 and pair.completed_together < pair.total_interactions * 0.5:
risk_score += 0.3 # repeated interaction with poor completion rate
return min(risk_score, 1.0)
This is a starting point. As you collect labeled outcomes, you can replace the hand-tuned weights with a trained model -- but the features remain the same, and so does the serving architecture.
Handling the Cold Start Problem
New accounts have no history. The materialized views return NULL or zero for all features. A naive risk model treats this as low risk (no signals), which is exactly backwards -- new accounts are disproportionately used for fraud.
For new sellers, compensate with listing-level signals available at creation time:
- Is the listing price anomalously below market for the category?
- Is the seller's profile complete (photo, bio, phone verified)?
- Was the account created within the last 7 days?
- Did the account skip the standard onboarding steps?
CREATE MATERIALIZED VIEW new_seller_signals AS
SELECT
seller_id,
DATEDIFF('day', account_created_at, NOW()) AS account_age_days,
profile_photo_verified,
phone_verified,
identity_verified,
COUNT(*) AS total_listings
FROM seller_profiles
LEFT JOIN marketplace_events ON seller_profiles.seller_id = marketplace_events.seller_id
GROUP BY seller_id, account_created_at, profile_photo_verified, phone_verified, identity_verified;
For new buyers, use payment-level signals:
- Is the payment method newly added?
- Does the billing address match the IP geolocation?
- Is this the first transaction above a certain value threshold?
Cross-entity new account pairing is the highest-risk case: both buyer and seller are new accounts transacting with each other. This specific pattern warrants automatic manual review escalation regardless of individual scores.
A reasonable policy:
| Account age (buyer) | Account age (seller) | Recommended action |
| > 30 days | > 30 days | Score-based decision |
| < 7 days | > 30 days | Apply buyer uplift (+0.2 score) |
| > 30 days | < 7 days | Apply seller uplift (+0.25 score) |
| < 7 days | < 7 days | Escalate to manual review |
Cold start handling doesn't need to be ML-based to be effective. A good rule set with clear thresholds will catch the majority of new-account fraud while you accumulate the labeled data to train a model.
FAQ
Do I need a dedicated feature store for this?
Not at this scale. Materialized views in a streaming database are a feature store -- they're queryable, incrementally updated, and low-latency. The difference from a specialized feature platform is that you don't have separate training and serving stores to keep in sync. What you query at serving time is exactly what your model saw during training (assuming you use the same view definitions and logged feature snapshots for training). For teams under 5 engineers in trust and safety, avoiding a dedicated feature platform is usually the right tradeoff.
What happens if the streaming system falls behind?
Feature freshness degrades gracefully. A seller who cancels ten bookings in a burst will have stale cancel counts for the duration of the lag, not no counts. You should monitor the event_time lag per view and alert when it exceeds your SLO (typically 30 seconds for transaction-time features). For high-value transactions, you can also add a synchronous check against the raw event log as a fallback.
Can I use this for post-transaction risk (chargebacks, disputes)?
Yes, with different time windows and looser latency requirements. Post-transaction features can use longer windows (180 days) and tolerate more lag. The same materialized view pattern applies -- you're just not making a real-time blocking decision, so you have more time to compute.
How do I handle feature drift as fraud patterns change?
The SQL definitions are your feature spec. When fraud patterns shift, you update the view definition and it recomputes from the stream. There is no re-training pipeline to coordinate with a feature store migration -- the view is the single source of truth. That said, you still need to retrain your model after changing feature definitions. Track view definition changes in version control alongside model versions.
What should I use as the streaming SQL backend?
Any system that supports incremental materialized views over event streams and allows low-latency point queries on the results. The SQL in this article runs on RisingWave, which supports exactly-once semantics, window functions, and joins between materialized views out of the box. It also exposes a PostgreSQL-compatible query interface, which means your risk service can use standard PG drivers to query features at decision time.
Real-time marketplace risk is an architecture problem as much as a modeling problem. The features described here aren't exotic -- response rate, cancellation velocity, cross-entity history -- but computing them correctly, continuously, and with low enough latency to block a transaction requires the right infrastructure pattern. Materialized views over event streams are that pattern: define it once in SQL, query it at decision time, and let the system handle the incremental updates.
The infrastructure tax of a full feature platform is worth paying at scale. Before you get there, streaming SQL gets you most of the way there with a fraction of the complexity.

