Hooks are how B2C Commerce lets your code run at points it does not own: inside a Shopper API request, during basket calculation, at the start of every storefront request, or at an extension point your own storefront defines. They are also where many production bugs live, because the rules for which implementation runs, in what order, and what a return value does differ between hook families, and most tutorials collapse them into one.
This guide covers registration, execution order, dw.system.HookMgr, SCAPI and OCAPI hooks, system hooks, errors, performance and testing. It assumes you know how cartridges work; if not, start with the SFCC cartridge development guide, and see the SFCC guide for the wider platform.
What a hook is in B2C Commerce
A hook is a named extension point mapped to a CommonJS module that exports a function. The platform, or your own code, calls the extension point by name; B2C Commerce finds the registered modules and runs them. The HookMgr reference calls it "an extension point in the business logic, where you can register scripts to customize functionality."
There are three families, and the differences matter later:
| Family | Name pattern | Who calls it | Example |
|---|---|---|---|
| Shopper API hooks (SCAPI and OCAPI) | dw.ocapi.shop.<resource>.<hook><METHOD> | The platform, during an API request | dw.ocapi.shop.basket.beforePATCH |
| System hooks | dw.order.*, dw.system.request.* | The platform, or your code via HookMgr | dw.order.calculate |
| Custom hooks | Your own namespace, such as app.* | Your code, via HookMgr.callHook | app.payment.processor.default (SFRA) |
Hooks are not SFRA route extension: server.append extends a route, a hook extends a business operation wherever it runs. The SFRA hooks documentation notes that a custom hook can be called from OCAPI and storefront code alike, which makes hooks the home for logic shared by an SFRA storefront and a headless channel.
Registering hooks in a cartridge
Registration takes three pieces, shown here in one custom cartridge:
app_custom_hooks/
package.json
cartridge/
scripts/
hooks.json
hooks/
loyalty.js
product.js
address.js
Step 1: point package.json at hooks.json
The cartridge's top-level package.json names the hook file with the hooks key, relative to the directory containing package.json:
{
"hooks": "./cartridge/scripts/hooks.json"
}
Step 2: map extension points to scripts in hooks.json
Each entry in the hooks array has a name (the extension point) and a script (a path relative to hooks.json, or any other valid module identifier):
{
"hooks": [
{ "name": "app.loyalty.orderPlaced", "script": "./hooks/loyalty.js" },
{ "name": "dw.ocapi.shop.product.modifyGETResponse", "script": "./hooks/product.js" },
{ "name": "dw.ocapi.shop.basket.shipment.shipping_address.beforePUT", "script": "./hooks/address.js" }
]
}
Step 3: export the function
For platform hooks the exported name is the hook name without its package qualification: dw.ocapi.shop.basket.shipment.shipping_address.beforePUT is exported as beforePUT. For a custom hook, the caller passes the function name to callHook, so keep the two in sync:
'use strict';
// cartridge/scripts/hooks/loyalty.js
var Logger = require('dw/system/Logger').getLogger('loyalty', 'hooks');
// Implements app.loyalty.orderPlaced; the return value goes back to the caller
exports.orderPlaced = function (order) {
var points = Math.floor(order.totalGrossPrice.value);
Logger.info('Order {0} earns {1} loyalty points', order.orderNo, points);
return { points: points };
};
Where the cartridge has to be
Uploading the code is not enough. The CalculateHooks reference says the hook script "must be located inside a site cartridge", so it must be on the cartridge path of the site serving the request; for organisation-level resources shared by all sites, Salesforce says to use the Business Manager site. A hook that works on one site but not another is usually a cartridge-path difference.
Execution order and return values
The rules depend on the hook family; mixing them up is a common bug.
Custom hooks: every registration runs
Per the SFRA hooks documentation, B2C Commerce runs all hooks registered for an extension point in all cartridges on the cartridge path, in cartridge-path order. Within one hooks.json you can register several modules for an extension point, but you cannot control their order. All modules run whatever they return, and only the last hook's return value reaches the caller.
Three consequences:
- Re-registering does not override. If a cartridge further right registers
app.validate.basketand yours does too, both run. Controllers, templates and scripts work the other way: the leftmost match wins. - Reordering the cartridge path reorders execution, and can change whose return value the caller receives.
- Do not aggregate results through the return value. Pass in an object that each implementation writes to, and read it after
callHookreturns.
Shopper API hooks: a return value ends the chain
For dw.ocapi.shop.* hooks, the Commerce API hooks guide states the opposite rule: a hook that returns a value skips the system implementation and any subsequent registered hooks. The method reference puts it as "a non-null Status ends the hook execution".
Default implementations do real work. The default logic of the basket after* hooks, such as dw.ocapi.shop.basket.items.afterPOST and dw.ocapi.shop.basket.coupon.afterPOST, implicitly calls dw.order.calculate. Return new Status(Status.OK) from your items.afterPOST and the item is added but the basket is not recalculated. Salesforce recommends returning nothing unless you intend to stop processing.
| Your function returns | Custom hook (app.*) | Shopper API hook (dw.ocapi.shop.*) |
|---|---|---|
Nothing (undefined) | Next registration runs; caller gets the last value | System implementation and later registrations still run |
Status.OK | Next registration still runs | Execution ends; default logic such as recalculation is skipped |
Status.ERROR | Next registration still runs; the caller decides | Processing stops, transaction rolls back, HTTP 400 |
| Throws | callHook throws to the caller | Transaction rolls back, HTTP 500 |
System hooks such as dw.order.calculate ship with a platform default implementation that your registration overrides. Register each of them in exactly one cartridge, so there is no ordering question to answer.
hasHook is broader than it looks
HookMgr.hasHook(extensionPoint) returns true if a hook is registered or a system default implementation exists, so it is true for dw.order.calculate even when no cartridge registers it. It checks the extension point, not whether a particular function is exported. Use it to make an optional custom hook safe to call, not as proof that your cartridge is wired up.
Calling hooks with dw.system.HookMgr
callHook(extensionPoint, functionName, ...args) returns the hook's result, or undefined if there is neither a registered hook nor a default implementation. If a hook throws, callHook throws. A defensive call site for the loyalty hook:
'use strict';
var HookMgr = require('dw/system/HookMgr');
var Logger = require('dw/system/Logger').getLogger('loyalty', 'hooks');
var LOYALTY_HOOK = 'app.loyalty.orderPlaced';
// Loyalty must never break checkout, so failures are logged, not rethrown
function notifyOrderPlaced(order) {
if (!HookMgr.hasHook(LOYALTY_HOOK)) {
return null; // no cartridge on this site implements it
}
try {
return HookMgr.callHook(LOYALTY_HOOK, 'orderPlaced', order) || null;
} catch (e) {
Logger.error('{0} failed for order {1}: {2}', LOYALTY_HOOK, order.orderNo, e.message);
return null;
}
}
module.exports = { notifyOrderPlaced: notifyOrderPlaced };
Catch exceptions only when the feature is optional; otherwise let them propagate so the transaction rolls back. System hooks are called the same way: HookMgr.callHook('dw.order.calculate', 'calculate', basket).
SCAPI and OCAPI hooks
The same hooks are called for SCAPI and OCAPI requests. Extension point names keep the historical dw.ocapi.shop.* prefix, and Salesforce's documentation now labels OCAPI itself as deprecated; see SCAPI vs OCAPI for what that means for new builds. One implementation can serve both; where behaviour must differ, branch on request.isSCAPI().
Prerequisites and coverage
Enable API hooks per instance in Business Manager under Administration > Global Preferences > Feature Switches by turning on API hook execution, which requires the Account Manager role Business Manager Administrator. Most Shopper APIs support hooks; Shopper Login (SLAS), Experience, Payments, Custom Objects and Consents do not.
Before, after and modifyResponse
| Hook type | Runs | Typical use |
|---|---|---|
before<METHOD> | Before the platform processes the request | Validation, access checks |
after<METHOD> | After processing, inside the write transaction | Changing data; external calls whose failure should roll back the write |
modify<METHOD>Response | Before the response is returned | Adding c_ properties for the client |
GET requests support before and modifyResponse; POST, PATCH, PUT and DELETE add after. There are exceptions, such as an afterGET on basket payment methods, and some names carry a _v2 or _v3 suffix, so take names and signatures from Hook Method Details rather than inferring them:
| Extension point | Endpoint (OCAPI path) | Arguments |
|---|---|---|
dw.ocapi.shop.basket.beforePATCH | PATCH /baskets/{basket_id} | basket, basketInput |
dw.ocapi.shop.basket.shipment.shipping_address.beforePUT | PUT /baskets/{basket_id}/shipments/{shipment_id}/shipping_address | basket, shipment, shippingAddress |
dw.ocapi.shop.order.afterPOST | POST /orders | order |
dw.ocapi.shop.product.modifyGETResponse | GET /products/{id} | scriptProduct, doc |
A validation hook
Return Status.ERROR for expected failures, with machine-readable details:
'use strict';
// cartridge/scripts/hooks/address.js
var Status = require('dw/system/Status');
var addressRules = require('*/cartridge/scripts/helpers/addressRules'); // your own module
// dw.ocapi.shop.basket.shipment.shipping_address.beforePUT
exports.beforePUT = function (basket, shipment, shippingAddress) {
if (!addressRules.isDeliverable(shippingAddress)) {
var status = new Status(Status.ERROR, 'ADDRESS_UNDELIVERABLE');
status.addDetail('field', 'shippingAddress');
return status; // HTTP 400, transaction rolled back
}
// Valid: return nothing, so the default implementation still runs
};
Under SCAPI the client receives HTTP 400 as application/problem+json, naming the extension point and carrying your details in statusDetails.
A modifyResponse hook
'use strict';
// cartridge/scripts/hooks/product.js
// dw.ocapi.shop.product.modifyGETResponse: runs for SCAPI and OCAPI product GETs
exports.modifyGETResponse = function (scriptProduct, doc) {
// Only c_-prefixed custom properties may be added to the response
doc.c_hasBrand = !!scriptProduct.brand;
doc.c_isMaster = scriptProduct.master;
// No return value: a non-null Status would end hook execution
};
When caching is enabled for an endpoint, the hook's result is cached with the response, and neither the server logic nor your hook runs again until the entry expires. Anything that depends on the individual shopper or the current moment is therefore not recomputed per request; the SFCC caching guide covers this interaction. For conditional behaviour use c_ query parameters (request.httpParameterMap), never custom headers: headers are not part of the cache key and are meant for diagnostics only.
Passing data between hooks
Adding a payment instrument to a basket calls dw.ocapi.shop.basket.payment_instrument.beforePOST, afterPOST and modifyPOSTResponse in one request. Use request.custom, which lives for the request, to carry values between them. Salesforce's example calls the payment provider in afterPOST (so a failure rolls back the write), stores the provider's reference in request.custom, and copies it to a c_ response property in modifyPOSTResponse.
System hooks you will meet
Calculation: dw.order.calculate
dw.order.calculate is the single place for basket and order (re)calculation, with dw.order.calculateShipping and dw.order.calculateTax as sub-steps that the CalculateHooks reference recommends invoking through HookMgr. The platform default falls back to the deprecated dw.ocapi.shop.basket.calculate, which writes to the deprecated API usage logs. Older examples, including some in official documentation, still register that name; use dw.order.calculate.
- No transactions when SCAPI is involved. Salesforce warns that transactions in the calculate hook, common in controller-era code, break SCAPI. If the hook also serves controllers, branch on
request.isSCAPI(). - Keep it fast and local. The default logic of thirty basket and order hooks calls it, so a remote call here repeats on nearly every cart interaction.
Payment: dw.order.payment.*
The PaymentHooks reference defines dw.order.payment.authorize, authorizeCreditCard, capture, refund, reauthorize, validateAuthorization and releaseAuthorization under the same prefix. authorize(order, paymentDetails) and authorizeCreditCard(order, paymentDetails, cvn) return Status.OK for a successful authorisation and Status.ERROR for a failed one.
Request lifecycle: onRequest and onSession
dw.system.request.onRequest runs when a storefront request is received, dw.system.request.onSession when a new storefront session starts. onRequest sits on every storefront request: no remote calls, no heavy queries.
SFRA custom hooks: payment processors and basket validation
SFRA uses custom hooks for pluggable checkout logic. Its documentation shows a hooks.json registering app.payment.processor.default, app.payment.processor.basic_credit and app.validate.basket, and a call to HookMgr.callHook('app.payment.processor.default', 'Handle', ...): one extension point per payment processor, each under scripts/hooks/payment/processor/.
For a payment integration:
- Register a new processor extension point rather than re-registering an existing one. Registering
app.payment.processor.basic_creditagain adds a second implementation; it does not replace the base one. - Mirror the base cartridge's contract. Read the processor scripts and checkout helpers in your
app_storefront_baseversion for how the hook name is derived and which functions and arguments checkout passes.
Error handling with dw.system.Status
A dw.system.Status is built with new Status(Status.OK) or new Status(Status.ERROR, code, message), extended with addDetail(key, value), and read with status.error and status.code. Branch on the code, never the message: the reference reserves getCode() for identifying a status.
For Shopper API hooks, Status.ERROR gives HTTP 400 and a rollback; an uncaught exception gives HTTP 500 ("Hook Execution Error") and a rollback. Return the former for anything you expect, so the client gets a parseable error.
The circuit breaker. Each extension point has its own hook circuit breaker. If more than 50 of the 100 most recent calls fail, it opens and every call to that extension point returns HTTP 503 for 60 seconds. It then goes half-open; if more than 5 of the next 10 calls fail, it opens again. A hook that throws on a common path can take an endpoint down for every shopper.
Finding the failure. In Log Center, run the LCQL query category: ( com.demandware.wapi.servlet.ShopRestServlet ) AND stackTrace: ( HookInvocationException ), or start from the failing SCAPI request's correlation ID. For circuit-breaker events, search HookCircuitBreaker and read the stack trace's Caused by section.
Performance and transaction pitfalls
- Time budget. Per Timeouts and Limits, Shopper API hook execution defaults to 10 seconds per hook (HTTP 500 when exceeded) and Shopper API requests to a 10-second gateway timeout (HTTP 504). Both are customisable, but a slow third party still spends the shopper's budget.
- Remote calls in after hooks. A failure rolls the database write back, but not the remote side effect. Make such calls idempotent or store enough state to reconcile.
- Hot paths first.
onRequest,dw.order.calculateand product or searchmodifyGETResponserun far more often than checkout hooks. Profile them first.
Testing and debugging hooks
Hook scripts are CommonJS modules, so you can unit-test them off-platform. The SFRA testing documentation lists mocha, chai, sinon and proxyquire for unit tests. Use proxyquire to replace dw/system/Status and your helper modules with stubs, and for every Shopper API hook add a test asserting that the valid path returns undefined: it encodes the rule easiest to break in review. On a sandbox:
- Confirm the feature switch and the site's cartridge path before debugging code.
- Search every
hooks.jsonon the path for a name before registering it; duplicates are allowed and all of them run. - Use a dedicated logger such as
Logger.getLogger('loyalty', 'hooks'), with a fixed set of prefixes:getLoggerthrows once the daily maximum of custom log files is reached. - If both SCAPI and OCAPI clients exist, test both;
request.isSCAPI()branches are easy to leave unexercised.
SFCC hooks checklist
| Check | Why it matters |
|---|---|
package.json has a hooks key; script paths are relative to hooks.json | Otherwise nothing registers or resolves |
| Exported names match the hook function names | Platform hooks call the name without its package qualification |
| Cartridge is on the site's path (Business Manager site for organisation-level code) | Hook scripts must live in a site cartridge |
| API hook execution feature switch is on | SCAPI and OCAPI hooks do not run otherwise |
| No transactions in calculate hooks that SCAPI reaches | Documented to break SCAPI |
| Remote calls fit the 10-second hook and gateway defaults | Timeouts surface as 500 or 504 |
modifyResponse adds only c_ properties, nothing per-request on cached endpoints | Cached responses do not re-run your hook |
| The extension point is not registered elsewhere on the path | Custom hooks run every registration |
Key takeaways
- Registration is
package.jsontohooks.jsonto an exported function, in a cartridge on the site's path. - Custom hooks run every registration in cartridge-path order;
callHookreturns only the last value. - Shopper API hooks stop at the first returned value, so return nothing on success.
- Use
Status.ERRORwith a code for expected failures; uncaught exceptions cost a 500 and count towards the circuit breaker. - Treat
onRequest, calculation and product or searchmodifyGETResponsehooks as hot paths.
Auditing duplicate registrations, controller-era calculate hooks now serving SCAPI, or a circuit breaker that keeps opening is part of the SFCC architecture and development services I offer. Get in touch if you want a second pair of eyes.
Further reading
- SFRA Hooks: registration format and multi-cartridge execution order.
- Customization with Hooks (SCAPI): before, after and modifyResponse hooks, return-value rules, errors and caching.
- Hook Method Details: every Shopper API extension point with its signature.
- Class HookMgr: the
callHookandhasHookreference.