---
title: Build a payment validation UI extension
description: Learn how to use POS intercepts to require a cash count before Shopify POS accepts a large cash payment, and build the interface where staff confirm it.
source_url:
  html: https://shopify.dev/docs/apps/build/pos/tutorials/build-payment-validation-extension
  md: https://shopify.dev/docs/apps/build/pos/tutorials/build-payment-validation-extension.md
---

# Build a payment validation UI extension

**Feature preview:**

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

Require staff to confirm that they counted cash payments of 100 or more. Shopify Point of Sale (POS) opens your confirmation interface before accepting the payment, then checks it again after staff tap **Done**.

## What you'll learn

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

* Generate a POS intercept extension.
* Configure a payment interceptor and its resolution target.
* Return a blocking finding for large cash payments.
* Save a confirmation from payment resolution that the interceptor reads.
* Test the payment validation 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 SDK doesn't grant access.

## Generate the extension

Generate a payment 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 payment intercept template catalog:

## Terminal

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

Generate the extension using that catalog and template branch:

## Terminal

```sh
SHOPIFY_CLI_APP_TEMPLATES_JSON_PATH="/tmp/templates.pos-payment-intercept.json" \
  shopify app generate extension \
  --template pos_payment_intercept \
  --name cash-count-confirmation \
  --clone-url "https://github.com/Shopify/extensions-templates#pos-payment-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/cash-count-confirmation/` with a starter example in `src/payment_intercept/` and the paired background and payment resolution targets. The following steps replace that starter example with the cash count confirmation example. All file paths in those steps are relative to this extension directory. Keep its generated `uid`.

## Configure intercepts

Update `extensions/cash-count-confirmation/shopify.extension.toml`, keeping its `name`, `handle`, and generated `uid`. Use `api_version = "2026-10"` and set `runs_offline = true` under `[extensions.supported_features]`.

Configure `pos.app.ready.data` to use `./src/Background.js`. Set `blocking = true` because the callback returns an `ERROR` finding, and set `applies_to = ["cash"]`, the only supported payment method.

## /extensions/cash-count-confirmation/shopify.extension.toml

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


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

Add the matching payment resolution target, pointing it at `./src/PaymentResolution.jsx`. Every intercepted event needs one.

## /extensions/cash-count-confirmation/shopify.extension.toml

```toml
... (20 unchanged lines)
[[extensions.targeting]]
target = "pos.payment.validations.resolution.render"
module = "./src/PaymentResolution.jsx"
```

Shopify CLI checks the TOML structure. When you deploy, Shopify also checks the event names, payment method values, 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).

## Share the cash count constants

The interceptor and the resolution interface use the same threshold, storage key, and confirmation format.

Create `src/cashCount.js` so that both files use one definition. The resolution interface saves the cash amount received and its currency so that the interceptor can recognize the confirmation when Shopify POS revalidates the payment.

`cashAmountLabel()` combines the amount and currency code, so a confirmation saved for one currency doesn't match a payment in another.

## Register an interceptor

Create `src/Background.js`. Register a callback for the `paymentvalidations` event from the target's default entry function. Shopify POS runs the callback when staff select or confirm cash, before Shopify POS adds the payment.

Read the storage key when the background target starts to begin loading it. Until it loads, its [`shopify.storage.current`](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/platform-apis/storage-api) value is `undefined`, so the interceptor treats the payment as unconfirmed.

## /extensions/cash-count-confirmation/src/Background.js

```javascript
... (7 unchanged lines)
  // The first read of a key starts loading it. Until it loads, its value is undefined.
  void shopify.storage.current[CASH_COUNT_KEY];
... (39 unchanged lines)
```

Clear the saved confirmation whenever the cart has no items. Check the cart when the background target starts and whenever it changes.

Keep confirmation unavailable until deletion succeeds.

Identical split cash payments within the same cart can reuse the confirmation until the cart empties.

## /extensions/cash-count-confirmation/src/Background.js

```javascript
... (10 unchanged lines)
  let confirmationReady = true;
  let resetPending = false;
  async function resetConfirmation({lineItems}) {
    if (lineItems.length > 0 || resetPending) return;


    confirmationReady = false;
    resetPending = true;
    confirmationReady = await shopify.storage.delete(CASH_COUNT_KEY).then(
      () => true,
      () => false,
    );
    resetPending = false;
  }


  void resetConfirmation(shopify.cart.current.value);
  shopify.cart.current.subscribe(resetConfirmation);
... (22 unchanged lines)
```

Register the callback and return an empty `operations` array for cash amounts below the threshold. The `amount` field contains the cash amount received, not the amount charged, as a string.

The callback must return `{operations: [...]}` synchronously, not a `Promise`.

## /extensions/cash-count-confirmation/src/Background.js

```javascript
... (27 unchanged lines)
  shopify.intercept('paymentvalidations', ({amount}) => {
    if (Number(amount.amount) < CASH_COUNT_THRESHOLD) return {operations: []};
... (19 unchanged lines)
```

