---
title: Build a cart validation UI extension
description: Learn how to use POS intercepts to check a serial number for each cart item and collect inspection notes before checkout.
source_url:
  html: https://shopify.dev/docs/apps/build/pos/tutorials/build-cart-validation-extension
  md: https://shopify.dev/docs/apps/build/pos/tutorials/build-cart-validation-extension.md
---

# Build a cart validation UI extension

**Feature preview:**

This feature is available only to approved apps and is subject to change.

With POS intercepts, you can add checks before checkout or a cash payment. Your extension returns validation findings, which Shopify Point of Sale (POS) displays as required or recommended actions. A resolution target renders the interface where staff address those findings. When staff try to proceed, Shopify POS checks the cart or payment again to see whether any findings remain.

In this tutorial, you'll build a demo that requires a serial number for every item in the cart. Staff enter a serial number, and the extension checks it against a demo registry when they proceed. They can also add recommended inspection notes. The required serial check and recommended notes apply to the same cart line.

The demo calls JSONPlaceholder, a public test service. `DEMO-4` maps to a fixture that passes, and every other value maps to one that fails. The entered serial isn't sent.

## What you'll learn

In this tutorial, you'll learn how to do the following tasks:

* Generate a POS intercept extension.
* Configure a background target and a resolution target.
* Return required and recommended findings for individual cart lines.
* Make an HTTP request from the resolution save handler.
* Save line item properties before Shopify POS revalidates the cart.
* Test the validation and resolution flow in Shopify POS.

## Requirements

