Used Newie, our AI search tool in the docs? Take a 2-minute survey to rate your experience!

Tutorial: Setting up self-checkout in a store

Prev Next

The aim of this tutorial is to set up self-checkout in the New York City store that you created in the earlier tutorials. At the end, a shopper can scan a product on a browser-based web app driven self-checkout terminal, see the total amount, pay by card, and receive a receipt, and the order appears in Omnichannel Manager, in the Orders page for the store.

The example business has the following characteristics:

APIs in beta

The self-checkout APIs are in beta and can change. Validate your integration against the Self-checkout integration guide before you go live in production.

Important

Before you start this tutorial, ensure that you have:

NewStore does not provide a self-checkout frontend or hardware. In this tutorial, you build the frontend as a browser-based web app in TypeScript. For a native or desktop app the platform calls are identical. The login callback, the token handling, and the device lockdown differ. See Redirect targets by kind of client. The code examples show the platform calls the app makes. They do not cover the user interface, which you design for your business.

Limitations and rollout timeline

Self-checkout uses the Order placement API and not the order injection API (used by other NewStore tutorials). Self-checkout orders are cash-and-carry orders that have the channel.type as STORE and are handed over in the store itself. This removes the need for routing, shipping offer tokens, or fulfillment configuration for self-checkout. See Limitations.

NewStore records payments but does not process them. The payment backend you create captures the payment and places the order. Placed orders are displayed in Omnichannel Manager in the Orders page, but payment processing is asynchronous. This means that a successful order placement does not guarantee a successful payment transaction. Reconcile such scenarios with your payment service provider separately.

Planning the rollout

The phases shown in this table can run in parallel. Timelines are indicative and are applicable for 1 store and 1 self-checkout kiosk.

Phase

What happens

Completion criteria depends on

Indicative timeline

1. Provisioning

  • Raise the credentials ticket with NewStore Support.

  • Create the Application client in Omnichannel Manager for staging and production.

The tenant exists and the store store1_NYC is created.

3 days

2. Hardware

  • Order the tablet, enclosure, barcode scanner, receipt printer, and payment terminal.

  • Board the terminal on the PSP.

A PSP account and the merchant account for POS_USA are available.

1 to 2 weeks.

Note

Adyen live terminals need at least 1 week of lead time.

3. Build against the stub servers

  • Implement catalog lookup, promotions calculation, tax quotation, order placement, and receipt creation against the local stub servers.

  • Replace the payment step with a fake transaction that returns a successful result.

You have access to the stub server package from NewStore

2 days

4. Payment backend

Build the thin server backend that drives the payment terminal, against the PSP test environment with a test terminal and test cards.

A test terminal from phase 2 and PSP test credentials with the required role.

1 week

5. Staging

  • Switch the base URLs to the staging tenant and add the login flow.

  • Run the complete flow with the barcode 1000101000007.

  • Verify the order in Omnichannel Manager.

Phases 1, 3, and 4

2 to 3 days

6. Production pilot

  • Run one low-value transaction on the live terminal.

  • Verify the order in Omnichannel Manager and the payment in the PSP.

Phase 5, production credentials, and a live terminal.

1 day

7. Store readiness

  • Configure and lock down the kiosk.

  • Train the store manager on the opening and closing procedure.

  • Agree on process for incident contact with NewStore.

Phase 6

2 to 3 days

Order the hardware and raise the credentials ticket on the first day. Both have considerable lead time so starting early is recommended.

How a self-checkout transaction works

A self-checkout transaction in store1_NYC follows these steps. Each step uses the output of the previous one.

  1. Catalog lookup: The shopper scans the barcode 1000101000007. The app resolves it to the dress and its price via the Catalog API.

  2. Promotions calculation: After every change to the cart, the app submits the cart and any coupon code via the Promotions API. The response contains the discounted price of every unit.

  3. Tax quotation: After every change to the cart, the app requests totals for the discounted cart via the Tax Quotation API. The response contains the amount the shopper pays.

  4. Payment: The app sends that amount to the payment terminal through a thin backend that you own. The shopper taps their card.

  5. Order placement: The app places the order with the payment details via the Order Placement API.

  6. Receipt creation: The app creates the receipt via the Receipt API and shows the receipt link to the shopper.

Every monetary amount in every request and response is an integer in minor units. 99500 means USD 995.00. Never use floats for money, and never compute totals or discounts in the app. The Promotions API is the only source of discounts, and the Tax Quotation API is the only source of the amount the shopper pays. See Money handling.

Requesting self-checkout credentials

Self-checkout terminals authenticate with per-store credentials through a dedicated OAuth client. You create the client yourself in Omnichannel Manager. See Creating an API client in Omnichannel Manager. NewStore provisions the per-store credentials and the kiosk role. Raise a support ticket with NewStore Support and specify:

  • State that the credentials are for a self-checkout integration.

  • Provide your tenant name and the environments you need. Request both staging and production.

  • Name the store, in this tutorial store1_NYC.

  • Identify and provide a list of users who need the kiosk role. Request at least two per store, for example the store manager and a deputy or lead sales associate.

  • Provide a contact for your integration team.

Expand to view a sample self-checkout request credentials template for NewStore support.

Subject: Self-checkout credentials for <tenant> / <store_id>

Request type: Self-checkout integration (per-store credentials and kiosk role)

Tenant: <tenant name>
Environments: staging and production
Store IDs: store1_NYC
Users who need the kiosk role (name, email, role in store):
  - <store manager>
  - <lead sales associate>
Integration contact: <name, email>
Planned go-live date: <date>

Credentials you receive

  • A username and password per store. Credentials are provisioned per store, not per developer or per application, because each terminal operates on behalf of a store.

  • Your tenant name, used in all API base URLs and in the OpenID Connect (OIDC) discovery URL.

  • The client ID of the Application client you created in Omnichannel Manager.

Important

  • Do not reuse the client ID of Associate App or Omnichannel Manager for the self-checkout app. Tokens issued to the self-checkout client carry only the permissions that self-checkout needs.

  • Keep the self-checkout credentials separate from the credentials the store manager uses in Associate App, and do not commit them to version control.

Provisioning takes time. Raise the ticket before you start building. You can build and test a self-checkout flow against local stub servers while you wait. See Building against the local stub servers.

Note

