Guest Order Access with an Access Code (OTP)

When a guest shopper checks out without an account and later loses their session by closing the browser, switching devices, or clearing cookies, they can’t get back to the order they placed.

The Order Access Code flow gives that shopper a secure way back in. The shopper requests a 6-digit, time-limited access code (OTP), the platform emails it to the address on the order, and the shopper enters it to look up, cancel, or return that order through the Shopper Orders API. This guide is for merchants and Commerce App developers enabling guest order self-service (view, cancel, and return) on B2C Commerce.

There is one thing you must build to make this flow work: the email delivery hook. The platform generates, stores, expires, and validates the code for you. It does not send the email. Instead, it hands the code to a hook you implement, and your hook delivers it to the email address on the order (for example, via a transactional email provider or Marketing Cloud). If you don’t register the hook, no code is ever delivered and the flow cannot complete.

Email is the supported channel. The flow is designed around the email address on the order. That address is what the platform validates the request against. Other channels are out of scope today.

Note

An integrated OMS unlocks the full flow. With no OMS, the access code enables order lookup only. The shopper can view the order, but cannot cancel or return it. Cancel and return require an integrated Order Management System (OMS): they run through the oms-cancel-order and oms-return-order endpoints, which are backed by OMS. To offer guests the complete self-service experience (view, cancel, and return), you need OMS integration.

Important

What the Platform Does vs. What You Do 

ConcernOwner
Generate the 6-digit codePlatform
Store the code, enforce 15-minute expiry, attempt limits, and regeneration cooldownPlatform
Validate the code on lookup / cancel / returnPlatform
Match the submitted email against the order before generating a codePlatform
Restrict the flow to guest ordersPlatform
Deliver the code to the shopper by emailYou (the sendOrderAccessCode hook)
Compose the message body, subject, branding, localizationYou

The platform is deliberately delivery-agnostic. It never sends an email itself and doesn’t ship a default email template. Delivery is entirely your hook’s responsibility.

Capabilities With and Without OMS 

The access-code mechanism is the same either way: request a code, receive it by email, submit it. What the shopper can do with the order afterward depends on whether your orders are backed by an integrated OMS.

CapabilityWithout OMSWith integrated OMS
Request an access codeYesYes
Look up / view the order (lookup)YesYes
Cancel the order (oms-cancel-order)NoYes
Return items (oms-return-order)NoYes

Cancel and return are only available for orders integrated with an OMS. They run exclusively through the oms-cancel-order and oms-return-order endpoints, which are OMS-backed. Without OMS, the access code is a read-only key: great for letting a guest re-find and view their order, but it stops at lookup.

Integrate an OMS to offer guests the complete self-service journey: view, cancel, and return.

Reach Orders No Longer in B2C Commerce 

With an integrated OMS, the flow reaches orders that no longer exist in B2C Commerce, for example, orders that have been purged or archived out of the B2C Commerce order store but are still held in the OMS. When the order isn’t found in B2C Commerce, resolution falls back to the OMS, so both the access code request (email validation) and the lookup succeed against the OMS copy. Cancel and return are OMS-backed to begin with, so they operate on the OMS order directly.

This makes the access code a durable way for a guest to reach an old order long after B2C Commerce would have dropped it, as long as the request carries expand=oms. Without an OMS integration, an order missing from B2C Commerce is simply a 404.

Flow Overview 

Lookup Only (No OMS) 

11. Shopper enters order number + email on your "Track Order" page.
22. Storefront → POST .../orders/{orderNo}/actions/request-access-code   Body: { email }
33. Platform verifies the email matches the order, generates a 6-digit code,
4   stores it in a 15-minute window, and calls your sendOrderAccessCode hook.
54. Your hook emails the code to the shopper.
65. Platform returns 202 Accepted (always — see "Why the response is always 202").
76. Shopper enters the code.
87. Storefront → POST .../orders/{orderNo}/lookup            Body: { orderViewCode: "<code>" }
9   → returns the full order for the shopper to view.

Full Self-Service (with Integrated OMS) 

Steps 1–7 are identical; the lookup response additionally exposes eligibility so the storefront can offer cancel/return, and steps 8+ become available:

17. Storefront → POST .../orders/{orderNo}/lookup            Body: { orderViewCode: "<code>" }
2   → returns the full order; storefront shows Cancel / Return where eligible.
38. Storefront → POST .../orders/{orderNo}/actions/oms-cancel-order   Body: { orderAccessCode, reason }
4   or           POST .../orders/{orderNo}/actions/oms-return-order   Body: { orderAccessCode, productItems }

The same code works for lookup and the follow-up cancel/return, as long as all calls happen within the 15-minute window. The shopper requests the code once.

Implement the Delivery Hook 

Register the Extension Point 

The extension point is sfcc.app.order.sendOrderAccessCode. Register it in your cartridge’s hooks.json:

1{
2  "hooks": [{ "name": "sfcc.app.order.sendOrderAccessCode", "script": "./order/sendAccessCode.js" }]
3}

