Skip to main content

Shop Pay in Checkout Kit

You build with Checkout Kit, Shopify's open-source SDK for embedding checkout in any surface. Shop Pay is one import from it, @shopify/checkout-kit/shop-pay, which adds a <shop-pay> element to your page. The element opens Shop Pay with your session and tells you how the purchase goes, and you never collect or hold the buyer's actual payment details.

Today, Shop Pay opens a checkout that Shopify creates from your carts, for merchants on Shopify, and the element runs in browsers and in your app's web view. Support for any merchant that accepts Shop Pay, and for Checkout Kit's Swift, Kotlin, and React Native SDKs, is coming soon.

shop-pay.js

import '@shopify/checkout-kit/shop-pay';

const shopPay = document.querySelector('shop-pay');
shopPay.src = continueUrl; // the Universal session URL that your server created
shopPay.button = true;
shopPay.addEventListener('complete', ({detail}) =>
showConfirmation(detail.map(({checkout}) => checkout.order)), // the merchants that completed in this step
);

Your server creates a cart at each merchant and combines the carts into one universal session. Shop Pay opens in its own window over your page, either from Shop Pay's button, placed where your other wallet buttons go, or from your own pay button, with Shop Pay as a row in your payment list. The buyer signs in and pays, Shop Pay completes the checkout at each merchant, and your page receives the completed checkouts with their orders.

The calls between your page, Shop Pay, your server, and the merchant to complete a purchase.

Anchor to Build your integrationBuild your integration

Early access

Universal Checkout is in early access and available by request. Tool names, inputs, and response fields might change before general availability. To request access for your API key, submit the Universal Checkout early access request form.

  • API credentials from Catalogs in the Dev Dashboard, with your client ID and client secret. Your server exchanges them for an access token, as Authentication describes, and sends it as a Bearer token in every request below.

Anchor to Step 1: Prepare the checkoutStep 1: Prepare the checkout

On your server, create a cart at each merchant with the create_cart tool on its Cart MCP endpoint. Then create one Universal session from the carts with the create_checkout tool at https://catalog.shopify.com/api/ucp/mcp. Pass up to 15 carts, one for each merchant, all in the same currency.

create_checkout returns a continue_url, the Universal session URL, that your page hands to Shop Pay in the next step. It gives access to the buyer's checkout, so return it to your page only, and don't log it. Prepare a new checkout whenever the cart changes.

server.js

// Your profile must have ucp.version 'draft' and declare dev.shopify.shopping.universal_checkout.
const PROFILE = 'https://your-app.example.com/.well-known/ucp';

async function callTool(endpoint, name, args, {token, buyerIp}) {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(buyerIp && {'Shopify-Buyer-IP': buyerIp}),
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
id: 1,
params: {name, arguments: {meta: {'ucp-agent': {profile: PROFILE}}, ...args}},
}),
});
const {result, error} = await response.json();
if (error) throw new Error(error.message); // a JSON-RPC error: fix the request
return result;
}

/** The merchant's myshopify.com domain from your own records, never from the browser. */
function shopDomainFor(merchantId) {
const shopDomain = merchants.get(merchantId)?.shopDomain; // for example, stored from Global Catalog results
if (!/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(shopDomain ?? '')) throw new Error('Unknown merchant');
return shopDomain;
}

/** lineItems: [{item: {id: 'gid://shopify/ProductVariant/…'}, quantity: 1}] */
async function createCart(shopDomain, lineItems, auth) {
const result = await callTool(`https://${shopDomain}/api/ucp/mcp`, 'create_cart', {cart: {line_items: lineItems}}, auth);
if (result.isError) throw new Error(`${shopDomain} didn't create a cart`);
return result.structuredContent; // the cart: {id: 'gid://shopify/Cart/…?key=…', …}
}

const GLOBAL_UCP = 'https://catalog.shopify.com/api/ucp/mcp';

app.post('/api/shop-pay/session', async (request, response) => {
// [{merchantId, lineItems}], one for each merchant. Resolve each domain on the server.
const carts = request.body.carts.map(({merchantId, lineItems}) => ({shopDomain: shopDomainFor(merchantId), lineItems}));
const auth = {token: await getAccessToken(), buyerIp: request.ip}; // getAccessToken: see Authentication
const created = await Promise.all(
carts.map(({shopDomain, lineItems}) => createCart(shopDomain, lineItems, auth)),
);
const result = await callTool(GLOBAL_UCP, 'create_checkout', {cart_ids: created.map((cart) => cart.id)}, {token: auth.token});
const {ucp, continue_url, messages = []} = result.structuredContent;
if (result.isError || ucp.status !== 'success') {
console.warn('No combined checkout:', messages.map((message) => message.code)); // never log continue_url
return response.status(422).json({});
}
response.json({continueUrl: continue_url});
});