The grant_type=password call to /v0/token used in the other NewStore tutorials does not work for the self-checkout setup. The kiosk logs in through the OAuth 2.0 Authorization Code flow with PKCE against the NewStore-hosted login page, using per-store credentials.

For more information, see Authentication and setup for self-checkout and Setting up provisioning of credentials.

Configuring the terminal

The store identity is configuration on the terminal, not something derived from the user who logs in. If you derive the store from the login and the user is later assigned to another store, orders silently move to that store. Set the following values on each terminal during initial setup, for example as environment variables or through your mobile device management (MDM) solution.

For store1_NYC, the values match the store you created in Tutorial: Setting up a store.

Variable

Value for store1_NYC

Used in

NEWSTORE_BASE_URL

https://{your-tenant}.{env}.newstore.net

All API calls. Use http://localhost:450x for the stub servers.

STORE_ID

store1_NYC

Order placement and receipt

STORE_LABEL

001_US_ABC_Street

Receipt

STORE_TIMEZONE

America/New_York

Receipt and processed_at timestamps

STORE_ADDRESS_LINE_1

12 ABC Street

Tax quotation and receipt

STORE_CITY

New York

Tax quotation and receipt

STORE_STATE

NY

Tax quotation and receipt

STORE_ZIP

10001

Tax quotation and receipt

STORE_COUNTRY

US

Tax quotation and receipt

CATALOG_ID

storefront-catalog-en

Catalog lookup and order placement

PRICEBOOK

default

Catalog lookup

CURRENCY

USD

Tax quotation, order placement, and receipt

LOCALE

en-US

Catalog lookup, order placement, and receipt

AUTH_CLIENT_ID

From the Application client in Omnichannel Manager

Login

AUTH_REDIRECT_URI

A redirect target of the API client

Login

OIDC_DISCOVERY_URL

https://id.{env}.newstore.net/auth/realms/{your-tenant}/.well-known/openid-configuration

Login. {env} is s for staging and p for production.

Use the store's real address, even during local development. The tax provider resolves the tax jurisdiction from it and rejects a placeholder address with 422.

This tutorial uses tax-excluded pricing, the usual model for a US store - The shelf price of USD 995.00 is pre-tax, and NewStore adds sales tax at checkout. Ensure that the tax_included setting of the store and the price book match the tax method you send in the tax quotation and the order.

Treat these values as part of the terminal image. If a terminal moves to another store, reconfigure the terminal. Logging in again is not enough.

Run the app in a kiosk mode that prevents shoppers from opening other tabs, using the address bar, reaching developer tools, or reloading the page. Manage the device through your MDM solution.

Aligning the tax model

The tax quotation and the order use the same tax method value, TAX_EXCLUDED for this store. That value must match how the store and its pricebook are configured. If it does not match, the platform quotes tax on top of a price that already contains it, or the reverse, and the shopper is mischarged. Check your store’s configuration and update it using the Store API. Also ensure that the pricebook includes the right prices that include or exclude tax as required for your self-checkout setup.

For this store (store1_NYC), since it is in the US, the tax_included property is set to false.

Note

For a store where item prices already include tax, keep tax_included set to true, and send TAX_INCLUDED in the tax quotation, the order and the receipt instead of the values shown in this tutorial.

Creating the coupon in Omnichannel Manager

The example business offers 10% off the whole cart to shoppers who enter the coupon code WELCOME10 at the kiosk. Create the coupon in Omnichannel Manager before you test the kiosk against your tenant.

  1. Go to Promotions > Coupons and click Add Coupon.

  2. In Name, enter Welcome 10% off. This name is printed on the receipt as the discount description.

  3. Set Start date to the current date, and select All customers.

  4. In Discount Type, select Cart, and select USD in Currencies.

  5. In Discount details, select Percentage and enter 10 in Discount value.

  6. In Restrictions, select Apply only to specific stores and select the store1_NYC store.

  7. Add the coupon code WELCOME10 to the coupon, save the coupon, and enable it. The status of the coupon changes to Running.

For the complete procedure and the other coupon types, see Creating a coupon for the whole cart.

To check the coupon before you use it at the kiosk, go to Promotions > Cart Simulator, and simulate the promotion in Omnichannel Manager. See Simulating promotions in a cart.

Note

Automatic promotions need no code to be entered by the shopper. The Promotions API returns them in the same response shape, without coupon_codes in the request, so the kiosk flow in this tutorial is valid. See Creating automatic promotions.

Building against the local stub servers

NewStore provides local stub servers that mirror the five self-checkout APIs. They accept requests without an Authorization header, so you can build and test the complete flow before your credentials arrive. Request access to the stub server package from your point of contact at NewStore.

Start the stub servers:

bash scripts/stubs/start-stubs.sh

Point the app at the stubs by setting the base URLs to http://localhost:4501 (catalog), http://localhost:4502 (tax), http://localhost:4503 (orders), http://localhost:4504 (receipts), and http://localhost:4505 (promotions). Confirm the catalog stub is running:

GET http://localhost:4501/catalogs/storefront-catalog-en/products?id=0123456789012&id_type=ean13&pricebook=default
Accept-Language: en-US

The catalog stub does not know the dress 1000101. It recognizes three demo barcodes, for example 0123456789012 for Organic Bananas at 129 (USD 1.29). Use the demo barcodes while you build against the stubs, and use 1000101000007 when you switch to your staging tenant. The request and response shapes are identical, so the code you write in the following sections works against both. The promotions stub recognizes the coupon WELCOME10 used in this tutorial, so the coupon flow also works against the stubs, at the demo prices.

Because the app runs on a different port than the stubs, the browser blocks the requests unless you route them through a same-origin proxy. Most bundlers provide one. For the demo barcodes, the cross-origin options, and the full list of environment variables, see Running a self-checkout integration locally.

Logging in on the terminal

Against your staging or production tenant, every API call carries an access token. The app obtains it through the OAuth 2.0 Authorization Code grant with Proof Key for Code Exchange (PKCE). No client secret is stored in the browser. The Resource Owner Password Credentials grant does not work with this platform.

At app startup, fetch the OIDC discovery document instead of hardcoding the endpoints:

const discovery = await fetch(process.env.OIDC_DISCOVERY_URL!).then(r => r.json());
const authorizationEndpoint: string = discovery.authorization_endpoint;
const tokenEndpoint: string = discovery.token_endpoint;