Read the saved confirmation from `shopify.storage.current`, which returns the stored value synchronously. When confirmation is available and matches the cash amount received, return no findings. A missing, unloaded, or mismatched confirmation leaves the payment unconfirmed.

## /extensions/cash-count-confirmation/src/Background.js

```javascript
... (30 unchanged lines)
    const confirmation = shopify.storage.current[CASH_COUNT_KEY]?.value;
    if (confirmationReady && confirmation?.amount === cashAmountLabel(amount)) {
      return {operations: []};
    }
... (14 unchanged lines)
```

Otherwise, return an `ERROR` finding targeting `$.payment`. After staff select the cash tendered amount, Shopify POS opens payment resolution, even if the store labels the finding **Recommended**. For the supported fields, see [Validation operations](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/intercept-api#validation-operations).

## /extensions/cash-count-confirmation/src/Background.js

```javascript
... (35 unchanged lines)
    return {
      operations: [
        {
          validationAdd: {
            level: 'ERROR',
            handle: 'cash-count-required',
            target: '$.payment',
          },
        },
      ],
    };
  });
... (1 unchanged line)
```

## Build the resolution interface

Payment resolution receives the payment method and cash amount received. It can read the cart but can't change it, so save the confirmation with the [Storage API](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/platform-apis/storage-api).

Create `src/PaymentResolution.jsx` with the form shown in the code panel. Store the form state in signals. Reset the confirmation and error at each launch so a previous count doesn't check the switch for a new payment. Keep those values during revalidation within the same flow. `countMissing` tracks whether staff tried to continue without confirming the count.

## /extensions/cash-count-confirmation/src/PaymentResolution.jsx

```jsx
... (6 unchanged lines)
const counted = signal(false);
const countMissing = signal(false);
... (2 unchanged lines)
  counted.value = false;
  countMissing.value = false;
... (43 unchanged lines)
```

Render the cash amount received and a switch that staff turn on after they count the cash. When `countMissing` is set, the switch shows an error that says what's incomplete.

## /extensions/cash-count-confirmation/src/PaymentResolution.jsx

```jsx
... (31 unchanged lines)
function Extension() {
  const {amount} = shopify.resolution.event.value;


  return (
    <s-stack gap="base">
      <s-text>
        Cash received: {amount.amount} {amount.currencyCode}
      </s-text>
      <s-switch
        label="I counted the cash received"
        checked={counted.value}
        error={
          countMissing.value
            ? 'Count the cash, then confirm to continue.'
            : undefined
        }
        onChange={(event) => {
          counted.value = event.currentTarget.checked;
          countMissing.value = false;
        }}
      />
    </s-stack>
  );
}
```

Register a save handler before rendering the interface. Shopify POS keeps only the most recently registered handler, so register it once, when the target starts, and don't unregister it.

## /extensions/cash-count-confirmation/src/PaymentResolution.jsx

```jsx
... (13 unchanged lines)
  await shopify.resolution.onSave(saveCashCount);
... (41 unchanged lines)
```

Use [`shopify.resolution.onSave()`](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/resolution-api#properties) to save the confirmation when staff tap **Done**, before Shopify POS checks the payment again. If staff haven't confirmed the count, then show the error and save nothing. If saving fails, show a retry toast. In either case, the finding remains unresolved.

## /extensions/cash-count-confirmation/src/PaymentResolution.jsx

```jsx
... (17 unchanged lines)
async function saveCashCount() {
  countMissing.value = !counted.value;
  if (!counted.value) return;


  const {amount} = shopify.resolution.event.value;
  try {
    await shopify.storage.set(CASH_COUNT_KEY, {
      amount: cashAmountLabel(amount),
    });
  } catch {
    shopify.toast.show('Could not save the cash count. Try again.');
  }
}
... (25 unchanged lines)
```

After staff finish the resolution flow with no blocking findings remaining, Shopify POS closes the interface and resumes the original payment. Closing the interface or navigating back dismisses it without resuming the payment, even when the findings are Recommended.

Validation findings decide whether the payment continues, not the save handler's return value. See [Revalidation](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/contextual-apis/resolution-api#revalidation).

After the new modules are in place, delete the template's `src/payment_intercept/` directory. The configuration now points at `src/Background.js` and `src/PaymentResolution.jsx`, which use `src/cashCount.js`.

## 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 Shopify 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.

1. Start a sale and pay with less than 100 in cash. Check that the callback returns no finding.
2. Start another sale and pay in full with 100 or more in cash. Check that Shopify POS opens the payment resolution interface with the cash amount received.
3. Turn on the confirmation switch and tap **Done**. Check that the payment resumes and the sale completes. Change the form or callback and repeat with the same cash amount in a new sale to check your local changes and the confirmation reset.

### 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 rather than 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: 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 payment 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 **Cash payments** as its intercept surface.
8. Turn on **Allow app to block checkout**. Despite its label, this setting also controls blocking for payment validations.
9. Leave **Block checkout if app experiences a problem** off for the normal validation checks.
10. Click **Save**, 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 Shopify 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 Shopify 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. Start a sale and pay with less than 100 in cash. Shopify POS should add the payment without a finding.
2. Start another sale and pay in full with 100 or more in cash. Shopify POS should open the payment resolution interface with the cash amount received.
3. Tap **Done** without turning on the switch. The error should appear, and the interface should stay open.
4. Turn on the switch and tap **Done**. Shopify POS should close the interface, resume the payment, and complete the sale. Check that the cart is empty.
5. Start another sale and pay the same cash amount of 100 or more. Shopify POS should open payment resolution with the switch off. Tap **Done** without confirming again. The error should appear, and the interface should stay open.
6. Close the interface without confirming. Shopify POS shouldn't add the payment.
7. Start that unconfirmed payment again. The switch should still be off, and the previous error should be gone until staff tap **Done**.
8. With **Allow app to block checkout** off, start an unconfirmed cash payment of 100 or more. The finding should be Recommended instead of Required, and staff should be able to proceed without confirming the count.
9. With **Activate on** off, start another unconfirmed cash payment of 100 or more. This app shouldn't open payment resolution.
10. With both settings back on, repeat steps 2–4.

##### 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. Read stored values through `shopify.storage.current` instead of awaiting `shopify.storage.get()`.

##### Validation doesn't run again after a failed payment

Retrying a failed payment submission doesn't run payment validation again. Start the cash payment again to rerun the interceptor.

## /extensions/cash-count-confirmation/shopify.extension.toml

```toml
api_version = "2026-10"


[[extensions]]
type = "ui_extension"
name = "Cash count confirmation"
handle = "cash-count-confirmation"
# 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 = "paymentvalidations"
blocking = true
applies_to = ["cash"]


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

## /extensions/cash-count-confirmation/src/cashCount.js

```javascript
export const CASH_COUNT_THRESHOLD = 100;
export const CASH_COUNT_KEY = 'cash-count-confirmation';


export function cashAmountLabel({amount, currencyCode}) {
  return `${amount} ${currencyCode}`;
}
```

## /extensions/cash-count-confirmation/src/Background.js

```javascript
import {
  CASH_COUNT_KEY,
  CASH_COUNT_THRESHOLD,
  cashAmountLabel,
} from './cashCount.js';


export default function extension() {
  // The first read of a key starts loading it. Until it loads, its value is undefined.
  void shopify.storage.current[CASH_COUNT_KEY];


  let confirmationReady = true;
  let resetPending = false;
  async function resetConfirmation({lineItems}) {
    if (lineItems.length > 0 || resetPending) return;


    confirmationReady = false;
    resetPending = true;
    confirmationReady = await shopify.storage.delete(CASH_COUNT_KEY).then(
      () => true,
      () => false,
    );
    resetPending = false;
  }


  void resetConfirmation(shopify.cart.current.value);
  shopify.cart.current.subscribe(resetConfirmation);


  shopify.intercept('paymentvalidations', ({amount}) => {
    if (Number(amount.amount) < CASH_COUNT_THRESHOLD) return {operations: []};


    const confirmation = shopify.storage.current[CASH_COUNT_KEY]?.value;
    if (confirmationReady && confirmation?.amount === cashAmountLabel(amount)) {
      return {operations: []};
    }


    return {
      operations: [
        {
          validationAdd: {
            level: 'ERROR',
            handle: 'cash-count-required',
            target: '$.payment',
          },
        },
      ],
    };
  });
}
```

## /extensions/cash-count-confirmation/src/PaymentResolution.jsx

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


import {CASH_COUNT_KEY, cashAmountLabel} from './cashCount.js';


const counted = signal(false);
const countMissing = signal(false);


export default async () => {
  counted.value = false;
  countMissing.value = false;


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


async function saveCashCount() {
  countMissing.value = !counted.value;
  if (!counted.value) return;


  const {amount} = shopify.resolution.event.value;
  try {
    await shopify.storage.set(CASH_COUNT_KEY, {
      amount: cashAmountLabel(amount),
    });
  } catch {
    shopify.toast.show('Could not save the cash count. Try again.');
  }
}


function Extension() {
  const {amount} = shopify.resolution.event.value;


  return (
    <s-stack gap="base">
      <s-text>
        Cash received: {amount.amount} {amount.currencyCode}
      </s-text>
      <s-switch
        label="I counted the cash received"
        checked={counted.value}
        error={
          countMissing.value
            ? 'Count the cash, then confirm to continue.'
            : undefined
        }
        onChange={(event) => {
          counted.value = event.currentTarget.checked;
          countMissing.value = false;
        }}
      />
    </s-stack>
  );
}
```

## Learn more

[Build a cart validation UI extension\
\
Check a demo product serial number, and collect recommended inspection notes before checkout.](https://shopify.dev/docs/apps/build/pos/tutorials/build-cart-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 they support.](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/targets/resolution-targets)[Storage API\
\
Store values that other targets of your extension can read.](https://shopify.dev/docs/api/pos-ui-extensions/2026-10/target-apis/platform-apis/storage-api)