{} The result of create_checkout

{
"structuredContent": {
"ucp": {"version": "draft", "status": "success"},
"continue_url": "https://shop.app/checkouts/uc/…",
"carts": [
{"id": "gid://shopify/Cart/shop1-cart?key=…", "continue_url": "https://shop1.myshopify.com/cart/c/shop1-cart?key=…"},
{"id": "gid://shopify/Cart/shop2-cart?key=…", "continue_url": "https://shop2.myshopify.com/cart/c/shop2-cart?key=…"}
],
"messages": []
}
}

Anchor to Step 2: Open Shop PayStep 2: Open Shop Pay

Add a <shop-pay> element to your page. Importing @shopify/checkout-kit/shop-pay registers the element. When your server returns the combined checkout, set its continue_url as the element's src. Whenever the cart changes, take Shop Pay away and clear src before you ask for a new combined checkout, and offer Shop Pay again only with the new checkout. Otherwise Shop Pay can pay for a cart that your page no longer shows. If requests can overlap, then keep only the latest response.

With target="popup", Shop Pay opens in a pop-up window over your page. Without it, Shop Pay opens in a new tab. While Shop Pay is open, the element shows an overlay over your page, with a button that brings Shop Pay's window to the front and a button that closes it.

What opens Shop Pay is up to you: Shop Pay's button, or your own pay button.


Add the element where your other wallet buttons go, and set button after you set src. The element draws the Shop Pay button that buyers trust to accelerate checkouts, and a tap opens Shop Pay. Hide the button whenever you clear src.

Shop Pay's button: the Shop wordmark on a purple button.

checkout.html

<div id="wallet-buttons">
<shop-pay id="shop-pay" target="popup"></shop-pay>
</div>

shop-pay.js

import '@shopify/checkout-kit/shop-pay';

const shopPay = document.querySelector('#shop-pay');

let latest = 0;

/** Call it whenever the cart changes. carts: [{merchantId, lineItems}] */
async function showShopPay(carts) {
const request = ++latest;
shopPay.button = false;
shopPay.removeAttribute('src'); // the old checkout no longer matches the cart
const response = await fetch('/api/shop-pay/session', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({carts}),
});
if (!response.ok || request !== latest) return; // leave Shop Pay out, or a newer cart replaced this one
const {continueUrl} = await response.json();
if (request !== latest) return;
shopPay.src = continueUrl;
shopPay.button = true;
}

Add Shop Pay as a row in your payment list, and add the element without the button attribute anywhere in your page. The element draws nothing until Shop Pay opens, but it must stay in the page, because it shows the overlay and receives Shop Pay's messages. Hide the Shop Pay row whenever you clear src.

Create the combined checkout before the buyer presses your pay button, for example when your checkout page loads. When the buyer presses your pay button with Shop Pay selected, call open() from the click handler itself. Browsers open a window only in response to the buyer's click, so a click handler that waits for your server first loses the chance to open it.

A payment method list with Shop Pay selected, above Apple Pay, PayPal, Venmo, and a credit or debit card.

Download the Shop Pay icon (SVG)

checkout.html

<fieldset id="payment-methods">
<label><input type="radio" name="payment" value="shop_pay" /> Shop Pay</label>
<label><input type="radio" name="payment" value="card" /> Credit card</label>
</fieldset>
<button id="pay-button" type="button">Pay now</button>
<shop-pay id="shop-pay" target="popup"></shop-pay>

shop-pay.js

import '@shopify/checkout-kit/shop-pay';

const shopPay = document.querySelector('#shop-pay');

let latest = 0;

/** Call it whenever the cart changes. carts: [{merchantId, lineItems}] */
async function offerShopPay(carts) {
const request = ++latest;
hideShopPayRow();
shopPay.removeAttribute('src'); // the old checkout no longer matches the cart
const response = await fetch('/api/shop-pay/session', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({carts}),
});
if (!response.ok || request !== latest) return; // leave Shop Pay out, or a newer cart replaced this one
const {continueUrl} = await response.json();
if (request !== latest) return;
shopPay.src = continueUrl;
showShopPayRow();
}

document.querySelector('#pay-button').addEventListener('click', () => {
if (selectedPaymentMethod() === 'shop_pay') shopPay.open(); // in the click itself
});

Anchor to Step 3: Follow the purchaseStep 3: Follow the purchase

Shop Pay opens, and the buyer signs in and reviews the purchase. When the checkouts become interactive, the element dispatches start. When a checkout changes, for example when the buyer's Shop Pay address replaces yours or the buyer picks a shipping option, the element dispatches update. Shop Pay applies each change at the merchant for you.

Each event's detail lists only the checkouts that it describes, as {context, checkout} entries. context.shopId names the merchant. The element keeps no state for you to read back, so keep the latest entry for each merchant yourself, and add your listeners before you call open().