When the store manager selects Log in, generate the PKCE values and redirect to the NewStore-hosted login page:

function base64url(bytes: ArrayBuffer): string {
  return btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
const codeChallenge = base64url(
  await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)),
);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)).buffer);
const nonce = base64url(crypto.getRandomValues(new Uint8Array(16)).buffer);
sessionStorage.setItem('pkce', JSON.stringify({ codeVerifier, state, nonce }));

const params = new URLSearchParams({
  response_type: 'code',
  client_id: process.env.AUTH_CLIENT_ID!,
  redirect_uri: process.env.AUTH_REDIRECT_URI!,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
  scope: 'openid',
  state,
  nonce,
});
window.location.assign(`${authorizationEndpoint}?${params}`);

The store manager enters the provisioned username and password for store1_NYC. The authorization server redirects to your callback URL with a code and the state. Exchange the code for tokens, check the nonce in the id_token, keep the tokens in memory only, and remove the parameters from the URL:

let accessToken: string | undefined;
let refreshToken: string | undefined;
let accessTokenExpiresAt = 0;

async function handleCallback() {
  const url = new URL(window.location.href);
  const { codeVerifier, state, nonce } = JSON.parse(sessionStorage.getItem('pkce') ?? '{}');
  if (url.searchParams.get('state') !== state) throw new Error('state mismatch');

  const res = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: url.searchParams.get('code')!,
      redirect_uri: process.env.AUTH_REDIRECT_URI!,
      code_verifier: codeVerifier,
      client_id: process.env.AUTH_CLIENT_ID!,
    }),
  });
  const tokens = await res.json();
  const claims = JSON.parse(atob(tokens.id_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
  if (claims.nonce !== nonce) throw new Error('nonce mismatch');
  accessToken = tokens.access_token;
  refreshToken = tokens.refresh_token;
  accessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;   // refresh shortly before this

  sessionStorage.removeItem('pkce');
  history.replaceState({}, document.title, window.location.pathname);
}

Attach the token to every platform call as Authorization: Bearer {accessToken}. Refresh it proactively from expires_in rather than waiting for a 401 in the middle of a transaction. The access token lasts five minutes and the refresh token the trading day, so the app refreshes about every four minutes without any action from staff. See Choosing the token lifetimes. If a refresh fails, return to the login screen.

Important

  • Store the access and refresh tokens in a module-scoped variable, never in localStorage, sessionStorage, or cookies. Only the short-lived PKCE verifier and state are held in sessionStorage across the redirect.

  • Reject any callback whose state does not match the value you sent.

  • Your app must serve index.html at the callback path so your client-side router handles the redirect instead of returning a 404.

  • Never log tokens. Redact the Authorization header in request logging.

For the full flow, token lifetimes, and the 401 handling procedure, see Authentication and setup for self-checkout.

Scanning the product

The shopper scans the dress. Resolve the barcode with the Catalog API. Pass the barcode as id, set id_type to ean13, and include pricebook so that the response contains a price. The Accept-Language header is required.

GET /catalogs/storefront-catalog-en/products?id=1000101000007&id_type=ean13&pricebook=default
Accept-Language: en-US
Authorization: Bearer {accessToken}

Example response:

{
  "products": [
    {
      "id": "1000101",
      "title": "Bohemian Print Cutout Chiffon Maxi Dress",
      "is_available": true,
      "images": [
        { "url": "https://mycompany/hoster/images/default/products/SU16/10001_014_Multi_OFR-1.jpg", "is_main": true }
      ],
      "external_identifiers": { "sku": "3825952358", "ean13": "1000101000007" },
      "tax_class": "standard",
      "price": { "pricebook": "default", "amount": 99500, "currency": "USD" }
    }
  ],
  "pagination": { "has_next_page": false, "total_count": 1 },
  "messages": null
}

The price 99500 is USD 995.00 in minor units. The tax_class is a required top-level field. Pass its value to the Tax Quotation API unchanged. An empty string is valid: it means the product has no tax class and the tax provider applies its default.

Add the product to the cart only when the lookup succeeds and the product is available:

interface CartItem {
  productId: string;
  name: string;
  unitPriceMinor: number;
  taxClass: string;
  quantity: number;
}

async function scan(barcode: string): Promise<CartItem> {
  const url = `${BASE_URL}/catalogs/${CATALOG_ID}/products?id=${barcode}&id_type=ean13&pricebook=${PRICEBOOK}`;
  const res = await fetch(url, {
    headers: { 'Accept-Language': LOCALE, Authorization: `Bearer ${accessToken}` },
  });
  const data = await res.json();

  const product = data.products?.[0];
  if (!product) throw new Error('Product not found');        // products is [] or null, status is 200
  if (!product.is_available) throw new Error('Product unavailable');

  const taxClass: string = product.tax_class;   // opaque to the app; "" means the provider default applies

  return {
    productId: product.id,
    name: product.title,          // needed later for the receipt
    unitPriceMinor: product.price.amount,
    taxClass,
    quantity: 1,
  };
}

Note

  • A barcode that is not in the catalog returns 200 with an empty or null products array, not a 404. Show Product not found to the shopper and leave the cart unchanged.

  • Pass tax_class through as it is, including an empty string. Do not substitute a class of your own.

  • Keep the product title in the cart. The receipt requires the product name, and the order payload does not carry it.

Applying the coupon

The shopper enters WELCOME10 in a coupon field on the kiosk. Before you quote the totals, verify that the platform submits the cart and the code to the Promotions API. The response contains the discounted price of every unit and the discount that produced it.

Important

Call the API after every change to the cart, whether or not the shopper entered a code.

Automatic promotions apply without a code, and a cart with no promotion comes back with discounted_price equal to the catalog price and an empty discounts array, so the flow is no broken.

Send one line_items entry per physical unit, with:

  • The line_item_id values that you later send on the order,

  • The catalog price in price.price, and

  • product_categories: [].

Do not send list_price, customer, or manual discounts. The session_ref identifies the shopper's cart session and stays the same for every call of the same cart, unlike the idempotency key of the order, which changes with every placement attempt. In this tutorial, the session is 7c1d3e2a.

POST /promotions/calculations
Content-Type: application/json
Authorization: Bearer {accessToken}
{
  "session_ref": "7c1d3e2a",
  "currency": "USD",
  "channel": { "type": "STORE" },
  "store": { "id": "store1_NYC", "timezone": "America/New_York" },
  "catalog": { "name": "storefront-catalog-en", "locale": "en-US" },
  "coupon_codes": ["WELCOME10"],
  "line_items": [
    { "id": "1000101-unit-1", "product_id": "1000101", "price": { "price": 99500 }, "product_categories": [] },
    { "id": "1000101-unit-2", "product_id": "1000101", "price": { "price": 99500 }, "product_categories": [] }
  ]
}

Example response:

{
  "line_items": [
    {
      "id": "1000101-unit-1",
      "discounted_price": 89550,
      "manual_discount_forbidden": false,
      "discounts": [
        { "type": "PERCENTAGE", "id": "9f1c2b7e-3d4a-4c5b-8e6f-7a8b9c0d1e2f", "description": "Welcome 10% off", "scope": "ORDER", "source": "COUPON", "coupon_code": "WELCOME10", "rate": 10, "applied_amount": 9950 }
      ]
    },
    {
      "id": "1000101-unit-2",
      "discounted_price": 89550,
      "manual_discount_forbidden": false,
      "discounts": [
        { "type": "PERCENTAGE", "id": "9f1c2b7e-3d4a-4c5b-8e6f-7a8b9c0d1e2f", "description": "Welcome 10% off", "scope": "ORDER", "source": "COUPON", "coupon_code": "WELCOME10", "rate": 10, "applied_amount": 9950 }
      ]
    }
  ],
  "shipping_options": [],
  "messages": []
}

From the response:

  • discounted_price is the price to charge for each unit: 89550, or USD 895.50. Display it, and pass it to the tax quotation as the item_price. Do not recalculate it from applied_amount in the self-checkout app.

  • The coupon applies to the whole cart, so its scope is ORDER and it appears on every unit with the same id. To display it once on the kiosk, group the entries by id and sum applied_amount: Welcome 10% off, USD 199.00.

  • id is the identifier of the coupon on the platform. Copy it as returned onto the order and the receipt.

  • messages is always present. Empty is defined as every code applied. A code that does not apply to the cart does not fail the request.

If the shopper mistypes the code, the response contains no discounts and a message that points to the code through its path in your request:

{
  "line_items": [
    { "id": "1000101-unit-1", "discounted_price": 99500, "manual_discount_forbidden": false, "discounts": [] },
    { "id": "1000101-unit-2", "discounted_price": 99500, "manual_discount_forbidden": false, "discounts": [] }
  ],
  "shipping_options": [],
  "messages": [
    { "type": "warning", "code": "discount_code_invalid", "content": "Coupon WELCOME1O is not valid for this cart.", "path": "$.coupon_codes[0]" }
  ]
}

Display a message such as This code is not valid next to the coupon field, and keep the cart as it is. Display it only when messages says so.

Note

If the promotion engine is unreachable, the service can return 200 with no discounts and no messages, which looks exactly like a cart that matches nothing. Log a response that carries no discounts for a cart with a coupon code, but do not tell the shopper that the code is invalid.

interface UnitDiscounts {
  discountedPriceMinor: number;   // the price to charge for this unit
  discounts: any[];               // forwarded to the order and the receipt
}

async function applyPromotions(cart: CartItem[], couponCodes: string[]): Promise<Map<string, UnitDiscounts>> {
  const line_items = cart.flatMap(item =>
    Array.from({ length: item.quantity }, (_, i) => ({
      id: `${item.productId}-unit-${i + 1}`,        // the line_item_id you send on the order
      product_id: item.productId,
      price: { price: item.unitPriceMinor },          // the catalog price; no list_price at a kiosk
      product_categories: [],
    })),
  );
  const res = await fetch(`${BASE_URL}/promotions/calculations`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
    body: JSON.stringify({
      session_ref: sessionId,                           // constant for the shopper's cart session
      currency: CURRENCY,
      channel: { type: 'STORE' },
      store: { id: STORE_ID, timezone: STORE_TIMEZONE },
      catalog: { name: CATALOG_ID, locale: LOCALE },
      coupon_codes: couponCodes,
      line_items,
    }),
  });
  if (!res.ok) throw new Error('Promotions unavailable');   // continue at catalog prices or block: your decision
  const data = await res.json();
  return new Map(data.line_items.map((u: any) => [u.id, { discountedPriceMinor: u.discounted_price, discounts: u.discounts }]));
}

