Fraud Commerce Apps (Beta)

Fraud Commerce Apps is a beta service that is subject to the Beta Services Terms at Agreements - Salesforce.com or a written Unified Pilot Agreement if executed by Customer, and applicable terms in the Product Terms Directory. Use of this beta service is at the Customer’s sole discretion.

Note

Breaking changes are possible until GA. While these domains are in beta, Salesforce reserves the right to change hook signatures, extension-point names, data model field names and shapes (including paymentDetails and transactionDetails), Script API surfaces introduced for these domains, and commerce-app.json manifest fields, without the notice period or deprecation window that applies to GA APIs. Changes will be included in release notes for the release in which they land. Once GA is declared, standard backward-compatibility guarantees apply from that point forward.

Warning

A fraud Commerce App runs risk assessments at two points around payment authorization and returns decisions that steer order creation and order placement. It has two parts: hooks that run on the server and, optionally, extension components in the Storefront Next storefront that collect device data.

Prerequisites 

  • Payments Orchestration is enabled on the instance. The feature toggle PaymentFraudOrchestrationEnabled is off by default. Contact Salesforce Customer Support to enable it.
  • SCAPI hook execution is enabled. The feature toggle ScapiHookExecutionEnabled is off by default. Contact Salesforce Customer Support to enable it.
  • Your app is installed on the site.
  • Your app registers at least one fraud hook. An app that registers neither beforePayment nor afterPayment has no effect. If it registers only one, the other phase is recorded as NOT_REVIEWED.

Overview 

Unlike payment, a fraud Commerce App doesn’t claim payment methods. The platform dispatches the fraud extension points for every checkout after the prerequisites are met, and only one fraud Commerce App can be installed per site. beforePayment runs on every order creation. afterPayment runs only for orders placed through attemptOrderTransition. For backward compatibility, code can still place an order directly with OrderMgr.placeOrder, which skips afterPayment.

Fraud depends on the payment domain for its data: beforePayment reads the payment details on the basket’s instruments, and afterPayment reads the transaction details the payment app recorded. afterPayment still runs when attemptOrderTransition runs on an order paid only with gift cards. The beforePayment hook fires during POST /orders, before the order is created and before any authorization. The afterPayment hook fires inside attemptOrderTransition, after all payment transactions on the order reach AUTHORIZED or CAPTURED. The pre-authorization decision (APPROVED, CHALLENGE, DECLINED, or NOT_REVIEWED to skip) is recorded on the transaction. A failed hook call blocks order creation. See Failure Handling. The post-authorization decision (GUARANTEED, NOT_GUARANTEED, PENDING, or NOT_REVIEWED to skip) decides whether the order is placed, held, or failed.

Extension Points 

Register your hooks. In your site cartridge, add a package.json that points to hooks.json. Script paths in hooks.json are relative to that file. Each exported function name must match the last segment of its extension point.

1{ "hooks": "./hooks.json" }
1{
2  "hooks": [
3    { "name": "sfcc.app.fraud.beforePayment", "script": "./fraudHooks.js" },
4    { "name": "sfcc.app.fraud.afterPayment", "script": "./fraudHooks.js" }
5  ]
6}

For signatures and the latest class details, see the Script API reference for dw.apps.checkout.hooks.FraudAppHooks.

sfcc.app.fraud.beforePayment (pre-authorization) 

sfcc.app.fraud.beforePayment(basket) fires during POST /orders, before the order is created and before any authorization. This happens on every order-creation path, including OrderMgr.createOrder. You receive the basket with its payment instruments. Card details are available. Authorization results and the address verification (AVS), card verification code (CVC), and 3D Secure (3DS) results aren’t available.

  • APPROVED (Status.OK): order creation proceeds.
  • CHALLENGE (Status.OK): order creation proceeds. The payment app reads the decision and decides whether to request 3DS. The platform doesn’t force it.
  • DECLINED (Status.ERROR): the platform rolls back order creation. No order exists, and the shopper stays on the basket.
  • NOT_REVIEWED (Status.OK): you skipped the check. Order creation proceeds.

