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:
One store located in New York City, whose
store_idisstore1_NYC. The store uses the catalogstorefront-catalog-en, the price bookdefaultinUSD, and is open every day except Monday. See Tutorial: Setting up a store.One product, the dress with product ID
1000101and EAN-13 barcode1000101000007, priced at USD 995.00 in thedefaultprice book. See Tutorial: Importing products.One coupon,
WELCOME10, which gives 10% off the whole cart and is valid only instore1_NYC. You create it in Omnichannel Manager. See Creating the coupon in Omnichannel Manager.One self-checkout lane - a tablet running a web app in kiosk mode, with a barcode scanner, and a receipt printer. For details on recommended hardware, see Hardware prerequisites for self-checkout.
One payment terminal. This tutorial describes both options -
An Adyen
e285terminalNYC_1from Tutorial: Setting up Adyen in a storeA Stripe S700 reader via NewStore Payments.
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:
Created the store
store1_NYC. See Tutorial: Setting up a store.Imported the product
1000101and thedefaultprice book. See Tutorial: Importing products.Imported stock for the product into
store1_NYC. See Tutorial: Importing stock information.If you use Adyen, set up and boarded the terminal
NYC_1for the merchant accountPOS_USA. See Tutorial: Setting up Adyen in a store.The
Manage discountspermission in Omnichannel Manager, to create the couponWELCOME10. See Promotions.Read Self-checkout via NewStore to understand what NewStore provides and what you build yourself.
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 |
| The tenant exists and the store | 3 days |
2. Hardware |
| A PSP account and the merchant account for | 1 to 2 weeks.
|
3. Build against the stub servers |
| 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 |
| Phases 1, 3, and 4 | 2 to 3 days |
6. Production pilot |
| Phase 5, production credentials, and a live terminal. | 1 day |
7. Store readiness |
| 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.
Catalog lookup: The shopper scans the barcode
1000101000007. The app resolves it to the dress and its price via the Catalog API.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.
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.
Payment: The app sends that amount to the payment terminal through a thin backend that you own. The shopper taps their card.
Order placement: The app places the order with the payment details via the Order Placement API.
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.
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=passwordcall to/v0/tokenused 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 | Used in |
|---|---|---|
|
| All API calls. Use |
|
| Order placement and receipt |
|
| Receipt |
|
| Receipt and |
|
| Tax quotation and receipt |
|
| Tax quotation and receipt |
|
| Tax quotation and receipt |
|
| Tax quotation and receipt |
|
| Tax quotation and receipt |
|
| Catalog lookup and order placement |
|
| Catalog lookup |
|
| Tax quotation, order placement, and receipt |
|
| Catalog lookup, order placement, and receipt |
| From the Application client in Omnichannel Manager | Login |
| A redirect target of the API client | Login |
|
| Login. |
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_includedset totrue, and sendTAX_INCLUDEDin 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.
Go to
Promotions>Couponsand clickAdd Coupon.In
Name, enterWelcome 10% off. This name is printed on the receipt as the discount description.Set
Start dateto the current date, and selectAll customers.In
Discount Type, selectCart, and selectUSDinCurrencies.In
Discount details, selectPercentageand enter10inDiscount value.In
Restrictions, selectApply only to specific storesand select thestore1_NYCstore.Add the coupon code
WELCOME10to the coupon, save the coupon, and enable it. The status of the coupon changes toRunning.
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_codesin 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.shPoint 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-USThe 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 andstateare held insessionStorageacross the redirect.Reject any callback whose
statedoes not match the value you sent.Your app must serve
index.htmlat the callback path so your client-side router handles the redirect instead of returning a404.Never log tokens. Redact the
Authorizationheader 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
200with an empty ornullproductsarray, not a404. ShowProduct not foundto the shopper and leave the cart unchanged.Pass
tax_classthrough as it is, including an empty string. Do not substitute a class of your own.Keep the product
titlein 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_idvalues that you later send on the order,The catalog price in
price.price, andproduct_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_priceis the price to charge for each unit:89550, or USD 895.50. Display it, and pass it to the tax quotation as theitem_price. Do not recalculate it fromapplied_amountin the self-checkout app.The coupon applies to the whole cart, so its
scopeisORDERand it appears on every unit with the sameid. To display it once on the kiosk, group the entries byidand sumapplied_amount: Welcome 10% off, USD 199.00.idis the identifier of the coupon on the platform. Copy it as returned onto the order and the receipt.messagesis 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
200with 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_totalis what the shopper pays:194995, or USD 1,949.95. The discount is already inside the quoted prices, sodiscount_totalstays0. Display it and pass it to the payment terminal and the order unchanged.items[0].price.taxis 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
idand the calculatedprice. Match them back to the cart onid.messagesis always present. Awarningentry 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_1is assigned tostore1_NYCand boarded on the merchant accountPOS_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 APIrole. The credential created in Tutorial: Setting up Adyen in a store has only thePOS Terminal Management APIrole, which is not sufficient for the self-checkout kiosk backend. In theAdyen Customer Area, either add theCloud Device APIrole 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_1communicates with Adyen over cloud. If it was only set up for local communication, go to theAdyen Customer Areato 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 examplee285-XXXXXXXX.
Configure the backend with:
Variable | Value |
|---|---|
| API key with the Cloud Device API role. Server-side only. |
|
|
|
|
|
|
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.methodisTAX_EXCLUDED, matching the tax method of the quotation.Each line carries the catalog
list_priceand the coupon indiscounts, with the sameidon both units. The platform derives the charge of89550per unit from them.placed_atis the time the order was placed on the terminal, captured once and reused on the receipt.Every
line_item_idappears in exactly one fulfillment. For in-store handover, the fulfillmentpriceandtaxare both0.The instrument
amountistotals.grand_totalfrom the quotation, copied unchanged. ThestatusisCAPTUREDbecause the terminal has already taken the money. The instrumentidis unique within the order, and the PSP reference is a natural value for it.For Adyen,
providerisadyenandprovider_account_idis the merchant accountPOS_USA. For NewStore Payments, setprovidertonewstore, use the PaymentIntent ID asreference, and omitprovider_account_id.notifications.is_preconfirmedistruebecause the kiosk shows its own confirmation screen and the platform must not message the shopper a second time.Do not send an order-level
idor line-itemid. The platform generates both.
Payment instrument fields (Beta)
The payment instrument contract is in beta. The Order Placement API declares
brand,last_digits, andprovider_account_idas typed fields on the instrument, andprovider_account_idis required whenproviderisadyen. Do not put these values inmetadata. The only well-knownmetadatakey iscustomer_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
400or422from this endpoint arrives after the shopper's money has moved. Validate the payload before you charge the terminal: required fields present, every instrument has anid, and the instrument amounts sum tototals.grand_total. Treat a400or422after payment as acall staffstate, 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-e07fc1f90ae7Expand 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_linkas 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_numberand encodepermanent_linkas a QR code on the printable document so the shopper can also access a digital copy.Fetch the document at
permanent_linkand 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-Keywith 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 returns409.This rollout covers non-fiscal countries. In fiscal countries, the create request does not return a link: it returns
422and saves nothing. Further information follows once the APIs are enabled for fiscal countries.
placed_atis the same value that you sent on the order. It and everyprocessed_atmust be RFC 3339 timestamps with a timezone offset. Epoch numbers are rejected. Captureplaced_atfrom the terminal clock immediately before you place the order.The receipt takes the same
discountsobject as the order. The coupon name from Omnichannel Manager,Welcome 10% off, prints on the receipt as the discount description.The Receipt API lowercases
providerbefore it reaches the receipt layout, soadyenandADYENrender the same.Only
SALES_RECEIPTis 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.
Go to Orders and search for the order number, in this tutorial
GD000067894. The order appears under the storestore1_NYC. See Monitoring orders.Open the order and confirm that the payment shows as processed and that the coupon
WELCOME10is 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.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
201and fails here afterward.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:
Replace the
localhostbase URLs withhttps://{your-tenant}.{env}.newstore.net.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 IDheaders.
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,
stateis 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:writescope, andsession_refstays 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-Afteron429.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 staffstate, 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, andx-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