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

Self-checkout integration guide

Prev Next

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:

  1. Catalog lookup — Resolve a scanned barcode to a product and its price.

  2. Promotions calculation — Submit the cart and any coupon codes to get the discounts and the price each unit is left at.

  3. Tax quotation — Submit the discounted cart to get the authoritative subtotal, tax, and grand total.

  4. Order placement — Post the finalized cart as an order, referencing the tax-quoted totals and carrying the discounts.

  5. 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

totals.grand_total

What the shopper pays. Display this and pass it to the order payment instrument.

totals.tax_total

Total tax (display).

totals.subtotal

Pre-tax subtotal (display).

totals.net_total, totals.discount_total, totals.shipping_total, totals.shipping_tax_amount

Always present. For in-store self-checkout the shipping amounts are 0.

items[n].price.tax

Tax for all units of line n. Distribute it across the units for order placement.

items[n].id

Your request id — the only key for matching a response line back to a cart line.

totals.tax_strategy

Which tax strategy produced these numbers.

items[n].price.tax_lines, totals.tax_lines_summary

Per-rate breakdowns.

messages

Always present. Empty means no incident; a warning entry means the configured tax provider failed and a fallback produced the totals. Totals are still usable, but log it.

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[].product is an object with id and name, both required. The order payload only ever carried product_id.

  • store.label and store.timezone. StoreDetails requires id, label, timezone and address. store.address requires address_line_1, city and country (ISO 3166-1 alpha-2). The receipt address names this field country, while the tax quotation's address names it country_code. Order placement needed only store_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

product_id (string)

product object, id + name required

The receipt prints the name

Per-unit platform id

returned as items[].id

sent as unit_uuid

Carry the order response item id into the receipt line, matched via the echoed line_item_id

Tender id, status, reference, origin, provider_account_id

required / used

not declared; accepted by the service

A receipt records money already taken; placement needs the linkage

Tax line country

tax_lines[].country_code

not declared

Drop it when decorating

Billing address contact

billing_address.phone

billing_address.email

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_total from the tax quotation. Copy the value directly.

Error handling

Scenario

Response

Recommended handling

Barcode not in catalog

products: [] or products: null

Surface "product not found" to the shopper. Do not modify the cart.

Product unavailable

is_available: false

Surface "product unavailable". Do not add to cart.

Catalog: missing Accept-Language

400

Add the required header. This is a programming error.

Tax: missing id, type or currency

422 with errors: [...]

All three are required on every item, and id must be unique within the request. A missing currency or a repeated id points at #/items. This is a programming error.

Tax: fallback provider used

200 with a warning in messages

Totals are usable. Log the message — the authoritative provider was unavailable.

Tax: amount out of range

422 with errors: [...]

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 5xx

Surface a retry prompt. Do not proceed to payment. A wrong total is worse than a slow one.

Promotions: coupon did not apply

200 with a messages entry (discount_code_invalid, discount_code_expired, …)

Not an error. Show it next to the coupon field; path names the code. The totals in the same response are valid.

Promotions: no discounts and empty messages for a cart with coupon codes

200

Possibly the degraded path (promotion engine unreachable). Log it; do not tell the shopper the code is invalid.

Promotions: token rejected

403

The scope promotions:cart-discounts:write is checked by the promotions service itself, so a token that works for the catalog can still fail here. Have the scope added to the client.

Promotions: unavailable

500 / 503

503 is retryable once. Then continue at catalog prices and tell the shopper discounts are unavailable, or block — a retailer decision. Never invent a discount.

Tax address validation failure

422 with errors: [...]

The tax provider could not resolve the address. Check that all five STORE_ADDRESS_* environment variables are set to the store's real address.

Order: retry with same key and same body

201 (replay)

Treat the replayed 201 as success — it is the original order.

Order: same key, different body

422

Programming error. Do not retry blindly; use a new key for a genuinely new order.

Order: duplicate order id, item id or external_id

409

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 (x-amzn-requestid; order responses also echo x-newstore-request-id).

Order, receipt, promotions, or tax error, or any gateway failure

{type, title, status, detail}

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 detail, and log x-amzn-requestid — the only correlation ID present on every failure. The Catalog API keeps its own body, so the catalog adapter must tolerate this shape on top.

Any endpoint: 403 Missing Authentication Token

403

The path is not configured at the gateway — a renamed or misspelled endpoint, not a credential problem. The gateway answers 401 for a known path with a bad token. If a sibling call works on the same token, fix the path.

Tax: request validation failed

422 with errors: [...]

Every broken rule is one errors entry with a pointer, for example #/items/0/tax_method. Log every entry. A 400 means the body is not valid JSON.

Tax: token rejected

403

The token lacks the taxes:quote scope. Have it added to the API client.

Order: invalid payload or unknown product

422 with errors: [...]

Check that instrument amounts sum to grand_total, every instrument has an id, and all required fields are present. errors lists every failed field with a pointer.

Order: malformed request

400

The body is not valid JSON, or the idempotency-key header is missing, empty or invalid. Programming error.

Order: rate limited

429

Back off for the Retry-After interval, then retry with the same key.

Order: downstream unavailable

503

Retryable. Resend the identical request with the same key.

Receipt create: order already has one, created under a different key

409

Terminal. A financial document is never replaced — do not retry and do not create a second one.

Receipt create: exchange or return type

422

Only SALES_RECEIPT is specified today.

Receipt create: snapshot rejected

422 with errors: [...]

The body parses, but the receipt contract refuses it. errors lists every fault, with a pointer to the field where there is one. Terminal. Fix the payload and do not retry.

Receipt create: malformed request

400

The body is not valid JSON, or the Idempotency-Key header is missing, blank, or longer than 255 characters. Programming error.

Receipt create: fiscal country

422

Not supported yet. Nothing is saved, so the order can get a receipt later. Do not retry.

Receipt create: downstream unavailable

503

Retryable with the identical request — unlike the 409, this is not terminal.

Related topics