Skip to main content

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.

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

Use an approved app

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.

Anchor to Generate the extensionGenerate 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

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

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.

Anchor to Configure interceptsConfigure 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.

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

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.

Anchor to Share the cash count constantsShare 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.

Anchor to Register an interceptorRegister 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 value is undefined, so the interceptor treats the payment as unconfirmed.

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.

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.

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.

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.

Anchor to Build the resolution interfaceBuild 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.

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.

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.

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.

Use shopify.resolution.onSave() 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.

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.

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.

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

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.

Anchor to Test with a dev extensionTest with a dev extension

From your app root, run shopify app dev. Follow Connect your app and preview it in Shopify POS 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.

Anchor to Configure the app preview in the POS editorConfigure the app preview in the POS editor

shopify app dev also builds and uploads an app preview on your dev store. 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 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.
  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.
  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.

Anchor to Test the app preview in Shopify POSTest 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.
Troubleshooting
Anchor to The event isn't supportedThe event isn't supported

Use cartvalidations or paymentvalidations.

Anchor to The event is supported but isn't configuredThe event is supported but isn't configured

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

Anchor to An interceptor is already registeredAn 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.

Anchor to The interceptor returned a PromiseThe interceptor returned a Promise

Return {operations: [...]} synchronously. Read stored values through shopify.storage.current instead of awaiting shopify.storage.get().

Anchor to Validation doesn't run again after a failed paymentValidation 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.

Was this page helpful?