The fraud app sets the decision and returns the matching Status. The platform enforces the decision. Only a failed hook call blocks creation: Status.ERROR, an exception, a timeout, or a null return (see Failure Handling). The persisted decision alone doesn’t block creation. Script callers of OrderMgr.createOrder get a CreateOrderException.

  • A decline leaves no record on the platform. When you return Status.ERROR, order creation rolls back, and so does everything your hook wrote: the DECLINED decision and any basket custom attributes. Keep decline evidence on your side, keyed by basket.getUUID(). On APPROVED, CHALLENGE, and NOT_REVIEWED, the platform records the decision on the order.
  • Which instruments you see. You see the payment instruments on the basket when POST /orders runs, with their card details. If the basket has no instruments yet, no instrument exists to hold a decision. Decide from the basket alone and return the matching Status.
  • The hook runs once per order attempt. A shopper who is declined and retries calls you again with the same basket UUID. Make your vendor submission idempotent on it.

sfcc.app.fraud.afterPayment (post-authorization) 

sfcc.app.fraud.afterPayment(order) runs inside attemptOrderTransition, normally once per order; see Can run twice below. It fires when every payment instrument on the order, gift cards included, is AUTHORIZED or CAPTURED and their transaction amounts together cover the order’s gross total.

It supports a synchronous pattern (decide and return) and an asynchronous one (set PENDING, return OK, and resolve through your webhook).

  • GUARANTEED (Status.OK): the platform places the order.
  • NOT_GUARANTEED (Status.ERROR): the platform reverses the order’s payments (see Payment Commerce Apps), refunds captured gift cards, fails the order, and reopens the basket. A persisted NOT_GUARANTEED has this effect even if you return Status.OK.
  • PENDING (Status.OK): the order stays in CREATED until your webhook sets a final decision.
  • NOT_REVIEWED (Status.OK): you skipped the check. The platform places the order. The platform also sets this post-auth value when no fraud app is installed or the decision is left unset. The pre-auth decision stays null when no fraud app is installed, and on an instrument added to the order with POST.

Status.ERROR, an exception, a timeout, or a null return all fail the order. To let an order through when your service is down, see Failure Handling.

Decision semantics.

  • One decision per order. Set the same decision on every instrument. Different decisions per instrument aren’t supported: one NOT_GUARANTEED rejects the order, and one PENDING holds it.
  • GUARANTEED is your contract, not the platform’s. The platform places the order. It doesn’t enforce any chargeback guarantee.
  • GUARANTEED doesn’t guarantee placement. Placement can still fail after you return. The order history then records placement_failed. Treat GUARANTEED as “intended to place” and reconcile against the order’s final status.
  • Gift cards are already captured. The amounts you see include gift card tender. A NOT_GUARANTEED refunds them.
  • Can run twice. Concurrent attemptOrderTransition calls, such as a payment webhook and a shopper PATCH, can both reach afterPayment. Use orderNo as your idempotency key.

Time Budget 

Each fraud hook has a fixed 1-second execution budget. A hook that exceeds it is treated as an error: beforePayment blocks order creation, and afterPayment fails the order.

  • Set your service’s HTTP timeout below the budget, through the LocalServiceRegistry service profile. A platform timeout can’t be caught in script, but your own shorter timeout can.
  • If your vendor can’t reliably answer within the budget post-auth, use the asynchronous pattern.
  • Pre-auth has no asynchronous option, so decide whether to fail open when the vendor is slow.

Failure Handling 

OutcomebeforePaymentafterPayment
Status.ERRORBlocks order creationFails the order and reverses payment
Exception or timeoutBlocks order creationFails the order and reverses payment
null returnBlocks order creationFails the order and reverses payment
Status.OK, no decision setProceeds; decision backfilled to NOT_REVIEWEDProceeds; decision backfilled to NOT_REVIEWED

