Use this topic for UCP extensibility architecture and custom hook implementations. If you’re still deciding whether you need custom work, start with the Technical Audit.
Core concept: A UCP Checkout Session is a headless “view” on a standard Commerce Cloud Basket. Standard Basket and Order hooks (calculate, calculateTax, calculateShipping, validateBasket, createOrderNo) run automatically. UCP isn’t a second checkout to build—it’s an API in front of the basket you already have. Because it’s headless, storefront UI/controllers are bypassed, and hooks registered on other SCAPI resources (Shopper Baskets, Shopper Orders, and similar) don’t run. Remap that logic onto the UCP checkout-session hooks below. The Technical Audit has the inventory checklist.
Enable Salesforce Commerce API Hook Execution
This Business Manager feature toggle is required for end-to-end UCP checkout. Checkout-session operations depend on default hook execution (calculate, calculateTax, calculateShipping, validateBasket, createOrderNo, and related payment hooks). If the toggle is off, those hooks don’t run and a shopper can’t complete a checkout session—custom third-party payment hooks don’t run either. Enable it before you test or go live.
Note
Extensibility Models
These diagrams show how hooks are invoked during each Checkout Session operation. Each diagram shows the sequence of platform operations and hook invocation points:
How to Read the Diagrams
Boxes with labels: Platform service operations or hook execution points
Flow direction: Operations proceed left to right
Hook names: Extension points where your custom code can execute
Multiple hooks: May execute in sequence during a single operation
POST /checkout-sessions - Create Checkout Session
Opens a new UCP checkout session, creates the underlying basket.
PUT /checkout-sessions/{id} - Update Checkout Session
Updates/replaces products, buyer information, fulfillment, etc. on the underlying basket.
GET /checkout-sessions/{id} - Get Checkout Session
Returns the current basket state as a checkout session.
POST /checkout-sessions/{id}/cancel - Cancel Checkout Session
Cancel the checkout session, delete the basket (terminal).
POST /checkout-sessions/{id}/complete - Complete Checkout Session
Updates the billing address on the basket, creates the order, takes payment, and facilitates order placement. This operation is only available once the checkout session has the ready_for_complete status.
Complete uses more than one database transaction. Read Transaction Management before you put callouts or Transaction.wrap() in complete-flow hooks.
Complete Transaction 1
Complete Transaction 2 - SFP (Default)
Complete Transaction 2 - Third-Party
SFP Adyen Notification — Async Place Order
Salesforce Payments places the order on the gateway webhook, not in /complete. This diagram is the Adyen AUTHORISATION step. Stripe Salesforce Payments is also webhook-placed (payment_intent.succeeded). See If You Use Salesforce Payments.
Extension Hooks Reference
This is the hook catalog for UCP. Every Checkout Session hook returns a dw.system.Status.
Transactional is whether the hook runs inside a platform-managed database transaction:
Yes — persist Script API changes (basket or order). Returning ERROR rolls back that transaction.
No — the hook isn’t in a transaction. Shape the response or payload only. Persisting Script API objects can cause an ORMTransactionException.
Complete uses more than one transaction, so Yes doesn’t mean the whole /complete request rolls back. Which transaction a complete-flow hook runs in—and what an ERROR actually undoes—is in Transaction Management.
beforeOrderCreate runs on every complete, including Salesforce Payments. buildPaymentInstrumentRequest and authorize apply to the third-party payment provider path. See Third-Party Payment Providers. Salesforce Payments–only hooks are under If You Use Salesforce Payments.
Hook
Transactional
Typical Use
beforeOrderCreate(basket)
Yes
Last chance to adjust the basket after payment instruments are built and before createOrder
Mutate the signed lifecycle payload immediately before delivery to the platform
If modifyEventPayload fails or returns a non-OK status, the webhook is retried. See Order Post-Processing Events for how post-processing events become those signed snapshots.
Considerations for Business Profile modifyGETResponse Hook
The Business Profile modifyGETResponse hook isn’t guaranteed to run on every request. B2C Commerce caches the fully resolved, post-hook Business Profile by site and UCP version, and serves that cached copy until the TTL expires. The hook runs once when the cache entry is built, not once per incoming request.
Write modifyGETResponse as a pure function. Don’t depend on per-request data such as headers or the calling agent. If the hook fails or returns a non-OK status, the result isn’t cached, and a later request rebuilds the Business Profile and runs the hook again.
The default TTL for Business Profile cache entries is 10 minutes. This value is only modifiable by Salesforce Support.
Note
UCP Checkout Session Validation
By default, Checkout Session APIs perform basic validations for:
Buyer email
Line items
Shipping address
Shipping method
On checkout session complete, these additional validations run:
Billing address
Payment method
Adding and Removing Validation Errors
Add, edit, or remove validation errors in dw.ocapi.shop.basket.validateBasket.
Use UCP standard error codes for the type field so error handling stays consistent across platforms.
Note
1exports.validateBasket = function(basketResponse, duringSubmit){2 // Add a validation error3 basketResponse.addFlash({4 type: "eligibility_invalid",5 message: Resource.msg('error.age.verification', 'checkout', null),6 path: "$"7});89 // Remove a validation error10 var toRemove = [];11 for each(f in basketResponse.flashes){12 if(f.type === "InvalidBillingAddress"){13 toRemove.push(f);14}15}16 for(var i = 0; i<toRemove.length; i++){17 basketResponse.removeFlash(toRemove[i]);18}19}
Flash to Message Mapping
Salesforce automatically maps Flashes to UCP Messages:
Flash Property
UCP Message Property
flash.type
message.code
flash.path
message.path
flash.message
message.content
These properties are always set automatically:
message.type is always ERROR
message.severity is always RECOVERABLE
message.contentType is always PLAIN
Checkout Status Derivation
If a Basket has one or more Flashes, Checkout.status is INCOMPLETE. Otherwise, the status is READY_FOR_COMPLETE.
To override the standard behavior of mapping Basket and Flashes to Checkout, use any of these hooks:
Add custom address verification by using a third-party service (Loqate) in dw.ocapi.shop.basket.validateBasket.
1// Hook: dw.ocapi.shop.basket.validateBasket2exports.validateBasket = function(basketResponse, duringSubmit){3 // Validate shipping address with Loqate4 var address = basketResponse.shipments[0].shippingAddress;5 if(address){6 var addressForm = {7 Address1: address.address1,8 Address2: address.address2,9 Country: address.countryCode,10 State: address.stateCode,11 City: address.city,12 PostalCode: address.postalCode,13};14 var response = loqate.validateLoqateAddress(addressForm);15 if(response && response.response){16 basketResponse.addFlash({17 type: "address_undeliverable",18 message: "Item(s) in your cart can't be shipped to the selected address",19 path: "$.shipments[0].shippingAddress",20});21}22}23};
Filtering Store Pickup Fulfillment Methods
Use the modifyResponse hooks to filter out store pickup fulfillment methods when the platform doesn’t support store pickup.
Example: Don’t show store pickup fulfillment methods
1// sfcc.ucp.shopperCheckouts.v1.checkoutSessions.modifyPOSTResponse2exports.modifyPOSTResponse = function(checkoutSession)3{4 var pickupMethodIds = {};5 for each(m in ShippingMgr.getAllShippingMethods()){6 if(m.custom.ucpStorePickupEnabled === true)7{8 pickupMethodIds[m.ID] = true;9}10}1112 for each(method in checkoutSession.fulfillment.methods){13 for each(group in method.groups){14 var filtered = [];15 for each(option in group.options){16 if( !pickupMethodIds[option.id])17{18 filtered.push(option);19}20}21 group.options = filtered;22}23}24}
Payment Configuration
A UCP checkout is paid with a single payment instrument. How you process that payment depends on your provider, and the two paths require different work:
If you use Salesforce Payments (the default), the native SFP implementation handles payment for you. See If You Use Salesforce Payments.
If you use a third-party provider (DIY), you register a small set of hooks that advertise your payment handlers and authorize payment with your own payment service provider (PSP). See Third-Party Payment Providers.
The platform assembles the Google Pay / Salesforce Payments handler from Business Manager and authorizes payment for you. You don’t register the DIY payment hooks. See Payment Provider Setup.
Authorization starts in Transaction 2, but placement is asynchronous for both Salesforce Payments gateways: the order stays in CREATED until the webhook arrives. Don’t treat a successful /complete response as a placed order.
Adyen via Salesforce Payments: placement finishes when Adyen delivers its AUTHORISATION webhook.
Stripe via Salesforce Payments: the PaymentIntent is confirmed during Transaction 2, but placement finishes when Stripe delivers payment_intent.succeeded.
Optional hooks on this path:
dw.extensions.payments.adyenNotification — Adyen Salesforce Payments only; runs when the Adyen notification is processed.
dw.extensions.payments.stripePaymentEvent — Stripe Salesforce Payments only; runs when the Stripe payment event is processed.
dw.extensions.payments.sendOrderConfirmationEmail — Salesforce Payments (Adyen or Stripe); runs after the webhook places the order.
These hooks don’t fire for third-party (DIY) payment.
Sharing an Adyen merchant account
If you use the same Adyen merchant account for both your storefront’s own third-party Adyen integration and Salesforce Payments, you must filter webhook notifications so your storefront integration ignores UCP/SFP orders it didn’t create. See Adyen Webhook Filtering for Shared Merchant Accounts.
Note
Adyen Webhook Filtering for Shared Merchant Accounts
Why: The Adyen merchant account would have a webhook for both the third-party and SFP configurations. Adyen delivers payment notifications to all webhook endpoints on that account. The Salesforce Payments handler already ignores non-SFP orders; your storefront Adyen integration might not, and can try to process UCP/SFP notifications it didn’t create.
The code snippets here are for a third-party integration using the Adyen-managed adyen-salesforce-commerce-cloud cartridge. Your own implementation may vary, but the same principles apply.
Note
Required Action
1. Tag Your Third-Party Payments with Adyen Metadata
Where you build the Adyen payment request, add a metadata field. Adyen echoes this field back in webhook notifications.
File location:cartridges/int_adyen_SFRA/cartridge/adyen/utils/adyenHelper.js (example path for Adyen cartridge)
1createAdyenRequestObject(orderNo, orderToken, paymentInstrument, customerEmail){2 // ...3 stateData.applicationInfo = adyenHelperObj.getApplicationInfo();4 stateData.additionalData = {};56 // Tag payments created by this cartridge so webhook notifications7 // can be identified. Adyen echoes this metadata back in webhook8 // additionalData as "metadata.<key>".9 stateData.metadata = {10 paymentSource: 'adyen-sfcc-cartridge'11};1213 return stateData;14}
2. Filter on That Metadata Field in Your Webhook Handler
Where you receive webhook events in your third-party flow, check for the metadata tag before taking any action. If absent, return [accepted] without doing any processing.
1function notify(req, res, next){2 // ...3 var source = req.form["additionalData.metadata.paymentSource"];4 if(source !== "adyen-sfcc-cartridge"){5 // This payment wasn't created from this cartridge6 // Return [accepted] and do no processing7 res.render("/notify");8 return next();9}1011 // This payment had our metadata => was created by this cartridge12 // Continue with typical webhook processing flow13 Transaction.begin();14 // ...15}
Using a metadata field as shown here is the simplest path forward, but alternative checks exist to determine if an order was initiated from third-party or SFP, such as checking the payment method of an order. Confirm this for your third-party implementation.
Deployment
Make the code changes in your local development environment
Test webhook filtering with both SFP and third-party payment scenarios
Upload the modified cartridge to your Commerce Cloud instance via Business Manager or command-line tools
Add the cartridge to your site’s cartridge path if not already present
Verify webhook handling in both Staging and Production environments
Third-Party Payment Providers
Register the hooks below to advertise payment handlers and authorize with your own PSP. Set Payment Provider to Third Party Provider (DIY) so the platform doesn’t assemble the Salesforce Payments handler. See Payment Provider Setup.
sfcc.ucp.shopperBusinessProfiles.v1.businessProfile.modifyGETResponse — advertise payment handlers on the well-known profile
sfcc.app.ucp.payment.buildPaymentInstrumentRequest — translate the selected instrument (including its handler_id) into a payment instrument request
sfcc.app.ucp.payment.authorize — authorize with your PSP. Fires only for a third-party instrument; Salesforce Payments bypasses this hook.
Advertising Payment Handlers
Register sfcc.ucp.shopperBusinessProfiles.v1.businessProfile.modifyGETResponse to replace the payment handlers on the well-known profile. See Configuration and Discovery. The hook argument exposes ucp and signingKeys. Assign handlers on ucp.paymentHandlers. Don’t change ucp.version or signingKeys.
Copy the handler namespace (the map key, for example com.google.pay) and the handler id from the payment handler’s author — usually the PSP or the platform. That id is what comes back on /complete as handler_id. The rest of the handler object (config and related fields) is also defined by that author. Consult the handler’s official documentation.
1// sfcc.ucp.shopperBusinessProfiles.v1.businessProfile.modifyGETResponse2var Status = require("dw/system/Status");34exports.modifyGETResponse = function(businessProfile){5 businessProfile.ucp.paymentHandlers = {6 "com.google.pay":[7{8 id: "...", // exact id from the handler author; do not invent one9 config:{10 /* see the handler author's spec */11},12},13],14};15 return new Status(Status.OK);16};
The checkout mapper reads payment handlers from the business profile, so the handlers you assign here appear in the checkout session response without a separate hook.
Authorizing Payment
Register sfcc.app.ucp.payment.authorize to authorize the payment instrument with your PSP. The hook receives the Order and the OrderPaymentInstrument, and returns a Status: return Status.OK when authorization succeeds, or Status.ERROR to fail the order. Read any PSP tokens or handler data from the payment instrument’s custom attributes (set earlier by your buildPaymentInstrumentRequest hook).
If you already have PSP authorization logic elsewhere, bridge to it from this hook rather than duplicating it:
SFRA merchants call their existing app.payment.processor.<METHOD>.Authorize script.
Pipeline merchants call their private Authorize start-node.
Merchants with an existing dw.order.payment.authorize script delegate to it directly — the signature is the same (Order, OrderPaymentInstrument).
No storefront browser in UCP
UCP checkout runs headless and server-side. There is no merchant-controlled browser, so any PSP logic that assumes client-side setup happened first (for example, a payment intent created in the shopper’s browser) must be adapted to complete the full PSP interaction server-side inside the authorize hook.
Note
Order Placement
Order placement is synchronous — it happens inside the /complete request. The platform places the order when both of these are true:
Your sfcc.app.ucp.payment.authorize hook returns Status.OK.
The order’s authorized amount covers its total.
At the start of /complete, the platform creates the order in CREATED status and runs your authorize hook. Leave the payment transaction in its created state: when the hook returns Status.OK, the platform promotes the transaction to authorized and places the order for you. (You can set the transaction to authorized yourself if you prefer, but it isn’t required.)
If authorize returns Status.ERROR or throws, the platform fails the order, reopens the basket, and returns an error so the shopper can retry.
Don’t set the payment transaction to captured
Placement requires the order’s authorized amount to cover its total, and a captured transaction doesn’t count toward that amount. So if your authorize hook captures the payment, the order is never placed — it stays in CREATED and is later failed by the AutoFailOrders job. Authorize during /complete; capture later (for example, at fulfillment).
Note
Post-Placement Actions
A browser-based checkout finishes an order in the storefront controller — sending the confirmation email, exporting to an OMS, and so on. UCP checkout is headless, so that controller never runs. How you replace that work depends on the payment path:
Salesforce Payments: use dw.extensions.payments.sendOrderConfirmationEmail, which runs after the gateway webhook places the order. Don’t send the email from complete.afterPOST — the order is still CREATED. See If You Use Salesforce Payments.
Third-party (DIY):complete.afterPOST receives the Order after /complete has placed it. Send the confirmation email there. Send only if the order is already placed (not CREATED). Don’t return ERROR if the email send fails; log it, leave confirmationStatus unconfirmed, and let a job retry. A hook failure doesn’t un-place the order, but it can still fail the /complete response. See Transaction Management.
Recommendation: whether or not you send the email in complete.afterPOST, run a scheduled job as a backstop for the email and for OMS export. Use the order’s status attributes as an idempotent work queue — confirmationStatus for the email and exportStatus for OMS export. Guard each action by its own flag and commit each in its own transaction, so a failure in one action neither undoes the other nor re-fires it on the next run.
1// Custom job step: run post-placement actions for newly placed orders.2var OrderMgr = require("dw/order/OrderMgr");3var Order = require("dw/order/Order");4var Transaction = require("dw/system/Transaction");56exports.processPlacedOrders = function(){7 OrderMgr.processOrders(8 function(order){9 if(order.confirmationStatus === Order.CONFIRMATION_STATUS_NOTCONFIRMED){10 // Send the email server-side (for example, dw.net.Mail or a helper),11 // then mark the order confirmed so later runs skip it.12 Transaction.wrap(function(){13 order.setConfirmationStatus(Order.CONFIRMATION_STATUS_CONFIRMED);14});15}16 if(order.exportStatus === Order.EXPORT_STATUS_NOTEXPORTED){17 // Export the order to your OMS, then mark it exported.18 Transaction.wrap(function(){19 order.setExportStatus(Order.EXPORT_STATUS_EXPORTED);20});21}22},23 "status = {0} AND (confirmationStatus = {1} OR exportStatus = {2})",24 Order.ORDER_STATUS_OPEN,25 Order.CONFIRMATION_STATUS_NOTCONFIRMED,26 Order.EXPORT_STATUS_NOTEXPORTED,27);28};
Splitting into separate jobs
Run the confirmation email and OMS export as two separate jobs instead if you prefer. If they run concurrently, be aware that B2C Commerce guards each order at the object level, not the field level: when both jobs touch the same order at the same moment, one transaction fails with a stale-data error even though they write different fields. Because the status flags make each update idempotent, the loser simply retries on its next run — or stagger the two schedules so they don’t overlap. A single job doing all post-placement work avoids the contention entirely.