// The order and the receipt take the same discount object as promotions returned it, with only source dropped.
function forwardDiscounts(discounts: any[]) {
  return discounts.map(({ source, ...rest }) => rest);
}

If the Promotions API returns a 503, retry once. If it still fails, or returns 500, decide for your business whether the kiosk continues at catalog prices and notifies the shopper that discounts are unavailable, or blocks the checkout flow. See Promotions in the integration guide.

Quoting the totals

In this example, the shopper scans the same dress twice, so the cart holds product 1000101 with a quantity of 2. The promotions calculation left both units at 89550, so they form one quotation item. After every change to the cart, and after the promotions calculation, request totals from the Tax Quotation API. The quotation has no side effects and stores nothing, so you can call it after every scan.

Use the standard quotation for a live scan. The reverse quotation back-calculates tax from amounts already charged to the shopper and is not needed for self-checkout.

Each item needs an id that is unique within the request, the tax_class from the catalog, the tax_method, the discounted price of one unit in minor units from the promotions calculation, the quantity, the type, the currency, and the store address as both destination and origin. The optional product object with id, name, and sku is passed to the tax provider for its audit trail. The optional store-id header identifies the store to the tax service.

POST /taxes/standard-quotations
Content-Type: application/json
Authorization: Bearer {accessToken}
store-id: store1_NYC
{
  "items": [
    {
      "id": "1000101",
      "product": { "id": "1000101", "name": "Bohemian Print Cutout Chiffon Maxi Dress", "sku": "3825952358" },
      "tax_class": "standard",
      "tax_method": "TAX_EXCLUDED",
      "item_price": 89550,
      "quantity": 2,
      "type": "product",
      "currency": "USD",
      "destination": {
        "address_line_1": "12 ABC Street",
        "city": "New York",
        "state": "NY",
        "zip_code": "10001",
        "country_code": "US"
      },
      "origin": {
        "address_line_1": "12 ABC Street",
        "city": "New York",
        "state": "NY",
        "zip_code": "10001",
        "country_code": "US"
      }
    }
  ]
}

