Signals, rules, and external fraud detection services
Without systematic fraud prevention, a Magento store relies on luck at checkout: address mismatches, unusual order frequencies, and card-country mismatches go unnoticed until the chargeback arrives. This article shows how a custom risk-scoring service, built as a service contract, evaluates concrete fraud signals, how suspicious orders get an order hold instead of a hard block for human review, and how external fraud detection providers such as Signifyd, Riskified, or ClearSale can be integrated cleanly via webhook, without turning away legitimate customers through false positives.
Table of Contents
- 1. Why fraud prevention only really works at checkout time
- 2. Concrete fraud signals available at checkout time
- 3. RiskScoringInterface: a service contract for risk scoring
- 4. Order hold instead of blocking: a plugin on OrderManagementInterface
- 5. Registering in di.xml and plugin chain priorities
- 6. Audit trail: logging fraud signal scores via db_schema.xml
- 7. Integrating external fraud prevention services
- 8. A webhook receiver for asynchronous status callbacks
- 9. Avoiding false positives: allow-listing and a review queue
- 10. Summary
- 11. FAQ
1. Why fraud prevention only really works at checkout time
Many Magento projects treat fraud prevention as an afterthought: wait for the chargeback, cancel manually, and hope the next case is rarer. That is expensive, because by that point the goods have usually left the warehouse and the payment has already been captured. Effective fraud detection starts exactly at checkout, before an order reaches a "processing" status, evaluating available signals while there is still room to act.
The key difference from generic fraud filters is that Magento already holds a rich set of contextual data at checkout time: customer history, payment method, address data, cart value, and the sequence of previous order attempts. A custom fraud detection mechanism that evaluates this data directly in the order-placement flow is cheaper to run than a later reconciliation against an external blacklist, and can be tuned precisely to your own catalog and customer base.
What matters is separating two responsibilities: a scoring mechanism assesses the risk of an order, and a separate decision layer determines what happens with that score. This separation lets you adjust thresholds over time without touching signal collection itself, and keeps the entire fraud prevention pipeline testable and auditable.
2. Concrete fraud signals available at checkout time
The foundation of any fraud detection effort is a manageable set of concrete, measurable signals rather than a vague gut feeling. Comparing billing and shipping address is the simplest signal: when country, postal region, or even the name differ significantly, the risk of a stolen-card transaction rises measurably. Velocity is another core signal, meaning how many orders the same customer identifier, IP address, or email domain triggers within a short time window. Three orders from the same IP within five minutes using different credit cards is a classic card-testing pattern for stolen card data.
Another strong signal is the high-value first-time guest order: a new, non-logged-in customer with no order history who buys an unusually large cart and picks express shipping deviates statistically from normal purchase behavior. Equally telling is comparing the BIN country (Bank Identification Number of the credit card) against the shipping country: a US card being shipped to an address in Eastern Europe is not automatically fraudulent, but it is a signal that must feed into a risk assessment. Disposable email domains from known lists of temporary providers are an additional, easily checked indicator, as are multiple failed payment attempts in quick succession, which point to systematic testing of stolen card numbers.
None of these signals alone justifies automatic rejection. Only the combination of several signals in a weighted score gives you a reliable basis for fraud prevention that distinguishes truly risky orders from ones that are merely unusual but legitimate.
3. RiskScoringInterface: a service contract for risk scoring
To keep fraud detection from turning into scattered conditional logic inside observer classes, we define it as its own service contract. The RiskScoringInterface takes an order entity and returns a result object carrying a numeric score and the signals that triggered. The concrete implementation collects the individual signal checks, weighs them, and aggregates the outcome, without the caller needing to know the internal calculation logic.
Constructor property promotion keeps the implementation compact: each individual signal checker (address match, velocity check, BIN country check) is implemented as its own injectable class and wired together through the scoring class's constructor. That keeps every checker individually testable and lets you add new signals without changing existing checks, a direct application of the single-responsibility principle to fraud detection.
<?php
declare(strict_types=1);
namespace Mironsoft\FraudPrevention\Api;
use Magento\Sales\Api\Data\OrderInterface;
use Mironsoft\FraudPrevention\Api\Data\RiskScoreResultInterface;
/**
* Service Contract for order-level fraud risk scoring.
*/
interface RiskScoringInterface
{
/**
* Evaluate a placed order and return an aggregated risk score.
*
* @param OrderInterface $order
* @return RiskScoreResultInterface
*/
public function evaluate(OrderInterface $order): RiskScoreResultInterface;
}
<?php
declare(strict_types=1);
namespace Mironsoft\FraudPrevention\Model;
use Magento\Sales\Api\Data\OrderInterface;
use Mironsoft\FraudPrevention\Api\Data\RiskScoreResultInterface;
use Mironsoft\FraudPrevention\Api\Data\RiskScoreResultInterfaceFactory;
use Mironsoft\FraudPrevention\Api\RiskScoringInterface;
use Mironsoft\FraudPrevention\Model\Signal\SignalCheckerInterface;
/**
* Aggregates individual fraud signal checkers into one weighted risk score.
*/
class RiskScoringService implements RiskScoringInterface
{
/**
* @param SignalCheckerInterface[] $signalCheckers Injected via di.xml as virtual type array
* @param RiskScoreResultInterfaceFactory $resultFactory
* @param int $holdThreshold Score at or above which an order should be held
*/
public function __construct(
private readonly array $signalCheckers,
private readonly RiskScoreResultInterfaceFactory $resultFactory,
private readonly int $holdThreshold = 60
) {
}
/**
* Evaluate a placed order and return an aggregated risk score.
*
* @param OrderInterface $order
* @return RiskScoreResultInterface
*/
public function evaluate(OrderInterface $order): RiskScoreResultInterface
{
$totalScore = 0;
$triggeredSignals = [];
foreach ($this->signalCheckers as $checker) {
$signalResult = $checker->check($order);
if ($signalResult->isTriggered()) {
$totalScore += $signalResult->getWeight();
$triggeredSignals[] = $signalResult->getCode();
}
}
/** @var RiskScoreResultInterface $result */
$result = $this->resultFactory->create([
'score' => $totalScore,
'signals' => $triggeredSignals,
'shouldHold' => $totalScore >= $this->holdThreshold,
]);
return $result;
}
}
4. Order hold instead of blocking: a plugin on OrderManagementInterface
Hard-blocking checkout on every suspicious signal is harmful from a UX perspective: legitimate customers whose order happens to trigger a signal simply abandon, and the revenue is lost. The better path is to accept the order normally, but move it into Order::STATE_HOLDED, so it pauses in the fulfillment process and is flagged for manual review in the admin. The customer receives their order confirmation; the actual shipment waits for approval.
Technically we implement this via a plugin on OrderManagementInterface::place rather than a preference, so other modules can keep using the same extension point. The plugin calls the RiskScoringInterface service after the order has actually been placed, and holds the order if the score exceeds the configured threshold. Importantly, the hold happens after a successful place, not before, so payment processing and inventory reservation stay untouched and only the fulfillment workflow pauses.
<?php
declare(strict_types=1);
namespace Mironsoft\FraudPrevention\Plugin;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderManagementInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
use Mironsoft\FraudPrevention\Api\RiskScoringInterface;
use Mironsoft\FraudPrevention\Model\FraudSignalLogRepositoryInterface;
use Psr\Log\LoggerInterface;
/**
* Holds suspicious orders for manual review instead of blocking checkout outright.
*/
class HoldSuspiciousOrderPlugin
{
/**
* @param RiskScoringInterface $riskScoring
* @param OrderRepositoryInterface $orderRepository
* @param FraudSignalLogRepositoryInterface $fraudSignalLogRepository
* @param LoggerInterface $logger
*/
public function __construct(
private readonly RiskScoringInterface $riskScoring,
private readonly OrderRepositoryInterface $orderRepository,
private readonly FraudSignalLogRepositoryInterface $fraudSignalLogRepository,
private readonly LoggerInterface $logger
) {
}
/**
* Score the order after placement and hold it when the risk score is too high.
*
* @param OrderManagementInterface $subject
* @param OrderInterface $result
* @return OrderInterface
*/
public function afterPlace(OrderManagementInterface $subject, OrderInterface $result): OrderInterface
{
$scoreResult = $this->riskScoring->evaluate($result);
$this->fraudSignalLogRepository->logScore((int) $result->getEntityId(), $scoreResult);
if ($scoreResult->shouldHold() && $result instanceof Order) {
$result->hold();
$this->orderRepository->save($result);
$this->logger->info(sprintf(
'Order #%s held for fraud review, score %d, signals: %s',
$result->getIncrementId(),
$scoreResult->getScore(),
implode(',', $scoreResult->getSignals())
));
}
return $result;
}
}
5. Registering in di.xml and plugin chain priorities
The plugin is registered the usual way in di.xml, bound to Magento\Sales\Api\OrderManagementInterface. A meaningful sortOrder matters here, because production stores often have other plugins working on the same method, for example for inventory reservation or email dispatch. The fraud check should run after these core processes, so a hold can never collide with actual order processing.
We also wire the signal checkers in as a virtual type with array injection. That lets you add new signal checkers purely declaratively through di.xml without touching the RiskScoringService class, a direct implementation of the open-closed principle for fraud prevention rules.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Register the order hold plugin on the place() method -->
<type name="Magento\Sales\Api\OrderManagementInterface">
<plugin name="mironsoft_fraudprevention_hold_suspicious_order"
type="Mironsoft\FraudPrevention\Plugin\HoldSuspiciousOrderPlugin"
sortOrder="500"/>
</type>
<!-- Virtual type: array of individually injectable signal checkers -->
<type name="Mironsoft\FraudPrevention\Model\RiskScoringService">
<arguments>
<argument name="signalCheckers" xsi:type="array">
<item name="address_mismatch" xsi:type="object">Mironsoft\FraudPrevention\Model\Signal\AddressMismatchChecker</item>
<item name="order_velocity" xsi:type="object">Mironsoft\FraudPrevention\Model\Signal\OrderVelocityChecker</item>
<item name="high_value_guest" xsi:type="object">Mironsoft\FraudPrevention\Model\Signal\HighValueGuestChecker</item>
<item name="bin_country_mismatch" xsi:type="object">Mironsoft\FraudPrevention\Model\Signal\BinCountryMismatchChecker</item>
<item name="disposable_email" xsi:type="object">Mironsoft\FraudPrevention\Model\Signal\DisposableEmailChecker</item>
<item name="failed_payment_attempts" xsi:type="object">Mironsoft\FraudPrevention\Model\Signal\FailedPaymentAttemptsChecker</item>
</argument>
<argument name="holdThreshold" xsi:type="number">60</argument>
</arguments>
</type>
<preference for="Mironsoft\FraudPrevention\Api\RiskScoringInterface"
type="Mironsoft\FraudPrevention\Model\RiskScoringService"/>
</config>
6. Audit trail: logging fraud signal scores via db_schema.xml
Without logging, every fraud prevention decision remains a black box: a support agent asked to release a held order needs traceable reasons, not just a status. So we define a dedicated entity that stores, per order, the computed score, the triggered signals, and the evaluation timestamp. This table is defined declaratively through db_schema.xml, no install scripts, no manual SQL migrations.
The audit trail serves two purposes: it makes fraud detection traceable for the support team, and it supplies the data foundation to calibrate thresholds over time. Anyone who regularly reviews how many held orders were actually confirmed as fraud in review can adjust the holdThreshold based on data instead of gut feeling.
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mironsoft_fraud_signal_log" resource="default" engine="innodb"
comment="Fraud signal score audit log per order">
<column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false"
identity="true" comment="Entity ID"/>
<column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false"
comment="Sales Order ID"/>
<column xsi:type="smallint" name="risk_score" unsigned="true" nullable="false" default="0"
comment="Aggregated risk score"/>
<column xsi:type="varchar" name="triggered_signals" nullable="true" length="512"
comment="Comma-separated list of triggered signal codes"/>
<column xsi:type="smallint" name="was_held" unsigned="true" nullable="false" default="0"
comment="1 if the order was put on hold"/>
<column xsi:type="timestamp" name="created_at" on_update="false" nullable="false"
default="CURRENT_TIMESTAMP" comment="Evaluation timestamp"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_FRAUD_SIGNAL_LOG_ORDER_ID_SALES_ORDER_ENTITY_ID"
table="mironsoft_fraud_signal_log" column="order_id"
referenceTable="sales_order" referenceColumn="entity_id" onDelete="CASCADE"/>
<index referenceId="MIRONSOFT_FRAUD_SIGNAL_LOG_ORDER_ID" indexType="btree">
<column name="order_id"/>
</index>
</table>
</schema>
7. Integrating external fraud prevention services
Custom signal checkers cover the obvious cases but hit limits with more complex patterns, such as device fingerprinting across multiple stores or global fraud databases. This is where specialized providers such as Signifyd, Riskified, or ClearSale usefully complement your own fraud detection. The common integration pattern: after order placement, the order and relevant metadata are transmitted to the provider's API, which internally evaluates its own machine-learning model.
The key architectural point is that the response from these services is usually not available synchronously within the checkout request. Providers like Signifyd often deliver an initial assessment within a few seconds, but a final verdict sometimes only after several minutes, once additional data sources have been evaluated. So we first store a preliminary order mapping via PaymentAdditionalInformation with the provider's external case ID and wait for the asynchronous callback instead of artificially delaying checkout.
This decoupling is central to good UX: the customer completes checkout as usual, while external fraud detection keeps running in the background, and the final result only updates the order later, either releasing it or adding a further hold for manual review.
8. A webhook receiver for asynchronous status callbacks
The webhook receiver is a regular Magento controller that only accepts POST requests from the external fraud service. Signature verification is essential: every incoming callback must be verified against an HMAC header before its contents are allowed to affect an order, otherwise an attacker could artificially approve arbitrary orders. The controller itself contains no scoring logic; it merely translates the external response into an internal order status change.
After successful signature verification, the controller loads the order by the case ID contained in the payload, compares the reported status (for example "approved", "declined", or "under review"), and updates the order status as well as the previously created fraud signal log entry accordingly. If the external service rejects the order, it stays in hold status and additionally lands in the support team's review queue with a clear reason, instead of being automatically cancelled.
<?php
declare(strict_types=1);
namespace Mironsoft\FraudPrevention\Controller\Webhook;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Controller\ResultInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Mironsoft\FraudPrevention\Model\ExternalFraudCallbackValidatorInterface;
use Mironsoft\FraudPrevention\Model\FraudSignalLogRepositoryInterface;
use Psr\Log\LoggerInterface;
/**
* Receives asynchronous fraud verdict callbacks from external providers (Signifyd, Riskified, ClearSale).
*/
class Callback implements HttpPostActionInterface, CsrfAwareActionInterface
{
/**
* @param RequestInterface $request
* @param JsonFactory $resultJsonFactory
* @param ExternalFraudCallbackValidatorInterface $callbackValidator
* @param OrderRepositoryInterface $orderRepository
* @param FraudSignalLogRepositoryInterface $fraudSignalLogRepository
* @param LoggerInterface $logger
*/
public function __construct(
private readonly RequestInterface $request,
private readonly JsonFactory $resultJsonFactory,
private readonly ExternalFraudCallbackValidatorInterface $callbackValidator,
private readonly OrderRepositoryInterface $orderRepository,
private readonly FraudSignalLogRepositoryInterface $fraudSignalLogRepository,
private readonly LoggerInterface $logger
) {
}
/**
* Validate the incoming HMAC signature, load the referenced order and apply the verdict.
*
* @return ResultInterface
*/
public function execute(): ResultInterface
{
$result = $this->resultJsonFactory->create();
$rawBody = (string) $this->request->getContent();
$signature = (string) $this->request->getHeader('X-Fraud-Signature');
if (!$this->callbackValidator->isValid($rawBody, $signature)) {
$this->logger->warning('Rejected fraud callback with invalid signature');
return $result->setHttpResponseCode(401)->setData(['status' => 'invalid_signature']);
}
$payload = json_decode($rawBody, true);
$order = $this->orderRepository->get((int) $payload['order_id']);
$this->fraudSignalLogRepository->updateExternalVerdict(
(int) $order->getEntityId(),
(string) $payload['verdict'],
(string) $payload['case_id']
);
return $result->setData(['status' => 'accepted']);
}
/**
* Webhook endpoints are called server-to-server, CSRF validation is not applicable.
*
* @param RequestInterface $request
* @return InvalidRequestException|null
*/
public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
{
return null;
}
/**
* @param RequestInterface $request
* @return bool
*/
public function validateForCsrf(RequestInterface $request): ?bool
{
return true;
}
}
9. Avoiding false positives: allow-listing and a review queue
The biggest danger of any fraud prevention setup is not the missed fraud case, it is the legitimate customer turned away. A repeat customer ordering for the first time to a different shipping address, say a gift for someone else, should not automatically receive the same score as an unknown first-time buyer with the identical address mismatch. An allow-list for verified, returning customers reduces the weight of individual signals in a targeted way and prevents established customer relationships from being damaged by rigid rules.
Equally important is a structured review queue in the admin rather than a plain status indicator: held orders should display the concrete signals that triggered, the score, and a recommended action, so the support team can decide in seconds rather than minutes. Every manual decision, whether a held order is released or cancelled, should flow back into the audit log and, over time, serve as a training basis for calibrating the weights, a feedback loop that systematically improves the accuracy of fraud detection over time.
Two metrics deserve permanent monitoring: the false-positive rate (released orders among those held) and the false-negative rate (confirmed fraud cases that never triggered a hold). Together they give a reliable picture of whether the current configuration is too aggressive or too lax.
The table below maps concrete signals against their typical risk and the recommended response, as a practical guide for calibrating your own thresholds.
| Signal | Risk | Recommended Action |
|---|---|---|
| Billing/shipping address mismatch | Medium | Weigh lightly in the score, ignore for established customers |
| Order velocity per IP/email | High | Order hold from the third attempt within the window |
| High-value first-time guest order | High | Order hold, manual review before shipment |
| BIN country vs. shipping country mismatch | Medium | Evaluate combined with other signals, never block in isolation |
| Disposable email domain | Medium | Score contribution, additionally require email verification |
| Repeated failed payment attempts | High | Order hold, temporary lock of the payment method for the session |
10. Summary
Effective fraud prevention in the Magento 2 checkout does not come from a single hard filter, but from several cleanly separated building blocks working together: concrete, measurable fraud signals such as address mismatch, order velocity, high-value guest first orders, BIN country deviations, disposable emails, and repeated failed payment attempts; a risk-scoring service implemented as a service contract with constructor property promotion; a plugin on OrderManagementInterface::place that holds suspicious orders via Order::STATE_HOLDED for manual review instead of hard-blocking checkout; an audit log declared via db_schema.xml for traceability and calibration.
External fraud detection providers such as Signifyd, Riskified, or ClearSale complement the custom logic where more complex pattern recognition is needed, through a signature-verified webhook receiver with asynchronous status updates. The single most important success factor across all building blocks is consistently avoiding false positives through allow-listing, a structured review queue, and a feedback loop that adjusts signal weights based on data rather than intuition.
Fraud Prevention in the Magento 2 Checkout, the key takeaways
Combine signals
Address mismatch, velocity, high-value guest, BIN country, disposable email, and failed payment attempts combined into a weighted score, never evaluated in isolation.
Service contract for scoring
RiskScoringInterface with injectable signal checkers wired through di.xml, testable and without scattered conditional logic.
Order hold instead of blocking
A plugin on OrderManagementInterface::place sets Order::STATE_HOLDED, better UX than a hard checkout block.
External services and feedback loop
Signifyd, Riskified, ClearSale integrated via webhook. Allow-listing and a review queue prevent false positives.
11. FAQ: Fraud Prevention in the Magento 2 Checkout
1What is fraud prevention in the Magento 2 checkout?
2Why is order hold better than a hard block?
3How do you implement risk scoring as a service contract?
4Plugin or observer for the fraud check?
5Which fraud signals are most meaningful?
6How do you log fraud scores for audits?
7How do you integrate external fraud prevention services?
8How do you protect the webhook receiver from manipulation?
9How do you avoid false positives?
10Which metrics show if the configuration is well tuned?
Mironsoft
Magento 2 checkout security and fraud prevention integration
Ready to build systematic fraud prevention into your Magento checkout?
We implement risk scoring as a service contract, set up order hold workflows, and integrate external fraud prevention services cleanly via webhook, without hurting your checkout conversion.
Risk scoring service
RiskScoringInterface, signal checkers, and audit log as a clean service contract
Order hold workflow
Plugins instead of preferences, admin review queue, feedback loop for thresholds
External integration
Signifyd, Riskified, and ClearSale reliably integrated via webhook receiver