# SFCC Hooks: A Practical Guide with hooks.json Examples

URL: https://www.ozanaltinbas.com/en/blog/sfcc-hooks
Author: Ozan Altinbas (Ozan Altınbaş), SFCC Technical Architect
Published: 2026-11-12
Category: Development | Tags: SFCC, Hooks, Cartridges, SFRA, SCAPI

---

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](https://www.ozanaltinbas.com/en/blog/sfcc-cartridge-development), and see the [SFCC guide](https://www.ozanaltinbas.com/en/sfcc) 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](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/b2c-script-api/dw.system.HookMgr.html) 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](https://developer.salesforce.com/docs/commerce/sfra/guide/b2c-sfra-hooks.html) 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:

```text
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`:

```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):

```json
{
  "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:

```javascript
'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](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/b2c-script-api/dw.order.hooks.CalculateHooks.html) 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:

1. **Re-registering does not override.** If a cartridge further right registers `app.validate.basket` and yours does too, both run. Controllers, templates and scripts work the other way: the leftmost match wins.
2. **Reordering the cartridge path reorders execution**, and can change whose return value the caller receives.
3. **Do not aggregate results through the return value.** Pass in an object that each implementation writes to, and read it after `callHook` returns.

### Shopper API hooks: a return value ends the chain

For `dw.ocapi.shop.*` hooks, the [Commerce API hooks guide](https://developer.salesforce.com/docs/commerce/commerce-api/guide/extensibility_via_hooks.html) 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:

```javascript
'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](https://www.ozanaltinbas.com/en/blog/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](https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/hook-method-details.html) 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:

```javascript
'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

```javascript
'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](https://www.ozanaltinbas.com/en/blog/sfcc-caching) 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](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/b2c-script-api/dw.order.hooks.PaymentHooks.html) 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_credit` again 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_base` version for how the hook name is derived and which functions and arguments checkout passes.

---

## Error handling with dw.system.Status

A [`dw.system.Status`](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/b2c-script-api/dw.system.Status.html) 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](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/b2c-commerce-ocapi/hookcircuitbreaker.html). 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](https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/timeouts-limits.html), 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.calculate` and product or search `modifyGETResponse` run 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](https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/b2c-testing-sfra.html) 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.json` on 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: `getLogger` throws 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.json` to `hooks.json` to an exported function, in a cartridge on the site's path.
- Custom hooks run every registration in cartridge-path order; `callHook` returns only the last value.
- Shopper API hooks stop at the first returned value, so return nothing on success.
- Use `Status.ERROR` with a code for expected failures; uncaught exceptions cost a 500 and count towards the circuit breaker.
- Treat `onRequest`, calculation and product or search `modifyGETResponse` hooks 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](https://www.ozanaltinbas.com/en/service) I offer. [Get in touch](https://www.ozanaltinbas.com/en/contact) if you want a second pair of eyes.

## Further reading

- [SFRA Hooks](https://developer.salesforce.com/docs/commerce/sfra/guide/b2c-sfra-hooks.html): registration format and multi-cartridge execution order.
- [Customization with Hooks (SCAPI)](https://developer.salesforce.com/docs/commerce/commerce-api/guide/extensibility_via_hooks.html): before, after and modifyResponse hooks, return-value rules, errors and caching.
- [Hook Method Details](https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/hook-method-details.html): every Shopper API extension point with its signature.
- [Class HookMgr](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/b2c-script-api/dw.system.HookMgr.html): the `callHook` and `hasHook` reference.
