Transaction Management for Checkout Session APIs

The UCP Checkout Session APIs are Salesforce Commerce API (SCAPI) resources, not storefront controllers or OCAPI endpoints. That means when you extend the UCP Checkout Session APIs with hooks, your custom code runs inside a database transaction that the platform manages for you. Understanding where the transaction boundaries are — what is committed, what is rolled back, and where your hook runs relative to those boundaries — is essential to writing correct customizations for the create, get, update, complete, and cancel operations.

The Checkout Session APIs power headless checkout. Because the flow is headless, no storefront controller logic runs — only the server-side hooks described in this article execute. Your customizations must live in these hooks.

The transaction behavior described in this article is a platform implementation detail. Hook extension points and ordering reflect the current implementation and can change. This article covers B2C Commerce transaction management; it does not describe the checkout protocol itself.

Note

The Checkout Session APIs Are SCAPI APIs 

Like all SCAPI resources, each UCP state-changing request runs its system logic and hooks inside one platform-managed transaction — the standard contract described in Extensibility via Hooks. Two things make UCP different from a typical SCAPI resource, and both matter when you adapt existing hook code:

  • The complete operation departs from the one-transaction-per-request model that the other operations follow (see The Complete Transaction Model).
  • Transaction.wrap() behaves differently across channels. An SFRA storefront hook opens its own script transaction, and so can the Checkout Session create, update, and cancel hooks — but the complete flow already runs inside a platform-managed transaction where no script transaction is open, so calling Transaction.wrap() there can fail. Shared helper code must account for the channel it runs in (see Sharing Hook Code Between SFRA and Checkout Session APIs).

Transaction Responsibilities 

Transaction management for Checkout Session hooks is a shared responsibility:

ResponsibilityOwner
Beginning, committing, and rolling back transactionsSalesforce (the platform)
Returning a Status to signal success or failureYou
Correctness and performance of the code inside your hookYou
Placing external callouts in the correct hookYou

You never call begin(), commit(), or rollback() yourself. Instead, you return a Status object, and the platform commits or rolls back the surrounding transaction based on that status.

The Standard Transaction Model 

For create, update, and cancel, the entire operation runs inside a single database transaction. The system logic that modifies the basket and the after hook both participate in that one transaction. Either everything commits, or nothing does.

This is the same contract documented for OCAPI and other SCAPI customizations: for state-changing HTTP methods like POST, PUT, PATCH, and DELETE, the server executes the after hook logic plus the system logic in the context of one database transaction. The transaction ensures that everything (or nothing) is committed into the database.

The processing flow for a create request:

Processing StepComment
Server receives the create requestThe server begins the transaction.
Server applies system logicThe server creates the basket and applies line items, discounts, and defaults.
Server calls the beforePOST hookYour code can observe the basket before it is fully populated. Returning an ERROR status stops processing and rolls back the transaction.
Server calls the afterPOST hookYour code can adjust the basket. The platform runs the basket calculation here. Returning an ERROR status stops processing and rolls back the transaction.
Server commits the transactionAll basket changes from the steps above are persisted together.
Server calls the modifyPOSTResponse hookRuns after the commit, outside any transaction. Use it to shape the response document only.
Server sends the responseThe server renders and returns the response document.

The update operation follows the same pattern with beforePUT, afterPUT, and modifyPUTResponse. The get operation is a read and does not run in a state-changing transaction.

Don’t modify a Script API object in an HTTP GET request or a modifyResponse hook, because they aren’t executed in a transaction. Doing so can cause an ORMTransactionException and an HTTP 500 fault response. If you need to persist a change, make it in a before/after hook, complete.beforePOST, complete.afterPOST, or a payment hook instead.

Note

The Complete Transaction Model 

The complete operation combines several traditionally separate operations — order creation, payment authorization, and order placement — and therefore uses more than one transaction. Atomicity does not span the whole request. This is the most important difference to understand before you customize the complete flow.

TransactionStepsYour hooks
Transaction 1Update billing address, build payment instruments, create the order from the basketcomplete.beforePOST, payment.buildPaymentInstrumentRequest (DIY), payment.beforeOrderCreate
Transaction 2Authorize payment and place the order if fully authorizedpayment.authorize (DIY only)
Transaction 3 (compensating)Runs only if Transaction 2 fails: clean up the basket and fail the order, reopening the basket
After Transaction 2Own nested transaction after Transaction 2 has committed (order is placed on DIY; still CREATED on Salesforce Payments)complete.afterPOST

