This article covers the complete self-checkout flow from barcode scan to receipt, and documents the money-handling rules and error scenarios that apply throughout.
Beta version of APIs
The self-checkout APIs are in Beta and subject to change. Validate your integration against the latest documentation before going to production.
Overview
A self-checkout transaction follows five steps in sequence:
Catalog lookup — Resolve a scanned barcode to a product and its price.
Promotions calculation — Submit the cart and any coupon codes to get the discounts and the price each unit is left at.
Tax quotation — Submit the discounted cart to get the authoritative subtotal, tax, and grand total.
Order placement — Post the finalized cart as an order, referencing the tax-quoted totals and carrying the discounts.
Receipt creation — Create the receipt document from a snapshot of the transaction and get the receipt URL.
Each step depends on the output of the previous one. Do not skip or reorder them.
Catalog
Endpoint: GET /catalogs/{catalogId}/products Spec: Catalog API
Pass the scanned barcode as id, set id_type to the barcode format (ean13, upc, sku, gtin), and include pricebook to get a price in the response. Always include Accept-Language: en-US — the platform returns 400 without it.
From the response, check is_available before adding to the cart — unavailable products must not be sold. The tax_class value you need for the tax quotation is a required top-level field of the product.
A barcode not in the catalog returns an empty products array (or products: null) with a 200 status — not a 404. Handle both as "not found". The messages array names which identifier failed to resolve, which matters when you look up several barcodes in one call.
Good to know
Reading the tax class
const taxClass = product.tax_class;Pass this value to the Tax API unchanged. Do not interpret or map it client-side — it is an opaque string that the tax service resolves.
An empty string is a valid value: it means the product has no tax class and the tax provider applies its default. Pass it through as it is; do not substitute a class of your own.
TypeScript types
export interface ProductResource {
id: string;
title: string;
images: ImageResource[] | null;
external_identifiers: Record<string, string>; // flat map — do not narrow key type
is_available: boolean;
tax_class: string; // required; "" = no class, provider default applies
extended_attributes?: Array<{ name: string; value: string }> | null;
price?: PriceResource; // only present when ?pricebook= was supplied
}
export interface PriceResource {
pricebook: string;
amount: number; // integer minor units — 129 = $1.29
currency: string; // ISO 4217
markdown_price?: number;
}
export interface MessageResource {
type: string; // severity, e.g. "info"
code: string; // machine-readable reason, e.g. "not_found"
reference: string; // subject, e.g. the unresolved barcode
}
export interface ListProductsResponse {
products: ProductResource[] | null; // nullable — treat null same as []
pagination: { has_next_page: boolean; total_count: number };
messages: MessageResource[] | null; // objects, NOT strings
}external_identifiers is Record<string, string> — a flat string map. Never type it as { ean13?: string }.
messages holds objects, not strings. To find the barcodes that did not resolve:
const notFound = new Set(
(data.messages ?? []).filter(m => m.code === 'not_found').map(m => m.reference),
);Variation data lives in options and variants on ProductResource. There are no variation_color_value / variation_size_type / variation_size_value fields.
Promotions
Endpoint: POST /promotions/calculations Spec: Promotions API
Submit the cart, with any coupon codes the shopper typed, to receive the discounts the retailer's automatic promotions and those codes produce, per unit, together with the price each unit is left at. The service applies the discounts — you do not: discounted_price is the price to charge, and discounts reports how it was reached. Nothing is persisted, so call it after every cart change, before the tax quotation.
Send one line_items entry per physical unit — the same rule as order placement, and for the same reason: promotions such as "buy two, get one" count entries. Give each entry the line_item_id you will later send the order, product_id, price.price set to the catalog price.amount, and product_categories: [] (the field is required, and the Catalog API exposes no categories). Send session_ref — one stable value for the whole shopper session, such as your cart id — currency, channel: { type: "STORE" }, store: { id, timezone } and catalog: { name, locale }.
From the response, use each unit's discounted_price as the tax quotation's item_price, and carry discounts forward onto the order and receipt lines. messages is the shopper's coupon feedback: a code that did not apply does not fail the request, it produces an entry such as discount_code_invalid or discount_code_expired whose path ($.coupon_codes[0]) names the code the shopper typed.
A retailer without promotions gets the same shapes back — discounted_price equals the catalog price and discounts is empty — so the flow does not branch on whether promotions exist.
Good to know
What not to send from a kiosk
Omit list_price on the line: present and different from price it means a manual price override, and the unit comes back manual_discount_forbidden: true with no discounts. Omit customer: a kiosk shopper is anonymous. Omit discounts (manual discounts need an associate) and shipping_options (cash and carry has no shipping).
Reading the discounts
Every discounts entry carries type (FIXED or PERCENTAGE), id, description, scope (ITEM or ORDER), source (AUTOMATIC, COUPON or MANUAL), applied_amount (what came off this unit, in minor units) and either amount or rate; a coupon discount also carries coupon_code. Entries are sequential: each applies to what the previous one left.
An order-level discount has no entry of its own. It appears on every unit it touched, each with that unit's share in applied_amount, all with the same id. To show it once, group by id and sum applied_amount.
Forwarding the discounts
Copy each unit's discounts onto the matching order and receipt line (line_items[].price.discounts), dropping source. Everything else, coupon_code included, keeps its name on both. Keep list_price as the catalog price: the platform derives the charge as list_price minus the sum of applied_amount, which is the discounted_price you quoted tax on.
Building the quotation from units
The quotation takes a quantity, promotions return units. Group a cart line's units by discounted_price and send one quotation item per group; an order-level discount can leave one unit a cent apart from its siblings, and that unit becomes its own item.
When the service cannot answer
503 is retryable once. 500 includes the case where the promotion engine itself failed. What the kiosk does then is a retailer decision: most continue at catalog prices and tell the shopper discounts are unavailable. When the NewStore engine is unreachable the service can also answer 200 with no discounts and empty messages, which looks exactly like "nothing matched" — log promotions responses that carry no discounts for a cart with coupon codes, and do not tell the shopper a code is invalid unless messages says so.
Tax quotation
Endpoint: POST /taxes/standard-quotations Spec: Tax Quotation API
Submit the current cart to receive authoritative totals. This is the only permitted source of tax amounts and grand total — never compute these client-side.
Call this endpoint after every cart mutation (item added, removed, or quantity adjusted), not only at the point of payment. The quotation is side-effect-free: it stores no cart, writes no record and triggers no commit. Calling it repeatedly costs nothing and reserves nothing.
For each cart item you need: an id of your choosing that is unique within the request, the tax_class (the catalog product's tax_class, verbatim), the item_price per unit in minor units (the unit's discounted_price from the promotions calculation, which equals the catalog price when no discount applies), the quantity, type: "product", the currency ISO 4217 code, and your store's physical address for both destination and origin. The address is passed to the tax provider for jurisdiction resolution — a placeholder address returns 422, with an errors entry that says the address could not be resolved to a tax jurisdiction. Optionally add product (id, sku, name from the catalog) for the provider's audit trail, and the store-id request header.
From the response, use totals.grand_total as what the shopper pays. Each response item carries only your id and the calculated amounts under price — nothing you sent is echoed back, so match on id and keep the cart as the source of truth for what was scanned. Store items[n].price.tax per cart line — you need it to build the order. Note that it covers all units of a line, not one unit.
Good to know
Required item fields
id, tax_class, tax_method, item_price, quantity, destination, origin, type, currency.
id is your identifier for the line, unique within the request. The response is matched on it and on nothing else.
tax_method takes the same values as the order's pricing.method:
TAX_EXCLUDED— shelf price is pre-tax; tax is added on top. Use this for US.TAX_INCLUDED— shelf price already includes tax. Use this for EU-style pricing.
item_price is the price for one unit in minor units. The API multiplies by quantity internally.
type is product for scanned goods (gift-card, gift-wrapping and shipping also exist).
currency is the ISO 4217 code. It must be identical across all items — it also determines the minor-unit exponent (USD = 2, JPY = 0).
Send at most 240 items per request.
Key response fields
Field | Use |
|---|---|
| What the shopper pays. Display this and pass it to the order payment instrument. |
| Total tax (display). |
| Pre-tax subtotal (display). |
| Always present. For in-store self-checkout the shipping amounts are |
| Tax for all units of line n. Distribute it across the units for order placement. |
| Your request |
| Which tax strategy produced these numbers. |
| Per-rate breakdowns. |
| Always present. Empty means no incident; a |
Response items are in the same order as request items and carry only id and price (gross, net, tax, tax_lines), all always present — no null checks needed. Nothing else from the request is echoed.
Order placement
Endpoint: POST /orders Spec: Order Placement API
Place an order after payment is authorized. A 201 response means the order is accepted; payment capture and downstream processing complete asynchronously. Placing an order does not generate a receipt — receipts are a separate, explicit API (see Receipt creation below).
The self-checkout scope is a cash-and-carry order: one tender, in-store handover. That is a product decision, not an API limit — the API accepts up to 40 instruments per order and also declares GIFT_CARD, LEGACY_GIFT_CARD and EXTERNAL tenders, which are out of scope for self-checkout today.
Send an idempotency-key header. The key identifies one placement attempt, not one shopping session. If a network error prevents you from receiving the 201, resend the identical request with the same key: the platform replays the original order rather than placing a second one, and a placement that fails does not consume the key.
The key must advance whenever the payload changes. Reusing it with a different body returns 422, which no retry escapes — so a bare cart-session id deadlocks as soon as a shopper adjusts the cart after a failed placement and pays again. Derive the key from the operation so it survives a client restart, but make it change with the payload, for example {sessionId}-{attemptNumber}.
Each line carries list_price (the catalog price) and, when the promotions calculation returned any, discounts forwarded from it unchanged. There is no price field: the platform derives the charge as list_price minus the discounts. 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. The payment amount must equal totals.grand_total from the tax quotation — copy the value directly. Copy totals.tax_strategy into pricing.tax_strategy as well: it is optional, but it records which strategy priced the order, and only the caller knows whether the configured provider or its fallback produced the numbers.
The order request needs six top-level objects: channel (name, type: "STORE", store_id), catalog (name, locale), pricing (currency, method), line_items, fulfillments and payment. Set pricing.method to TAX_EXCLUDED or TAX_INCLUDED, the same value you sent as the quotation's tax_method. If any of them is missing, the platform rejects the order with 422 after the shopper has paid, so validate the request before you charge the shopper.
Every 400 or 422 from this endpoint arrives after the shopper's money has moved: self-checkout pays first and places second, because an order carrying a CAPTURED instrument cannot exist before the capture does. Validate the payload's shape — required fields, the instrument id, amounts summing to the total — before the terminal is touched, and treat a 400 or 422 after payment as a call-staff state, not as something to retry.
The response includes id (a UUID) and order_number (a human-readable label). Use id when calling the receipt endpoint. order_number is for display only.
Good to know
One line item per physical unit
The line_items array contains one entry per physical unit. There is no quantity field. A cart line with quantity 3 produces three LineItem objects with distinct line_item_id values.
This design enables unit-level tracking and return processing downstream. The per-unit tax comes from the tax quotation response, where the line's price.tax covers the whole line; each order unit gets its share in its own price.tax.
However you distribute a line's tax across its units, the parts must sum back to the line's price.tax exactly — the platform validates the payment amount against the sum of every line's charge and tax. There is no price field on a line: the platform derives the charge as list_price (or override.price) minus discounts. Minor units are indivisible, so a line that does not divide evenly leaves a remainder that has to land on some unit rather than be rounded away: 32¢ across 3 units rounded independently gives 11 × 3 = 33, and the order is rejected. Where you put the remainder is up to you. The example below puts it on the first unit.
function expandToLineItems(cartItems, quotation) {
const items = [];
for (const item of cartItems) {
const line = quotation.items.find(l => l.id === item.productId);
const totalTax = line?.price.tax ?? 0;
const taxPerUnit = Math.floor(totalTax / item.quantity);
const taxRemainder = totalTax % item.quantity;
for (let unit = 0; unit < item.quantity; unit++) {
items.push({
line_item_id: `${item.productId}-unit-${unit + 1}`,
product_id: item.productId,
price: {
list_price: item.unitPriceMinor,
tax: unit === 0 ? taxPerUnit + taxRemainder : taxPerUnit,
},
});
}
}
return items;
}Fulfillments
Every line_item_id must appear in exactly one fulfillment. Use a single fulfillment grouping all IDs for in-store handover. price and tax must both be 0 for IN_STORE_HANDOVER:
{
"fulfillments": [{
"line_item_ids": ["prod_001-unit-1", "prod_001-unit-2"],
"method": {
"service_level_identifier": "IN_STORE_HANDOVER",
"price": 0,
"tax": 0
}
}]
}Payment instruments
The payments contract is still being refined. The typed instrument fields described here may change before general availability.
payment.instruments is discriminated by type. Every instrument carries an id — unique within the order and meaningful on your side, so that re-adding the same payment does not record it twice; the PSP reference is the natural value for a card. A card payment requires provider and reference; a cash payment requires given_amount, rounded_amount and change_amount and has no provider. All of these sit directly on the instrument — there is no card or cash wrapper object. The instrument amounts must add up to the order total.
{
"payment": {
"instruments": [{
"id": "pi_abc123",
"type": "CARD",
"amount": 27900,
"provider": "newstore",
"status": "CAPTURED",
"processed_at": "2026-01-01T12:00:00Z",
"reference": "pi_abc123",
"origin": "terminal",
"brand": "Visa",
"last_digits": "4242"
}]
}
}Card brand and last four digits are typed fields, brand and last_digits — the same names the receipt uses, so the same tender object serves both calls. For Adyen, provider_account_id (the merchant account) is required; Adyen cannot capture without it. metadata is stored as-is; its one well-known key is customer_receipt, the raw terminal receipt block (Adyen only).
status is either AUTHORIZED or CAPTURED. For self-checkout send CAPTURED — the shopper has already tapped and the terminal has taken the money before you place the order. Use AUTHORIZED only when capture genuinely happens later. Instruments may only be empty for a zero-value order.
external_id
Omit external_id by default. Set it only when the client has a pre-existing external reference that should be attached to the order; it must be unique per tenant. The idempotency-key header handles retry safety independently.
Notifications
The optional notifications object controls the platform's own customer communication. A self-checkout kiosk shows its own confirmation screen — send notifications.is_preconfirmed: true so the platform does not message the customer a second time, and set notifications.language (lowercase ISO 639-1) to the shopper's language when known. notifications.exclusions suppresses individual notifications by exact name.
Three fields called id
The order-level id and the line-item id are deprecated caller-supplied identifiers kept for legacy callers. Do not send them — the platform generates both and returns them in the response. The payment instrument's id is the opposite case: required, caller-supplied, unique within the order.
Receipt creation
Endpoint: POST /receipts Spec: Receipt API
Create the receipt document from a snapshot of the transaction. The response contains id and permanent_link, and permanent_link is the receipt URL.
Send an Idempotency-Key header with every create, and send the same value on every retry of it. A retry under the same key returns the receipt the first attempt created. A create for the same order under a different key returns 409, because an immutable financial document is never replaced. If you get no create response even after a retry, show the order number and use your own transaction snapshot for the printed or on-screen receipt.
Only SALES_RECEIPT works today. Exchange and return receipts are declared in the schema but return 422.
Good to know
The receipt is built from a snapshot, not from an order id
Creating a receipt requires the whole transaction again: type, order_id, order_number, placed_at, locale, pricing, line_items, fulfillments, payment and store. The cart therefore cannot be discarded when order placement returns 201.
Two things need carrying forward that nothing earlier in the flow required:
The catalog product name.
line_items[].productis an object withidandname, both required. The order payload only ever carriedproduct_id.store.labelandstore.timezone.StoreDetailsrequiresid,label,timezoneandaddress.store.addressrequiresaddress_line_1,cityandcountry(ISO 3166-1 alpha-2). The receipt address names this fieldcountry, while the tax quotation's address names itcountry_code. Order placement needed onlystore_id. Take these from configuration or the Store API.
You also need both order identifiers — order_id and order_number are both required — and placed_at, the same value you sent on the order. placed_at and every instrument's processed_at must be RFC 3339 including a timezone offset; epoch numbers are rejected.
Reusing the order payload
The receipt uses the same shapes as the order — method, price.tax, flattened type/provider instruments with typed brand and last_digits, the tax_exemption object. Build the receipt by decorating your order payload rather than translating it, and send the order's tender objects unchanged: the receipt schema does not declare status or the placement-only fields (id, reference, origin, provider_account_id), and the service accepts a tender that carries them, because the receipt schema ignores undeclared fields. What differs is deliberate:
Concept | Order Placement | Receipt | Why |
|---|---|---|---|
Product |
|
| The receipt prints the name |
Per-unit platform id | returned as | sent as | Carry the order response item id into the receipt line, matched via the echoed |
Tender | required / used | not declared; accepted by the service | A receipt records money already taken; placement needs the linkage |
Tax line country |
| not declared | Drop it when decorating |
Billing address contact |
|
| Different field |
Fiscalised receipts
The rollout covers non-fiscal countries. In fiscal countries, create does not return a link: it returns 422 and saves nothing, so the order can get a receipt later. Further information follows once the APIs are enabled for fiscal countries.
Money handling
All monetary amounts throughout the platform are integers in minor units. 129 means $1.29. This rule applies to every field in every request and response.
Never use floats for money. Integer arithmetic is exact; float arithmetic is not.
Never compute the grand total or tax client-side. The Tax API is the source of truth.
To display amounts, format at the presentation layer only, with the currency's minor-unit exponent (USD 2, JPY 0):
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 10 ** exponent). Never hardcode/ 100.The payment instrument amount must exactly equal
totals.grand_totalfrom the tax quotation. Copy the value directly.
Error handling
Scenario | Response | Recommended handling |
|---|---|---|
Barcode not in catalog |
| Surface "product not found" to the shopper. Do not modify the cart. |
Product unavailable |
| Surface "product unavailable". Do not add to cart. |
Catalog: missing |
| Add the required header. This is a programming error. |
Tax: missing |
| All three are required on every item, and |
Tax: fallback provider used |
| Totals are usable. Log the message — the authoritative provider was unavailable. |
Tax: amount out of range |
| A monetary field exceeds 15 digits, alone or once tax is added. Almost always a float multiplied by 100 twice. |
Tax API unreachable | Network error or | Surface a retry prompt. Do not proceed to payment. A wrong total is worse than a slow one. |
Promotions: coupon did not apply |
| Not an error. Show it next to the coupon field; |
Promotions: no discounts and empty |
| Possibly the degraded path (promotion engine unreachable). Log it; do not tell the shopper the code is invalid. |
Promotions: token rejected |
| The scope |
Promotions: unavailable |
|
|
Tax address validation failure |
| The tax provider could not resolve the address. Check that all five |
Order: retry with same key and same body |
| Treat the replayed |
Order: same key, different body |
| Programming error. Do not retry blindly; use a new key for a genuinely new order. |
Order: duplicate order |
| An order already exists. Do not create a new one. The error body carries no order number, so you cannot show a confirmation — surface a "call staff" state with the correlation id ( |
Order, receipt, promotions, or tax error, or any gateway failure |
| RFC 9457 problem details. The Order, Receipt, Promotions, and Tax Quotation APIs answer every error this way, and so does the gateway when a request never reaches a service. There is no machine-readable code: branch on the status, show |
Any endpoint: |
| The path is not configured at the gateway — a renamed or misspelled endpoint, not a credential problem. The gateway answers |
Tax: request validation failed |
| Every broken rule is one |
Tax: token rejected |
| The token lacks the |
Order: invalid payload or unknown product |
| Check that instrument amounts sum to |
Order: malformed request |
| The body is not valid JSON, or the |
Order: rate limited |
| Back off for the |
Order: downstream unavailable |
| Retryable. Resend the identical request with the same key. |
Receipt create: order already has one, created under a different key |
| Terminal. A financial document is never replaced — do not retry and do not create a second one. |
Receipt create: exchange or return type |
| Only |
Receipt create: snapshot rejected |
| The body parses, but the receipt contract refuses it. |
Receipt create: malformed request |
| The body is not valid JSON, or the |
Receipt create: fiscal country |
| Not supported yet. Nothing is saved, so the order can get a receipt later. Do not retry. |
Receipt create: downstream unavailable |
| Retryable with the identical request — unlike the |
Related topics