The platform fails closed. To fail open when your service is unavailable, set your service timeout below the 1-second budget, catch the error, set NOT_REVIEWED on every instrument, and return Status.OK. Whether to fail open is a merchant policy decision, so expose it as an app setting.

Service errors don’t throw. A LocalServiceRegistry service returns a Result with ok set to false for HTTP errors and timeouts. Check it and throw, or the fail-open path never runs. Set the service profile timeout to about 700 milliseconds: the 1-second budget also covers building the request body and platform overhead.

1var Logger = require('dw/system/Logger');
2var PaymentTransaction = require('dw/order/PaymentTransaction');
3var Status = require('dw/system/Status');
4
5function decide(basket) {
6    var result = fraudService.call(buildPayload(basket));   // fraudService, buildPayload: your stubs (a LocalServiceRegistry service)
7    if (!result.ok) {
8        throw new Error('fraud service ' + result.error + ': ' + result.errorMessage);
9    }
10    return applyDecision(basket, result.object);             // your code: sets decisions, returns the matching Status
11}
12
13exports.beforePayment = function (basket) {
14    try {
15        return decide(basket);                       // your vendor call; sets decisions, returns matching Status
16    } catch (e) {
17        Logger.warn('Fraud service unavailable: {0}', e.message);
18        if (!failOpenEnabled()) {                    // your site-preference read
19            return new Status(Status.ERROR, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_DECLINED);
20        }
21        setOnAllInstruments(basket, function (txn) {
22            txn.setPreAuthFraudDecision(PaymentTransaction.PRE_AUTH_FRAUD_DECISION_NOT_REVIEWED);
23        });
24        return new Status(Status.OK, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_NOT_REVIEWED);
25    }
26};

The afterPayment fail-open variant is the same pattern with POST_AUTH_FRAUD_DECISION_NOT_REVIEWED.

Matching Status to the Decision 

Decision Set on TransactionReturn
PRE_AUTH_FRAUD_DECISION_APPROVEDnew Status(Status.OK, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_APPROVED)
PRE_AUTH_FRAUD_DECISION_CHALLENGEnew Status(Status.OK, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_CHALLENGE)
PRE_AUTH_FRAUD_DECISION_DECLINEDnew Status(Status.ERROR, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_DECLINED)
PRE_AUTH_FRAUD_DECISION_NOT_REVIEWEDnew Status(Status.OK, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_NOT_REVIEWED)
POST_AUTH_FRAUD_DECISION_GUARANTEEDnew Status(Status.OK, PaymentTransaction.POST_AUTH_FRAUD_DECISION_GUARANTEED)
POST_AUTH_FRAUD_DECISION_NOT_GUARANTEEDnew Status(Status.ERROR, PaymentTransaction.POST_AUTH_FRAUD_DECISION_NOT_GUARANTEED)
POST_AUTH_FRAUD_DECISION_PENDINGnew Status(Status.OK, PaymentTransaction.POST_AUTH_FRAUD_DECISION_PENDING)
POST_AUTH_FRAUD_DECISION_NOT_REVIEWEDnew Status(Status.OK, PaymentTransaction.POST_AUTH_FRAUD_DECISION_NOT_REVIEWED)

The platform reads only whether the Status is an error. The code string is for logs and metrics.

Set the decision on every payment instrument, gift cards included. beforePayment receives the basket, and afterPayment receives the order. Iterate getPaymentInstruments() on whichever you receive. Any instrument you leave unset is backfilled to NOT_REVIEWED, which then counts as your decision, and afterPayment isn’t called again for that order. Set the same decision on all of them.

1function setOnAllInstruments(ctnr, apply) {
2    var pis = ctnr.getPaymentInstruments().iterator();
3    while (pis.hasNext()) {
4        var txn = pis.next().getPaymentTransaction();
5        if (txn) { apply(txn); }                     // a transaction can be null
6    }
7}

beforePayment Example 