shop-pay.js

shopPay.addEventListener('start', ({detail}) => showProgress(detail));
shopPay.addEventListener('update', ({detail}) => showProgress(detail));

function showProgress(entries) {
for (const {context, checkout} of entries) {
const status =
!context.status || context.status === 'active' ? checkout.status : context.status;
showMerchantStatus(context.shopId, status, checkout.totals);
}
}

Anchor to Step 4: Show your confirmationStep 4: Show your confirmation

When the buyer presses Pay now, which happens once for the whole cart, Shop Pay completes each checkout at its merchant. Each merchant that completes arrives in a complete event while the others can still send updates, so one complete doesn't end the session. The session ends after one merchant has completed and every other merchant has completed, failed, or left, and Shop Pay's window stays open until you call close().

In each completed checkout, status is completed, and order contains that merchant's order, with its id and label. Shop Pay doesn't show an order confirmation. Show yours, with one order for each merchant, using order.label as the order number.

Use order.id on your server to follow each order with Order MCP and order webhooks, which return the order's permalink_url for the buyer's order status page. Each merchant also sends the buyer its usual order confirmation.

shop-pay.js

shopPay.addEventListener('complete', ({detail}) => {
for (const {context, checkout} of detail) showOrder(context.shopId, checkout.order); // {id, label}; your server adds permalink_url
});

The steps above are the happy path. Handle the following too.


Anchor to The buyer closes Shop PayThe buyer closes Shop Pay

If the buyer closes Shop Pay's window before paying, or closes it from the overlay, then the element dispatches close. Nothing is completed at the merchants, and the buyer can open Shop Pay again with the same session, or pay another way. A close with no earlier start, update, or error means that the buyer left Shop Pay's sign-in page before any checkout was shown.

To close Shop Pay from your page, call close(). Changing src or target also closes it.

shop-pay.js

shopPay.addEventListener('close', () => {
showPaymentMethods();
});

Anchor to A merchant drops outA merchant drops out

In a session with several merchants, one merchant can fail while the others continue. An update reports that merchant with context.status set to failed, which Shop Pay can still retry, or ejected, which means that the merchant left the session. A merchant that can't continue also arrives in error, as an entry whose context.shopId names it. The other checkouts carry on, and complete reports only the merchants that completed. Tell the buyer which items weren't bought.

shop-pay.js

shopPay.addEventListener('update', ({detail}) => {
for (const {context, checkout} of detail) {
if (context.status === 'ejected') showNotBought(checkout.lineItems);
}
});

Anchor to Shop Pay can't continueShop Pay can't continue

If Shop Pay can't continue, then the element dispatches error with an entry that has no context.shopId, and the session ends. Each entry has an error with a code and a message. Use code to decide what to do, and treat message as diagnostic text. Offer another payment method, and create a new combined checkout if the buyer wants to try Shop Pay again.

The element also reports problems on your page this way, before it opens Shop Pay, as entries with no context at all: unsupported_browser when the browser lacks a feature that Shop Pay needs, and sdk_error when src is empty or isn't an HTTPS URL.

shop-pay.js

shopPay.addEventListener('error', ({detail}) => {
for (const {context, error} of detail) {
if (context?.shopId) continue; // one merchant: see A merchant drops out
showPaymentError(error.code);
showPaymentMethods();
}
});

{} The detail of an error event

[{"error": {"code": "unsupported_browser", "message": "This browser does not support: …"}}]

Anchor to The browser blocks Shop Pay's windowThe browser blocks Shop Pay's window

Browsers open a window only in response to the buyer's tap. Shop Pay's button opens Shop Pay from the tap itself. With your own button, call open() directly from the buyer's click, and never from a blocked listener, a timer, or after an await. If the browser blocks the window anyway, then the element dispatches blocked, and its overlay offers the buyer a button that opens Shop Pay again. If the buyer dismisses the overlay instead, then the element dispatches close.

shop-pay.js

shopPay.addEventListener('blocked', () => {
showPopupHint(); // optional: tell the buyer to allow pop-ups
});

Anchor to Your page runs in your app's web viewYour page runs in your app's web view

Checkout Kit for the web works the same in a native app's web view, and needs three things from the app:

  • JavaScript and DOM storage, which Android web views don't enable by default.
  • Your app's shared, persistent cookie store, so that the buyer signs in to Shop Pay on shop.app once per install.
  • A window for Shop Pay. The <shop-pay> element opens Shop Pay with window.open. Create that window as a web view of its own, on the same cookie store, present it in your own UI with a close button, and dismiss it when the page closes it.

CheckoutViewController.swift

import WebKit