[Create a Partner account](https://www.shopify.com/partners)

[Create a development store](https://shopify.dev/docs/apps/build/stores/development-stores#create-a-dev-store)

[Create a Shopify app](https://shopify.dev/docs/apps/build/scaffold-app)

Create a Shopify app, or use an existing app. Run the generation commands from the app root, which contains `shopify.app.toml`. Use [Shopify CLI](https://shopify.dev/docs/api/shopify-cli) 4.8.0 or later. The template includes `@shopify/ui-extensions@2026.10.0-rc.13`, `preact`, and `@preact/signals`.

For an extension-only app, see [Create an extension-only app](https://shopify.dev/docs/apps/build/app-extensions/build-extension-only-app#create-an-extension-only-app).

[Use an approved app](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/intercept-api#configuration-requirements)

Your app's API client must be approved for POS intercepts before you can test with `shopify app dev`. Creating a new app or installing Shopify CLI and the SDK doesn't grant access.

## Generate the extension

Generate a cart intercept extension from the template. If you've already generated one, then skip this step and use its existing directory.

From your Shopify app root, download the cart intercept template catalog:

## Terminal

```sh
curl --fail --location \
  "https://raw.githubusercontent.com/Shopify/extensions-templates/pos-cart-intercept-feature-preview/templates.pos-intercept.json" \
  --output /tmp/templates.pos-intercept.json
```

Generate the extension using that catalog and template branch:

## Terminal

```sh
SHOPIFY_CLI_APP_TEMPLATES_JSON_PATH="/tmp/templates.pos-intercept.json" \
  shopify app generate extension \
  --template pos_intercept \
  --name authenticity-check \
  --clone-url "https://github.com/Shopify/extensions-templates#pos-cart-intercept-feature-preview" \
  --path "$PWD"
```

The custom catalog applies only to this command. Don't pass `--flavor`. The template uses Preact automatically.

Shopify CLI creates `extensions/authenticity-check/` with a background module, a cart resolution module, and their paired targets. The following steps replace the starter example with the authenticity check demo. All file paths in those steps are relative to this extension directory. Keep its generated `uid`.

## Configure intercepts

Update `extensions/authenticity-check/shopify.extension.toml`, or the configuration file in your existing cart intercept extension. The template already declares the paired targets and enables offline support. Keep the existing `name`, `handle`, and generated `uid`. Point the `pos.app.ready.data` entry at `./src/Background.js` and the resolution entry at `./src/CartResolution.jsx`. Later steps create those modules. Intercepts require `api_version = "2026-10"` and `runs_offline = true` under `[extensions.supported_features]`.

Add the cart interceptor to the `pos.app.ready.data` targeting entry. Set `blocking = true` because the callback can return a Required finding.

## /extensions/authenticity-check/shopify.extension.toml

```toml
... (11 unchanged lines)
[[extensions.targeting]]
target = "pos.app.ready.data"
module = "./src/Background.js"


[[extensions.targeting.capabilities.intercepts]]
event = "cartvalidations"
blocking = true
... (4 unchanged lines)
```

Declare the matching cart resolution target. Every intercepted event needs one.

## /extensions/authenticity-check/shopify.extension.toml

```toml
... (19 unchanged lines)
[[extensions.targeting]]
target = "pos.cart.validations.resolution.render"
module = "./src/CartResolution.jsx"
```

Shopify CLI checks the TOML structure. When you deploy, Shopify also checks the event names, API version, and matching resolution targets. For all supported settings, see [Configuration rules](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/intercept-api#configuration-rules).

## Define the demo rules

Create `src/authenticity.js` for the rules and registry lookup shared by the interceptor and resolution target.

Define three public line item properties: `Serial number`, `Demo checked serial`, and `Inspection notes`.

## /extensions/authenticity-check/src/authenticity.js

```javascript
export const SERIAL = 'Serial number';
export const CHECKED_SERIAL = 'Demo checked serial';
export const NOTES = 'Inspection notes';
... (18 unchanged lines)
```

A line needs a serial check until `Demo checked serial` matches its `Serial number`. Changing the serial reopens the check. A line needs notes when `Inspection notes` is blank.

## /extensions/authenticity-check/src/authenticity.js

```javascript
... (4 unchanged lines)
export function needsSerialCheck(line) {
  const serial = line.properties[SERIAL];
  return !serial || line.properties[CHECKED_SERIAL] !== serial;
}


export function needsNotes(line) {
  return !line.properties[NOTES]?.trim();
}
... (9 unchanged lines)
```

Check the serial against the demo registry. `DEMO-4` requests `https://jsonplaceholder.typicode.com/todos/4`, whose fixture has `completed: true`. Any other value requests `/todos/1`, whose fixture has `completed: false`. An HTTP error throws, so it never counts as a pass.

## /extensions/authenticity-check/src/authenticity.js

```javascript
... (13 unchanged lines)
export async function checkSerial(serial) {
  // Demo only: DEMO-4 maps to a fixture with `completed: true`. Every other value maps to one with `completed: false`.
  const id = serial === 'DEMO-4' ? 4 : 1;
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  if (!response.ok) throw new Error(`Registry returned ${response.status}`);
  const {completed} = await response.json();
  return completed === true;
}
```

## Register the interceptor

Create `src/Background.js` to replace the template's `src/Background.jsx`. Register a callback for the `cartvalidations` event from the target's default entry function. After the new file is in place, remove `src/Background.jsx`. The configuration now points at `src/Background.js`.

Register the callback. It returns an empty `operations` array for an empty cart or a cart in which every line passes both checks. An empty array adds no findings.

The callback must return `{operations: [...]}` synchronously, not a `Promise`. The resolution target performs the asynchronous registry lookup and saves the result for the callback to read on the next validation event.

## /extensions/authenticity-check/src/Background.js

```javascript
... (3 unchanged lines)
  shopify.intercept('cartvalidations', ({cart}) => {
    const operations = [];
    for (const line of cart.lineItems) {
      const target = `$.cart.lineItems['${line.uuid}']`;
... (20 unchanged lines)
    }
    return {operations};
  });
... (1 unchanged line)
```

Return an `ERROR` finding when a line needs a serial check.

Each finding uses the line from the current event and targets `$.cart.lineItems['${line.uuid}']`. Handles must be unique within one result, so add the line's `uuid` to each handle. Shopify POS labels an `ERROR` finding **Required** and blocks checkout only when the interceptor declares `blocking = true` and the store allows your app to block checkout. Otherwise, Shopify POS labels it **Recommended**.

## /extensions/authenticity-check/src/Background.js

```javascript
... (8 unchanged lines)
      if (needsSerialCheck(line)) {
        operations.push({
          validationAdd: {
            level: 'ERROR',
            handle: `authenticity-check-required-${line.uuid}`,
            target,
          },
        });
      }
... (14 unchanged lines)
```

Return an independent `WARNING` finding when the same line has blank inspection notes. Shopify POS labels the warning **Recommended**, so it doesn't block checkout.

## /extensions/authenticity-check/src/Background.js

```javascript
... (18 unchanged lines)
      if (needsNotes(line)) {
        operations.push({
          validationAdd: {
            level: 'WARNING',
            handle: `inspection-notes-recommended-${line.uuid}`,
            target,
          },
        });
      }
... (4 unchanged lines)
```

Each line gets its own findings, so resolving one item doesn't clear another. For the supported fields and target values, see [Validation operations](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/intercept-api#validation-operations).

## Build the resolution interface

Staff can open your interface from an item-level finding. Your extension renders the serial and notes fields. Shopify POS owns the item indicator, validation banner, and controls for saving or proceeding.

Replace the starter `src/CartResolution.jsx` with the interface shown in the code panel. Use signals for the selected line, serial, notes, and serial error. The resolution event contains the cart, not the finding that staff selected, so pick a line from the cart and load its saved properties. Pick the first line that needs a serial check, and then the first line missing notes, so staff reach required work before recommended work. Subscribe to the event and pick the line again each time it changes. Clear the serial error each time the target opens, not when the event changes, so an earlier rejection doesn't appear on another line and a new rejection stays visible after Shopify POS revalidates.

## /extensions/authenticity-check/src/CartResolution.jsx

```jsx
... (13 unchanged lines)
const serial = signal('');
const notes = signal('');
const serialError = signal('');
const line = signal();


function loadLine({cart}) {
  line.value =
    cart.lineItems.find(needsSerialCheck) ?? cart.lineItems.find(needsNotes);
  serial.value = line.value?.properties[SERIAL] ?? '';
  notes.value = line.value?.properties[NOTES] ?? '';
}
... (2 unchanged lines)
  serialError.value = '';
  loadLine(shopify.resolution.event.value);
  shopify.resolution.event.subscribe(loadLine);
... (59 unchanged lines)
```

Register the save handler before rendering the interface. Shopify POS keeps only the most recently registered handler, so register it once when the target starts.

## /extensions/authenticity-check/src/CartResolution.jsx

```jsx
... (30 unchanged lines)
  await shopify.resolution.onSave(saveInspection);
... (57 unchanged lines)
```

When staff proceed, [`shopify.resolution.onSave()`](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/resolution-api#properties) checks the serial and saves the line properties. Shopify POS waits for the handler before it revalidates the cart.

The handler saves the serial and notes before the lookup. Because the interceptor passes a line only when `Demo checked serial` matches `Serial number`, a replaced serial can't pass on an old check, even if Shopify POS stops waiting after 10 seconds. After the lookup, a pass sets `Demo checked serial` to the serial and a rejection clears it. A failed lookup leaves it unchanged, so the new serial stays unchecked.

## /extensions/authenticity-check/src/CartResolution.jsx

```jsx
... (34 unchanged lines)
async function saveInspection() {
  if (!line.value) return;


  const {uuid} = line.value;
  const value = serial.value.trim();
  serialError.value = '';
  try {
    await shopify.cart.addLineItemProperties(uuid, {
      [SERIAL]: value,
      [NOTES]: notes.value.trim(),
    });
  } catch {
    shopify.toast.show('Could not save the inspection. Try again.');
    return;
  }


  try {
    const passed = await checkSerial(value);
    if (!passed) {
      serialError.value = 'The demo registry rejected this serial. Try DEMO-4.';
    }
    await shopify.cart.addLineItemProperties(uuid, {
      [CHECKED_SERIAL]: passed ? value : '',
    });
  } catch {
    serialError.value = 'Could not check the serial. Try again.';
  }
}
... (26 unchanged lines)
```

Render the line title, the serial field with any registry error, and the optional notes. Shopify POS provides the controls for proceeding.

## /extensions/authenticity-check/src/CartResolution.jsx

```jsx
... (63 unchanged lines)
function Extension() {
  if (!line.value) return <s-text>No items need attention.</s-text>;


  return (
    <s-stack gap="base">
      <s-heading>{line.value.title ?? line.value.sku}</s-heading>
      <s-text>Enter DEMO-4 to validate.</s-text>
      <s-text-field
        label="Serial number"
        value={serial.value}
        error={serialError.value || undefined}
        onInput={(event) => {
          serial.value = event.currentTarget.value;
        }}
      />
      <s-text-area
        label="Inspection notes (optional)"
        value={notes.value}
        onInput={(event) => {
          notes.value = event.currentTarget.value;
        }}
      />
    </s-stack>
  );
}
```

When Shopify POS revalidates, the interceptor reads the saved line item properties. The findings, not the save handler's return value, decide whether staff can proceed. For the APIs and components that you can use, see the [cart resolution target](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/targets/resolution-targets#cart-validations-resolution) and [supported components](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/targets/resolution-targets#supported-components).

## Test your extension

Use a dev extension to test local changes. Use the dev-store app preview to test saved POS editor settings.

### Build the extension

From your app root, run `shopify app build`. Shopify CLI bundles the extension and reports missing modules or imports. Unlike `shopify app dev`, this check doesn't require intercept preview approval.

### Test with a dev extension

From your app root, run `shopify app dev`. Follow [Connect your app and preview it in POS](https://shopify.dev/docs/apps/build/pos/getting-started#step-4-connect-the-shopify-app-and-the-extension-to-your-dev-store) to open the Dev Console and load the dev extension on your device through the mobile preview link. Local code changes update the dev extension without a separate deploy.

The dev extension doesn't use your saved POS editor settings, so its Required and Recommended labels don't confirm those settings.

1. Start a fresh cart and add one unit each of two products. Use a quantity of `1` for this test because the demo checks one serial per cart line, not per unit. The demo checks every cart line and doesn't filter by product or quantity. Check that each line has both the serial check and notes findings.
2. Open the resolution interface. Check that it shows the first line, and that the serial is required and the inspection notes are optional. The interface picks the line from the cart rather than from the finding you opened, because the resolution event contains the cart but not the selected finding.
3. Leave the notes blank, enter `DEMO-4`, and proceed. Check that the first line's Required finding clears and its Recommended notes finding remains.
4. Open the interface again. Check that it shows the second line, because serial checks come before notes. Enter `DEMO-4`, add inspection notes, and proceed. Check that both of the second line's findings clear.
5. Open the interface again. Check that it shows the first line, add inspection notes, and proceed. Check that all findings clear.
6. Check that the line item properties contain `Serial number` and `Demo checked serial` values of `DEMO-4`, along with the inspection notes.
7. In a fresh cart, enter `DEMO-1`, add inspection notes, and proceed. Check that the serial field shows the rejection, `Demo checked serial` is empty, and the Required finding remains.
8. Disconnect the device from the network, enter `DEMO-4`, and proceed. Check that the serial field reports the failed lookup and the Required finding remains. Reconnect before continuing.

### Configure the app preview in the POS editor

`shopify app dev` also builds and uploads an [app preview on your dev store](https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally). This preview uses uploaded extension code instead of the device's live dev extension.

1. Keep `shopify app dev` running, or rerun it from your app root. After your latest changes, wait for Shopify CLI to report that the dev preview is ready or updated.
2. On your device, open the [POS Dev Console](https://shopify.dev/docs/apps/build/pos/debugging#using-the-pos-dev-console-to-troubleshoot-and-refine) and select **Remove dev extensions**. Don't open the mobile preview link again during this pass because it reconnects the dev extension.
3. Keep the dev-store app preview. Don't run `shopify app dev clean` or select **Clean dev preview**, which would remove it and restore the released app version.
4. From the Shopify admin, go to **Sales channels** > **Point of Sale**.
5. Under **Customize the in-store experience**, click **Edit Point of Sale**. If this section isn't displayed, then use the [alternate settings route](https://help.shopify.com/en/manual/sell-in-person/shopify-pos/customize-pos/pos-editor#open-from-settings).
6. Click the apps icon in the editor menu bar. Under **Apps for Point of Sale**, find your app, and select its cart validation extension. For navigation help, see [Activate a POS app extension](https://help.shopify.com/en/manual/sell-in-person/getting-started/shopify-pos-from-admin/pos-ui-extensions#action-extensions).
7. Turn on **Activate on**. The extension lists **Cart, checkout** as its intercept surface.
8. Turn on **Allow app to block checkout**.
9. Leave **Block checkout if app experiences a problem** off for the normal validation checks.
10. Click **Save**, and then close and reopen the extension's configuration to check that the settings persist.

Click **Save** after every editor change, and reopen the settings to check persistence before testing the result in Shopify POS.

Use these configurations for the POS checks below:

* Turn on **Activate on** and **Allow app to block checkout** to test blocking.
* Keep **Activate on** on and turn off **Allow app to block checkout** to test recommendations.
* Turn off **Activate on** to test the inactive extension.

After testing with the extension off, turn **Activate on** and **Allow app to block checkout** back on, then click **Save**.

### Test the app preview in POS

Start with the extension active and allowed to block checkout. Return to Shopify POS with the device dev extension still disconnected, and run these checks:

1. Add one product with blank properties, and check that the serial finding is Required and the notes finding is Recommended.
2. Use `DEMO-4` to clear the Required finding. Leave the notes blank, and check that staff can proceed with the Recommended finding still present.
3. With **Allow app to block checkout** off, start a fresh cart with one product. Check that the serial finding is Recommended instead of Required and doesn't block staff from proceeding.
4. With **Activate on** off, start another fresh cart. Check that this app doesn't return cart findings.
5. With both settings back on, repeat the first check.

##### The event isn't supported

Use `cartvalidations` or `paymentvalidations`.

##### The event is supported but isn't configured

Add the event under `pos.app.ready.data`, and add its matching resolution target.

##### An interceptor is already registered

Register only one callback for that event in each extension instance, or call the existing callback's unregister function before replacing it.

##### The interceptor returned a `Promise`

Return `{operations: [...]}` synchronously. Perform the HTTP lookup in the resolution target, and save its result before Shopify POS revalidates.

## /extensions/authenticity-check/shopify.extension.toml

```toml
api_version = "2026-10"


[[extensions]]
type = "ui_extension"
name = "Authenticity check (demo)"
handle = "authenticity-check"
# Keep the uid generated for your own extension.


[extensions.supported_features]
runs_offline = true


[[extensions.targeting]]
target = "pos.app.ready.data"
module = "./src/Background.js"


[[extensions.targeting.capabilities.intercepts]]
event = "cartvalidations"
blocking = true


[[extensions.targeting]]
target = "pos.cart.validations.resolution.render"
module = "./src/CartResolution.jsx"
```

## /extensions/authenticity-check/src/authenticity.js

```javascript
export const SERIAL = 'Serial number';
export const CHECKED_SERIAL = 'Demo checked serial';
export const NOTES = 'Inspection notes';


export function needsSerialCheck(line) {
  const serial = line.properties[SERIAL];
  return !serial || line.properties[CHECKED_SERIAL] !== serial;
}


export function needsNotes(line) {
  return !line.properties[NOTES]?.trim();
}


export async function checkSerial(serial) {
  // Demo only: DEMO-4 maps to a fixture with `completed: true`. Every other value maps to one with `completed: false`.
  const id = serial === 'DEMO-4' ? 4 : 1;
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  if (!response.ok) throw new Error(`Registry returned ${response.status}`);
  const {completed} = await response.json();
  return completed === true;
}
```

## /extensions/authenticity-check/src/Background.js

```javascript
import {needsNotes, needsSerialCheck} from './authenticity.js';


export default function extension() {
  shopify.intercept('cartvalidations', ({cart}) => {
    const operations = [];
    for (const line of cart.lineItems) {
      const target = `$.cart.lineItems['${line.uuid}']`;


      if (needsSerialCheck(line)) {
        operations.push({
          validationAdd: {
            level: 'ERROR',
            handle: `authenticity-check-required-${line.uuid}`,
            target,
          },
        });
      }


      if (needsNotes(line)) {
        operations.push({
          validationAdd: {
            level: 'WARNING',
            handle: `inspection-notes-recommended-${line.uuid}`,
            target,
          },
        });
      }
    }
    return {operations};
  });
}
```

## /extensions/authenticity-check/src/CartResolution.jsx

```jsx
import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {signal} from '@preact/signals';


import {
  CHECKED_SERIAL,
  NOTES,
  SERIAL,
  checkSerial,
  needsNotes,
  needsSerialCheck,
} from './authenticity.js';


const serial = signal('');
const notes = signal('');
const serialError = signal('');
const line = signal();


function loadLine({cart}) {
  line.value =
    cart.lineItems.find(needsSerialCheck) ?? cart.lineItems.find(needsNotes);
  serial.value = line.value?.properties[SERIAL] ?? '';
  notes.value = line.value?.properties[NOTES] ?? '';
}


export default async () => {
  serialError.value = '';
  loadLine(shopify.resolution.event.value);
  shopify.resolution.event.subscribe(loadLine);


  await shopify.resolution.onSave(saveInspection);
  render(<Extension />, document.body);
};


async function saveInspection() {
  if (!line.value) return;


  const {uuid} = line.value;
  const value = serial.value.trim();
  serialError.value = '';
  try {
    await shopify.cart.addLineItemProperties(uuid, {
      [SERIAL]: value,
      [NOTES]: notes.value.trim(),
    });
  } catch {
    shopify.toast.show('Could not save the inspection. Try again.');
    return;
  }


  try {
    const passed = await checkSerial(value);
    if (!passed) {
      serialError.value = 'The demo registry rejected this serial. Try DEMO-4.';
    }
    await shopify.cart.addLineItemProperties(uuid, {
      [CHECKED_SERIAL]: passed ? value : '',
    });
  } catch {
    serialError.value = 'Could not check the serial. Try again.';
  }
}


function Extension() {
  if (!line.value) return <s-text>No items need attention.</s-text>;


  return (
    <s-stack gap="base">
      <s-heading>{line.value.title ?? line.value.sku}</s-heading>
      <s-text>Enter DEMO-4 to validate.</s-text>
      <s-text-field
        label="Serial number"
        value={serial.value}
        error={serialError.value || undefined}
        onInput={(event) => {
          serial.value = event.currentTarget.value;
        }}
      />
      <s-text-area
        label="Inspection notes (optional)"
        value={notes.value}
        onInput={(event) => {
          notes.value = event.currentTarget.value;
        }}
      />
    </s-stack>
  );
}
```

## Learn more

[Build a payment validation UI extension\
\
Require staff to confirm a cash count before Shopify POS accepts a large cash payment.](https://shopify.dev/docs/apps/build/pos/tutorials/build-payment-validation-extension)[Intercept API\
\
Explore callback types, validation operations, configuration rules, and when validation runs.](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/intercept-api)[Resolution targets\
\
Explore the cart and payment resolution targets, and the APIs and components that they support.](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/targets/resolution-targets)[Resolution API\
\
Read event data, save pending work, and see what Shopify POS does when staff proceed.](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/resolution-api)