1var Status = require('dw/system/Status');
2var PaymentTransaction = require('dw/order/PaymentTransaction');
3
4exports.beforePayment = function (basket) {
5    var decision = ExampleFraud.evaluatePreAuth(basket);   // ExampleFraud: your vendor client
6    var status;
7
8    if (decision.risk === 'LOW') {
9        setOnAllInstruments(basket, function (txn) {
10            txn.setPreAuthFraudDecision(PaymentTransaction.PRE_AUTH_FRAUD_DECISION_APPROVED);
11        });
12        status = new Status(Status.OK, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_APPROVED);
13    } else if (decision.risk === 'MEDIUM') {
14        setOnAllInstruments(basket, function (txn) {
15            txn.setPreAuthFraudDecision(PaymentTransaction.PRE_AUTH_FRAUD_DECISION_CHALLENGE);
16        });
17        status = new Status(Status.OK, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_CHALLENGE);
18    } else {
19        setOnAllInstruments(basket, function (txn) {
20            txn.setPreAuthFraudDecision(PaymentTransaction.PRE_AUTH_FRAUD_DECISION_DECLINED);
21        });
22        status = new Status(Status.ERROR, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_DECLINED, decision.reason);
23    }
24
25    // No order exists yet: persist vendor outputs on the basket. They copy to the order at creation.
26    basket.custom.example_caseId = decision.caseId;
27    basket.custom.example_riskScore = decision.score;
28    return status;
29};

In beforePayment, persist vendor outputs (case ID, risk score, reason codes) as custom attributes on the basket, because no order exists yet. They copy to the order at creation, so afterPayment reads them from order.custom. On a decline, the platform rolls back these writes. For naming and declaration, see Provider-Specific Data on Custom Attributes.

afterPayment Example (synchronous) 

1var Status = require('dw/system/Status');
2var PaymentTransaction = require('dw/order/PaymentTransaction');
3
4exports.afterPayment = function (order) {
5    var decision = ExampleFraud.evaluatePostAuth(order);
6    var approved = decision.outcome === 'APPROVED';
7    var value = approved
8        ? PaymentTransaction.POST_AUTH_FRAUD_DECISION_GUARANTEED
9        : PaymentTransaction.POST_AUTH_FRAUD_DECISION_NOT_GUARANTEED;
10
11    setOnAllInstruments(order, function (txn) {
12        txn.setPostAuthFraudDecision(value);
13    });
14
15    order.custom.example_guaranteeId = decision.guaranteeId;
16    return new Status(approved ? Status.OK : Status.ERROR, value);
17};

Asynchronous Post-Authorization Review 

Webhook endpoint. Use a site controller, as the sample does. Custom SCAPI endpoints always require a shopper or Account Manager token, so a vendor can’t call one directly.