final class CheckoutViewController: UIViewController, WKUIDelegate {
private lazy var webView: WKWebView = {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default() // your app's shared, persistent cookie store
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.uiDelegate = self
return webView
}()

// The <shop-pay> element opens Shop Pay with window.open. WebKit requires the configuration it passes here,
// which shares the cookie store with your page.
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
let sheet = WKWebView(frame: .zero, configuration: configuration)
sheet.uiDelegate = self
presentSheet(sheet) // your UI: a sheet with a close button
return sheet
}

// The page closes the window when the purchase ends.
func webViewDidClose(_ webView: WKWebView) {
dismissSheet(webView)
}
}

CheckoutWebView.kt

import android.os.Message
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView

fun configure(webView: WebView) {
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
javaScriptCanOpenWindowsAutomatically = true
setSupportMultipleWindows(true)
}
CookieManager.getInstance().apply {
setAcceptCookie(true)
}
}

class CheckoutChromeClient(private val activity: Activity) : WebChromeClient() {
// The <shop-pay> element opens Shop Pay with window.open.
override fun onCreateWindow(view: WebView, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message): Boolean {
val sheet = WebView(activity).also(::configure) // the default CookieManager, so the same cookies
sheet.webChromeClient = this
showSheet(sheet) // your UI: a dialog or bottom sheet with a close button
(resultMsg.obj as WebView.WebViewTransport).webView = sheet
resultMsg.sendToTarget()
return true
}

// The page closes the window when the purchase ends.
override fun onCloseWindow(window: WebView) {
hideSheet(window)
}
}

<shop-pay> is the whole interface: an element with attributes, two methods, and six events. Every event is dispatched on the element and doesn't bubble, so add your listeners to the element itself, before you call open(). Each attribute is also a property.


src

• string Required

The Universal session URL: the continue_url that create_checkout returned to your server. Changing it closes Shop Pay if it's open.


button

• boolean

Draws Shop Pay's button in the element. A tap calls open(). Without it, the element draws nothing, and you call open() from your own button.


target

• string

Where Shop Pay opens: popup for a pop-up window over your page, auto (the default) or _blank for a new tab, or the name of a window. Changing it closes Shop Pay if it's open.



open()

• method

Opens Shop Pay with the session in src. Shop Pay's button calls it on tap. With your own pay button, call it from the click handler itself.


close()

• method

Closes Shop Pay and the overlay. The element dispatches close.



start

• event

The checkouts became interactive. detail is an array of {context, checkout} entries.


update

• event

Checkouts changed, or a merchant's context.status changed. detail is an array of {context, checkout} entries for the merchants that changed.


complete

• event

Merchants completed. detail is an array of {context, checkout} entries for the merchants that completed in this step. The others can continue, so a session can dispatch complete more than once.


error

• event

detail is an array of {context, checkout, error} entries. An entry with context.shopId belongs to that merchant and leaves the session open. Any other error ends it.


close

• event

Shop Pay closed. detail is null. Closing says nothing about whether the purchase completed.


blocked

• event

The browser blocked Shop Pay's window. detail is {code: 'popup_blocked'}. The overlay offers the buyer a retry, and dismissing it dispatches close.


The shop-pay element

import '@shopify/checkout-kit/shop-pay'; // registers <shop-pay>

interface ShopPay extends HTMLElement {
src: string;
button: boolean;
target: 'auto' | 'popup' | '_blank' | string;
open(): void;
close(): void;
}

// The detail of start, update, and complete
interface ShopPayResourceEventEntry {
readonly context: {
readonly sessionId: string;
readonly revision: number;
readonly shopId: string;
readonly status?: 'active' | 'completed' | 'failed' | 'ejected' | 'unknown';
};
readonly checkout: Checkout; // a UCP checkout, camelCase, without ucp
}

// The detail of error
interface ShopPayErrorEventEntry {
readonly context?: {readonly sessionId: string; readonly revision: number; readonly shopId?: string}; // absent before a session
readonly checkout?: Checkout; // the merchant's final checkout, when there is one
readonly error: {readonly code: string; readonly message: string};
}

{} The detail of a complete event

[
{
"context": {"sessionId": "…", "revision": 7, "shopId": "79270248665", "status": "completed"},
"checkout": {
"id": "gid://shopify/Checkout/abc123",
"status": "completed",
"currency": "USD",
"lineItems": [...],
"totals": [...],
"order": {
"id": "gid://shopify/Order/1042",
"label": "#1042",
"permalinkUrl": "https://…"
}
}
}
]

  • Checkout Kit: The SDK that adds Shop Pay to your page, and embeds a merchant's full Shopify checkout too.
  • Cart MCP: Create the carts that you combine into one checkout.
  • Build a Universal Checkout: The create_checkout tool in full, with its requests, responses, and message codes.
  • Order MCP: Follow the orders that buyers place with Shop Pay.
  • Shop Pay in UCP: The handler that merchants advertise to accept Shop Pay.

Was this page helpful?