Use TAX_EXCLUDED because the shelf price in store1_NYC is pre-tax. For a store where the shelf price already includes tax, use TAX_INCLUDED.

Expand this section to view the sample response. The amounts come from the tax provider configured for your tenant and differ from the ones shown here.

{
  "items": [
    {
      "id": "1000101",
      "price": {
        "gross": 194995,
        "net": 179100,
        "tax": 15895,
        "tax_lines": [
          { "tax_name": "Sales tax", "rate": 0.08875, "amount": 15895, "country_code": "US" }
        ]
      }
    }
  ],
  "totals": {
    "net_total": 179100,
    "subtotal": 179100,
    "tax_total": 15895,
    "grand_total": 194995,
    "discount_total": 0,
    "shipping_total": 0,
    "shipping_tax_amount": 0,
    "tax_strategy": "avalara",
    "tax_lines_summary": [
      { "tax_name": "Sales tax", "rate": 0.08875, "amount": 15895, "country_code": "US" }
    ]
  },
  "messages": []
}

From the response:

  • totals.grand_total is what the shopper pays: 194995, or USD 1,949.95. The discount is already inside the quoted prices, so discount_total stays 0. Display it and pass it to the payment terminal and the order unchanged.

  • items[0].price.tax is the tax for all units of the line, not for one unit. Keep it. You need it to build the order.

  • Response items carry only your id and the calculated price. Match them back to the cart on id.

  • messages is always present. A warning entry means the configured tax provider failed and a fallback produced the totals. The totals are usable, but log the message.

const STORE_ADDRESS = {
  address_line_1: process.env.STORE_ADDRESS_LINE_1,
  city: process.env.STORE_CITY,
  state: process.env.STORE_STATE,
  zip_code: process.env.STORE_ZIP,
  country_code: process.env.STORE_COUNTRY,
};

async function quote(cart: CartItem[], units: Map<string, UnitDiscounts>) {
  const res = await fetch(`${BASE_URL}/taxes/standard-quotations`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, 'store-id': STORE_ID },
    body: JSON.stringify({
      items: cart.map(item => ({
        id: item.productId,
        product: { id: item.productId, name: item.name },
        tax_class: item.taxClass,
        tax_method: 'TAX_EXCLUDED',
        item_price: units.get(`${item.productId}-unit-1`)!.discountedPriceMinor,   // all units of this line share one discounted price
        quantity: item.quantity,
        type: 'product',
        currency: CURRENCY,
        destination: STORE_ADDRESS,
        origin: STORE_ADDRESS,
      })),
    }),
  });
  if (!res.ok) throw new Error('Totals unavailable, retry');   // do not proceed to payment
  return res.json();
}

If the Tax Quotation API is unreachable, display a retry prompt and do not proceed to payment. This avoids moving on to payment with a wrong total amount.

If a promotion converts the units of one line at different prices, for example a fixed order-level discount whose remainder is on one unit, send one quotation item per price. See Building the quotation from units.

Taking the payment

NewStore records the payment on the order but does not process it. The app charges exactly totals.grand_total to the payment terminal through your payment service provider (PSP), and places the order only after the terminal reports success. If the terminal declines, no order is placed and nothing is recorded on the platform.

PSP API keys are secrets and must never reach the browser. Route the payment step through a thin backend that you own. The kiosk app sends the amount to your backend, your backend drives the terminal via the PSP cloud API, and the result flows back to the app. This is the only part of the self-checkout architecture that needs a server.

Whichever provider you use, the app needs the following from the backend to build the order:

interface PaymentResult {
  reference: string;       // the PSP transaction reference
  processed_at: string;    // RFC 3339 with timezone offset
  brand: string;           // for example "Visa"
  last_digits: string;     // for example "4242"
}

async function pay(amountMinor: number): Promise<PaymentResult> {
  const res = await fetch('/api/payments', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount: amountMinor, currency: CURRENCY }),
  });
  if (!res.ok) throw new Error('Payment declined or failed');
  return res.json();
}

Select the tab for your payment provider.

Use the Adyen cloud Terminal API in synchronous mode. Your backend sends a SaleToPOIRequest to Adyen, Adyen pushes it to the terminal NYC_1, and the response returns when the shopper completes or cancels the payment.

Before the first payment, ensure that:

  • The terminal NYC_1 is assigned to store1_NYC and boarded on the merchant account POS_USA. See Tutorial: Setting up Adyen in a store.

  • You have enabled Terminal API in the Adyen Customer Area for POS_USA.

  • You have an Adyen API credential with the Cloud Device API role. The credential created in Tutorial: Setting up Adyen in a store has only the POS Terminal Management API role, which is not sufficient for the self-checkout kiosk backend. In the Adyen Customer Area, either add the Cloud Device API role to that credential or create a second credential for self-checkout with that role. Use a test credential against the Adyen test environment first.

  • The terminal NYC_1 communicates with Adyen over cloud. If it was only set up for local communication, go to the Adyen Customer Area to enable cloud communication for the terminal.

  • On the device, Settings > Diagnostics displays both PSP and Terminal API as successful.

  • You know the terminal's POIID, in the format <Model>-<Serial>, for example e285-XXXXXXXX.

Configure the backend with:

Variable

Value

ADYEN_API_KEY

API key with the Cloud Device API role. Server-side only.

ADYEN_MERCHANT_ACCOUNT

POS_USA

ADYEN_TERMINAL_POIID

e285-XXXXXXXX

ADYEN_ENVIRONMENT

test or live

NewStore uses integer minor units, and the Adyen Terminal API takes decimal major units in RequestedAmount. A NewStore amount of 194995 is 1949.95 for Adyen. Convert exactly once, at the PSP boundary, in the backend:

// backend: POST /api/payments
const requestedAmount = amount / 100;     // 194995 -> 1949.95, USD has 2 minor-unit digits

