Tevpro insights
Custom SFCC Promotions with SCAPI
How we made custom SFCC promotion dependencies work through SCAPI basket recalculation in a Next.js headless storefront.
Key takeaways
- Keep promotion eligibility and basket totals in SFCC, not only in the Next.js UI.
- Use dw.order.calculate to evaluate prerequisite promotion IDs before final discounts are applied.
- Verify SCAPI basket mutations, tax, shipping, and promotion stacking in a sandbox before release.
A Next.js storefront can show the right promotion message and still return the wrong basket total. The reason is simple: the storefront does not calculate Salesforce B2C Commerce promotions. SFCC does. When cart mutations go through the Shopper Baskets API, the dependency rule has to run where SFCC recalculates the basket, not only in the React application.
We ran into this while building a headless storefront with Next.js for the shopping experience, Sanity for editorial content, and Salesforce B2C Commerce for catalog, basket, promotions, and checkout. An accessory discount depended on other qualifying promotions being present. We needed the same rule to hold whether the shopper changed the cart from our storefront or another API client. Our solution was a site cartridge that registers dw.order.calculate and filters the active promotion plan before applying the final discounts.
The earlier order-level promotions walkthrough explains the linked promotion idea. This article covers the headless boundary: registration, calculation sequence, API testing, and the parts you should not casually copy from another site's cartridge.
Where the rule runs
Salesforce documents dw.order.calculate as the central hook for custom basket calculation; the same hook family applies to SCAPI and related OCAPI calls. Not every Shopper API supports hooks, so check the operation you actually use. The basket returned by the API is what the UI should render. Salesforce: Extensibility via Hooks
Model the dependency in Business Manager
The pattern uses a qualifying promotion as a signal and a separate discounting promotion. A custom Promotion attribute stores one or more prerequisite promotion IDs on the discounting promotion. In the supplied implementation, the prerequisite might represent a qualifying product or another basket condition, while the discount itself is configured in Business Manager. The cartridge does not hard code a customer facing discount percentage or cap.
Set up a custom Promotion attribute with the appropriate ID-list type and make it editable in the relevant Business Manager attribute group. Create the qualifying promotions and the dependent discounting promotion, then put the prerequisite IDs on the latter. Configure eligibility, stacking/exclusivity, discount limits, and campaign dates in Business Manager. If a requirement can be expressed cleanly with native promotion rules, do that instead; a calculate hook is code you will own through every pricing change.
Treat IDs below as illustrative. The attached cartridge uses a project specific attribute name and promotion IDs, which should not be copied into another site.
Promotion: Qualify-Kit // prerequisite signal
Promotion: Qualify-Service // prerequisite signal
Promotion: Accessory-Discount // actual discount
Accessory-Discount.custom.dependentPromotionIDs =
["Qualify-Kit", "Qualify-Service"]
The rule is AND: if either prerequisite is absent from the evaluated plan, remove the dependent promotion from the candidate plan. Recheck behavior when a prerequisite is a zero value promotion; the actual discount plan, not mere promotion eligibility, is the signal this design uses.
Register the calculation hook
The cartridge's package.json points to hooks.json. The mapping names the calculate hook and the site cartridge script that exports calculate.
{
"name": "headless_promotions",
"hooks": "./hooks.json"
}
{
"hooks": [
{
"name": "dw.order.calculate",
"script": "./cartridge/scripts/hooks/cart/calculate.js"
}
]
}
Deploy the cartridge and add it to the target site's cartridge path ahead of the base storefront cartridge. Enable API hook execution in Business Manager under Administration > Global Preferences > Feature Switches, then confirm the site is using the intended code version. The exact menu or permissions can vary by instance; Salesforce's hook guide is the reference for registration and the switch. The Script API requires the exported function name to be calculate. Salesforce: Extensibility via Hooks Salesforce: CalculateHooks
Do not replace an existing calculation hook blindly. Review cartridge precedence and reconcile the site's current price, shipping, tax, bonus product, and promotion behavior before deploying a replacement. A hook that applies the right coupon but drops tax or shipping is not a successful implementation.
Filter the plan during basket calculation
The heart of the supplied cartridge is a two stage calculation. It first prices the basket and calculates shipping. It asks PromotionMgr for active customer promotions, evaluates an initial discount plan, and identifies promotion IDs that actually appear in order discounts or product price adjustments. It then removes dependent promotions whose prerequisite IDs are missing and applies discounts from the filtered candidate plan. A second product price pass handles bonus products and restores option prices that were deliberately excluded while computing eligibility.
This example shows the dependency gate, not a complete drop-in calculate.js; the surrounding implementation must calculate prices, shipping, taxes, and final totals for the target site. The custom field name here is illustrative.
var PromotionMgr = require('dw/campaign/PromotionMgr');
function filterDependentPromotions(basket, activePromos, collectAppliedIDs) {
// The first pass determines which prerequisite promotions
// actually produced eligible order or product discounts.
var preliminary = PromotionMgr.getDiscounts(basket, activePromos);
PromotionMgr.applyDiscounts(preliminary);
var appliedIDs = collectAppliedIDs(
PromotionMgr.getDiscounts(basket, activePromos)
);
// Snapshot candidates before mutating the promotion plan.
var candidates = [];
var iterator = activePromos.promotions.iterator();
while (iterator.hasNext()) candidates.push(iterator.next());
candidates.forEach(function (promotion) {
var required = promotion.custom.dependentPromotionIDs;
if (!required || !required.length) return;
for (var i = 0; i < required.length; i++) {
if (appliedIDs.indexOf(String(required[i])) === -1) {
activePromos.removePromotion(promotion);
break;
}
}
});
return activePromos;
}
// In the site's full calculate(basket) implementation, after
// pricing/shipping and before the final promotion application:
var candidates = PromotionMgr.getActiveCustomerPromotions();
var permitted = filterDependentPromotions(
basket,
candidates,
collectAppliedPromotionIDs // inspect order discounts AND product adjustments
);
PromotionMgr.applyDiscounts(PromotionMgr.getDiscounts(basket, permitted));
collectAppliedPromotionIDs is deliberately a site specific adapter. In the supplied code, it walks discountPlan.orderDiscounts and product line item price adjustments, collecting their promotion.ID values. The important distinction is that getActiveCustomerPromotions() gives you candidates, while the evaluated discount plan tells you which prerequisites actually took effect. A product prerequisite can be missed if you check only order discounts.
The example also assumes prerequisites do not form cycles and that one pass is enough. Validate chains and promotion exclusivity for your own rules. Keep the pricing/tax pipeline intact: Salesforce recommends using the shipping and tax hooks from a custom calculate implementation. For external tax baskets, follow Salesforce's external tax flow rather than treating this excerpt as a tax implementation. Salesforce: CalculateHooks Salesforce: Extensibility via Hooks
Verify through the same API path as the storefront
Test against a sandbox with the cartridge enabled, actual promotions imported, and the storefront's SCAPI client configuration. Use the Shopper Baskets operations your Next.js application calls to create a basket, add or remove items, update shipping, and retrieve the returned basket. Inspect price adjustments, totals, and the recorded promotion IDs in the API response, then check checkout totals.
- Discountable accessory only: Dependent discount absent
- Accessory plus one of two required qualifications: Dependent discount absent
- Accessory plus both required qualifications: Dependent discount present, subject to Business Manager limits
- Remove a qualification after discount appears: Dependent discount removed after recalculation
- Add shipping or change quantity: Promotion and totals recalculated consistently
Also exercise an expired prerequisite, conflicting promotions, coupons, bonus products, gross-price versus net-price taxation, and external tax if your site uses it. Repeat the cart mutations through OCAPI if another channel still uses it: Salesforce documents that the same relevant hooks may run for both SCAPI and OCAPI. Measure hook errors and basket latency after rollout, since a faulty hook can fail the API request rather than merely hide a discount. Salesforce: Extensibility via Hooks
The architectural payoff is consistency: the storefront can change, but the discount is still decided by the same basket calculation path. If your headless migration depends on legacy promotions, audit that path before promising parity. Talk with Tevpro about SFCC headless implementations.
Sources
Why work with us
Why Tevpro?
Whether you’re a startup with a bold product idea or an established company seeking a stronger delivery partner, Tevpro delivers results. Our expert consultants specialize in building secure, scalable applications that simplify operations and drive real ROI.