1var PaymentTransaction = require('dw/order/PaymentTransaction');
2var Status = require('dw/system/Status');
3
4exports.afterPayment = function (order) {
5    var submitted = ExampleFraud.submitForReview(order.getOrderNo(), order.getOrderToken(), buildPayload(order)); // returns fast
6    if (!submitted.ok) {
7        return new Status(Status.ERROR);              // or fail open; see Failure Handling
8    }
9    setOnAllInstruments(order, function (txn) {
10        txn.setPostAuthFraudDecision(PaymentTransaction.POST_AUTH_FRAUD_DECISION_PENDING);
11    });
12    order.custom.example_caseId = submitted.caseId;
13    return new Status(Status.OK, PaymentTransaction.POST_AUTH_FRAUD_DECISION_PENDING);
14};
1var Logger = require('dw/system/Logger');
2var Order = require('dw/order/Order');
3var OrderMgr = require('dw/order/OrderMgr');
4var PaymentTransaction = require('dw/order/PaymentTransaction');
5var Transaction = require('dw/system/Transaction');
6
7exports.Decision = function () {
8    var body = request.httpParameterMap.requestBodyAsString;
9    if (!verifySignature(request, body)) { response.setStatus(401); return; }   // verifySignature: your vendor stub
10
11    var event = JSON.parse(body);
12    var order = OrderMgr.getOrder(event.orderNo, event.orderToken);
13    if (!order || order.status.value !== Order.ORDER_STATUS_CREATED) {
14        response.setStatus(200);                      // already resolved, auto-failed, or duplicate
15        return;
16    }
17
18    var guaranteed = event.decision === 'approve';
19    Transaction.wrap(function () {                    // commit the decision first
20        setOnAllInstruments(order, function (txn) {
21            txn.setPostAuthFraudDecision(guaranteed
22                ? PaymentTransaction.POST_AUTH_FRAUD_DECISION_GUARANTEED
23                : PaymentTransaction.POST_AUTH_FRAUD_DECISION_NOT_GUARANTEED);
24        });
25    });
26    var result = Transaction.wrap(function () {
27        return OrderMgr.attemptOrderTransition(order);   // places on GUARANTEED, fails on NOT_GUARANTEED
28    });
29    if (result.isError() && guaranteed) {
30        Logger.error('Order {0} not placed at {1}: {2}', order.orderNo, result.getStep(), result.getReason());
31    }
32    response.setStatus(200);
33};
34exports.Decision.public = true;
  • Correlate by order number and token. Send order.orderNo and order.orderToken with the submission, and look up the order with OrderMgr.getOrder(orderNo, orderToken).
  • Gate on order status. If the order is no longer CREATED, acknowledge and stop. This makes duplicate and late deliveries safe.
  • Set the final decision on every instrument, gift card included. One remaining PENDING holds the order. afterPayment isn’t called again, so nothing else resolves it.
  • Commit the decision, and then call OrderMgr.attemptOrderTransition(order) in a separate transaction, for both outcomes. On GUARANTEED it places the order. On NOT_GUARANTEED it reverses the payments, refunds gift cards, fails the order, and records fraud_rejected. Don’t call failOrder yourself. Keeping remote reversals out of the decision’s transaction means a rollback can’t lose the decision.
  • Check the result. isError() on a GUARANTEED order means placement failed. Log it and reconcile with your case. Return HTTP 200 once the decision is committed.
  • Use orderNo as your idempotency key for the submission and the callback.
  • Authenticate the endpoint. Verify the vendor’s signature (see the payment guide’s Webhook Handling section).
  • A pending decision doesn’t exempt the order from auto-fail. If the merchant sets the Auto-Fail Orders site preference, the job fails orders still in CREATED, including ones awaiting your decision.

Provider-Specific Data on Custom Attributes 

The Script API surface is intentionally provider-neutral. Provider-specific fields, such as case identifiers, risk scores, contributing factors, and decline reasons, go on Commerce App custom attributes on the Basket, Order, and PaymentTransaction objects, not on the platform Script API.

1basket.custom.example_caseId = decision.caseId;
2basket.custom.example_scoreBreakdown = JSON.stringify(decision.factors);
3basket.custom.example_recommendedAction = decision.action;

Prefix all custom attribute IDs with your app name (example_ above) to avoid collisions with other apps installed on the same site. Declare the attribute schema in your app’s IMPEX metadata, on both Basket and Order for basket attributes. Basket, instrument, and transaction custom attributes copy to the order at creation.

Important

Data Model 

You access all fraud data through the Script API classes described in this section.

PaymentTransaction Fraud Decision Constants 

The decision constants are Strings, not enum values. The decision fields are null until set or backfilled.

ConstantDescription
PRE_AUTH_FRAUD_DECISION_NOT_REVIEWEDNo pre-authorization fraud evaluation ran. Order creation proceeds.
PRE_AUTH_FRAUD_DECISION_APPROVEDLow risk. Order creation proceeds.
PRE_AUTH_FRAUD_DECISION_CHALLENGEMedium risk. Order creation proceeds. The payment app reads the decision and decides whether to request 3DS.
PRE_AUTH_FRAUD_DECISION_DECLINEDHigh risk. Return it with Status.ERROR to block order creation.
POST_AUTH_FRAUD_DECISION_NOT_REVIEWEDNo post-authorization fraud evaluation ran. The platform places the order.
POST_AUTH_FRAUD_DECISION_GUARANTEEDApproved with fraud coverage.
POST_AUTH_FRAUD_DECISION_NOT_GUARANTEEDRejected post-authorization.
POST_AUTH_FRAUD_DECISION_PENDINGHeld for asynchronous review. The order stays in CREATED until a final decision is set.