const saleToPOIRequest = {
  SaleToPOIRequest: {
    MessageHeader: {
      ProtocolVersion: '3.0', MessageClass: 'Service', MessageCategory: 'Payment', MessageType: 'Request',
      ServiceID: serviceId, SaleID: 'SelfCheckout-store1_NYC', POIID: process.env.ADYEN_TERMINAL_POIID,
    },
    PaymentRequest: {
      SaleData: {
        SaleTransactionID: { TransactionID: transactionId, TimeStamp: new Date().toISOString() },
        SaleToAcquirerData: `merchantAccount=${process.env.ADYEN_MERCHANT_ACCOUNT}`,
      },
      PaymentTransaction: { AmountsReq: { Currency: currency, RequestedAmount: requestedAmount } },
    },
  },
};

When the terminal response reports Result: "Success", return the following to the app: the value of PaymentResult.PaymentAcquirerData.AcquirerTransactionID.TransactionID as reference, and the card brand and last four digits from the response. The order instrument also needs the merchant account POS_USA as provider_account_id. Without it, the platform accepts the order but payment processing fails afterward with missing_merchant_account.

With NewStore Payments, Stripe powers the payment infrastructure and NewStore manages the merchant account. Your backend uses the Stripe Terminal API to drive a reader such as the Stripe S700 that is registered to store1_NYC.

NewStore Payments uses a Stripe connected account per store. Create the PaymentIntent on the store's connected account, not on the platform account. If you create the intent on the platform account, the terminal payment appears to succeed, but the order fails in Omnichannel Manager with a failed authorization error.

// backend: POST /api/payments
const intent = await stripe.paymentIntents.create(
  { amount, currency: currency.toLowerCase(), payment_method_types: ['card_present'], capture_method: 'automatic' },
  { stripeAccount: process.env.STRIPE_CONNECTED_ACCOUNT_ID },   // the store's connected account
);
await stripe.terminal.readers.processPaymentIntent(
  process.env.STRIPE_READER_ID!,
  { payment_intent: intent.id },
  { stripeAccount: process.env.STRIPE_CONNECTED_ACCOUNT_ID },
);
// Poll the reader or listen for the payment_intent.succeeded webhook, then return:
// { reference: intent.id, processed_at, brand, last_digits }

Stripe amounts are already integer minor units, so no conversion is needed. Return the PaymentIntent ID as reference.

Stripe SDKs default to a simulated reader in development. If the app shows failed to fetch or the physical reader never reacts, check that the integration is registered against the real reader and the simulator is disabled.

There is no payment terminal stub in the local stub server package. While you build against the stubs, replace the backend call with a fake that returns a successful PaymentResult. Then test against the PSP test environment with a boarded test terminal and test cards before you move to staging. For the details of both integrations and payment-specific troubleshooting, see Integrating payments for self-checkout.

Placing the order

After the terminal reports success, place the order with the Order Placement API. The order contains one line item per physical unit, so the two dresses become two line items. There is no quantity field.

The tax quotation returned 15895 as the tax for the whole line. Distribute it across the two units so that the parts sum back to 15895 exactly. Minor units are indivisible, so put the remainder on one unit rather than rounding it away: 7948 on the first unit and 7947 on the second. The platform rejects an order whose payment amount does not equal the sum of all line charges and taxes, where the charge of a line is its list_price minus its discounts.

function expandToLineItems(cart: CartItem[], quotation: any, units: Map<string, UnitDiscounts>) {
  const items = [];
  for (const item of cart) {
    const line = quotation.items.find((l: { id: string }) => l.id === item.productId);
    const totalTax: number = line.price.tax;
    const taxPerUnit = Math.floor(totalTax / item.quantity);
    const remainder = totalTax % item.quantity;
    for (let unit = 0; unit < item.quantity; unit++) {
      const lineItemId = `${item.productId}-unit-${unit + 1}`;
      items.push({
        line_item_id: lineItemId,
        product_id: item.productId,
        price: {
          list_price: item.unitPriceMinor,                       // the catalog price; there is no price field
          tax: unit === 0 ? taxPerUnit + remainder : taxPerUnit,
          discounts: forwardDiscounts(units.get(lineItemId)!.discounts),
        },
      });
    }
  }
  return items;
}

Each line carries list_price, the catalog price 99500, and the discounts forwarded from the promotions calculation unchanged, with only source dropped. There is no price field. The platform derives the charge as list_price minus the discounts, 89550 per unit, which is the price the quotation taxed. Send placed_at, the time the order was placed on the terminal, as an RFC 3339 date-time with a timezone offset. Capture it once, immediately before the call, and reuse the same value for the receipt.

Send an idempotency-key header. The key identifies one placement attempt. If a network error prevents you from receiving the response, resend the identical request with the same key and the platform replays the original order instead of placing a second one. The key must change whenever the payload changes, so derive it as {sessionId}-{attemptNumber} rather than from the cart session alone.

POST /orders
Content-Type: application/json
Authorization: Bearer {accessToken}
idempotency-key: 7c1d3e2a-store1_NYC-1
{
  "channel": { "name": "self_checkout", "type": "STORE", "store_id": "store1_NYC" },
  "catalog": { "name": "storefront-catalog-en", "locale": "en-US" },
  "placed_at": "2026-09-09T14:32:18-04:00",
  "pricing": { "currency": "USD", "method": "TAX_EXCLUDED" },
  "line_items": [
    {
      "line_item_id": "1000101-unit-1",
      "product_id": "1000101",
      "price": {
        "list_price": 99500,
        "tax": 7948,
        "discounts": [
          { "type": "PERCENTAGE", "id": "9f1c2b7e-3d4a-4c5b-8e6f-7a8b9c0d1e2f", "description": "Welcome 10% off", "scope": "ORDER", "coupon_code": "WELCOME10", "rate": 10, "applied_amount": 9950 }
        ]
      }
    },
    {
      "line_item_id": "1000101-unit-2",
      "product_id": "1000101",
      "price": {
        "list_price": 99500,
        "tax": 7947,
        "discounts": [
          { "type": "PERCENTAGE", "id": "9f1c2b7e-3d4a-4c5b-8e6f-7a8b9c0d1e2f", "description": "Welcome 10% off", "scope": "ORDER", "coupon_code": "WELCOME10", "rate": 10, "applied_amount": 9950 }
        ]
      }
    }
  ],
  "fulfillments": [
    {
      "line_item_ids": ["1000101-unit-1", "1000101-unit-2"],
      "method": { "service_level_identifier": "IN_STORE_HANDOVER", "price": 0, "tax": 0 }
    }
  ],
  "payment": {
    "instruments": [
      {
        "id": "GDQVVVSCRVC5VP75",
        "type": "CARD",
        "amount": 194995,
        "provider": "adyen",
        "provider_account_id": "POS_USA",
        "status": "CAPTURED",
        "processed_at": "2026-09-09T14:32:15-04:00",
        "reference": "GDQVVVSCRVC5VP75",
        "origin": "terminal",
        "brand": "Visa",
        "last_digits": "4242"
      }
    ]
  },
  "notifications": { "is_preconfirmed": true, "language": "en" }
}