…referenced from your package.json:

1{ "hooks": "./hooks.json" }

Implement the Script 

The hook receives the order and the generated access code, and returns a Status:

1"use strict";
2var Status = require("dw/system/Status");
3
4exports.sendOrderAccessCode = function (order, accessCode) {
5  // order      — the order the code is for (see "Hook Parameters")
6  // accessCode — the freshly generated 6-digit code, for example, "847291"
7
8  var email = order.customerInfo.email; // the address on the order
9
10  // Compose and send by email — for example, transactional email provider or Marketing Cloud.
11  MyMailer.send({
12    to: email,
13    subject: "Your order access code",
14    body: "Your access code is " + accessCode + ". It expires in 15 minutes.",
15  });
16
17  return new Status(Status.OK);
18};

Hook Parameters 

ArgumentTypeDescription
orderOrderThe full order the code was requested for. Use order.customerInfo.email for the recipient and order.orderNo in the message. All standard order fields are populated.
accessCodeStringThe generated code, always 6 digits, matching ^\d{6}$ (for example, "847291").

Return Values 

Your hook returnsEndpoint result
Status.OK202 Accepted. Normal success.
(hook not registered)202 Accepted, but no code is delivered. The flow is a dead end until you register it.
Status.ERRORA delivery-failure error surfaces to the caller (not masked as 202).

Delivery failures are intentionally not hidden behind the enumeration-safe 202 response, so that a genuinely broken mailer is observable to your storefront rather than failing silently.

Your hook runs only when the order/email combination is valid, so returning Status.ERROR surfaces a non-202 error and thereby reveals that this order/email was valid, the exact enumeration signal the always-202 response exists to hide. Never use it to signal “order/email didn’t match” (the platform already returns a uniform 202 for that, before your hook is called). Reserve it strictly for genuine delivery failures (for example, your mailer is down), and even then, note that the error is observable to the caller. When in doubt, return Status.OK and handle delivery problems out of band (logging, retries, alerting) so the 202 contract stays intact.

Deliver Through a Non-Email Channel 

The hook is technically channel-agnostic. Nothing stops you from reading a phone number off the order and sending the code by SMS. But the flow is designed for email only today, and there are two things to be aware of before going down that path:

  • The platform validates the request against the order’s email, not a phone number. The request-access-code endpoint takes an email and matches it against the address on the order. There is no phone-number equivalent, so an SMS channel would inherit email-based authorization while delivering to a number the platform never checked.
  • You own the trust implications. Because the phone number isn’t validated in the flow, sending the code by SMS is entirely at your discretion and risk.

Treat SMS or any non-email channel as an unsupported extension for now, not part of the intended design.

Call the Shopper Orders Endpoints 

All endpoints live under /organizations/{organizationId}/orders/{orderNo} in the Shopper Orders SCAPI family and require a SLAS ShopperToken.

Request an Access Code 

POST .../actions/request-access-code

1curl "https://{shortCode}.api.commercecloud.salesforce.com/checkout/shopper-orders/v1/organizations/{organizationId}/orders/{orderNo}/actions/request-access-code?siteId={siteId}" \
2  -X POST \
3  -H "Authorization: Bearer {access_token}" \
4  -H "Content-Type: application/json" \
5  -d '{ "email": "shopper@example.com" }'
FieldRequiredDescription
emailYesMust match the email on the order (exact, case-sensitive) for a code to be generated and sent.
ResponseMeaning
202Accepted. Always returned when the request is well-formed (see below).
400The email field is missing or empty.

Add expand=oms to include the integrated OMS. With expand=oms on the query string, email validation resolves the order from the integrated OMS when it isn’t found in B2C Commerce. Omit it and the code request considers only the B2C Commerce order store.

Note

Why the response is always 202. The endpoint returns 202 whether or not the order + email combination is valid, and whether or not a code was actually generated. This prevents an attacker from discovering valid order/email pairs, or probing cooldown state, by watching for response differences. Your storefront should always tell the shopper “If that order and email match, we’ve sent you a code,” never “code sent” vs. “no such order.”

Look Up the Order 

POST .../lookup

Full order access with the code uses the orderViewCode field (the same field the legacy, long-lived OrderViewCode used; the server tells the two apart by format, so both keep working).

Without Integrated OMS 

The lookup resolves the order from the B2C Commerce order store only. The shopper can view the order, but the response does not carry the OMS-sourced eligible quantities (quantityAvailableToCancel, quantityAvailableToReturn), and cancel/return are not available. Orders that no longer exist in B2C Commerce return 404.

1curl ".../orders/{orderNo}/lookup?siteId={siteId}" \
2  -X POST -H "Authorization: Bearer {access_token}" -H "Content-Type: application/json" \
3  -d '{ "orderViewCode": "847291" }'

With Integrated OMS (expand=oms) 