If Transaction 3 also fails, the scheduled Auto-Fail-Orders job is the backstop.

The consequences for your custom code:

  • A hook error in Transaction 1 (for example, complete.beforePOST returns ERROR) rolls back Transaction 1. No order is created and no payment is authorized. Because no external payment provider has been called yet, nothing is left stranded.
  • A failure in Transaction 2 does not roll back the order created in Transaction 1 — the order already exists. Instead, the platform runs the compensating Transaction 3 to fail the order and reopen the basket. complete.afterPOST does not run.
  • Place any hook logic that has an external payment side effect (such as a call to a payment service provider) in the authorize hook, which runs in Transaction 2. This ensures that a rollback of the order-creation transaction cannot leave a dangling authorization.
  • complete.afterPOST runs after Transaction 2 has committed. A failure there does not un-place the order, but it can still fail the /complete HTTP response. See Post-Placement Actions.

On the third-party (DIY) path, Transaction 2 places the order when authorize succeeds and the authorized amount covers the total.

On Salesforce Payments, Transaction 2 still runs and starts authorization, but the order is not placed in Transaction 2. The order stays CREATED until the gateway webhook arrives:

  • Adyen via Salesforce Payments: AUTHORISATION
  • Stripe via Salesforce Payments: payment_intent.succeeded

Don’t treat a successful /complete response as a placed order on the Salesforce Payments path. complete.afterPOST still fires after /complete succeeds — with a CREATED order — so do not send the confirmation email from that hook on Salesforce Payments.

Hook catalog 

The hook catalog — grouped by create/get/update, cancel, complete, and payment — is in Extension Hooks Reference. This page covers only the transaction contract around those hooks.

Controlling the Transaction from Your Hook 

Your hook controls the transaction through its return value, following the same contract as other SCAPI hooks:

  • If your hook returns an OK status, the server continues processing and commits the transaction.
  • If your hook returns an ERROR status (a handled error), the server stops further processing and rolls back that hook’s transaction. For create, update, cancel, and complete Transactions 1 and 2, that rolls back the work in that transaction. For Checkout Session APIs, the error is surfaced in the checkout session response.
  • If your hook throws an uncaught exception, the server rolls back the transaction and returns an internal error.
  • complete.afterPOST is different: Transaction 2 has already committed. Returning ERROR or throwing does not un-place the order, but it can still fail the /complete HTTP response. Don’t return ERROR for a failed confirmation email; log it and let a job retry.
1var Status = require('dw/system/Status');
2
3exports.afterPOST = function (basket) {
4  // Returning ERROR rolls back the transaction — no basket changes
5  // from this request are persisted.
6  if (shouldBlockCheckout(basket)) {
7    return new Status(Status.ERROR, 'MY_ERROR_CODE', 'Explain what went wrong.');
8  }
9
10  // Safe to modify the basket here; changes participate in the transaction.
11  // basket.custom.myAttribute = '...';
12
13  return new Status(Status.OK);
14};

For each hook, your code should return a Status object. If your hook code doesn’t return a Status, multiple registered hook scripts might be executed, including the overridden base implementation, and one of their return values is used instead of yours.

Note

Do and Don't 

Do:

  • Return a Status object from every hook.
  • Modify persistent objects (the basket or order) only in a before/after hook (beforePOST, afterPOST, beforePUT, afterPUT), complete.beforePOST, complete.afterPOST, or a payment hook.
  • Place external payment callouts in the authorize hook so that a rollback of Transaction 1 can’t strand them.

Don’t:

  • Don’t begin, commit, or roll back transactions yourself. The platform owns the transaction boundary.
  • Don’t modify persistent objects in a modifyResponse hook or a get flow. It causes an ORMTransactionException and an HTTP 500 fault.
  • Don’t assume complete is atomic end to end. It spans multiple transactions.
  • Don’t set a payment transaction to CAPTURED inside the authorize hook.
  • Don’t treat complete.afterPOST as a rollback point for placement, and don’t send the confirmation email from that hook on Salesforce Payments.

Sharing Hook Code Between SFRA and Checkout Session APIs 