In this request:

  • pricing.method is TAX_EXCLUDED, matching the tax method of the quotation.

  • Each line carries the catalog list_price and the coupon in discounts, with the same id on both units. The platform derives the charge of 89550 per unit from them.

  • placed_at is the time the order was placed on the terminal, captured once and reused on the receipt.

  • Every line_item_id appears in exactly one fulfillment. For in-store handover, the fulfillment price and tax are both 0.

  • The instrument amount is totals.grand_total from the quotation, copied unchanged. The status is CAPTURED because the terminal has already taken the money. The instrument id is unique within the order, and the PSP reference is a natural value for it.

  • For Adyen, provider is adyen and provider_account_id is the merchant account POS_USA. For NewStore Payments, set provider to newstore, use the PaymentIntent ID as reference, and omit provider_account_id.

  • notifications.is_preconfirmed is true because the kiosk shows its own confirmation screen and the platform must not message the shopper a second time.

  • Do not send an order-level id or line-item id. The platform generates both.

Payment instrument fields (Beta)

The payment instrument contract is in beta. The Order Placement API declares brand, last_digits, and provider_account_id as typed fields on the instrument, and provider_account_id is required when provider is adyen. Do not put these values in metadata. The only well-known metadata key is customer_receipt, which carries the raw receipt block returned by an Adyen terminal, unchanged. See Payment instruments in the integration guide, and validate the instrument shape against the Order Placement API specification before you go live.

Example response:

{
  "id": "2a0295b0-50a8-459c-82cd-5079d3f35bfb",
  "order_number": "GD000067894",
  "items": [
    { "product_id": "1000101", "line_item_id": "1000101-unit-1", "id": "0f9a2c44-6b1e-4d7a-9c3e-1a2b3c4d5e01" },
    { "product_id": "1000101", "line_item_id": "1000101-unit-2", "id": "0f9a2c44-6b1e-4d7a-9c3e-1a2b3c4d5e02" }
  ]
}

A 201 means the order is accepted. Payment processing completes asynchronously. Show order_number to the shopper, and keep id and the items[].id values for the receipt. Keep the cart too: the receipt is built from a snapshot of the whole transaction.

Important

A 400 or 422 from this endpoint arrives after the shopper's money has moved. Validate the payload before you charge the terminal: required fields present, every instrument has an id, and the instrument amounts sum to totals.grand_total. Treat a 400 or 422 after payment as a call staff state, not as something to retry. See Error handling.

Creating the receipt

Placing an order does not generate a receipt. Use the Receipt API to create the receipt document from a snapshot of the transaction. The response contains the receipt URL.

The snapshot reuses the shapes of the order. Add the product name to each line item, the platform item ID from the order response as unit_uuid, the order identifiers, the same placed_at that you sent on the order, and the store details from the terminal configuration. The receipt tender does not declare the placement-only fields id, reference, status, origin, and provider_account_id, so send the tender without them, as in the example.

POST /receipts
Content-Type: application/json
Authorization: Bearer {accessToken}
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7

Expand to view the receipt JSON example.

{
  "type": "SALES_RECEIPT",
  "order_id": "2a0295b0-50a8-459c-82cd-5079d3f35bfb",
  "order_number": "GD000067894",
  "placed_at": "2026-09-09T14:32:18-04:00",
  "locale": "en-US",
  "pricing": { "currency": "USD", "method": "TAX_EXCLUDED" },
  "line_items": [
    {
      "line_item_id": "1000101-unit-1",
      "unit_uuid": "0f9a2c44-6b1e-4d7a-9c3e-1a2b3c4d5e01",
      "product": { "id": "1000101", "name": "Bohemian Print Cutout Chiffon Maxi Dress" },
      "price": {
        "list_price": 99500,
        "tax": 7948,
        "discounts": [
          { "type": "PERCENTAGE", "id": "9f1c2b7e-3d4a-4c5b-8e6f-7a8b9c0d1e2f", "description": "Welcome 10% off", "scope": "ORDER", "coupon_code": "WELCOME10", "rate": 10, "applied_amount": 9950 }
        ]
      }
    },
    {
      "line_item_id": "1000101-unit-2",
      "unit_uuid": "0f9a2c44-6b1e-4d7a-9c3e-1a2b3c4d5e02",
      "product": { "id": "1000101", "name": "Bohemian Print Cutout Chiffon Maxi Dress" },
      "price": {
        "list_price": 99500,
        "tax": 7947,
        "discounts": [
          { "type": "PERCENTAGE", "id": "9f1c2b7e-3d4a-4c5b-8e6f-7a8b9c0d1e2f", "description": "Welcome 10% off", "scope": "ORDER", "coupon_code": "WELCOME10", "rate": 10, "applied_amount": 9950 }
        ]
      }
    }
  ],
  "fulfillments": [
    {
      "line_item_ids": ["1000101-unit-1", "1000101-unit-2"],
      "method": { "service_level_identifier": "IN_STORE_HANDOVER", "price": 0, "tax": 0 }
    }
  ],
  "payment": {
    "instruments": [
      {
        "type": "CARD",
        "amount": 194995,
        "provider": "adyen",
        "processed_at": "2026-09-09T14:32:15-04:00",
        "brand": "Visa",
        "last_digits": "4242"
      }
    ]
  },
  "store": {
    "id": "store1_NYC",
    "label": "001_US_ABC_Street",
    "timezone": "America/New_York",
    "address": {
      "address_line_1": "12 ABC Street",
      "city": "New York",
      "state": "NY",
      "zip_code": "10001",
      "country": "US"
    },
    "phone_number": "202-555-0138"
  }
}

Example response:

{
  "id": "9b8e5c1d-4f3a-4e2b-8d7c-6a5b4c3d2e10",
  "permanent_link": "https://{your-tenant}.{env}.newstore.net/v0/d/documents/b90e8807-4165-4a55-acb6-11a165d34d2d"
}

permanent_link is the receipt URL. The document ID in the URL is not the receipt ID. Show the URL to the shopper, for example as a QR code, or send it to them.

Printing the receipt

The Receipt API produces the receipt document and its permanent link. Getting a paper receipt to the shopper is to be completed via your frontend or backend.

  • Digital receipt: Display permanent_link as a QR code on the kiosk screen so the shopper can open it on their phone.

  • Paper receipt: Your backend or frontend sends a receipt to the thermal printer on the store network, for example as ESC/POS or Star line mode commands over TCP port 9100. You have two sources for the content:

    • Render your own receipt from the snapshot you sent to the Create receipt endpoint. This already contains the line item, price, tax, tender, and store detail, so no further platform call is needed. Print order_number and encode permanent_link as a QR code on the printable document so the shopper can also access a digital copy.

    • Fetch the document at permanent_link and print it.

Print only after the create call returns 201, so that the paper receipt and the platform receipt carry the same order_number. If the create call fails or its response is lost, print the paper receipt from the snapshot anyway and display the call staff state at the kiosk. Retry a lost response or a 503 in the background with the same Idempotency-Key.

For the recommended printer models, see Hardware prerequisites for self-checkout.

Note

  • Send an Idempotency-Key with every create request, and the same value on every retry of it. A retry under the same key returns the receipt the first attempt created. A create request for the same order under a different key returns 409.

  • This rollout covers non-fiscal countries. In fiscal countries, the create request does not return a link: it returns 422 and saves nothing. Further information follows once the APIs are enabled for fiscal countries.

  • placed_at is the same value that you sent on the order. It and every processed_at must be RFC 3339 timestamps with a timezone offset. Epoch numbers are rejected. Capture placed_at from the terminal clock immediately before you place the order.

  • The receipt takes the same discounts object as the order. The coupon name from Omnichannel Manager, Welcome 10% off, prints on the receipt as the discount description.

  • The Receipt API lowercases provider before it reaches the receipt layout, so adyen and ADYEN render the same.

  • Only SALES_RECEIPT is supported. To customize the receipt layout, see Tutorial: Creating templates.

The transaction is complete. Clear the cart and start a new session for the next shopper.

Verifying the order in Omnichannel Manager

After your first end-to-end transaction in a staging or production environment against a real tenant, you can verify the order in Omnichannel Manager.

  1. Go to Orders and search for the order number, in this tutorial GD000067894. The order appears under the store store1_NYC. See Monitoring orders.

  2. Open the order and confirm that the payment shows as processed and that the coupon WELCOME10 is recorded on both units. A confirmation screen on the kiosk is not proof that the payment side is wired correctly, because payment processing is asynchronous.

  3. Check the failed payment queue. An instrument without a merchant account (Adyen) or a PaymentIntent created on the wrong account (NewStore Payments) is accepted with 201 and fails here afterward.

  4. Open the receipt link and confirm that it shows two units of the dress, the discount Welcome 10% off, the tax amount, and the card details.

Also verify the payment on the PSP side. For Adyen, the PSP reference in the Adyen Customer Area shows Received > Authorised > Settled. For NewStore Payments, the PaymentIntent in the Stripe dashboard shows as succeeded on the store's connected account.

Going live

To switch from the stub servers to your tenant, make two changes in configuration:

  1. Replace the localhost base URLs with https://{your-tenant}.{env}.newstore.net.

  2. Add the Authorization: Bearer {accessToken} header to every request, using the login flow described in Logging in on the terminal.

Important

Before you switch, add the kiosk origin to Allowed origins on the API client, so that the browser can call the platform directly, as in this tutorial. This is only necessary if the application is browser-based. The API gateway must also expose the request ID headers.

Validate the complete flow in staging with the PSP test environment first: scan 1000101000007, pay with a test card, and verify the order in Omnichannel Manager. Then repeat in production with a live terminal and a low-value transaction, and refund it through your PSP.

Before you open the store with self-checkout, confirm the following:

  • The Application client exists in staging and production with the recommended token lifetimes, the per-store credentials are provisioned, and no Associate App client ID is in the frontend.

  • Store ID, address, time zone, catalog, and price book are configured on each terminal, not derived from the login.

  • The kiosk browser or app is locked down and managed through MDM.

  • Tokens are held in memory only, state is validated on the callback, and the authorization code is removed from the URL.

  • All discounts are copied from the promotions calculation, all amounts from the tax quotation, and the PSP reference and merchant account are recorded on every payment instrument.

  • The self-checkout client has the promotions:cart-discounts:write scope, and session_ref stays constant for a cart while the idempotency key changes with every placement attempt.

  • Idempotency keys advance with every payload change, and the app honors Retry-After on 429.

  • Returns, exchanges, and other out-of-scope operations are handled in Associate App, not added to the kiosk.

  • Store management knows the incident contact for NewStore Support.

For the complete list, see the Integration checklist.

Operating self-checkout in the store

Self-checkout credentials are per store, and the token lives in memory on each terminal. Add the following to the opening and closing checklists of store1_NYC:

  • Store opening: The store manager or deputy logs in on each self-checkout terminal individually with the self-checkout credentials for store1_NYC. Each terminal receives its own token and stays authenticated for the trading day. The refresh token lifetime of the API client must cover the longest trading day of the store, because an expired refresh token blocks checkout until someone logs in again. Record which user logged in on which terminal. The platform attributes all orders of the day to that user and cannot tell which terminal placed an order.

  • During the day: If a terminal shows a login screen, for example after a page reload, log in again. If a terminal shows a call staff state, complete the sale for the shopper in Associate App and note the request IDs shown on the screen for NewStore Support: x-amzn-requestid, which the API gateway returns on every response, and x-newstore-request-id, which the Order Placement API returns.

  • Store closing: Log out on each terminal. The app discards the tokens and ends the session on the server, so the refresh token cannot be used after closing. Do not leave a terminal logged in overnight.

  • Regularly: Review the self-checkout orders of the store in Omnichannel Manager, including the failed payment queue.

Authentication failures during the trading day block checkout entirely. Complete the login before the store opens to avoid a midday disruption.

You have set up self-checkout in the New York City store.

Related topics