Fields Read From the Payment Domain 

The fraud Commerce App reads instrument and transaction attributes from the payment domain’s Script API classes. See Payment Commerce Apps for the full field list. The fraud domain uses these fields.

  • SalesforceCardPaymentDetails: read at beforePayment for pre-authorization risk evaluation. token, tokenType, brand, walletType, last4, bin, fundingType, issuingCountry, and dpanLast4 are the primary velocity and risk signals.
  • SalesforceCardTransactionDetails: read at afterPayment for post-authorization risk evaluation. cvcResult, avsResult, threeDSResult, threeDSVersion, threeDSTransactionId, and electronicCommerceIndicator are the primary post-authorization signals.
  • PaymentTransaction: read at afterPayment for authorization outcome. Use amount together with status.
  • Order-level signals: Order.customerEmail. To score the shopper’s IP or user agent, forward them from your storefront extension as basket custom attributes (see Handing the Session Identifier to beforePayment).

What you can read at each hook:

SignalbeforePayment (basket)afterPayment (order)
Items, totals, shipping and billing address, promotionsYesYes
customerEmail, customerNo; registered customer profile via getCustomer()YesYes
Card details (SalesforceCardPaymentDetails)YesYes
Authorization result, AVS, CVC, 3DS (SalesforceCardTransactionDetails)NoCards only
Shopper IP or user agentOnly what your storefront extension forwardsSame, copied from the basket
Device or session IDOnly what your storefront extension forwardsSame, copied from the basket

Non-card methods (PayPal, Klarna, and others) carry no AVS, CVC, or 3DS results. Score them on method, amount, and customer signals. token is a payment service provider (PSP) vault token, not a card number, so velocity on it works within one PSP’s vault, not across PSPs. Extra Script API reads, such as customer order history, count against the 1-second budget.

Device data comes from the vendor beacon and goes in custom attributes. For integrators who use an order management system (OMS), order export and import carry <fraud-decisions>.

Flow Patterns 

This section shows how fraud screening fits into checkout when a payment app is also installed.

Payment and Fraud Interaction 

When a fraud Commerce App is installed alongside a payment Commerce App, the two domains cooperate through the sequence in which their hooks fire.

  1. The storefront calls POST /orders. The platform dispatches sfcc.app.fraud.beforePayment(basket) on every order-creation path, including OrderMgr.createOrder. On Status.ERROR, order creation rolls back and the flow stops. Otherwise the platform creates the order in CREATED status with your decision on each instrument.
  2. The order transition runs: the storefront calls the payment app’s order transition endpoint, which calls attemptOrderTransition. The payment app reads your decision when it authorizes.
  3. If any instrument is still PENDING, the flow pauses until the payment app calls attemptOrderTransition again.
  4. After payment is complete, the platform dispatches sfcc.app.fraud.afterPayment(order). On PENDING, the flow pauses until your webhook resolves it.
  5. On GUARANTEED or NOT_REVIEWED, the platform places the order. On NOT_GUARANTEED or an error, it reverses the payments, refunds captured gift cards, fails the order, and reopens the basket.

Steps 3 to 5 run inside attemptOrderTransition. Each call skips steps already complete, so retries and duplicate webhooks don’t re-run finished work.

Device Data Handoff 

Your app decides which device and behavioral signals it collects and how it scores them. Whatever they are, they must reach beforePayment on the basket, because the hook sees only the basket. Your app writes its beacon, mounts it in the storefront, and writes what the hook needs to basket custom attributes before order creation. Storefront Presence shows one way to do this, with a session cookie and an action hook.