Add expand=oms to the query string. The lookup then resolves the order from the integrated OMS, including orders no longer present in B2C Commerce, and the response carries the per-item eligible quantities (quantityAvailableToCancel, quantityAvailableToReturn), so your storefront can show Cancel/Return only where the action is actually possible.

1curl ".../orders/{orderNo}/lookup?siteId={siteId}&expand=oms" \
2  -X POST -H "Authorization: Bearer {access_token}" -H "Content-Type: application/json" \
3  -d '{ "orderViewCode": "847291" }'

Intended flow: always load the order via lookup first, then offer cancel/return based on the eligibility in that response.

Note

Cancel an Order (Requires Integrated OMS) 

POST .../actions/oms-cancel-order

Cancel and return are OMS-backed and only work when your orders are integrated with an OMS. Both take the code in the orderAccessCode field (note: a different field name than lookup):

1curl ".../orders/{orderNo}/actions/oms-cancel-order?siteId={siteId}" \
2  -X POST -H "Authorization: Bearer {access_token}" -H "Content-Type: application/json" \
3  -d '{ "reason": "Changed mind", "orderAccessCode": "847291" }'
FieldRequiredDescription
reasonNoMust match a configured cancel reason code (see oms-meta-data). Server applies the default when omitted.
orderAccessCodeNoThe code. Only needed when the current ShopperToken doesn’t already grant access to the order.

Return Items (Requires Integrated OMS) 

POST .../actions/oms-return-order

1curl ".../orders/{orderNo}/actions/oms-return-order?siteId={siteId}" \
2  -X POST -H "Authorization: Bearer {access_token}" -H "Content-Type: application/json" \
3  -d '{
4        "productItems": [ { "itemId": "10uVF0000003W0BYAU", "quantity": 1.0, "reason": "Defect" } ],
5        "orderAccessCode": "847291"
6      }'
FieldRequiredDescription
productItemsYesItems to return. quantity must not exceed quantityAvailableToReturn.
productItems[].reasonNoMust match a configured return reason code. Server applies the default when omitted.
orderAccessCodeNoThe code. Only needed when the ShopperToken doesn’t already grant access.

Access Code Rules 

RuleValue
Code format6 numeric digits (000000999999)
Validity15 minutes from generation
Reusable within the windowYes, a successful lookup does not consume the code; the same code works for a following cancel/return
Failed-attempt limit3 wrong entries → the code is invalidated immediately
Regeneration cooldownA new code cannot be generated for 15 minutes after the last one, even if it was invalidated early by failed attempts
Single active codeOnly one valid code exists per order at any time

What this means:

  • If the shopper enters the wrong code 3 times, tell them to request a new code, but they must wait out the 15-minute cooldown before a new one can be issued.
  • A 404 is always a generic “order not found.” It never states why, so don’t parse it for a reason. Interpret it from the sequence of calls instead:
    • If earlier calls with the same code returned 200 and a later one suddenly returns 404, the shopper has lost access. The code expired mid-flow. Prompt them to request a new code.
    • If there was never a 200, the code is likely wrong or was invalidated early (for example, by too many failed attempts). Have the shopper re-check the code, or request a new one.
  • The code is delivered in the email body as plain text, not as a link. Don’t build magic-link flows around it. Putting the code in a URL would expose it in server, proxy, and browser logs.

Limitations 

  • Guest orders only. The platform rejects an access code submitted for an order owned by a registered customer. Registered shoppers use the standard login-based order access. There is no merchant configuration to change this today.
  • Email is the trust boundary. The code goes to whatever email is on the order. Anyone with access to that inbox (within 15 minutes) can access the order. This is by design and matches standard OTP delivery.
  • No feature toggle to “turn on.” There is no site preference to enable. The request-access-code endpoint is always live; the flow becomes usable the moment you register the sendOrderAccessCode hook. Without the hook, no code is delivered.
  • Cancel and return require an integrated OMS. Without OMS, the access code enables order lookup only. The oms-cancel-order and oms-return-order endpoints are OMS-backed; there is no non-OMS cancel/return path for this flow.
  • expand=oms is required to reach OMS-resolved orders. On request-access-code and lookup, the platform only consults the OMS when the request carries expand=oms. This is what lets email validation and lookup succeed against an OMS copy, including orders no longer present in B2C Commerce. Cancel and return are OMS-native and don’t take this parameter.

Best Practices 

  • The hook is not optional for this flow. No hook → no email → the shopper never gets a code. This is the single most common reason “the flow doesn’t work.”
  • Be careful with Status.ERROR. It breaks the uniform 202 and can leak whether an order/email was valid, so reserve it for genuine delivery failures. See Return Values for the full rationale.
  • Mind the two field names: orderViewCode on lookup, orderAccessCode on cancel/return.
  • Don’t message “no such order” on the request step. Always give the neutral “if it matches, we sent a code” response to preserve enumeration protection.
  • Localize inside your hook. The platform renders no content. The request’s locale is available to your storefront/hook logic; the message body, subject, and language are entirely yours to build.

See Also