Payment 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. Salesforce can make these changes without the notice period or deprecation window that applies to GA APIs. Changes will be called out in release notes for the release in which they land. After GA is declared, standard backward-compatibility guarantees apply from that point forward.
Warning
A payment Commerce App integrates Salesforce B2C Commerce with a payment service provider (PSP). It claims one or more payment methods and handles authorization, capture, refund, and reversal for them. It has two parts: hooks that run on the server and extension components that run in the Storefront Next storefront.
Method claims are exclusive. A payment method is owned by one app at a time. In Beta, a site supports one payment app, and the platform has no conflict resolution between apps.
Prerequisites
Payments Orchestration is enabled on the instance. The feature toggle PaymentFraudOrchestrationEnabled is off by default. Contact Salesforce to enable it.
SCAPI hook execution is enabled. The feature toggle ScapiHookExecutionEnabled is off by default. Contact Salesforce to enable it.
Your app is installed on the site.
Your app claims at least one method. It registers getClaimedPaymentMethods and returns at least one payment method ID.
Architecture
A payment Commerce App has these parts, all talking to the same PSP:
Server-side hooks in a cartridge. These implement the sfcc.app.payment.* extension points, such as onAttemptOrderTransition and reversePayment.
Extension components in the Storefront Next storefront. These render the SDK provider, method selector, wallet buttons, and saved-instrument picker.
Redirect return handling (optional). When a redirect flow returns, your storefront extension handles the shopper’s return.
Webhook handling (optional). A webhook receiver on the B2C Commerce server handles asynchronous PSP notifications.
Your extension components call the shopper-baskets and orders SCAPI and your app’s order transition endpoint, which dispatch your app’s hooks. How your app maps those calls to PSP operations is up to you, and so is whether to tokenize payment data on the client. The hooks call the PSP and read or write PaymentTransaction and the SalesforcePayment* details classes.
Payment UI Targets
Storefront Next exposes named extension slots on the product detail, quick add, mini-cart, cart, checkout, and my-account pages. A payment Commerce App registers React components against these targets to render its own card entry form, wallet buttons, saved instrument picker, express-checkout surfaces, and billing address form. See Building Your Commerce App for how target-config.json maps components to target IDs.
Commerce Apps target Storefront Next. Other storefronts can call the same SCAPI endpoints, but they have no extension to handle the storefront side.
Order Placement From Your Extension
The Storefront Next checkout hands the payment step to your extension through one callback. Your component replaces the payment step at sfcc.checkout.payment and registers onPlaceOrder on the payment submission ref, which it gets from usePaymentSubmissionRef(). Register it in a useEffect and clear it in cleanup. A component at sfcc.checkout.payment.paymentMethods unmounts when the shopper collapses the payment step, which clears the callback. Also register billingAddressGetter so the template saves the billing address before it calls you.
When the shopper selects Place Order, the template validates the basket, awaits your callback, and then finalizes: it saves profile data, tears down the basket, and navigates to order confirmation. The template’s beforePlaceOrder and afterPlaceOrder hooks don’t fire on this path.
Your callback calls an action route in your extension’s routes/ folder. The route runs on the storefront server with a shopper-scoped SCAPI client, so no token reaches the browser. It creates the order and then calls your order transition endpoint, which runs onAttemptOrderTransition (step 3 in Hook Sequence During Checkout). Register the endpoint as a custom client, for example sfnext scapi add --schema ./schema.yaml --name examplePsp. The client is available on createApiClients(context) as examplePsp, with one method per operationId, and Storefront Next adds siteId to each call. Resolve the callback with the order number when the order is placed, or with null after showing your own error.
Keep the checkout page mounted. The order consumes the basket. If you call the route with a React Router fetcher or form, return a revalidateTags value outside checkout.update, as above, or the checkout page reloads an empty basket and unmounts your payment UI. A plain fetch() doesn’t trigger revalidation.
Send card data for card-type methods. If you PATCH an order payment instrument for a card method, the body must carry card data, such as paymentCard or customerPaymentInstrumentId. Otherwise, SCAPI rejects it with HTTP 400. On a PATCH, always set a status in afterUpdateOrderPaymentInstrument. An instrument left at CREATED is treated as authorized.
Handle each outcome. If the order is placed, resolve with the order number. If the hook set a next action, navigate the shopper to it (see Redirect Return). If it failed, the platform has reopened the basket; show your error and resolve with null. If it’s still pending, resolve with the order number (see Pending Orders).
Clean up an order you can’t continue. If the order exists but a call throws or you can’t run the payment step, call failOrder with reopenBasket=true so the shopper can retry, and resolve with null.
Redirect Return
Your extension owns the return URL. The shopper returns with data from the PSP, so update the order payment instrument here. Add a route to your extension’s routes/ folder, for example /example-psp/return, and give the PSP its absolute URL with the order number and payment instrument ID in the query string. The URL includes the storefront’s site and locale prefix, for example https://shop.example.com/RefArch/en-US/example-psp/return?orderNo=…&piId=…. When the shopper lands there, the route’s loader updates the order payment instrument, which runs afterUpdateOrderPaymentInstrument. Then route on the order status. Redirect Flows describes the server side.
The platform accepts the update only while the order is CREATED. If your webhook already placed or failed the order, or the shopper reloads the page, the loader reads the order and routes on its status instead. The template’s finalize step doesn’t run when the shopper leaves for a redirect, so the template doesn’t save profile data such as a new address. The order confirmation page clears the basket when it loads.
Pending Orders
When an asynchronous authorization is still pending, send the shopper to order confirmation. Don’t poll for the result. The template’s confirmation page shows the same copy for a pending order as for a placed one. It has no UI target for payment status, so the merchant customizes the page to tell the shopper the order is awaiting payment confirmation. Your webhook places or fails the order, and the merchant’s order emails tell the shopper the outcome. If the PSP never reports a result, the auto-fail job fails the order (see Abandoned Orders).
PSP SDK Loading
Every payment Commerce App loads a client-side SDK from its PSP to tokenize card entry, mount the PSP’s hosted fields, and drive wallet flows (Apple Pay, Google Pay). The Commerce Apps framework provides a standard SDK-injection pattern that uses React 19 script hoisting, an app-supplied Context Provider, and per-storefront SPA-navigation handling.
The Context Provider is the extraction point for publishable keys. The provider reads the publishable key from the extension’s environment config and passes it to child components. Never inline the key in a component file.
The SDK must survive SPA navigation. Storefront Next is a single-page app. The payment step can mount, unmount, and remount without a full page reload. Framework-standard contextProviders guarantee the SDK is initialized once and reused, which prevents double-loads and mount-order races.
Configuration and Credentials
A payment Commerce App has three configuration tiers: publishable keys, secret keys, and per-site PSP settings. Each tier has a different exposure surface and a different place to configure it.
Publishable keys. Client-visible keys, such as the PSP’s public tokenization key or client key, go through the extension’s environment config. The Context Provider reads the key from the environment and passes it as a prop to child components.
Secret keys via credential profiles. Server-side hooks call the PSP through dw/svc/LocalServiceRegistry. The Business Manager service profile carries the PSP’s secret API key, and the app reads it through the service’s credential binding. No secret ever appears in a .js file, custom preference, or environment variable.
Per-site settings. Non-secret PSP settings, such as the sandbox mode flag, webhook URL, and currency-to-account mapping, live on Site custom preferences. Hooks read them through Site.getCurrent().getCustomPreferenceValue(...). Merchants configure these per storefront in Business Manager without touching code.
Webhook endpoints. The webhook URL configured on the PSP dashboard points at an endpoint your app owns. Use a site controller, as the samples do, for example /on/demandware.store/Sites-<site>-Site/default/ExamplePaymentWebhook-Notify. See Webhook Handling. The platform doesn’t provide a shared webhook endpoint. Each installation configures its own URL, keyed by site. In multi-site deployments, the merchant configures one webhook URL per site on the PSP.
1var LocalServiceRegistry = require('dw/svc/LocalServiceRegistry');23var service = LocalServiceRegistry.createService('example.psp.authorize', {4 createRequest: function(svc, request){5 svc.setAuthentication('BASIC'); // credentials come from the profile6 svc.addHeader('Idempotency-Key', request.correlationId);7 return JSON.stringify(request.body);8},9 parseResponse: function(svc, response){10 return JSON.parse(response.text);11}12});
Never embed secret keys in extension code. Anything client-visible must be a publishable key, one whose exposure surface the PSP already considers safe.
Important
Payment Methods
A payment Commerce App claims one or more payment methods: the platform PaymentMethod IDs it handles. The platform routes hook dispatch by payment method ID: when your extension adds an instrument whose paymentMethod matches an ID the app claims, the platform dispatches that app’s hooks for the instrument.
What Counts as a Method
A payment method ID is a server-side routing key, not a shopper-facing choice. How your extension presents choices to the shopper is separate. One ID can cover several choices; for example, CREDIT_CARD can cover cards and Apple Pay, and BANK_TRANSFER can cover ACH, SEPA, and BECS. Your app can also use one ID per choice.
Method Identifiers
A method ID is a string the shopper never sees. It flows into every downstream system that reasons about the payment. It lands on PaymentInstrument.paymentMethod, in the outbound order-export XML packet, on OMS payment records, and in merchant reports. There is no platform convention, but the ID shape has interop consequences with Salesforce Order Management on the far side of the order export.
OMS classification. When B2C Commerce exports an order to Salesforce Order Management, OMS applies a four-step cascade against the method ID to decide which Core payment object to create:
Case-insensitive regex match against paypal|visa_checkout|pay_by_check|.*(apple|google|android|amazon|ali).*(pay)* (and several gift-card and store-credit tokens) creates a DigitalWallet record.
If unmatched, a lookup of a GtwyProvPaymentMethodType junction record whose DeveloperName equals the method ID creates an AlternativePaymentMethod record.
If still unmatched, a match on the exported credit-card-type value against the Core CardType picklist creates a CardPaymentMethod record.
As a fallback, CardPaymentMethod is created with Type = "Unknown" and empty fields.
Choose the ID shape that lands the method on the intended Core object.
Method Class
ID Shape
Example
OMS Record
Wallet covered by the regex
Token matching the regex (case-insensitive)
PAYPAL, APPLE_PAY, GOOGLE_PAY, AMAZON_PAY, ALIPAY
DigitalWallet
APM outside the regex
Any string that a merchant GtwyProvPaymentMethodType record maps to
KLARNA_PAYNOW, IDEAL, SEPA_DEBIT
AlternativePaymentMethod
Card method
Any string; the credit-card element carries the card-type that drives classification
CARD, CREDIT_CARD
CardPaymentMethod
Two operational notes:
The regex is broader than the table suggests. It also catches giftcard, gift_cert, customer_credit, cash, writeoff, comped, and shop_payment. Avoid these tokens as method IDs unless the intent is to route through the DigitalWallet path.
GtwyProvPaymentMethodType.DeveloperName records are per-merchant configuration. A merchant installing this app must have the junction records to route to AlternativePaymentMethod. Without them, the fallback triggers and orders land as CardPaymentMethod with Type = "Unknown". Document the required junction records alongside the app’s install instructions.
Claimed Method Declaration
A payment app declares the methods it owns at runtime, through the sfcc.app.payment.getClaimedPaymentMethods hook. This hook is the source of truth: on every routing decision, the platform calls it and dispatches the app’s other hooks only if the current instrument’s paymentMethod is in the returned list.
It takes no arguments and returns a String[] of payment method IDs. The platform calls it on every routing decision, once for each payment instrument on every hook dispatch, so a slow hook adds latency many times over. Keep it cheap and side-effect free, with no PSP calls.
If it throws or returns null, the platform treats the app as claiming nothing. A throw logs a warning; a null return logs nothing. The shopper sees no error, so the app’s methods silently stop routing.
IDs match exactly, including case. Each ID must equal the payment method ID the merchant configured in Business Manager and the storefront sends as paymentMethodId. paypal doesn’t match PAYPAL. Document your app’s expected IDs in its install instructions.
COMMERCE_APP_GIFT_CARD can’t be claimed. The gift card domain owns it.
A claimed CREDIT_CARD instrument needs an enabled card type. It must carry a card type that’s enabled for the site. Otherwise SCAPI rejects the instrument before your hook runs.
One payment app per site in Beta. There’s no conflict resolution between apps.
Payment Method Records
The B2C Commerce platform maintains a native PaymentMethod metadata record per method ID. The record carries the merchant-visible name, eligibility rules (currencies, countries, customer groups, amount ranges), and Business Manager display metadata. PaymentMgr.getPaymentMethod(id) returns this record. The Storefront Next templates don’t read these records to render payment options. Your extension decides what the shopper sees, and the method IDs it sends don’t have to match its choices one for one. A card method your app claims must be applicable to the basket, or SCAPI rejects the instrument.
The app installs any custom PaymentMethod IDs it wants to claim and use, for example in impex/install/site/payment-methods.xml. An app can also claim a system method such as CREDIT_CARD. How the app maps real payment types (ACH, SEPA Debit, Bacs, BECS) to a method ID such as BANK_TRANSFER, or whether it maps them at all, is up to the app.
Merchants can edit these records per site in Business Manager.
Method-Specific Behavior
Hooks receive the payment instrument, and the instrument’s paymentMethod string identifies which method a hook call is for. One hook handler serves every method the app claims, so an app that claims several methods can branch on the method ID:
1function authorize(order, paymentInstrument){2 switch(paymentInstrument.getPaymentMethod()){3 case 'CREDIT_CARD':4 return authorizeCard(order, paymentInstrument);5 case 'PAYPAL':6 return authorizePaypalRedirect(order, paymentInstrument);7 case 'IDEAL':8 return authorizeIdealRedirect(order, paymentInstrument);9 default:10 return new Status(Status.ERROR, 'UNKNOWN_METHOD', paymentInstrument.getPaymentMethod());11}12}
Method Eligibility
A payment method isn’t always available for every basket. Klarna may require an order total above a threshold, PayPal may only be enabled in certain shopper countries, and a corporate-card method may only be visible for B2B customer groups.
Existing PaymentMethod rules. The record’s countries, currencies, customer-groups, min-purchase-amount, max-purchase-amount, and per-currency ranges elements filter the methods that SCAPI GET /baskets/{basketId}/payment-methods returns. SCAPI applies the same rules when an instrument is added or updated and rejects a method that doesn’t apply, so your app doesn’t call PaymentMethod.isApplicable itself. For CREDIT_CARD, it also checks that the card type is enabled for the site, and rejects an instrument without a card type, so wallet flows on CREDIT_CARD must send one. Gift card instruments are exempt.
Rejecting a method in afterCreateBasketPaymentInstrument. For eligibility that depends on runtime signals, such as the shopper’s IP-derived country or a promotional flag, reject with Status.ERROR. Do this when the shopper tries to add a method that shouldn’t be available for their basket. The basket operation is rolled back, so the instrument isn’t created.
1exports.afterCreateBasketPaymentInstrument = function(basket, paymentInstrument, request){2 var methodId = paymentInstrument.getPaymentMethod();3 if(methodId === 'KLARNA' && basket.getTotalGrossPrice().getValue()<50.00){4 return new Status(Status.ERROR, 'KLARNA_MIN_AMOUNT', 'Klarna requires order total >= $50');5}6 return new Status(Status.OK);7};
Your extension decides which choices the shopper sees. Filter them with the same predicates your hook uses, so shoppers don’t pick a method the app will reject.
Important
Registering and Installing Your App
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.
The sample omits afterCreateOrderPaymentInstrument because creating order payment instruments through SCAPI isn’t recommended. See Hook Sequence During Checkout.
These packaging and install facts are specific to payment apps. For the commerce-app.json schema, packaging layout, and architecture, see the published Commerce Apps pages.
Manifest: install reads only name from commerce-app.json. app-configuration/tasksList.json is required. adminComponents.json, translations/, and icons/ are optional.
Cartridges: everything under cartridges/site_cartridges/ and cartridges/bm_cartridges/ is installed.
Custom attributes: put the type extensions for Basket, Order, OrderPaymentInstrument, and PaymentTransaction in impex/install. It’s imported in merge mode on install. impex/uninstall is imported, also in merge mode, when the last site that uses the app uninstalls it, so mark each element you want removed with mode="delete". See Uninstall IMPEX. Use the SITEID and LIBRARYID placeholders in paths and file content; install replaces them with the target site and library.
The payment domain exposes 11 extension points in two groups. For signatures and the latest class details, see the Script API reference for dw.apps.checkout.hooks.PaymentAppHooks.
Platform-dispatched (seven):getClaimedPaymentMethods, afterCreateBasketPaymentInstrument, afterUpdateBasketPaymentInstrument, afterCreateOrderPaymentInstrument, afterUpdateOrderPaymentInstrument, onAttemptOrderTransition, and reversePayment.
Utility hooks (four):authorizePayment, cancelAuthorization, capturePayment, and refundCapture. The platform never calls these. Any server-side code can call them through dw.system.HookMgr.callHook('sfcc.app.payment.capturePayment', 'capturePayment', order, pi), including merchant custom cartridges, jobs, and your own app code. No claim check runs, so verify that the instrument is yours.
The platform dispatches the platform-dispatched hooks only for an installed Commerce App after the prerequisites are met.
Hook Sequence During Checkout
This is the recommended sequence for a SCAPI checkout:
Basket phase. Your extension adds every payment instrument to the basket with POST /baskets/{id}/payment-instruments, including the data your app needs to authorize. SCAPI dispatches afterCreateBasketPaymentInstrument. A PATCH dispatches afterUpdateBasketPaymentInstrument.
Order creation. Your extension calls POST /orders. The platform runs fraud beforePayment (if a fraud app is installed) and creates the order in CREATED status. No payment hook runs.
Order transition. Your extension calls OrderMgr.attemptOrderTransition through your app’s custom SCAPI endpoint (see Order Transition Endpoint). It captures gift cards and dispatches onAttemptOrderTransition for each claimed instrument still at CREATED. Your hook authorizes, or starts an asynchronous authorization and sets STATUS_PENDING. After payment is complete, it runs fraud afterPayment and places the order. It returns an OrderTransitionResult. Don’t call attemptOrderTransition from inside a payment hook.
Asynchronous outcome. If an authorization is pending, your webhook records the PSP’s outcome on the transaction and calls attemptOrderTransition again, which continues with post-auth fraud and places the order. On failure, it calls failOrder.
Reversal. If the order is failed or canceled, the platform dispatches reversePayment.
If your app needs data from the client after the order exists, such as an encrypted token from the PSP’s SDK, PATCH the order payment instrument instead of step 3. The platform dispatches afterUpdateOrderPaymentInstrument, where you authorize, and then calls attemptOrderTransition itself.
A redirect or 3D Secure step uses both. In step 3, onAttemptOrderTransition starts the session and sets STATUS_PENDING. When the shopper returns, your extension PATCHes the instrument, afterUpdateOrderPaymentInstrument finalizes it, and the platform calls attemptOrderTransition. See Redirect Flows.
Choosing Where to Authorize
Each hook sees the instrument in a different state:
Hook
Runs When
Instrument Status on Entry
Use It To
onAttemptOrderTransition
attemptOrderTransition, only for instruments still at CREATED
CREATED
Authorize with the data your extension set on the basket instrument. This is the recommended place.
afterUpdateOrderPaymentInstrument
SCAPI PATCH of an order payment instrument
Whatever it was: CREATED for an instrument copied from the basket, PENDING after a redirect
Authorize with client-side data obtained after order creation, or finish a redirect or 3DS step. Branch on the PSP-side state you stored, not on CREATED.
afterCreateOrderPaymentInstrument
SCAPI POST of an order payment instrument
CREATED
Not recommended. An instrument this hook leaves at CREATED is set to PENDING, so onAttemptOrderTransition never runs for it. Create instruments in the basket before order creation.
Extension Point Contracts
sfcc.app.payment.getClaimedPaymentMethods(): Returns a String[] of payment method IDs the app owns. See Claimed Method Declaration for the rules.
sfcc.app.payment.afterCreateBasketPaymentInstrument(basket, paymentInstrument, request): Called after a payment instrument is added to the basket.
sfcc.app.payment.afterUpdateBasketPaymentInstrument(basket, paymentInstrument, request): Called after a basket payment instrument is updated.
sfcc.app.payment.afterCreateOrderPaymentInstrument(order, paymentInstrument, request): Called after a payment instrument is added to an order through SCAPI.
sfcc.app.payment.afterUpdateOrderPaymentInstrument(order, paymentInstrument, request): Called after an order payment instrument is updated through SCAPI. A common trigger is a 3D Secure challenge result or a redirect return.
sfcc.app.payment.onAttemptOrderTransition(order, paymentInstrument): Called for each claimed instrument still at STATUS_CREATED whenever attemptOrderTransition runs.
sfcc.app.payment.reversePayment(order, paymentInstrument): Called when an order is failed or canceled.
The utility hooks have no platform caller. Code that calls them must check that the instrument belongs to your app. Each returns Status.OK on success or Status.ERROR on failure.
sfcc.app.payment.authorizePayment(order, paymentInstrument): Authorizes payment for the instrument.
sfcc.app.payment.cancelAuthorization(order, paymentInstrument): Voids the instrument’s authorization before capture.
sfcc.app.payment.capturePayment(order, paymentInstrument): Captures a previous authorization, for example on shipment.
sfcc.app.payment.refundCapture(order, paymentInstrument): Refunds a previously captured payment.
All of these except getClaimedPaymentMethods return dw.system.Status or null for success. Each hook is a Commerce App entry point and has its own error semantics. See Error Model.
The third parameter on the create and update hooks is the body of the SCAPI request that triggered the hook: BasketPaymentInstrumentRequestWO for basket hooks, OrderPaymentInstrumentRequestWO for order hooks. Use it to read what the storefront sent, such as paymentMethodId, amount, paymentCard, paymentBankAccount, giftCertificateCode, customerPaymentInstrumentId, and paymentReferenceRequest. Order requests also carry createCustomerPaymentInstrument. Basket requests also carry giftCardRequest.
Passing PSP data from the storefront. Send a card token as paymentCard.creditCardToken. The platform saves it on the instrument. Send anything else as custom properties on the instrument, such as a payment method token or a 3DS result (c_pspToken, c_threeDSResult). The platform copies them to the instrument before your hook runs. Define the attributes on OrderPaymentInstrument in your app’s IMPEX. Your hook can also read paymentReferenceRequest from the request body to get PSP data such as a payment method reference. If you need it in a later hook, copy it to a custom attribute on the instrument. gatewayProperties accepts only fixed gateway keys.
onAttemptOrderTransition in detail.attemptOrderTransition calls it once for each claimed instrument still at STATUS_CREATED.
Synchronous: authorize or capture with your PSP, set STATUS_AUTHORIZED or STATUS_CAPTURED, and return Status.OK.
Asynchronous: start the PSP operation, set STATUS_PENDING, and return Status.OK. The order stays in CREATED until your webhook resolves it.
Decline or error: return Status.ERROR or throw. The platform fails the order, reopens the basket, and records payment_failed in the order history.
If you return OK and leave the status at CREATED, the platform sets it to PENDING and doesn’t call this hook again for that instrument.
Amount: payment counts as complete only when the transaction amounts of all instruments cover the order’s gross total. If the storefront created the instrument without an amount, set it with txn.setAmount(...).
Don’t call OrderMgr methods from this hook.
reversePayment in detail. The platform calls reversePayment whenever an order is failed or canceled, once for each claimed instrument, whatever the transaction status. Check the status and transactionID first: an instrument at CREATED or PENDING may have nothing to reverse. If the hook returns Status.ERROR, the platform logs it and fails the order anyway. Retry or alert on failed reversals in your own code.
Make it idempotent.reversePayment runs inside the failOrder transaction. If that transaction rolls back, the order stays in CREATED but your PSP reversal has already happened, and a later failOrder calls you again. Treat an already-voided or already-refunded PSP object as success.
In-flight authorizations. An instrument at PENDING may still authorize after the order fails. How your app handles a late authorization is up to you.
Platform Semantics
Behaviors the platform applies across the payment hooks.
Transaction wrapping. Every platform-dispatched hook runs inside a platform-managed transaction. See Error Model for what an error rolls back.
attemptOrderTransition checks order completion. It runs when your order transition endpoint or webhook calls it, and after a SCAPI POST or PATCH of an order payment instrument for a claimed method. A hook return by itself doesn’t trigger that check. Payment apps never call OrderMgr.placeOrder() from lifecycle hooks.
Legacy payment hooks still run. For compatibility, SCAPI order payment instrument calls also run the standard dw.order.payment.* hooks. Making a merchant’s own customizations work with installed apps is the merchant’s responsibility.
Reversal on order failure. When an order is failed or canceled, the platform dispatches reversePayment. See reversePayment in detail.
Error Model
Errors fail the order. On order payment instrument hooks (afterCreateOrderPaymentInstrument, afterUpdateOrderPaymentInstrument, onAttemptOrderTransition), returning Status.ERROR and throwing have the same effect. The platform fails the order, reverses any captured gift cards and authorized payments, and reopens the basket. The caller retries from the reopened basket with a new order. On basket payment instrument hooks, Status.ERROR rolls back the basket operation, so the instrument isn’t created or updated.
Return Status.ERROR with a code and message for expected declines. Reserve throwing for programming errors. The order history records the failure: “Payment app error” from the create and update hooks, payment_failed from onAttemptOrderTransition.
Error codes are for logs, not shoppers. The code and message on a Status.ERROR go to the platform log with the hook name and order context. The shopper and the SCAPI caller don’t see them. Put shopper-facing copy in your storefront extension. On order payment instrument hooks, the SCAPI call still returns HTTP 200, and the order in the response has "status": "failed", so storefront code must check it.
Transaction Type vs. Status
PaymentTransaction carries two independent fields.
type (TYPE_AUTH, TYPE_CAPTURE, TYPE_AUTH_REVERSAL, TYPE_CREDIT) records what operation the transaction represents.
status (STATUS_CREATED, STATUS_PENDING, STATUS_AUTHORIZED, STATUS_CAPTURED, STATUS_AUTHORIZATIONVOIDED, and others) records where the transaction is in its lifecycle.
The platform reads status, not type, to decide whether payment is complete. Set the type when you authorize or capture. A void or refund changes only the status:
Operation
Type
Status
Authorize
TYPE_AUTH
STATUS_AUTHORIZED
Capture
TYPE_CAPTURE
STATUS_CAPTURED
Void
unchanged
STATUS_AUTHORIZATIONVOIDED
Refund
unchanged
STATUS_CAPTUREVOIDED
No platform logic depends on the refund status. It’s for reporting.
Authorization
A payment app authorizes with its PSP in onAttemptOrderTransition, with the data your extension set on the basket instrument. If it needs client-side data obtained after order creation, it authorizes in afterUpdateOrderPaymentInstrument instead. See Choosing Where to Authorize.
If the authorization is synchronous, set STATUS_AUTHORIZED or STATUS_CAPTURED. If it’s asynchronous, set STATUS_PENDING until your webhook records the outcome.
Keep PSP calls short. The hook runs inside the shopper’s SCAPI request, and the platform sets no separate payment-hook time limit. Use the asynchronous pattern for anything that needs shopper action.
If a fraud Commerce App is installed, the platform runs its pre-authorization hook during POST /orders, before the order exists. A fraud app that returns Status.ERROR blocks order creation, so no order exists and no order payment hook runs. On CHALLENGE, order creation proceeds and the payment app can read the decision and decide whether to request strong customer authentication. The platform doesn’t force it.
After all instruments on the order reach AUTHORIZED or CAPTURED, attemptOrderTransition dispatches the fraud app’s post-authorization hook (if installed) and then places the order.
Basic synchronous authorize. This is the minimal shape. In this and later samples, ExamplePSP is your own service wrapper around the PSP’s API, not a platform class. The Asynchronous Authorization sample is closer to what a production PSP integration needs.
1var PaymentTransaction = require('dw/order/PaymentTransaction');2var Status = require('dw/system/Status');34exports.onAttemptOrderTransition = function(order, paymentInstrument){5 var result = ExamplePSP.authorize(order, paymentInstrument); // reads the data your extension set on the instrument6 if(result.status !== 'AUTHORIZED'){7 return new Status(Status.ERROR, result.reasonCode, result.reason);8}9 var txn = paymentInstrument.getPaymentTransaction();10 txn.setTransactionID(result.transactionId);11 txn.setType(PaymentTransaction.TYPE_AUTH);12 txn.setStatus(PaymentTransaction.STATUS_AUTHORIZED);13 return new Status(Status.OK);14};
Asynchronous Authorization
This sample authorizes in onAttemptOrderTransition. It guards on the PSP reference it stored, handles a pending PSP result, and reads the pre-auth fraud decision.
1var PaymentTransaction = require('dw/order/PaymentTransaction');2var Status = require('dw/system/Status');34function authorize(order, paymentInstrument){5 var txn = paymentInstrument.getPaymentTransaction();6 var status = txn.getStatus();7 if(status === PaymentTransaction.STATUS_AUTHORIZED || status === PaymentTransaction.STATUS_CAPTURED){8 return new Status(Status.OK); // already authorized9}10 if(paymentInstrument.custom.pspReference){11 var existing = ExamplePSP.retrieve(paymentInstrument.custom.pspReference); // ExamplePSP: your service wrapper12 return applyResult(txn, existing);13}14 var idempotencyKey = order.getOrderNo() + ':' + paymentInstrument.getPaymentMethod();15 paymentInstrument.custom.pspReference = idempotencyKey; // written before the call, so a retry finds it16 var pspRequest = {orderNo: order.getOrderNo(), idempotencyKey: idempotencyKey};17 if(txn.getPreAuthFraudDecision() === PaymentTransaction.PRE_AUTH_FRAUD_DECISION_CHALLENGE){18 pspRequest.force3DS = true;19}20 return applyResult(txn, ExamplePSP.authorize(order, paymentInstrument, pspRequest));21}2223function applyResult(txn, result){24 if(result.declined){25 return new Status(Status.ERROR, result.reasonCode, result.reason);26}27 txn.setTransactionID(result.pspReference);28 txn.setType(PaymentTransaction.TYPE_AUTH);29 txn.setStatus(result.pending ? PaymentTransaction.STATUS_PENDING30 : PaymentTransaction.STATUS_AUTHORIZED);31 return new Status(Status.OK);32}3334exports.onAttemptOrderTransition = function(order, paymentInstrument){35 return authorize(order, paymentInstrument);36};
If you authorize after a client-side step instead, register the same authorize on afterUpdateOrderPaymentInstrument.
With an instrument at STATUS_PENDING, the order stays in CREATED, and the platform doesn’t call onAttemptOrderTransition again for that instrument. Your webhook records the outcome (see Webhook Handling), or your extension’s redirect return PATCHes the instrument (see Redirect Return).
Retries and races.
Key PSP calls on the order, not the instrument. A retried instrument call can create a new instrument with a new UUID, so a UUID key lets the PSP authorize twice. Use the order number and method, or an attempt counter stored on the order.
Duplicate instruments. The platform doesn’t reject a second instrument with the same method. A duplicate adds to the amount sum, so the order can’t match its total. If your extension might add one, reject it in your hook.
The PSP call succeeds but the status isn’t saved. If the hook returns OK without setting a status, the next hook finds custom.pspReference and retrieves the result instead of authorizing again.
The PSP call succeeds but the hook throws. The platform fails the order and calls reversePayment, even though the PSP authorized. Your reversePayment must void that authorization.
A webhook races the storefront. Nothing locks the status check, so two callers can both pass it. The platform keeps the order outcome safe, but your status writes must be idempotent.
reversePayment can run on an instrument that never authorized. Check the status and transactionID before calling your PSP.
Pre-Authorization Fraud Decision
When a fraud Commerce App is installed, the pre-authorization fraud decision is available on the transaction by the time the order exists. The payment app can read it wherever it authorizes (the authorize function in the Asynchronous Authorization sample reads it), and doesn’t have to read it in any particular hook. The decision is copied from the basket instrument when the order is created. An instrument your extension POSTs to the order has no decision (null), so read it from the copied instrument, or treat null as not reviewed. When the decision is CHALLENGE, a payment app typically escalates the authorization to strong customer authentication, but the platform doesn’t require it.
Transaction Details Population
The authorization hook populates SalesforceCardTransactionDetails with the authorization outcome. Verification results and 3D Secure results live on this class. For other PSP values, see Where to Put PSP Data. The details object is null until you set it.
1var SalesforceCardTransactionDetails = require('dw/extensions/payments/SalesforceCardTransactionDetails');23var txn = paymentInstrument.getPaymentTransaction();4var details = txn.getTransactionDetails() || new SalesforceCardTransactionDetails();5details.setCvcResult(SalesforceCardTransactionDetails.CVC_RESULT_MATCH);6details.setAvsResult(SalesforceCardTransactionDetails.AVS_RESULT_MATCH); // persists as the one-letter code "Y"7details.setThreeDSResult(SalesforceCardTransactionDetails.THREE_DS_RESULT_AUTHENTICATED);8details.setThreeDSVersion(SalesforceCardTransactionDetails.THREE_DS_VERSION_V2);9details.setThreeDSTransactionId(pspResponse.threeDSTransactionId);10details.setElectronicCommerceIndicator('05'); // scheme-defined string; no constants11txn.setTransactionDetails(details);
Multi-Currency Amount Handling
The platform passes amounts as dw.value.Money objects with a value and a currency code. Different PSPs expect different wire formats, and the minor-unit multiplier is currency-dependent. A hard-coded * 100 fails for zero-decimal currencies (JPY, KRW) and three-decimal currencies (KWD, BHD). Read the digits from the currency instead of hard coding lists.
JPY ¥1000 sends as "1000", not "100000". KWD 24.990 sends as "24990", not "2499".
Split Tender
In a split-tender scenario (gift card $25 plus credit card $170 on a $195 order), each payment instrument’s transaction amount is that instrument’s share, not the order total. Authorization hooks read paymentInstrument.getPaymentTransaction().getAmount() for the amount to authorize, not order.getTotalGrossPrice(). The same rule applies to reversePayment. Payment is complete only when the instrument amounts together cover the order’s gross total. Inside attemptOrderTransition, the platform captures gift cards first. It then calls onAttemptOrderTransition for each claimed instrument still at CREATED, one at a time, in the order’s payment instrument order. If a later instrument declines, the platform fails the order. It refunds the captured gift cards and calls reversePayment for every claimed instrument, including those that already authorized.
Retry After Decline
A declined authorization fails the order and reopens the basket, so a retry is a new checkout with a new order. Don’t retry a decline on the same order. afterUpdateOrderPaymentInstrument is for finishing a step the shopper completed after order creation, such as a 3D Secure challenge or a redirect return. Check the transaction status before you change it, because your webhook may finalize the same authorization at the same time.
Post-Authorize Operations
Authorization is the beginning of the transaction lifecycle. Capture (moving money on shipment), refund (returning money after settlement), and void (releasing an authorization before capture) live in the four utility hooks. Reversal (rolling back on order failure) lives in reversePayment, which the platform dispatches.
The transaction moves through these operations: authorization sets TYPE_AUTH and STATUS_AUTHORIZED. capturePayment moves it to TYPE_CAPTURE and STATUS_CAPTURED. cancelAuthorization (void before capture) or reversePayment (order failed) moves it to STATUS_AUTHORIZATIONVOIDED. From capture, refundCapture or reversePayment moves it to STATUS_CAPTUREVOIDED. The type doesn’t change on a void or refund.
Capture at fulfillment usually happens outside B2C Commerce. When it needs to happen in B2C Commerce, server-side code such as a job or a custom cartridge can call capturePayment through HookMgr.callHook.
1var PaymentTransaction = require('dw/order/PaymentTransaction');2var Status = require('dw/system/Status');34exports.capturePayment = function(order, paymentInstrument){5 var txn = paymentInstrument.getPaymentTransaction();6 var amount = txn.getAmount();7 var result = ExamplePSP.capture(txn.getTransactionID(), amount.getValue(), amount.getCurrencyCode());8 if(result.status !== 'CAPTURED'){9 return new Status(Status.ERROR, result.reasonCode, result.reason);10}11 txn.setType(PaymentTransaction.TYPE_CAPTURE);12 txn.setStatus(PaymentTransaction.STATUS_CAPTURED);13 return new Status(Status.OK);14};
Void Before Capture
When a merchant cancels an order before any goods ship, release the authorization with cancelAuthorization. This releases the funds hold without moving money.
1var PaymentTransaction = require('dw/order/PaymentTransaction');2var Status = require('dw/system/Status');34exports.cancelAuthorization = function(order, paymentInstrument){5 var txn = paymentInstrument.getPaymentTransaction();6 if(txn.getStatus() !== PaymentTransaction.STATUS_AUTHORIZED){7 return new Status(Status.ERROR, 'INVALID_STATE',8 'cancelAuthorization requires an authorized transaction');9}10 var result = ExamplePSP.voidAuthorization(txn.getTransactionID());11 if(result.status !== 'VOIDED'){12 return new Status(Status.ERROR, result.reasonCode, result.reason);13}14 txn.setStatus(PaymentTransaction.STATUS_AUTHORIZATIONVOIDED);15 return new Status(Status.OK);16};
Refund After Capture
After funds have settled, releasing them requires a refund, because the money moves back through the network. refundCapture refunds the full captured amount.
1var PaymentTransaction = require('dw/order/PaymentTransaction');2var Status = require('dw/system/Status');34exports.refundCapture = function(order, paymentInstrument){5 var txn = paymentInstrument.getPaymentTransaction();6 var amount = txn.getAmount();7 var result = ExamplePSP.refund(txn.getTransactionID(), amount.getValue(), amount.getCurrencyCode());8 if(result.status !== 'REFUNDED'){9 return new Status(Status.ERROR, result.reasonCode, result.reason);10}11 txn.setStatus(PaymentTransaction.STATUS_CAPTUREVOIDED);12 return new Status(Status.OK);13};
Reversal on Order Failure
When an order is failed or canceled, the platform calls reversePayment per claimed instrument when payment needs to be reversed. See reversePayment in detail. The hook picks the correct operation based on the current transaction state: void for an authorized transaction, refund for a captured one, and nothing for an instrument that never reached the PSP. How to handle an authorization still in flight is up to your app.
1var PaymentTransaction = require('dw/order/PaymentTransaction');2var Status = require('dw/system/Status');34exports.reversePayment = function(order, paymentInstrument){5 var txn = paymentInstrument.getPaymentTransaction();6 var status = txn.getStatus();7 var result;8 if(status === PaymentTransaction.STATUS_AUTHORIZED){9 result = ExamplePSP.voidAuthorization(txn.getTransactionID()); // ExamplePSP: your service wrapper10 if(!result.ok && !result.alreadyVoided){11 return new Status(Status.ERROR, 'VOID_FAILED', result.reason);12}13 txn.setStatus(PaymentTransaction.STATUS_AUTHORIZATIONVOIDED);14}else if(status === PaymentTransaction.STATUS_CAPTURED){15 result = ExamplePSP.refund(txn.getTransactionID(), txn.getAmount().getValue(),16 txn.getAmount().getCurrencyCode());17 if(!result.ok && !result.alreadyRefunded){18 return new Status(Status.ERROR, 'REFUND_FAILED', result.reason);19}20 txn.setStatus(PaymentTransaction.STATUS_CAPTUREVOIDED);21}22 // PENDING with a transactionID: the authorization may still be in flight. How you handle it is up to your app.23 return new Status(Status.OK);24};
A Status.ERROR from reversePayment is logged and the order still fails. Merchants monitor the log for stuck reversals and reconcile manually.
Data Model
A payment app writes typed fields on the Script API classes and app-specific data in custom attributes.
OrderPaymentInstrument
The instrument carries the shopper’s payment method selection, its typed details view, and the transaction. The platform passes it into every payment hook.
Member
Status
Description
paymentMethod
Existing
Method identifier the shopper selected (for example, CREDIT_CARD).
paymentTransaction
Existing
The transaction attached to the instrument. See PaymentTransaction below.
paymentDetails
New
Typed payment details view. The app constructs the class that matches the method and calls setPaymentDetails. Read it with getPaymentDetails(). See the classes below.
UUID
Existing
Identifier for the instrument. A retried instrument call can create a new instrument with a new UUID, so don’t use it as the idempotency key on PSP calls.
custom.<name>
Existing
Custom attributes on the instrument.
The standard PaymentInstrument card fields (token, holder, expiration, masked number) are inherited.
PaymentTransaction
Reachable on the payment instrument via getPaymentTransaction().
Property
Type
Status
Description
Written by
transactionID
String
Existing
Opaque PSP correlation handle. The payment app writes the PSP’s transaction, authorization, or intent identifier here so later hooks and webhooks can look it up.
Payment app
amount
Money
Existing
Amount for this instrument’s share of the order. Use this, never order.totalGrossPrice, for authorize, capture, refund, and reversal amounts (split tender). Instrument amounts together must cover the order’s gross total. If the storefront created the instrument without an amount, the app sets it.
Payment app
paymentProcessor
PaymentProcessor
Existing
Ties the transaction to a PaymentProcessor record. The app can set it.
Payment app
custom.<name>
Any
Existing
Custom attributes on the transaction. Used to persist PSP-side references (session ID, intent ID, idempotency key) alongside the transaction ID.
Operation the transaction represents. Read txn.type.value. Values: TYPE_AUTH, TYPE_CAPTURE, TYPE_AUTH_REVERSAL, TYPE_CREDIT. setType throws on an unknown value.
Payment app
preAuthFraudDecision
String
New
null when no fraud app is installed, or on an instrument added to the order with POST. Otherwise the platform sets NOT_REVIEWED if the fraud app leaves it unset. Values: see the fraud guide.
Fraud app
postAuthFraudDecision
String
New
The platform sets NOT_REVIEWED if the fraud app leaves it unset or no fraud app is installed. Values: see the fraud guide.
Fraud app
transactionDetails
SalesforceTransactionDetails
New
Transaction-level details. null until set.
Payment app
accountID, accountType
String
New
The PSP account ID and type.
Payment app
paymentInstrument
OrderPaymentInstrument
New
Read-only back-reference.
Platform
Which statuses count:
Transaction Status
What the Platform Does
CREATED, or no transaction
Dispatches onAttemptOrderTransition for a claimed instrument
PENDING
Waits. The order stays in CREATED.
AUTHORIZED, CAPTURED
Counts as paid. Payment is complete when every instrument is in one of these and the amounts cover the order’s gross total.
Any other status leaves payment incomplete.
SalesforceCardTransactionDetails
Card transaction details collected after authorization. Reachable via paymentTransaction.getTransactionDetails(). It has a public constructor. Every field is a String. Values longer than the maximum length are truncated when set.
Property
Max Length
Description
cvcResult
20
Use the CVC_RESULT_* constants.
avsResult
1
Use the AVS_RESULT_* constants.
threeDSResult
20
Use the THREE_DS_RESULT_* constants.
threeDSVersion
2
Use the THREE_DS_VERSION_* constants.
threeDSTransactionId
40
Directory server transaction ID.
electronicCommerceIndicator
3
A scheme-defined string with no constants.
SalesforceCardPaymentDetails
Card metadata collected at tokenization and instrument creation. Reachable via paymentInstrument.getPaymentDetails(). It has a public constructor, and any payment app can set it. Values aren’t validated, so your code must keep them well-formed. Values longer than the maximum length are truncated when set.
Property
Type
Max Length
Description
token
String
40
Opaque PSP identifier for reusing the card.
tokenType
String
20
Use the TOKEN_TYPE_* constants.
brand
String
20
Use the BRAND_* constants.
last4
String
4
Last four digits of the card number.
bin
String
8
Bank identification number.
expirationMonth, expirationYear
Integer
Not validated.
walletType
String
20
Use the WALLET_TYPE_* constants.
dpanLast4
String
4
Last four digits of the device PAN for tokenized wallet payments.
fundingType
String
20
Use the FUNDING_TYPE_* constants.
issuingCountry
String
2
Card issuer country as ISO 3166-1 alpha-2.
Non-Card Payment Details Classes
The Script API includes parallel classes for non-card payment methods. Each class has a public constructor and carries method-specific instrument data reachable via getPaymentDetails().
Class
Fields
SalesforcePayPalPaymentDetails
captureID, payerEmailAddress
SalesforceKlarnaPaymentDetails
paymentMethodCategory
SalesforceVenmoPaymentDetails
captureID, payerEmailAddress
SalesforceBancontactPaymentDetails
last4, bankName
SalesforceEpsPaymentDetails
bank
SalesforceIdealPaymentDetails
bank
SalesforceSepaDebitPaymentDetails
last4, accountHolderName
GiftCardPaymentDetails
last4 (gift card domain)
For a method without a dedicated class, use the base SalesforcePaymentDetails(type) with a TYPE_* constant: BANCONTACT, CARD, GIFT_CARD, EPS, AFTERPAY_CLEARPAY, IDEAL, SEPA_DEBIT, KLARNA, AMAZON_PAY, TWINT, PAYPAL, VENMO.
Order
Ambient shopper and order context read during authorization.
Property
Type
Description
orderNo
String
Human-readable order identifier.
status
EnumValue
Compare order.status.value to Order.ORDER_STATUS_*. Values include CREATED, NEW, OPEN, COMPLETED, CANCELLED, REPLACED, and FAILED. COMPLETED has no meaning by default. Don’t treat it as a placed state.
paymentInstruments
Collection
All instruments on the order. getPaymentInstruments(paymentMethodID) filters by method.
customerEmail, billingAddress, shipments
Mixed
Shopper context for the authorize request.
Where to Put PSP Data
The typed fields cover what the platform reads. Put everything else in custom attributes that your app defines in IMPEX.
Data
Where
Card token from the storefront
paymentCard.creditCardToken on the request; SalesforceCardPaymentDetails.token
PSP authorization or intent reference
PaymentTransaction.transactionID
Capture reference, if the PSP issues a separate one
custom.* on PaymentTransaction
Scheme or network transaction ID, auth code, decline code and message
custom.* on PaymentTransaction
Raw AVS and CVC responses, 3DS transStatus, CAVV, ACS transaction ID
custom.* on PaymentTransaction. The typed fields hold only the normalized result.
Token for a non-card method, wallet payer details
custom.* on OrderPaymentInstrument
Provider-Specific Data on Custom Attributes
Basket, instrument, and transaction custom attributes copy to the order at creation. Declare the attributes in your app’s IMPEX metadata.
Flow Patterns
Many payment methods do not resolve inside a single synchronous authorize call. A payment app commonly supports synchronous card authorization, redirect flows, and asynchronous settlement flows.
Order Status
Instrument Status
What It Means
What the Shopper Sees
CREATED
CREATED
Order exists; attemptOrderTransition hasn’t run
Whatever your extension shows
CREATED
PENDING
Waiting on a redirect, 3DS, or webhook
Your redirect or challenge, or order confirmation while an asynchronous result is pending
FAILED
any
Declined, errored, or fraud-rejected; the basket is reopened, except when the auto-fail job fails the order
The basket. No decline code reaches the shopper unless your extension shows one.
NEW / OPEN
AUTHORIZED or CAPTURED
Placed
Order confirmation
Passing next-action data to the storefront. Write values that have no standard attribute, such as a redirect URL, to custom attributes on the payment instrument, for example nextActionType and nextActionUrl, defined in your app’s IMPEX. Your storefront extension reads them from the payment instrument in the SCAPI response. SCAPI shows a custom attribute with a c_ prefix: a hook writes paymentInstrument.custom.nextActionUrl, and the response carries c_nextActionUrl.
Abandoned Orders
An order left in CREATED can be failed later, for example by the auto-fail job if the merchant turns it on. Your reversePayment hook handles it like any other failed order.
Synchronous Card Authorization
The base pattern for card payments. Authorization completes in a single hook call, and attemptOrderTransition places the order in the same request.
Your extension tokenizes the card and adds the instrument to the basket with the token. The platform dispatches afterCreateBasketPaymentInstrument.
Your extension calls POST /orders. The platform runs fraud beforePayment and creates the order. No payment hook runs.
Your extension calls your order transition endpoint. The platform dispatches onAttemptOrderTransition.
The payment app reads the pre-authorization fraud decision (if a fraud app is installed) and calls the PSP’s authorize endpoint.
The payment app populates SalesforceCardTransactionDetails, sets TYPE_AUTH and STATUS_AUTHORIZED, and returns Status.OK.
attemptOrderTransition runs the fraud app’s post-authorization hook (if installed) and places the order.
Redirect Flows
A redirect flow is a payment method that navigates the shopper off the storefront to the provider’s hosted page (iDEAL, PayPal Redirect, Klarna Pay Later redirect, card 3D Secure challenge). The shopper returns to the storefront, and your storefront extension updates the order payment instrument so your hook can finalize the authorization server-to-server.
Your extension creates the order and calls your order transition endpoint. The platform dispatches onAttemptOrderTransition.
The payment app calls the PSP to initiate a redirect session. The PSP responds with a status of REDIRECT_REQUIRED plus a correlation ID and a redirectUrl.
The app saves the correlation ID as the transactionID, writes the redirect URL to a custom attribute on the instrument, sets STATUS_PENDING, and returns Status.OK. The order stays in CREATED.
Your order transition endpoint returns the redirect URL, and the storefront extension navigates the shopper to the PSP’s hosted page.
The shopper authenticates on the PSP’s hosted page.
On the happy path, the PSP redirects the shopper back to the storefront. Your extension PATCHes the order payment instrument. The hook finalizes server-to-server with the PSP and sets TYPE_AUTH and STATUS_AUTHORIZED. The platform then calls attemptOrderTransition, and your extension shows order confirmation.
If the shopper abandons instead of returning, the PSP eventually fires an asynchronous webhook. The webhook handler checks that the order is still CREATED and finalizes server-to-server with the PSP.
1var PaymentTransaction = require('dw/order/PaymentTransaction');2var Status = require('dw/system/Status');34exports.onAttemptOrderTransition = function(order, paymentInstrument){5 var txn = paymentInstrument.getPaymentTransaction();6 if(txn.getTransactionID()){7 return new Status(Status.OK); // already initiated: don't start a second session8}9 var response = ExamplePSP.initiate(buildAuthorizeRequest(order, paymentInstrument)); // your PSP wrapper10 if(response.status === 'REDIRECT_REQUIRED'){11 txn.setTransactionID(response.correlationId);12 txn.setStatus(PaymentTransaction.STATUS_PENDING);13 paymentInstrument.custom.nextActionUrl = response.redirectUrl; // read by your storefront extension14 return new Status(Status.OK);15}16 return authorizeSynchronously(order, paymentInstrument, response); // your synchronous path17};1819exports.afterUpdateOrderPaymentInstrument = function(order, paymentInstrument, request){20 var txn = paymentInstrument.getPaymentTransaction();21 if(txn.getStatus() === PaymentTransaction.STATUS_PENDING && txn.getTransactionID()){22 return finalize(txn); // shopper returned: query the PSP23}24 return new Status(Status.OK);25};2627function finalize(txn){28 var result = ExamplePSP.retrieve(txn.getTransactionID()); // never trust the redirect payload alone29 if(result.status === 'AUTHORIZED'){30 txn.setType(PaymentTransaction.TYPE_AUTH);31 txn.setStatus(PaymentTransaction.STATUS_AUTHORIZED);32 return new Status(Status.OK);33}34 if(result.status === 'PENDING'){35 return new Status(Status.OK); // the webhook finishes it36}37 return new Status(Status.ERROR, result.reasonCode, result.reason); // fails the order38}
On a cancel or failure, finalize returns Status.ERROR, which fails the order and reopens the basket. If the shopper never returns, your webhook finalizes the order. Both paths check that the order is still CREATED first.
Async Payment Flows
An asynchronous payment flow is a payment method whose PSP result arrives out-of-band. The instrument stays at STATUS_PENDING, the order stays in CREATED, and the PSP webhook resolves it. Common examples: SEPA Direct Debit with delayed confirmation, ACH pull, and wire transfer.
onAttemptOrderTransition starts the PSP operation, saves the PSP reference as the transactionID, sets STATUS_PENDING, and returns Status.OK.
The order stays in CREATED. Your extension shows order confirmation (see Pending Orders).
When the PSP confirms, the webhook sets TYPE_AUTH and STATUS_AUTHORIZED and calls OrderMgr.attemptOrderTransition(order), which places the order.
If the PSP reports a failure, the webhook calls OrderMgr.failOrder(order, true).
Asynchronous methods may not qualify for a post-authorization fraud guarantee. Fraud apps typically return NOT_REVIEWED rather than GUARANTEED for SEPA or ACH at post-authorization time, because settlement risk is separate from card-not-present risk. Merchants take on the reversal exposure.
Important
Webhook Handling
The webhook receiver finalizes asynchronous flows (redirect, delayed settlement). The platform doesn’t verify signatures or route webhook traffic. Each payment app owns end-to-end webhook handling.
Webhook endpoint. Use a site controller, as the samples do. Custom SCAPI endpoints always require a shopper or Account Manager token, so a PSP can’t call one directly; see Custom SCAPI Resources.
Recording the outcome. Your webhook doesn’t authorize. It records what the PSP reports and moves the order on:
Success: set the transaction to STATUS_AUTHORIZED or STATUS_CAPTURED, then call OrderMgr.attemptOrderTransition(order). It runs the remaining steps, such as post-auth fraud, and places the order.
Failure: call OrderMgr.failOrder(order, true) while the order is still CREATED. attemptOrderTransition doesn’t fail an order whose payment is pending, so this is the one place a payment app calls failOrder.
Wrap each call in Transaction.wrap. Neither call opens its own transaction.
Check the result.isError() means the order failed. isFinal() with no error means it was placed or there was nothing to do. Otherwise another step is still pending.
The receiver’s responsibilities:
Verify the signature or HMAC. Reject the request when verification fails. Never trust the webhook payload without cryptographic verification, even when the source IP is allowlisted.
Locate the order by number. Pass order.orderNo to the PSP as metadata when you authorize, and use OrderMgr.getOrder(orderNo). The lookup works for an order in CREATED, unless the merchant turns on Filter Storefront Orders by Customer Session, which hides it and makes the lookup return null. If the merchant limits storefront order access, a lookup of a placed or failed order throws unless your webhook controller is on the Storefront Order Access allow list. Say in your app’s install instructions whether merchants need to allowlist your webhook controller or change either preference.
Gate on order status. If the order isn’t CREATED, acknowledge and stop. This makes duplicate and late deliveries a no-op.
Guard against replay. Reject events whose signed timestamp is older than a small tolerance (5 minutes is typical). Date.now() is UTC server time, and the tolerance window is relative to it. Log rejected replays for monitoring.
Gate on the transaction ID. A fast PSP can call back before your hook has committed the transactionID. If no instrument matches yet, return 409 so the PSP redelivers. Acknowledging with 200 drops the event, and the order stays in CREATED.
1var Encoding = require('dw/crypto/Encoding');2var Mac = require('dw/crypto/Mac');3var LocalServiceRegistry = require('dw/svc/LocalServiceRegistry');45function safeEq(a, b){// constant-time compare6 if(!a || !b || a.length !== b.length){return false; }7 var diff = 0;8 for(var i = 0; i<a.length; i++){diff |= a.charCodeAt(i) ^ b.charCodeAt(i); }9 return diff === 0;10}1112// Use the signature scheme your PSP documents.13function verifySignature(request, body){14 var secret = LocalServiceRegistry.createService('example.psp.webhook', {}).getConfiguration().getCredential().getPassword();15 var computed = Encoding.toHex(new Mac(Mac.HMAC_SHA_256).digest(body, secret));16 return safeEq(computed, request.httpHeaders.get('x-psp-signature'));17}
1'use strict';2// verifySignature: the function above. ExamplePSP: your service wrapper.3var Logger = require('dw/system/Logger');4var Order = require('dw/order/Order');5var OrderMgr = require('dw/order/OrderMgr');6var PaymentTransaction = require('dw/order/PaymentTransaction');7var Transaction = require('dw/system/Transaction');89exports.Notify = function(){10 var body = request.httpParameterMap.requestBodyAsString;11 if(!verifySignature(request, body)){12 response.setStatus(401);13 return;14}15 var event;16 try{17 event = JSON.parse(body);18}catch(e){19 response.setStatus(400);20 return;21}22 // Send orderNo to the PSP as metadata at authorize.23 var order = OrderMgr.getOrder(event.metadata.orderNo);24 if(!order || order.status.value !== Order.ORDER_STATUS_CREATED){25 response.setStatus(200); // already placed or failed: ack and stop26 return;27}28 var pi = findInstrumentByTransactionID(order, event.data.id);29 if(!pi){30 response.setStatus(409); // hook hasn't committed the transaction ID yet; PSP retries31 return;32}33 var txn = pi.getPaymentTransaction();3435 if(event.type === 'authorized'){36 if(txn.getStatus() === PaymentTransaction.STATUS_PENDING){37 Transaction.wrap(function(){38 txn.setType(PaymentTransaction.TYPE_AUTH);39 txn.setStatus(PaymentTransaction.STATUS_AUTHORIZED);40});41}42 var result = Transaction.wrap(function(){43 return OrderMgr.attemptOrderTransition(order);44});45 if(result.isError()){46 Logger.error('Order {0} failed at {1}: {2}', order.orderNo, result.getStep(), result.getReason());47}48}else if(event.type === 'failed'){49 var failed = Transaction.wrap(function(){50 return OrderMgr.failOrder(order, true);51});52 if(failed.isError()){53 Logger.error('failOrder failed for order {0}', order.orderNo);54}55}56 response.setStatus(200);57};58exports.Notify.public = true;5960function findInstrumentByTransactionID(order, transactionID){61 var pis = order.getPaymentInstruments().iterator();62 while(pis.hasNext()){63 var pi = pis.next();64 var txn = pi.getPaymentTransaction();65 if(txn && txn.getTransactionID() === transactionID){return pi; }66}67 return null;68}
The status write and attemptOrderTransition are separate transactions on purpose. If attemptOrderTransition throws, the AUTHORIZED status stays saved, and the next PSP retry calls attemptOrderTransition again to finish the order.
Key rotation. Support multiple active webhook secrets during rotation. Try verification against each configured secret, and accept on first match. Store the active secrets in the service credential, for example as a comma-separated password, never in a site preference.
IP allowlisting. When the PSP publishes webhook source IPs, configure allowlisting at the network or CDN layer as defense-in-depth, never as a replacement for signature verification.
Custom SCAPI Resources
A payment app can host its own SCAPI custom APIs when the shipped /shopper-baskets, /shopper-customers, and /orders endpoints don’t cover an app-specific need. Common cases: PSP-specific admin operations, express-checkout bootstrapping, and saved-instrument sync jobs. See the Custom APIs documentation for how to define and register a custom API.
Order Transition Endpoint
SCAPI has no standard endpoint for attemptOrderTransition. Your app defines a shopper custom API that calls it, and your extension calls that API after POST /orders. Put three files in your cartridge’s rest-apis/example-psp/ folder: a schema, an api.json mapping, and the script.
Check ownership. Confirm the order belongs to the calling shopper before you transition it.
Safe to call twice.attemptOrderTransition skips completed steps and does nothing for an order that’s no longer CREATED, so a retried call doesn’t authorize again.
Add the c_examplePsp_rw scope to the merchant’s SLAS client so shopper tokens carry it.
1// rest-apis/example-psp/script.js2var Order = require('dw/order/Order');3var OrderMgr = require('dw/order/OrderMgr');4var RESTResponseMgr = require('dw/system/RESTResponseMgr');5var Transaction = require('dw/system/Transaction');67exports.attemptOrderTransition = function(){8 var orderNo = JSON.parse(request.httpParameterMap.requestBodyAsString).orderNo;9 var order = OrderMgr.getOrder(orderNo);10 if(!order || !order.getCustomer() || order.getCustomer().getID() !== session.customer.getID()){11 RESTResponseMgr.createError(404, 'order-not-found').render();12 return;13}14 Transaction.wrap(function(){15 OrderMgr.attemptOrderTransition(order);16});17 var status = order.getStatus().getValue();18 var pis = order.getPaymentInstruments('EXAMPLE_PSP');19 var pi = pis.isEmpty() ? null : pis.iterator().next();20 RESTResponseMgr.createSuccess({21 orderNo: orderNo,22 placed: status === Order.ORDER_STATUS_NEW || status === Order.ORDER_STATUS_OPEN,23 failed: status === Order.ORDER_STATUS_FAILED,24 nextActionUrl:(pi && pi.custom.nextActionUrl) || null // neither placed nor failed: still pending25}).render();26};27exports.attemptOrderTransition.public = true;
Custom APIs vs. cartridge controllers. Custom APIs go through the SCAPI shopper-auth or admin-auth stack, respect OAuth scopes, and return consistent JSON error envelopes. Use them for shopper-facing status endpoints and admin operations that need SCAPI’s auth model. Custom APIs require a shopper or Account Manager token, so a PSP can’t call one directly as a webhook.
Testing Your App
Install your app on a sandbox with the prerequisites met. Cover at least these scenarios:
Synchronous approve and decline.
3DS challenge, with the pre-auth fraud decision at CHALLENGE.
Redirect out and back, and redirect abandoned.
Webhook arriving before the shopper returns.
Retried order transition call or PATCH, with no second authorization.
Order failed after authorization, where reversePayment voids it.
Auto-fail of an abandoned PENDING order.
PSP test-mode webhooks don’t reproduce production concurrency, so idempotency has to hold by design, not because the tests pass. Hook errors are logged with the hook name and order number. Write your own log category with Logger.getLogger(prefix, category).
See Testing and Validation for install, uninstall, and storefront checks that apply to every Commerce App.
Before go-live. On the production instance, confirm:
The PSP’s live account sends webhooks to each site’s endpoint, signed with the secret your receiver verifies.
Auto-Fail Orders is set so orders abandoned in CREATED are failed and their payments reversed. See Abandoned Orders.
Best Practices
Change order state from outside your hooks. Inside a payment hook, set the transaction status and return. Your order transition endpoint, webhook receiver, or other server-side code calls OrderMgr.attemptOrderTransition(), or OrderMgr.failOrder(order, true) on failure.
Read the pre-auth fraud decision where you authorize. On CHALLENGE, your app decides whether to request strong customer authentication. The platform doesn’t force it.
Verify webhook signatures. Verify every webhook, even when the source IP is allowlisted.
Write the transaction in the hook that changes it. Set type, status, and transactionID when you authorize, capture, void, or refund. The fraud app writes the fraud decision fields.
Return Status.ERROR for expected declines. Include a reason code so the log has context. Reserve throwing for programming errors.
Key PSP calls on the order. Derive idempotency keys from the order, such as the order number and method, not the instrument UUID, which can change on a retried instrument call.
Keep card data tokenized. Store only tokens, BINs, and the last four digits. Full PANs and CVVs never reach your server-side code, including logs.
Interaction With a Fraud Commerce App
When both a payment Commerce App and a fraud Commerce App are installed, the platform interleaves their hooks so fraud decisions shape authorization behavior. The pre-authorization fraud decision is set during POST /orders, before the order exists, and is on each instrument copied from the basket by the time any payment hook runs. Post-authorization fraud evaluation runs inside attemptOrderTransition after all instruments are authorized.
Fraud Commerce Apps documents the full interaction sequence, including challenge and hold semantics.