Many implementations share helper scripts (for example, cart and pricing helpers) between an SFRA storefront and the Checkout Session API hooks. This introduces a subtle transaction-management difference that can cause runtime errors if you aren’t aware of it.

dw.system.Transaction.wrap() (and begin()) does not create a transaction on its own — it expects a script transaction to already be open and joins it. The available transaction context differs by execution channel:

Execution contextTransaction context available to your script
SFRA / SiteGenesis controllerThe request pipeline opens a script transaction for the request, so Transaction.wrap() works.
Checkout Session create, update, and cancelA script transaction is open around the hook, so Transaction.wrap() works.
Checkout Session completeThe operation runs inside platform-managed transactions, but a script transaction is not open around every hook. Calling Transaction.wrap() from a hook in this flow (including dw.order.calculate, complete.beforePOST, complete.afterPOST, and the payment hooks) can fail with IllegalStateException: No transaction set for current thread.

Because your persistent changes in the complete flow already run inside a platform-managed transaction, they are committed with the operation. Wrapping them again in Transaction.wrap() is unnecessary in that flow, and can cause the error above.

The Script API does not expose a way to test whether a transaction is currently open. To decide whether to wrap, detect the execution channel with dw.system.Request.isSCAPI() instead. This method returns true for Checkout Session API requests and false for SFRA storefront requests.

Note

To keep one shared helper working correctly in both channels, wrap only when the code is running outside a Checkout Session API request:

1var Transaction = require('dw/system/Transaction');
2
3function persistDerivedValues(lineItem, value) {
4  lineItem.custom.myDerivedValue = value;
5}
6
7if (request.isSCAPI()) {
8  // Checkout Session flow: already inside a platform-managed transaction — write directly.
9  persistDerivedValues(lineItem, value);
10} else {
11  // SFRA and other storefront entry points: open a script transaction.
12  Transaction.wrap(function () {
13    persistDerivedValues(lineItem, value);
14  });
15}

Guidance for shared helpers:

  • Keep the persistence logic in a single function and choose whether to wrap it at the call site based on request.isSCAPI(), rather than duplicating the logic.
  • Prefer to persist derived values in an after hook rather than in a frequently-invoked hook like dw.order.calculate, which runs on every recalculation.
  • Don’t rely on request.isSCAPI() as a general “no transaction is open” signal outside of the checkout hook context. In a Custom API that does not open a transaction, a direct persistent write would still fail. Use it specifically to distinguish the SFRA and Checkout Session channels for a shared hook helper.

Performance Considerations 

Your hook code runs inside these transactions and, for modifyResponse hooks, on the response path. Its execution time is added directly to the API’s response time. Shopper APIs must respond in under 10 seconds, or an HTTP 504 timeout is returned and the shopper can’t complete the transaction.

  • Hooks are always slower than the API alone, because both the API code and your customization run. You’re responsible for the performance of the code you add — performance-test your hooks.
  • Minimize calls to external systems. If a hook must call an external service, use the service framework with aggressive timeout and circuit-breaking settings. Holding a transaction open while waiting on a slow external service risks the 10-second limit — this is especially relevant for the authorize hook.
  • Cache expensive operations in custom caches rather than recomputing them on every request.
  • Use the code profiler to find bottlenecks in your hook execution.
  • Keep response-shaping logic in modifyResponse hooks lightweight, because it runs on every uncached response.

Summary 

ConcernAnswer
Who manages the transaction?The platform. Never begin, commit, or roll back in a hook.
Where does my code run?Inside the operation’s transaction for before/after and payment hooks; in its own nested transaction for complete.afterPOST (after T2 has committed); outside a transaction for modifyResponse and get.
How do I abort and roll back?Return an ERROR status, or throw. For complete.afterPOST, that does not un-place the order.
Are create, update, and cancel atomic?Yes. One transaction covers the whole operation.
Is complete atomic?No. It spans order-creation, authorization, and compensating fail-order transactions.
Can I persist data in modifyResponse?No. It’s non-transactional. Use a before/after, complete.beforePOST, complete.afterPOST, or payment hook.
Where do external payment callouts go?The DIY authorize hook, which runs in Transaction 2.
When is the order placed?DIY: Transaction 2. Salesforce Payments: later, on the gateway webhook.

See Also