Outcomes and Limits 

  • No outcome hook. The platform doesn’t call your app when an order is placed, canceled, refunded, or charged back. To feed outcomes back to your service, run a job that queries orders and calls your feedback API.
  • No reversal after placement. After an order is placed, your app can’t change its fraud decision through these hooks. A merchant who needs to stop a placed order uses their normal cancellation process.
  • CHALLENGE depends on the payment app. The platform passes it to the payment app and does nothing else. With a payment app that doesn’t request 3DS on CHALLENGE, the order proceeds as if approved. If step-up is essential, decline instead, or check in afterPayment and return NOT_GUARANTEED when your pre-auth decision was CHALLENGE and no 3DS authentication ran.

Storefront Presence 

The fraud domain doesn’t define named storefront UI targets. Fraud Commerce Apps have no shopper-facing surfaces on cart, checkout, or order confirmation. Merchant-facing surfaces, such as case review dashboards and override tooling, live in the fraud vendor’s own portal.

A fraud Commerce App usually ships a client-side beacon: your JavaScript that collects the device and behavioral data your service uses. The platform doesn’t provide one. Delivering it has two parts: mounting the script early in the storefront lifecycle, and getting what beforePayment needs onto the basket before order creation. The rest of this section is an example that uses a session cookie. Use whatever mechanism suits your service.

Mounting the Beacon 

Fraud Commerce Apps use the same client-side SDK injection pattern payment Commerce Apps use. See Building Your Commerce App: Injecting External SDK Scripts.

Mount the beacon as a context provider so it initializes at the application root and covers every page the shopper touches. Register the provider in your extension’s target-config.json:

1{
2  "contextProviders": [
3    {
4      "path": "extensions/example-fraud/providers/beacon.tsx",
5      "order": 0
6    }
7  ]
8}

The provider loads the vendor script, initializes it with the merchant’s site key, and has the script set a session cookie (for example, _examplefraud_sid) as the shopper browses. No React state is required. The provider exists so the script mounts once and persists across route changes.

Handing the Session Identifier to beforePayment 

In this example, the beacon sets a session cookie, and an action hook on the storefront server reads it and writes it to a basket custom attribute. The beforePayment hook then reads the attribute from the basket.

The storefront action-hook framework exposes two hooks that can carry the write. Register a handler for one, or both, in your extension’s target-config.json. Register afterSubmitContactInfo if any checkout path can skip beforePlace:

1{
2  "actionHooks": [
3    {
4      "hookId": "sfcc.checkout.fraud.afterSubmitContactInfo",
5      "handler": "extensions/example-fraud/hooks/after-submit-contact-info.ts",
6      "order": 0
7    },
8    {
9      "hookId": "sfcc.checkout.fraud.beforePlace",
10      "handler": "extensions/example-fraud/hooks/before-place.ts",
11      "order": 0
12    }
13  ]
14}
  • sfcc.checkout.fraud.afterSubmitContactInfo: fires after the shopper submits email and phone. Non-blocking.
  • sfcc.checkout.fraud.beforePlace: fires during place order, before the sfcc.checkout.payments.beforePlaceOrder hook and order creation. Blocking. It doesn’t fire for zero-total orders when the storefront allows zero-total checkout (off by default), or when a payment extension places the order itself. In both cases beforePayment still runs at order creation. A payment app that places the order itself is the usual case, so write the identifier in afterSubmitContactInfo, and treat beforePlace as a second chance.

Registering both is safe: the second write is idempotent when the identifier hasn’t changed. Give each hook ID its own handler file that re-exports a shared helper. Registering one file for both hook IDs generates a duplicate import, and the build fails.

Example handler that reads the vendor cookie and writes it to the basket:

