---
schema_version: "1"
id: 28c7834d23c28708cf91eb42f0561cbd7f057f3d5d3a8dad6135eccdd22c2860
title: Connect a Third-Party Order Management System (OMS)
collection: developer
version: current
locale: en-us
portal_url: https://docs.salesforce.com/developer/current/en-us/commerce-cloud/b2c-commerce/guides/commerce-apps/connect-third-party-oms.md
canonical_url: https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/connect-third-party-oms.html
source_site: developer.salesforce.com
content_type: documentation
content_hash: de0ee039f3c938e95685b1e901bc9cb6be53b57cfff8ad4d3d74cb678f8b92a6
products:
  - Composable Storefront
  - Salesforce APIs
---
# Connect a Third-Party Order Management System (OMS)

The OMS domain is a **backend-only** [Commerce App](/docs/commerce/b2c-commerce/guide/commerce-apps-overview.md): you implement `sfcc.app.oms.order.*` extension points so a third-party Order Management System serves current order data through Shopper Orders, Shopper Customers, and UCP order lifecycle updates. Declare `"domain": "oms"` in `commerce-app.json`. For the framework this app builds on, see [Architecture Overview](/docs/commerce/b2c-commerce/guide/architecture.md#the-three-architectures-for-commerce-apps) and [Building Your Commerce App](/docs/commerce/b2c-commerce/guide/building-your-app.md).

## Overview

B2C Commerce is a high-scale **order-taking engine**: it captures orders at peak traffic and is
the point of origin for every order placed on your digital storefronts and channels. After an order is
created, the work of **post-purchase processing** — fulfillment, shipping, cancellations, returns,
refunds — is typically handled by an **Order Management System (OMS)**.

Salesforce offers a tightly integrated OMS (Salesforce Order Management) for exactly this. But you
aren't limited to it: plug in a **third-party OMS** and have it serve order data
through the same integrated surfaces B2C Commerce already exposes — the [**Shopper Orders**](https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-orders?meta=Summary) and
[**Shopper Customers**](https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-customers?meta=Summary) SCAPIs, and the Universal Commerce Protocol (UCP) order lifecycle
updates. The shopper- and agent-facing experience is identical no matter which OMS is behind it.

Connect a third-party OMS so it serves current order data through the same shopper APIs. The standard `GET` endpoints return the order as
it was **created** in B2C Commerce, but carry no real-time data from the integrated OMS. That gap is
closed with the **`expand=oms`** parameter on certain shopper-facing APIs. That parameter loads the current
order data from the OMS. When Salesforce Order Management is integrated, that parameter works automatically.
When you want to integrate a **third-party OMS** instead, the rest of this document describes how.

Build a **Commerce App** whose backend (site) cartridge implements a small set
of server-side **hooks**. When your app registers the hooks and the integration is enabled, the
platform routes order operations to your hooks automatically. Your hooks are responsible for one
thing: talking to your OMS and mapping its data to the platform's order shape. Everything else —
shopper authentication, the access decision, request/response validation, the public API surface —
stays with the platform.

**Nothing changes for API consumers.** Storefronts, the mobile app, and agentic-commerce channels
keep calling the same SCAPI Shopper Orders endpoints. The response shape is identical whether the
data came from the built-in store or your OMS.

### How a Request Flows

![Request flow from a SCAPI expand=oms call through platform authentication, the retrieve hook, access control, and the SCAPI response](https://developer.salesforce.com/media/b2c-commerce/commerce-apps-third-party-oms-request-flow.png)

When a request arrives, **the platform** authenticates the shopper and loads the order from its own
store if it still holds it. It then hands **your hook** an order document — **pre-populated with the
platform's data when the order was found locally, or empty when it was not** — for your hook to
augment. Your hook decides what it needs from the OMS: it can fill in a document the platform left
empty, or enrich a pre-populated one with fields the platform doesn't have (live fulfillment status,
tracking, and so on), and it can skip the OMS call entirely when the platform's data already
suffices. **The platform** then takes over again: it applies access control, and, for a cancel or
return, calls **your** cancel or return hook, which executes the operation against the OMS and
returns the updated order. Finally **the platform** serializes the resulting document as the SCAPI
response.

Your hooks own every interaction with the OMS — enriching order data on reads and executing the
actions on writes; everything around them — authentication, access control, validation, and the
response — is the platform's. Access control, in particular, is only exercised for an order the
platform did **not** already load and access-check itself: an order the platform served from its own
store is already trusted, whereas an order that exists only in the OMS (or one your list hook adds)
is access-checked before it is returned. That check uses a sensible default (the shopper must own the
order), but when it doesn't fit your needs a custom access-control hook can **fully override** it — at
which point the platform steps aside and you own and enforce the access decision yourself.

---

## The Hooks

The integration is made up of two groups of hooks: four **core** hooks that form the integration
itself, and two **optional** hooks you use to refine specific behaviors.

### Core Hooks (Required)

These four are the integration. The platform routes order operations to your OMS only when **all
four** are registered together and the feature toggle is on. If any one is missing, the platform
ignores the others and falls back to its default behavior. Register all four even if some start out
as temporary no-ops during development.

- **`sfcc.app.oms.order.retrieve`** — provides a **single** order. The platform hands you the order
  document — pre-populated when it still holds the order locally, empty when it doesn't — and your
  hook augments it from the OMS: enriching it with data the platform lacks (live fulfillment status,
  tracking, ...) or filling it entirely when the platform had nothing. This is the workhorse of the
  integration: it runs when a shopper views an order, up front on every cancel, return, and
  access-code request, and whenever the platform needs the current order internally — for example to
  assemble an order snapshot for UCP webhook events.
- **`sfcc.app.oms.order.list`** — provides orders belonging to a **single customer** for their order
  history. The platform pre-populates the result with the orders it already holds for that customer,
  and your hook augments the list — typically appending the customer's OMS-only orders. This is the
  only hook that is customer-scoped by nature, and it may need to return a specific subset of orders
  (paging).
- **`sfcc.app.oms.order.cancel`** — cancels a whole order in your OMS. The platform calls it after
  it has loaded the order and confirmed the shopper is allowed to cancel it.
- **`sfcc.app.oms.order.return`** — starts a return for specific order items, called under the same
  post-access-check conditions as cancel.

### Optional Hooks

Both of these are independent — register only the ones you need; they don't affect whether the
integration is active.

- **`sfcc.app.oms.order.accessControl`** — overrides the platform's default ownership decision.
  Register it only when "does this shopper own this order?" can't be answered by matching the
  platform customer ID (for example, when your OMS uses a different customer identifier and you
  manage it via custom attributes on the customer profile). It runs after
  a retrieve and fully replaces the default check.
- **`sfcc.app.oms.order.reasonCodes`** — supplies the cancel and return reason codes your OMS
  accepts. The platform uses them both to serve the `oms-meta-data` endpoint (so the storefront can
  render a reason picker) and to validate a submitted reason.

**note:** **Reference:** The `sfcc.app.oms.order.*` extension points are documented with full contract details, parameter types, and return values in the [B2C Commerce Script API documentation](https://salesforcecommercecloud.github.io/b2c-dev-doc/).

---

## Prerequisites

- A Commerce App whose `commerce-app.json` declares `"domain": "oms"`, with a **backend (site) cartridge**. See [commerce-app.json Schema](/docs/commerce/b2c-commerce/guide/reference.md#commerce-appjson-schema) and [Packaging Your Commerce App](/docs/commerce/b2c-commerce/guide/packaging.md).
- The app **installed** on the site. App-specific extension points (`sfcc.app.*`) run only through Commerce App install—you can't invoke them from a cartridge that isn't part of an installed app. See [Building Your Commerce App — Extension Points](/docs/commerce/b2c-commerce/guide/building-your-app.md#extension-points).
- Your OMS reachable from hook scripts—typically through an HTTP service you define in the same cartridge (`dw.svc.LocalServiceRegistry`).

---

## Enabling the Integration

The integration is active for a site only when **both** of these are true:

1. The **OMS App Hooks** feature switch is **on**. This is a platform kill switch, **not a
   merchant-facing setting**, and it is **enabled by default** — so you normally don't need to touch
   it. For reference, it lives in Business Manager under **Administration > Feature Switches**,
   labeled **"Enable OMS App Hooks (sfcc.app.order.\*)"** in the **Commerce Apps** group section
   (internal name `OmsAppHooksEnabled`).
2. All **four core hooks** are registered:
   `sfcc.app.oms.order.retrieve`, `sfcc.app.oms.order.list`,
   `sfcc.app.oms.order.cancel`, and `sfcc.app.oms.order.return`.

This is **all-or-nothing**: if any of the four is missing, the platform doesn't route order
operations to your app and falls back to its default behavior. Register all four even if some are
temporary no-ops during development.

**note:** **Triggering the hook on reads.** For `getOrder` and `getCustomerOrders`, the storefront request
must include the query parameter **`expand=oms`** to route to your retrieve hooks. Without it, the
platform serves the request from its own order store as usual.

---

## Step 1 — Register the Hooks

Register the four core `sfcc.app.oms.order.*` extension points in the site cartridge's `hooks.json`, and point `package.json` at it. This is the same registration pattern as other backend-only domains (for example, Tax). The following JSON is what the app registers. Install still goes through the Commerce App package, not a standalone cartridge-path hook. See [Building Your Commerce App — Extension Points](/docs/commerce/b2c-commerce/guide/building-your-app.md#extension-points).

```json
// hooks.json
{
  "hooks": [
    { "name": "sfcc.app.oms.order.retrieve", "script": "./oms/retrieve.js" },
    { "name": "sfcc.app.oms.order.list", "script": "./oms/listOrders.js" },
    { "name": "sfcc.app.oms.order.cancel", "script": "./oms/cancel.js" },
    { "name": "sfcc.app.oms.order.return", "script": "./oms/return.js" }
  ]
}
```

```json
// package.json
{ "hooks": "./hooks.json" }
```

Add optional hooks as extra entries:

```json
({ "name": "sfcc.app.oms.order.accessControl", "script": "./oms/accessControl.js" },
{ "name": "sfcc.app.oms.order.reasonCodes", "script": "./oms/reasonCodes.js" })
```

---

## Step 2 — Implement the Hooks

Each hook takes two arguments — an **input** and an **order document** to work with — and returns a
`dw.system.Status`. For most hooks the input is a purpose-built **context** object (for example
`OmsOrderContext`, carrying `siteId` and `orderNo`). The one exception is `accessControl`, whose
first argument is the authenticated shopper as a standard `dw.customer.Customer` object rather than a
context.

The order document is **not always empty**. On the read hooks the platform pre-populates it with the
order it already holds locally, and hands you an empty document only when it has none. Your hook can
therefore either enrich the data already there or fill a blank document — inspect it and add only what
the OMS needs to contribute.

- `new Status(Status.OK)` — success.
- `new Status(Status.ERROR, ...)` — failure. On a single-order retrieve this surfaces as **404**.

**important:** **Always return a `Status`.** A hook that returns nothing is treated as a failure, not a success.

The order documents you populate are Script API work objects. Use **`snake_case`** property
names in your hook (`order_no`, `customer_info`, `product_items`, `tracking_number`). The platform
serializes them to the `camelCase` SCAPI response (`orderNo`, `customerInfo`, `productItems`,
`trackingNumber`) automatically.

### `retrieve` — Provide One Order

Signature: `retrieve(context, orderResponse)`.

The context identifies the order by the combined key **`context.siteId` + `context.orderNo`**. It does
**not** carry a customer id — a retrieve hook is a pure data provider and must not implement its own
ownership logic; the platform handles access control.

`orderResponse` arrives **pre-populated** when the platform still holds the order locally, and empty
when it doesn't. Your hook typically does one of two things:

- **Enrich** an order the platform already loaded — add the post-purchase data the platform doesn't
  have (live fulfillment status, tracking, carrier, delivery dates). Skip the OMS call
  entirely when the platform's data is already enough.
- **Fill** an order the platform no longer holds (empty document) — populate it fully from the OMS.

To detect which case you're in, check whether the document already carries an order number.

```js
"use strict";
var Status = require("dw/system/Status");

function retrieve(context, orderResponse) {
  var alreadyLoaded = !!orderResponse.orderNo; // platform pre-populated it

  var omsOrder = MyOms.getByOrderNo(context.siteId, context.orderNo);

  // Order isn't in the platform AND isn't in the OMS → not found.
  if (!alreadyLoaded && !omsOrder) {
    return new Status(Status.ERROR, "ORDER_NOT_FOUND", "Order not found");
  }

  if (!alreadyLoaded) {
    // Empty document: fill the base order from the OMS.
    orderResponse.orderNo = omsOrder.number;
    orderResponse.status = "open"; // must map to an allowed status enum value
    orderResponse.orderTotal = omsOrder.total;
    orderResponse.currency = omsOrder.currency;
    orderResponse.guest = omsOrder.isGuest;

    // customer_id must be the platform customer id — the default access check compares against it.
    orderResponse.customerInfo = {
      customer_id: omsOrder.platformCustomerId,
      customer_no: omsOrder.customerNo,
      email: omsOrder.email,
    };
    orderResponse.productItems = omsOrder.lines.map(function (l) {
      return {
        product_id: l.sku,
        product_name: l.name,
        quantity: l.qty,
        price: l.price,
        item_id: l.lineId, // stable line id — needed to return specific items
        shipment_id: l.shipmentId, // groups line items; NOT fulfillment/tracking data
      };
    });
    // A full order also carries payment instruments, promotions, gift certificates, taxes, etc.
    // Map whatever your storefront's order experiences need — omitted here for brevity.
  }

  // In both cases, enrich with the live post-purchase data the platform doesn't hold. Put
  // fulfillment/tracking under omsData.shipments — the shipment object that carries tracking
  // links, carrier, and delivery dates. (order.shipments is line-item grouping, not this.)
  if (omsOrder) {
    orderResponse.omsData = {
      shipments: omsOrder.shipments.map(function (s) {
        return {
          id: s.id,
          status: s.status, // e.g. 'shipped', 'delivered'
          tracking_number: s.tracking,
          tracking_url: s.trackingUrl,
          provider: s.carrier,
        };
      }),
    };
  }

  return new Status(Status.OK);
}

exports.retrieve = retrieve;
```

### `order.list` — Provide a Customer's Orders

Signature: `list(context, customerOrdersResponse)`.

This is the one hook that is customer-scoped by nature. The context carries the authenticated
shopper's identifiers and paging/filter inputs:

| Field                            | Notes                                                      |
| -------------------------------- | ---------------------------------------------------------- |
| `context.customerId`             | Platform customer id of the authenticated shopper.         |
| `context.start`, `context.count` | Paging window. Your hook owns pagination against your OMS. |
| `context.from`, `context.until`  | Optional date filters.                                     |
| `context.status`                 | Optional status filter.                                    |
| `context.siteId`                 | The site.                                                  |
| `context.crossSites`             | Whether the request spans sites.                           |

`customerOrdersResponse` arrives **pre-populated** with the orders the platform already holds for
this customer, in its `data` list. Your hook augments that list — typically **appending** the
customer's OMS-only orders rather than replacing what's there. Add **only** orders belonging to
`context.customerId`. The platform trusts the entries it put there and access-checks the ones you
add.

```js
"use strict";
var Status = require("dw/system/Status");

function listOrders(context, customerOrdersResponse) {
  var page = MyOms.getOrdersForCustomer(
    context.customerId,
    context.start,
    context.count,
    context.siteId,
  );

  // Append the OMS orders to whatever the platform already loaded.
  var existing = customerOrdersResponse.data || [];
  var omsOrders = page.orders.map(function (o) {
    return {
      order_no: o.number,
      status: "completed",
      creation_date: o.createdAt, // a java.util.Date
      order_total: o.total,
      currency: o.currency,
      customer_info: { customer_id: o.platformCustomerId, customer_no: o.customerNo },
      product_items: o.lines.map(function (l) {
        return {
          product_id: l.sku,
          product_name: l.name,
          quantity: l.qty,
          price: l.price,
          item_id: l.lineId,
        };
      }),
    };
  });

  customerOrdersResponse.data = existing.concat(omsOrders);
  customerOrdersResponse.count = customerOrdersResponse.data.length;

  return new Status(Status.OK);
}

exports.list = listOrders;
```

### `cancel` — Cancel a Whole Order

Signature: `cancel(context, orderResponse)`. Context carries `orderNo`, `siteId`, and `reason`
(already validated and defaulted by the platform).

Before the platform calls this hook, it has already loaded the order (via your `retrieve` hook) and
run it through access control, and it hands that populated order to you as `orderResponse`. Then
inspect it to perform your own eligibility checks (for example, refuse to cancel an order that has
already shipped). If the order is eligible, forward the cancellation to your OMS, then **update the
same `orderResponse` in place** to reflect the post-cancellation state — the platform returns it as
the response.

```js
"use strict";
var Status = require("dw/system/Status");

function cancel(context, orderResponse) {
  // orderResponse is the already-loaded, access-checked order — use it for eligibility checks.
  var shipments = orderResponse.omsData ? orderResponse.omsData.shipments : null;
  if (shipments && shipments.length > 0) {
    return new Status(Status.ERROR, "NOT_CANCELABLE", "Order has already shipped");
  }

  var result = MyOms.cancelOrder(context.orderNo, context.reason);
  if (!result.ok) {
    return new Status(Status.ERROR, "OMS_CANCEL_REJECTED", result.message);
  }

  // Update the loaded order to its post-cancellation state.
  orderResponse.status = "cancelled";
  return new Status(Status.OK);
}

exports.cancel = cancel;
```

### `return` — Start a Return

Signature: `returnOrder(context, orderResponse)`. The context carries `productItems` — the items to
return, each with `itemId`, `quantity`, and a validated `reason`. As with cancel, `orderResponse` is
the already-loaded, access-checked order: inspect it for eligibility, forward the return to your OMS,
then update it in place to reflect the post-return state.

Because `return` is a reserved word in JavaScript, **name the function anything else and export it
under the `return` key**:

```js
"use strict";
var Status = require("dw/system/Status");

function startReturn(context, orderResponse) {
  var result = MyOms.createReturn(context.productItems);
  if (!result.ok) {
    return new Status(Status.ERROR, "OMS_RETURN_REJECTED", result.message);
  }
  orderResponse.status = "completed";
  return new Status(Status.OK);
}

exports["return"] = startReturn; // NOT exports.return
```

### `accessControl` — Custom Ownership Decision _(Optional)_

Signature: `accessControl(customer, orderResponse)`, where `customer` is a `dw.customer.Customer`.

By default the platform grants access when the order's `customer_info.customer_id` matches the
authenticated shopper. Register this hook only when your ownership rule is different — for example
when your OMS uses a different customer identifier and you manage it via custom attributes on the
customer profile. When registered, this hook **fully replaces** the default check. Return
`Status.OK` to grant, `Status.ERROR` to deny.

```js
"use strict";
var Status = require("dw/system/Status");

exports.accessControl = function (customer, orderResponse) {
  // The OMS uses its own customer identifier, stored as a custom attribute on the profile.
  var externalCustomerId = customer.custom.externalOmsCustomerId;
  if (externalCustomerId && externalCustomerId === orderResponse.customerInfo.customer_id) {
    return new Status(Status.OK);
  }
  return new Status(Status.ERROR);
};
```

### `reasonCodes` — Supply Cancel / Return Reasons _(Optional)_

Signature: `reasonCodes(context, omsMetaData)`. The `context` carries `context.siteId` (use it if
your OMS has per-site reason codes). Fill `cancelReasonCodes` and `returnReasonCodes`, each an array
of `{ reason, default }`. Mark exactly one entry per list as `default: true`.

```js
"use strict";
var Status = require("dw/system/Status");

function reasonCodes(context, omsMetaData) {
  // context.siteId identifies the site the codes are requested for.
  var codes = [
    { reason: "Not specified", default: true },
    { reason: "Defect", default: false },
    { reason: "Wrong item", default: false },
  ];
  omsMetaData.cancelReasonCodes = codes;
  omsMetaData.returnReasonCodes = codes;
  return new Status(Status.OK);
}

exports.reasonCodes = reasonCodes;
```

The platform serves these codes on the `oms-meta-data` endpoint and validates cancel/return reasons
against them.

---

## The Order Response

Your hooks map your OMS's data onto the platform's order shape. The full set of available fields is
defined by the SCAPI **Order** schema in the [Shopper Orders API reference](https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-orders?meta=Summary); you decide which of them
your storefront's order experiences need and populate those — unset fields are absent from the
response.

Two things always hold, regardless of which fields you populate:

- **Property names are `snake_case` in the hook, `camelCase` on the wire** — for example set
  `order_no`, `customer_info`, `product_items`; the platform serializes them as `orderNo`,
  `customerInfo`, `productItems`.
- **`customer_info.customer_id` is load-bearing** for the default access check. It must carry the
  **platform customer id** of the shopper who owns the order, because the default access check
  compares it against the authenticated shopper. This has an
  upstream consequence: when an order is exported to your OMS, the OMS must **persist the platform
  customer id** alongside the order, so that your `retrieve` and `list` hooks can map it back into
  `customer_info.customer_id` on load. Storing it at creation time is what makes the default access
  check work; if your OMS instead keys the shopper on a different identifier, map that identifier and
  register a custom `accessControl` hook.

### Watch for Constrained Fields

Some fields in the Order schema are **enums** — the OpenAPI Specification (OAS) restricts them to a fixed set of allowed
values. `status` is the most common example. Where a field is an enum, you must map your OMS's native
value onto one of the allowed values; anything else is rejected. Check the Order schema in the
Shopper Orders API reference for the exact enum values on each field before you map to it.

---

## Access Control

The platform — never your `retrieve` hook — decides whether the authenticated shopper may see an
order. It does so either with its built-in checks or, when you register one, by delegating to your
custom `accessControl` hook.

**Access control runs only for orders the platform didn't already load and check itself.** When the
platform served the order from its own store, it was already access-checked during that load and is
trusted as-is — the OMS access decision below does **not** run again for it. The access decision
below applies to orders the platform did **not** hold: an order that exists only in the OMS (so your
`retrieve` hook filled an empty document), or an order your `list` hook appended. For those, after
your hook returns, the platform resolves access in this order:

1. **Guest order access code.** If the request carries a valid access code for a guest order, access
   is granted (and the `accessControl` hook is **not** consulted).
2. **Ownership check — your hook _or_ the default, never both.** If you registered an `accessControl`
   hook, the platform calls it and uses its verdict; the default check doesn't run. If you didn't,
   the platform applies the default check: the order's `customer_info.customer_id` must match the
   authenticated shopper's platform customer id.

If none grants access, the platform returns **404 Not Found** — order existence is never leaked to a
shopper who doesn't own it.

**note:** **Guest orders via OTP.** Step 1 is an alternative access path evaluated **up front**, and it
applies **only to guest orders** — the platform requires the order to be flagged as a guest order
(your hook set `guest = true` with a non-null `customer_info.customer_id`). For such an order, a
valid access code (OTP) grants access there and then — neither your `accessControl` hook nor the
platform's default ownership check is invoked. Orders owned by a **registered** customer can't be
accessed via OTP; for them the access code is ignored and access falls to step 2.

Because the platform owns this decision, your single-order hooks (`retrieve`, `cancel`, `return`)
receive **no customer id** and must not re-check ownership. The **list** hook does receive
`context.customerId` — it is customer-scoped by design and needs it to query your OMS for the right
customer's orders. Even so, the platform doesn't take the orders you add to the list on trust: it
runs the access decision **individually for each order your hook appended** — your `accessControl`
hook if registered, otherwise the default ownership check — and filters out any that doesn't pass.
(The orders the platform pre-populated are already access-checked and are kept as-is.) So the
`customerId` on the list context is a query aid, not the access control itself.

For cancel and return, the platform first calls your `retrieve` hook, verifies ownership, and only
then calls your `cancel` / `return` hook — so a mutation is never forwarded to your OMS for an order
the shopper doesn't own.

---

## Reason Codes for Cancel and Return

When a shopper cancels an order or returns items, the request may carry a `reason`. If your OMS has
its own set of accepted reasons, register the optional `reasonCodes` hook to supply them. The
platform uses those codes two ways:

1. The `GET .../orders/oms-meta-data` endpoint returns them so the storefront can render a reason
   picker.
2. Cancel and return requests are validated against them.

The reason-code list is **never empty**. If you don't register the hook (or it supplies no codes),
the platform injects a single default reason, `"Not specified"` (`default: true`). With no hook
registered, `"Not specified"` is therefore the only accepted reason: an omitted reason resolves to
it, and any other supplied reason is rejected with **400 Bad Request**. Register the hook to widen
the accepted set.

Rules:

- **Matching is case-sensitive and exact.** A supplied reason that is not in the list → **400 Bad
  Request**.
- **A missing reason resolves to the `default`-flagged code.**
- The picker and the validator read from the **same list**, so what the storefront shows is exactly
  what cancel / return accept.

**Caching.** Reason codes are near-static, so the platform does **not** call this hook on every
request. It caches the hook's output **per site** for a short time (a 180-second TTL) and serves both
`oms-meta-data` and cancel/return validation from that snapshot. When you activate a new code
version, the cache is flushed so an edit to `reasonCodes.js` takes effect immediately; otherwise a
change is picked up within the TTL window.

**note:** **Treat `reasonCodes` as a settings provider, not a per-request handler.** The platform can reuse a
cached result instead of calling your hook, so it won't necessarily run on every shopper request.
Return the list of codes. Don't put logic in it that runs each time an order is
canceled or returned. Its only input is `context.siteId`.

Even with caching, avoid calling your OMS from inside this hook on every cache miss. Reason codes
change rarely, so a live callout per miss adds latency to the first request in each TTL window for
no real benefit. Prefer a **synchronization** approach: periodically pull the codes from your OMS
and store them on B2C Commerce (for example in site preferences or a custom object), and have the
hook return that locally-stored copy. That keeps even uncached responses fast and insulates the
shopper path from OMS availability.

---

## Guest Order Access Codes (OTP)

**note:** **This is a separate, independent SCAPI-only feature.** Guest order access codes are a
**SCAPI-only** flow whose purpose is to let a guest shopper regain access to their own orders
**after the session they placed the order in is gone** (guests have no login to prove ownership).
It's not part of the OMS integration — it lives in its own `sfcc.app.order.*` namespace and works
with any order backend, not just a third-party OMS. This section covers only how the two features
interact — see [Guest Order Access with an Access Code (OTP)](https://developer.salesforce.com/docs/commerce/commerce-api/guide/guest-order-access-code.html) for the full feature.

Guests have no account, so after the session they checked out in is gone, they have no way to prove
ownership of their order. To bridge that, the platform issues a one-time, 6-digit access code for
guest order access, valid for a short window. A shopper requests one via
`POST .../orders/{orderNo}/actions/request-access-code`, and then supplies the code to prove
ownership on the guest read and write paths alike — the order lookup as well as the cancel and return
requests. Note that the field the code goes in differs by endpoint:

- **Order lookup** (`POST .../orders/{orderNo}/lookup`) — put the code in **`orderViewCode`**. That
  field accepts either the static OrderViewCode or the 6-digit access code; the server tells them
  apart by format. There is no `orderAccessCode` field on the lookup request.
- **Cancel / return** (`.../actions/oms-cancel-order`, `.../actions/oms-return-order`) — put the code
  in **`orderAccessCode`**.

Where it intersects the OMS integration: a valid access code is checked **before** the OMS
`accessControl` hook, so it grants access regardless of other rules. For the platform to know which
guest order the code belongs to, your OMS `retrieve` hook must set `guest = true` and a non-null
`customer_info.customer_id`.

When the access-code request is made with **`expand=oms`**, the code-generation and email flow runs
against the **order data from your OMS** (loaded via the `retrieve` hook) rather than the platform's
local order. This has a useful side effect: the flow keeps working even after B2C Commerce has
**deleted its local copy** of the order — for example after the order has been handed off to the OMS
and purged from the platform. As long as the OMS still has the order and your `retrieve` hook can
return it, a guest can still request an access code and receive the email.

---

## Push Status Updates to the Platform

The retrieve hooks are **pull-based**: the platform calls your OMS when a shopper or channel requests
an order. To let your OMS **proactively notify** the platform when an order ships or is adjusted —
so the update propagates to agentic-commerce order webhooks — your OMS calls an inbound Admin API:

```
POST /organizations/{organizationId}/orders/{orderNo}/oms-status-events?siteId={siteId}
```

| Property       | Value                                                                                                                                                |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Auth           | Account Manager OAuth (client credentials), scope `sfcc.orders.rw`                                                                                   |
| Success        | **202 Accepted** (queued; processed asynchronously)                                                                                                  |
| Bad Request    | **400** when the request body is invalid (see the request body rules in this section)                                                                                    |
| OMS not active | **409 Conflict** when the OMS integration is not active for the site — the OMS App Hooks toggle is off, or the required hooks are not all registered |

The request body carries **exactly one** of a fulfillment event or an adjustment. The platform
assembles the full order (by calling your `retrieve` hook), merges the pushed change, and publishes
the downstream order webhook.

```jsonc
{
  // exactly ONE of the following:

  "fulfillmentEvent": {
    "id": "ship-001", // required
    "occurredAt": "2026-06-03T10:15:00.000Z", // required (RFC 3339)
    "type": "shipped", // required — processing | shipped | in_transit |
    //   delivered | failed_attempt | canceled |
    //   undeliverable | returned_to_sender (open string)
    "lineItems": [
      // required, 1..200
      { "id": "pli-001", "quantity": 2 } //   quantity >= 0
    ],
    "trackingNumber": "1Z999AA10123456784", // required when type != "processing"
    "trackingUrl": "https://www.ups.com/track?num=1Z999AA10123456784", // required when type != "processing"
    "carrier": "UPS", // optional
    "description": "Handed to carrier" // optional
  },

  "adjustment": {
    "id": "ret-7788", // required
    "type": "return", // required — open string: refund | return | credit | ...
    "occurredAt": "2026-06-03T10:15:00.000Z", // required (RFC 3339)
    "status": "completed", // required — pending | completed | failed
    "lineItems": [
      // optional; quantity may be negative — a negative
      { "id": "pli-001", "quantity": -1 } //   value means units removed/returned
    ],
    "totals": [
      // optional; amount in minor units (cents) and may be
      { "type": "total", "amount": -1999, "displayText": "Refund" }, // negative — negative = money
      { "type": "tax", "amount": -160 } // back to the buyer
    ],
    "description": "Customer requested — defective item" // optional
  }
}
```

For how B2C Commerce turns these events into signed UCP order snapshots, see [Order Post-Processing Events in the UCP Order Lifecycle](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/ucp/ucp-order-post-processing-events.md). The inbound request uses camelCase (`occurredAt`, `lineItems`); the UCP snapshot uses snake_case (`occurred_at`, `line_items`).

---

## The SCAPI Surface (for Reference)

These are the public Shopper endpoints your hooks power. Consumers call them exactly as they would
without an OMS integration.

Most of these endpoints belong to the **Shopper Orders** API, whose base is
`/checkout/shopper-orders/v1/organizations/{organizationId}`. The one exception is **List customer
orders**, which is a **Shopper Customers** API endpoint, with the base
`/customer/shopper-customers/v1/organizations/{organizationId}`. The `API` column below notes which
base each path is relative to. **`siteId` is a required query parameter on every endpoint** — both
the base and `siteId` are omitted from the paths for readability.

| Operation            | API               | Method + path                                     | Request body                                                          | Routes to        |
| -------------------- | ----------------- | ------------------------------------------------- | --------------------------------------------------------------------- | ---------------- |
| Get order            | Shopper Orders    | `GET /orders/{orderNo}?expand=oms`                | —                                                                     | `order.retrieve` |
| List customer orders | Shopper Customers | `GET /customers/{customerId}/orders?expand=oms`   | —                                                                     | `order.list`     |
| Guest order lookup   | Shopper Orders    | `POST /orders/{orderNo}/lookup?expand=oms`        | `{ orderViewCode?, email?, phone?, postalCode? }`                     | `order.retrieve` |
| Reason codes         | Shopper Orders    | `GET /orders/oms-meta-data`                       | —                                                                     | `reasonCodes`    |
| Cancel order         | Shopper Orders    | `POST /orders/{orderNo}/actions/oms-cancel-order` | `{ reason?, orderAccessCode? }`                                       | `order.cancel`   |
| Return items         | Shopper Orders    | `POST /orders/{orderNo}/actions/oms-return-order` | `{ productItems: [{ itemId, quantity, reason? }], orderAccessCode? }` | `order.return`   |

### Error Responses

| Status              | When                                                                                                                                                                                   |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **400 Bad Request** | The provided input is not sufficient or valid for the requested action (for example, an unrecognized reason code, an unknown `itemId`, or a return quantity beyond what's returnable). |
| **404 Not Found**   | Order unknown, or the shopper is not authorized to see it (existence is not leaked).                                                                                                   |
| **409 Conflict**    | Order state doesn't permit the action, your OMS rejected it, or the OMS integration is not active for the site.                                                                        |

---

## Gotchas

- **Install the Commerce App.** `sfcc.app.oms.order.*` hooks don't run unless the app is installed on the site. Registering `hooks.json` on a cartridge that isn't part of an installed Commerce App isn't enough. See [Building Your Commerce App — Extension Points](/docs/commerce/b2c-commerce/guide/building-your-app.md#extension-points).
- **Always return a `Status`.** A missing return is treated as failure (surfaces as 404 on
  single-order retrieve), not as success.
- **Register all four core hooks together** — the integration is inactive unless all four are present
  and the toggle is on.
- **Pass `expand=oms`** on `getOrder` / `getCustomerOrders`, or your hook is not called at all and the
  platform serves only its own data.
- **The order document may arrive pre-populated.** On the read hooks, check what the platform already
  put there and enrich it rather than blindly overwriting it.
- **`retrieve` gets no customer id.** Don't implement ownership there — the platform runs access
  control for the orders it didn't already load.
- **`customer_info.customer_id` is load-bearing.** Set it to the platform customer id of the shopper
  who owns the order, or the default access check can't grant access.
- **The list hook appends, and returns only its customer's orders** — add to the pre-populated `data`
  list rather than replacing it, and include only `context.customerId`'s orders.
- **`reasonCodes` takes `(context, omsMetaData)`** and its output is cached per site. Return a
  static or per-site list; don't rely on per-request side effects.
