Full fraud-prevention suite for Vendure. Every placed order is risk-scored server-side the moment it lands — no storefront integration required — and what happens next is up to your per-channel policy: log it, hold it for manual review, or hold it and tell the customer it's being verified.
Plugin page & pricing: https://huloglobal.com/vendure-plugins/fraud-prevention/
Each fired signal adds weighted points (all weights overridable per channel):
| Signal | Default points |
|---|---|
| Blocklisted email / domain | 60 / 45 |
| Blocklisted IP / IP in CIDR range | 55 |
| Disposable email domain | 50 |
| Failed payments from IP (1h) | 45 |
| IP order velocity (hour / day) | 40 / 30 |
| High-risk country | 40 |
| Card AVS: postcode mismatch (issuer verdict) | 35 |
| Stripe Radar: highest risk | 35 |
| Email order velocity (24h) | 35 |
| Email daily value ceiling | 30 |
| Order value ceiling | 25 |
| Card AVS: street address mismatch (issuer verdict) | 20 |
| First order + high value | 18 |
| Stripe Radar: elevated risk | 15 |
Plus-addressed email (x+7@gmail) | 12 |
| 3-D Secure failed (no liability shift) | 10 |
| Billing / shipping postcode differ (typed) | 8 |
Emails are canonicalised before velocity counting — x+1@gmail.com,
x+2@gmail.com and x.y@gmail.com all count as one identity. Allowlisted
identities bypass everything.
AVS (Address Verification Service) is the card issuer's own verdict on the
billing postcode and street number the customer typed — the strongest
address signal there is, because it comes from the bank rather than the
customer. Only an explicit fail scores; unavailable / unchecked
(issuer doesn't support it, or the field wasn't sent) is silent.
Where the verdict comes from, in order:
avsResolver plugin option — any gateway. Called once per placed
order with the Vendure Order (payments loaded) and the request
context; return { postalCode, line1, source } using
pass | fail | unavailable | unchecked, or null when unknown.
Payment metadata — a custom payment handler can store the result on
the Payment as { avs: { postalCode: 'fail', line1: 'pass' } }
(Stripe-style checks: { address_postal_code_check, address_line1_check }
and flat keys are recognised too; Y/N/U, match/no_match and booleans
are all understood).
Stripe, automatically — for orders paid through Vendure's
StripePlugin the PaymentIntent is fetched with expand[]=latest_charge
using the payment method's own API key and
charge.payment_method_details.card.checks is read. One GET per placed
order, 5 s timeout, fails open. Switch it off per channel with
Check card AVS with Stripe under Rules → Signals.
Stripe only runs AVS when your checkout sends the billing address.
The Payment Element does not collect the street address by itself, and
many integrations never pass a postcode either — in which case both
checks come back null and nothing scores. Send the order's billing
address in confirmPayment
(confirmParams.payment_method_data.billing_details.address, with the
element created using fields: { billingDetails: { address: 'never' } })
and the issuer verifies postcode and street number on every card
payment. Wallet payments (Apple Pay, Google Pay, Link) are verified by
the wallet provider instead and carry no AVS checks.
A typed billing vs shipping postcode difference (same country, both present) is scored separately and weakly — gifts and office deliveries do this legitimately, but it compounds with the other signals. All three weights are overridable per channel like every other signal.
The plugin also counts failed payments the
checkout-guard plugin
records before a Vendure Payment row exists (gateway declines and
storefront-reported client declines in checkout_guard_payment_event) towards
the Failed payments from IP signal, when that table is present. Nothing to
configure — it is detected automatically.
The same Stripe lookup that reads the AVS checks also reads the charge's
Radar risk level (outcome.risk_level) and its 3-D Secure outcome
(payment_method_details.card.three_d_secure), so orders paid through
Vendure's StripePlugin get three more signals for free:
| Signal | Fires when | Default points |
|---|---|---|
radar_risk_highest | Radar rated the charge highest | 35 |
radar_risk_elevated | Radar rated the charge elevated | 15 |
three_ds_failed | 3DS ran and authenticated is false (result not attempt_acknowledged / exempted / not_supported) | 10 |
Radar sees the card across every Stripe merchant, so highest is a strong
signal even when nothing else fired; normal and not_assessed are silent.
three_ds_failed means liability did not shift to the issuer — a chargeback
would land on you. It is gated by the channel's Score a failed 3-D Secure
authentication rule (on by default). Wallet payments (Apple Pay, Google Pay,
Link) are authenticated by the wallet and carry neither AVS nor 3DS data;
Radar still assesses them.
Other gateways can supply the same verdicts through avsResolver or payment
metadata — return { riskLevel: 'elevated' | 'highest', threeDsAuthenticated: false, threeDsResult: 'failed' } alongside the AVS fields (the type is
CardChecks; AvsResult remains as an alias).
OrderService.cancelOrder, so every
OrderStateTransitionEvent subscriber sees it), voids Authorized payments
(card holds, bank transfers), refunds every settled payment in full through
the payment handler's createRefund, marks the order inactive and notifies
the customer. Set cancelOnReject: false / refundOnReject: false in the
plugin options to opt out, or pass cancel / refund per case in
POST /fraud-prevention/cases/:id/reject. Anything Vendure refused
(RefundOrderStateError, a handler without createRefund, …) comes back in
warnings and the audit log — the case still closes.Daily sync (03:00) into the blocklist: FireHOL Level 1, Spamhaus DROP (IPv4 and IPv6 CIDR ranges are matched), Tor exit nodes, and the community disposable-email-domains list. Manual allow/block entries ride on top.
This registers three things:
ReadCatalog permission, the same as the page's read endpoints.Weekly report. Every Monday at 08:00 (server-local time) the plugin emails a short report covering the previous seven days, with the week before for comparison: orders risk-scored, orders held for review, orders blocked, the value of the orders intercepted (the Vendure order totals behind every review case), and review cases approved and rejected. Below the figures it lists up to three next steps taken from the set-up checklist, the licence state when something needs attention (a trial or key about to end), and a link to the admin page. Nothing is reported that is not counted in the plugin's own tables — a quiet week says so.
The report goes to the first of: the recipient entered under Settings →
Weekly report; the alert address under Settings → Notifications → Email;
the defaultAdminEmail option; the licence's customer email; the address given
to "Email me before it ends" during the evaluation. The card shows which
address applies ("Defaults to …"), when the last report went out and when the
next is due, and has Send me a test and Preview buttons. Untick Send
the weekly report to stop it. One report is sent per install, however many
server or worker processes run; the scheduler runs on the worker.
It uses the same SMTP transport as the fraud alerts — the smtp option, or
the SMTP_SERVER / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / SMTP_FROM
environment variables. With no SMTP configured the report is silently off and
"Send me a test" says why.
Setup checklist. The Overview tab opens with a checklist of real checks against the configuration: scoring switched on for a channel, enforce mode on at least one channel, an alert email set, threat feeds synced in the last seven days, and a first review case worked. Each row links to the tab where it is done. Hide keeps it out of the way (remembered in the browser); it disappears for good once everything is done and comes back if something becomes undone. The same items supply the weekly report's next steps.
Dashboard widget. Dashboard → Add widget → Fraud Prevention. Three seven-day figures, the licence notice when one applies, and an "Open Fraud Prevention" link. Supported widths: 4, 6 and 8 columns.
Usage figures sent to HULO. The same six counters (last 30 days, numbers
only, no customer data) ride along with the daily heartbeat and, during the
evaluation, with the evaluation check-in, so renewal and reminder emails can
say what the plugin did for the store. HULO_HEARTBEAT_DISABLED=true stops
the heartbeat entirely.
The plugin marks orders as held; your fulfilment path asks before shipping:
pendingOrderIds() (open cases only) is unchanged. heldOrderIds() also
includes rejected cases, so a rejected order is never released by a
fulfilment path that only checks for open cases. isAssessed(orderId) (and
the batch assessedOrderIds(ids)) closes the race between OrderPlacedEvent
and the guard's asynchronous assessment: an order that has not been scored
yet has no case to hold it, so fulfil only once both are true. Every placed
order gets a fraud_log row — even with the channel off — so isAssessed
becomes true within a second or two of placement.
POST /fraud-prevention/check { email, orderValuePence, channelId } →
{ allowed, riskLevel } — rate-limited per client address, minimal response shape by design.
The verdict covers only the caller's own attempt (its IP and that IP's lists and velocity,
the e-mail's domain and shape, value, country). Customer history, e-mail spend/velocity and
the e-mail allow/block lists are never consulted, so the endpoint cannot be used to learn
anything about an address. The client address is the socket peer (or Express req.ip);
behind a proxy, list it in trustedProxies.
| Option | Default | Purpose |
|---|---|---|
publicBaseUrl | — | Used in admin notification links and licence host binding |
licenceKey | — | JWT from huloglobal.com; also activatable from the admin |
defaultAdminEmail | — | Where fraud-alert emails go when no per-install setting exists |
smtp | SMTP_* env | SMTP transport for alerts and customer notices |
rateLimit | 60 / min | Rate limit for the public /fraud-prevention/check endpoint |
trustedProxies | — | Proxies (IPs/CIDRs) whose cf-connecting-ip / right-most untrusted x-forwarded-for hop / cf-ipcountry the pre-check believes |
trustCloudflareHeader | true | Read cf-connecting-ip / cf-ipcountry from a trusted proxy |
logRetentionDays | 180 | Prune fraud_log rows older than this (0 = keep) |
disableFeedSync | false | Skip the daily threat-feed sync |
avsResolver | — | Supply CardChecks (AVS, Radar, 3DS) for any gateway |
cancelOnReject | true | Rejecting a case cancels the order through Vendure |
refundOnReject | true | Rejecting a case refunds settled payments in full |
Without a licence key the plugin runs in the free tier: monitor mode, manual lists and simulate. Enforce mode, review-queue holds, threat-feed sync and email alerts require a licence from https://huloglobal.com/vendure-plugins/fraud-prevention/.
Vendure >=3.5 <4. MariaDB/MySQL and PostgreSQL (through the licence SDK's
SQL dialect adapter). Tables are created/migrated automatically on boot,
including a best-effort index on Vendure's payment(createdAt); upgrading
from the pre-plugin HULO implementation preserves all existing config, log
and list data. Block/allow list ranges accept IPv4 and IPv6 CIDR notation.
AGPL-3.0-or-later — commercial licences available from HULO Global.
Pick monthly or annual on the plugin's admin banner and click Start 14-day free trial (card required, nothing charged until day 15, cancel any time), or choose lifetime — checkout opens in a new tab and the key installs itself within a minute. You can also buy at https://elite.charity/licence/buy/vendure-plugin-fraud-prevention and paste the emailed key into the admin.
All notable changes to @huloglobal/vendure-plugin-fraud-prevention are
documented here. The format follows
Keep a Changelog and this project
adheres to semantic versioning.
license field is now AGPL-3.0-only, matching LICENSE, which grants the GNU Affero
General Public License version 3 (it said AGPL-3.0-or-later). Published npm builds remain under the
separate commercial licence described in LICENSE. No code changes.^0.15.4, and only the first decision acts. The first weekly report is no longer skipped either (it used to start a week late). MySQL and MariaDB were not affected.homepage is now the product page, https://huloglobal.com/vendure-plugins/fraud-prevention/, instead of the GitHub repository.description that npm shows in full (npm cuts descriptions at 255 characters).POST /fraud-prevention/check pre-check no longer reveals anything about an e-mail address. It used to run the full assessment, so its riskLevel told an anonymous caller whether an address was a customer (returning-customer credit), its last-24 h spend (moving orderValuePence across the daily-value limit) and whether it was allow- or blocklisted. It now scores only the caller's own attempt: no allowlist, no e-mail block lists, no e-mail velocity or spend, no customer history. The response shape is unchanged. Placed orders are still scored in full.cf-connecting-ip or the left-most x-forwarded-for entry from any client, so rotating the header bypassed the rate limit and let a caller have any address scored. Forwarding headers (and cf-ipcountry) are now believed only from proxies listed in the new trustedProxies option (right-most untrusted hop); otherwise the address is Express's req.ip or the socket. Hosts behind a reverse proxy or Cloudflare should set trustedProxies, or every pre-check is keyed on the proxy's address.ok: false.rateLimit option was ignored (the endpoint was fixed at 60 / min); it now applies.smtp option or SMTP_* env) to the recipient set under Settings → Weekly report, else the alert email, defaultAdminEmail, the licence customer or the evaluation reminder address. Endpoints GET/POST fraud-prevention/digest/settings, POST fraud-prevention/digest/send-test, GET fraud-prevention/digest/preview; a "Weekly report" card on the Settings tab with Send-me-a-test and Preview. Scheduled on the worker only, once per install across processes.GET fraud-prevention/setup): scoring on for a channel, enforce on for a channel, alert email set, threat feeds synced in the last 7 days, first review case worked — real checks, each linking to the tab where it is done. Hideable per browser; returns if an item comes undone.GET fraud-prevention/widget): orders scored, risky orders intercepted and value intercepted for the last 7 days, plus the licence notice. Both are registered by the new shared FraudPreventionNavModule in uiExtensions.stats.intercepted30), the latter summing the Vendure order totals behind every review case.meta now carries notice (trial/key about to end), premiumFeatures and recentStats (top three non-zero 30-day figures). The licence banners show the lifecycle notice in an amber strip, the premium feature list on the trial and free tiers, and a "So far: …" line from the recent figures; the licensed banner reads "renews automatically — current key valid until ordersAssessed, ordersHeld, ordersBlocked, riskyValuePence, casesApproved and casesRejected; licensed installs keep reporting through the daily heartbeat (SDK 0.15.0 Heartbeat.setStatsProvider).@huloglobal/vendure-licence-sdk ^0.15.0.error: 'licence_required' and add buyUrl. In the admin UI any 402 (or a licence_required / premium-feature body) is shown as "hulo_plugin_prefs table, key eval.leadEmail) as the weekly report's last-resort recipient.2001:db8::/32, ::ffff:203.0.113.0/120, a bare address is a /128). Matching runs over the expanded 128-bit form, so :: compression, zone ids and IPv4-mapped forms all match; an IPv4 client is also checked against IPv4-mapped ranges and an IPv4-mapped client against IPv4 ranges. IPv4 ranges keep their integer fast path. Entry validation accepts either family.ip_range entries are now honoured (previously only exact ip entries were checked when deciding whether an address bypasses the checks).idx_fp_payment_created index on Vendure's payment(createdAt), created on boot, for the failed-payments signal and the 24 h failed-payment count (Vendure never indexes that column). Failure to create it is logged at debug level and never blocks start-up.order, customer, payment, order_channels_channel, channel) is quoted; SUM(condition) counts are written as SUM(CASE WHEN … THEN 1 ELSE 0 END); order.active is compared as a boolean; the customer-history query no longer sends an untyped null parameter; the feed-sync cross-process lock uses a Postgres advisory lock (it was MariaDB GET_LOCK only).fraud_blocked_orders(orderId, status) index was requested before the table existed and silently skipped; it is now created after the table.tests/pg-corpus.test.ts, runs when HULO_PG_URL is set) extracts every SQL statement in src/, translates it with the SDK's dialect adapter and prepares it against a scratch database with quoted stand-ins for the Vendure tables, so a quoting or dialect regression fails the unit suite.fraud_blocklist gains a source index, feeds that have not changed (same content hash) are skipped, changed feeds are loaded under a staging key and swapped in id-sized chunks with deadlock retries, and a sync cannot overlap another (in-process flag plus a database lock across server and worker).::ffff:1.2.3.4) are normalised, IPv6 addresses reach the IP-intelligence lookup, blocklisted emails match their canonical form, list entries are validated (type, CIDR syntax, email, domain), thresholds are clamped and ordered, and the public pre-check uses the caller's own address.fraud_log(orderId) and fraud_blocked_orders(orderId, status) indexes for the host fulfilment gate and order panel.LOWER() so the index is used.OrderService.cancelOrder (state machine, order history and every OrderStateTransitionEvent subscriber see it), voids Authorized payments (card holds, bank transfers) and refunds every settled payment in full through the payment handler's createRefund. Previously only order.active was cleared, so a rejected order was still fulfilled by hosts gating on open cases alone. New plugin options cancelOnReject and refundOnReject (both default true); POST /fraud-prevention/cases/:id/reject accepts per-case cancel / refund overrides and returns cancelled, orderState, refunds and warnings. Vendure refusals never block the decision — they are reported in warnings, the audit log, the ops alert and the admin toast.outcome.risk_level and payment_method_details.card.three_d_secure: radar_risk_elevated (15), radar_risk_highest (35) and three_ds_failed (10 — 3DS ran and the cardholder did not authenticate; attempt_acknowledged / exempted / not_supported are benign). three_ds_failed is gated by the channel's existing 3-D Secure rule, which now does something. The Simulate tab has Radar / 3DS inputs; card-network verdicts (AVS, Radar, 3DS) are highlighted together in the review queue and the order panel.failed / client_declined rows from checkout_guard_payment_event (written by @huloglobal/vendure-plugin-checkout-guard before any Vendure Payment row exists) when that table is present. Detected automatically; nothing to configure.FraudPreventionService.isAssessed(orderId) / assessedOrderIds(ids) — true once the order guard has scored the order — close the race between OrderPlacedEvent and the asynchronous assessment; heldOrderIds() returns pending and rejected cases. pendingOrderIds() is unchanged.CardChecks (AvsResult stays as an alias), RadarRiskLevel, ThreeDsResult, ResolveCaseResult, cardChecksFromStripeCharge, cardChecksFromMetadata, fetchStripeCardChecks, parseRiskLevel, parseThreeDsAuthenticated, threeDsFailed.avsResolver may return Radar / 3DS fields alongside the AVS ones; payment metadata readers accept riskLevel / threeDsAuthenticated / threeDsResult (canonical avs object), Stripe-style outcome / three_d_secure objects and flat keys.FraudPreventionService now injects OrderService and RequestContextService (both provided by PluginCommonModule).GET /fraud-prevention/log accepts signal=avs.GET /fraud-prevention/cases accepts signal=avs (any signal-key prefix).Stripe-Version header the request ran at the account's default version, and on accounts created before November 2022 expand[]=latest_charge is rejected with HTTP 400 — the lookup silently produced nothing. The GET is now pinned (read-only, so it never changes the account), and the unreachable charges.data fallback is gone.postcode_mismatch no longer fires when one postcode is a partial form of the other (5-digit ZIP vs ZIP+4, UK outward code vs full postcode).avs_postcode_fail (35) and avs_address_fail (20) fire when the card issuer's AVS verdict on the billing postcode / street address is an explicit fail; postcode_mismatch (8) fires when the typed billing and shipping postcodes differ within the same country. AVS verdicts come from a host avsResolver option (any gateway), from Payment.metadata (avs: { postalCode, line1 }, Stripe-style checks, or flat keys), or — for orders paid through Vendure's StripePlugin — automatically, by fetching the PaymentIntent's latest charge with the payment method's own API key. Fails open; unavailable / unchecked never score.avsLookup, default on).AvsCheck, AvsResult, avsFromMetadata, avsFromStripeCharge, fetchStripeAvs, normalisePostcode, parseAvsCheck, postcodesDiffer.confirmPayment → confirmParams.payment_method_data.billing_details — see README → Postcode / AVS. Wallet payments (Apple Pay, Google Pay, Link) carry no AVS checks.AssessInput gains billingPostalCode, shippingPostalCode and avs; the order guard fills them from the order's addresses and FraudPreventionService.resolveAvsForOrder().hulo-global-logo.svg is now the HG wordmark on the HULO black rounded square (the same mark as the huloglobal.com header), with explicit -light and -dark variants alongside the auto-switching default..env edit, no restart. Renewed subscription keys are picked up automatically too. New admin endpoints licence/purchase-link and licence/claim-status.@huloglobal/vendure-licence-sdk ^0.12.0.dbConnectionOptions use.
Verified against PostgreSQL 17. MySQL/MariaDB installs are unaffected
(byte-identical passthrough).# comments
ignored), typed as IP / CIDR range / email / email-domain. They sync
nightly alongside the built-ins and are matched identically (CIDR
included), with per-feed enable/disable, on-demand sync, and last
sync count / error surfaced. Licensed feature.GET/POST /fraud-prevention/feeds/custom,
POST /feeds/custom/:id (edit / {sync:true}), DELETE /feeds/custom/:id.POST /fraud-prevention/check returned Nest's default 201 for
a POST; it's a read-only risk check, so it now returns 200.@vendure/testing, real MariaDB dialect): admin
auth gating, public-check shape + rate limiting, and the assessment
engine (disposable email, order value, returning-customer trust credit,
blocklist incl. CIDR ranges, allowlist bypass, unlicensed enforce
downgrade). 11 e2e tests.[ngModel]="caseNotify(id)") which
Angular re-evaluated every change-detection pass. The per-case ticks
are now seeded from the global defaults when cases load and bound to
plain state. The Settings integrations list likewise iterates a stable
array instead of one rebuilt each cycle.@ escape was corrupted by the release tooling (sed &
back-reference) and still shipped a bare @ — admin builds kept
failing. Repaired and verified: no bare @ remains in any template
text node.@BotFather in the Telegram help text is now the @ entity.X-Hulo-Signature) — every held, approved,
rejected and auto-released case pings all configured channels. Each
transport fails independently.{{orderCode}}, {{firstName}}, {{supportEmail}} and
{{reviewHours}} variables, live preview and one-click reset. Bodies
are plain text (blank lines = paragraphs) so tone is editable without
HTML foot-guns. Defaults rewritten to be honest without being alarming
— a held order is "a quick security check", never an accusation, and a
rejection includes a human-appeal path.reviewHours promise surfaced in the templates.fraud_ip_intel,
fails open): VPN/proxy and datacentre-IP signals — the blockVpnProxy
toggle finally does something — plus IP vs billing-country mismatch.First release as a standalone plugin — a full rebuild of the fraud tooling that previously lived inside the HULO host project, with the piece that implementation never had: real enforcement.
OrderPlacedEvent is assessed — the
old /check endpoint required the storefront to call it, and nothing did.ipInCidr verification (unit-tested).pendingOrderIds()); approve releases + emails the customer, reject
cancels + emails. All decisions audited.fraud_config, fraud_log, fraud_blocked_orders, fraud_blocklist,
fraud_whitelist, fraud_notification_config) — upgrading preserves all
live data; new columns are added automatically.ReadCatalog / UpdateCatalog) — previously these endpoints were
mounted without any auth guard. The public /check endpoint is
rate-limited and returns a minimal shape (no signal internals to probe).