1import type { RouterContextProvider } from 'react-router';
2import type { ActionHookContext } from '@/targets/action-hook.server';
3import { createApiClients } from '@/lib/api-clients.server';
4import { getRequestFromContext } from '@/middlewares/request-origin';
5
6function readCookie(header: string | null, name: string): string | undefined {
7    for (const part of (header ?? '').split(';')) {
8        const i = part.indexOf('=');
9        if (i > 0 && part.slice(0, i).trim() === name) {
10            return decodeURIComponent(part.slice(i + 1).trim());   // values can contain '='
11        }
12    }
13    return undefined;
14}
15
16export default async function writeFraudSessionId(hookContext: ActionHookContext) {
17    const context = hookContext.actionContext as RouterContextProvider;
18    const { basket } = hookContext.data as { basket: { basketId?: string } };
19    try {
20        const headers = getRequestFromContext(context)?.headers;
21        const token = readCookie(headers?.get('cookie') ?? null, '_examplefraud_sid');
22        if (!token || !basket.basketId) {
23            return hookContext;                   // fail open: beforePayment sees no session ID
24        }
25        const { data: updated } = await createApiClients(context).shopperBasketsV2.updateBasket({
26            params: { path: { basketId: basket.basketId } },
27            body: {
28                c_example_fraudSessionId: token,
29                c_example_shopperIp: headers?.get('x-forwarded-for')?.split(',')[0].trim(),  // depends on your hosting
30                c_example_userAgent: headers?.get('user-agent') ?? undefined,
31            } as Record<string, unknown>,
32        });
33        return { ...hookContext, data: { basket: updated } };
34    } catch {
35        return hookContext;                       // under beforePlace, a throw would fail the place-order action
36    }
37}

beforePlace is blocking. Every action hook handler has a 5-second timeout. Throwing ActionHookError(message, hookId, 'placeOrder') returns HTTP 400 and the order isn’t created. Any other error fails the action. A fraud integration should usually fail open, as shown. Define example_fraudSessionId, example_shopperIp, and example_userAgent on Basket and Order in your app’s IMPEX.

  • Forward what the hook can’t see. beforePayment gets no shopper IP, user agent, or device ID. In Storefront Next, SCAPI calls come from the storefront server, so the IP the platform sees is the server’s. Forward the shopper’s IP and user agent from the storefront request, as shown.
  • A missing session ID is a degraded signal, not a decline. Score the order without device data. If a merchant requires device data, throw ActionHookError instead of failing open.
  • The cookie must be first-party. The action hook runs on the storefront server and sees only cookies sent to the storefront’s own domain. Configure the beacon to set its session cookie on that domain.
  • Custom attribute names differ by API. SCAPI request bodies use the c_ prefix (c_example_fraudSessionId). Script code reads the same attribute without it (basket.custom.example_fraudSessionId).
  • Which IP header to read depends on your hosting. The storefront server sits behind a CDN or proxy, so the shopper’s IP comes from a forwarding header, not the connection.

The beforePayment hook on the Commerce App side reads the same attribute from the basket:

1exports.beforePayment = function (basket) {
2    var sessionId = basket.custom.example_fraudSessionId;
3    var decision = ExampleFraud.evaluatePreAuth(basket, sessionId);
4    // ...set decisions on every instrument and return the matching Status, as above
5    return new Status(Status.OK, PaymentTransaction.PRE_AUTH_FRAUD_DECISION_APPROVED);
6};

Name and declare the attribute as described in Provider-Specific Data on Custom Attributes, on both Basket and Order.

Important

Testing Your App 

Install your app on a sandbox with the prerequisites met. Cover at least these scenarios:

  • Pre-auth outcomes: approve, challenge, and decline.
  • Vendor down: fail closed, and fail open if you offer it.
  • Async outcomes: approval and rejection.
  • Duplicate callback: a repeated callback, and a callback that arrives before afterPayment returns.
  • Order auto-failed: the order fails while a decision is pending.

Decisions appear on the order’s payment transactions and in the order export.

Testing without a payment app. beforePayment runs on every order creation, so you can test it on any sandbox checkout. afterPayment runs only inside attemptOrderTransition, which a payment app calls. Without a payment app installed, call it from a test script: set each instrument’s transaction to STATUS_AUTHORIZED with amounts that cover the order total, and then call OrderMgr.attemptOrderTransition(order).

See Testing and Validation for installation, uninstallation, and storefront checks that apply to every Commerce App.

See Also