Skip to main content

Build a Universal Checkout

Universal Checkout turns one or more carts into one combined checkout. When a buyer finds products from multiple Shopify merchants, you can send them to one combined checkout for all of those items, instead of a separate checkout for each store.

In this tutorial, you'll create carts for two merchants using the Cart MCP, then pass both cart IDs to the create_checkout tool on the global UCP endpoint. The tool returns a top-level continue_url that leads to a combined checkout, and a continue_url for each cart's separate checkout that you can use if a combined checkout isn't available.

A buyer reviews items from several merchants and pays for them in one combined checkout.
A buyer checks out with items from several merchants in one combined checkout.
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.


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

  • Create multiple merchant carts.
  • Create a combined checkout from multiple merchant carts.
  • Render the combined checkout with Checkout Kit.
  • Listen to checkout events and update your app.
  • Handle carts that can't join a combined checkout.


Anchor to Step 1: Create a cart for each merchantStep 1: Create a cart for each merchant

Universal Checkout combines carts. Create one cart per merchant with the create_cart tool on the Cart MCP. In this example, the buyer adds one item from shop1.myshopify.com and two items from shop2.myshopify.com.

POST

https://shop1.myshopify.com/api/ucp/mcp

{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 1,
"params": {
"name": "create_cart",
"arguments": {
"meta": { "ucp-agent": { "profile": "https://shopify.dev/ucp/agent-profiles/draft/valid-with-capabilities.json" } },
"cart": {
"line_items": [
{ "item": { "id": "gid://shopify/ProductVariant/111" }, "quantity": 1 }
]
}
}
}
}
POST

https://shop2.myshopify.com/api/ucp/mcp

{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 1,
"params": {
"name": "create_cart",
"arguments": {
"meta": { "ucp-agent": { "profile": "https://shopify.dev/ucp/agent-profiles/draft/valid-with-capabilities.json" } },
"cart": {
"line_items": [
{ "item": { "id": "gid://shopify/ProductVariant/222" }, "quantity": 2 }
]
}
}
}
}

Each response includes a cart id that ends with a key parameter. Store both IDs exactly as they're returned, including the key. Treat them as opaque values, and don't merge cart lines or build checkout URLs yourself.

If you call Cart MCP with an access token, then send the buyer's IP address in the Shopify-Buyer-IP header on every tools/call request. Refer to Buyer IP header.


Anchor to Step 2: Create a combined checkoutStep 2: Create a combined checkout

Call the create_checkout tool on the global UCP endpoint, and pass up to 15 merchant cart IDs in cart_ids. Send your access token in the Authorization: Bearer {access_token} header.

POST

https://catalog.shopify.com/api/ucp/mcp

{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 1,
"params": {
"name": "create_checkout",
"arguments": {
"meta": { "ucp-agent": { "profile": "https://shopify.dev/ucp/agent-profiles/draft/valid-with-capabilities.json" } },
"cart_ids": [
"gid://shopify/Cart/shop1-cart?key=…",
"gid://shopify/Cart/shop2-cart?key=…"
]
}
}
}

Shopify verifies every cart, then checks that the carts can share one checkout. The tool returns its payload in result.structuredContent. When the carts can share a checkout, that payload contains a top-level continue_url that leads to a combined checkout, and a continue_url for each cart's separate checkout:

Response: combined checkout created

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"structuredContent": {
"ucp": { "version": "draft", "capabilities": { … }, "status": "success" },
"continue_url": "https://shop.app/checkouts/…",
"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": []
}
}
}

The following fields are in result.structuredContent:

FieldDescription
ucp.statussuccess when a combined checkout was created. Otherwise error.
continue_urlA checkout link that combines all carts into one checkout. Returned only when ucp.status is success.
carts[].idThe cart ID, exactly as you sent it.
carts[].continue_urlThe cart's separate checkout link, on the merchant's myshopify.com domain. Returned whenever every cart was verified, including when a combined checkout isn't available.
messagesEmpty on success. Otherwise, explains why a combined checkout isn't available. Refer to Handle incompatible carts.
Treat continue_url as a secret

The top-level continue_url is a capability URL: anyone who has it can open the checkout. Don't parse it, build it yourself, share it with anyone other than the buyer, or log it.


Anchor to Step 3: Render the universal checkout using Checkout KitStep 3: Render the universal checkout using Checkout Kit

Use Checkout Kit for Web to render the combined checkout, instead of redirecting your app to its URL. Pass the top-level continue_url from step 2 to the Universal Checkout element as src. Checkout Kit presents the session in a popup while your app stays open to receive checkout events.

Importing @shopify/checkout-kit/shopify-universal-checkout registers the element; the package root exports the ShopifyUniversalCheckout class and its types. The element keeps no checkout or error state for you to read back, so attach your listeners before calling open(), and call open() from the buyer's click so the browser allows the popup:

UniversalCheckout.tsx

import {useEffect, useRef} from 'react';
import '@shopify/checkout-kit/shopify-universal-checkout';
import type {ShopifyUniversalCheckout} from '@shopify/checkout-kit';

export function UniversalCheckout({authenticatedContinueUrl}: {authenticatedContinueUrl: string}) {
const checkoutRef = useRef<ShopifyUniversalCheckout>(null);

useEffect(() => {
const checkout = checkoutRef.current;
if (!checkout) return;

const controller = new AbortController();
const options = {signal: controller.signal};

// Every lifecycle event carries an array of per-shop entries. The element keeps
// no state to read back, so attach listeners before open() and keep the latest
// entry per shop yourself.
checkout.addEventListener('start', handleBatch, options);
checkout.addEventListener('update', handleBatch, options);
checkout.addEventListener('complete', handleComplete, options);
checkout.addEventListener('error', handleError, options);
// Presentation events: no outcome implied.
checkout.addEventListener('close', showCheckoutClosed, options);
checkout.addEventListener('blocked', showAllowPopupsHint, options);

return () => controller.abort();
}, []);

return (
<>
<shopify-universal-checkout ref={checkoutRef} src={authenticatedContinueUrl} target="popup" />
<button type="button" onClick={() => checkoutRef.current?.open()}>
Continue to checkout
</button>
</>
);
}

The authenticatedContinueUrl prop is the combined checkout's continuation URL returned by your backend. If step 2 didn't return a top-level continue_url, then don't open a Universal Checkout element with a missing URL. Offer each cart's separate checkout continue_url instead.

The session URL grants access to the buyer's checkout: don't log it, persist it, or include it in diagnostics. Its path is /checkouts/uc/; the kit warns at the warn log level when src has another path. Create a new session for each checkout attempt. A URL that was already opened elsewhere can open as a copy of the session that reports no events to your page.

Authentication is the buyer's Shop Pay sign-in: a signed-out buyer sees a Shop sign-in page first. Until they finish, the element emits no start, update, or error, and the kit doesn't time out. A close with no earlier lifecycle event normally means the buyer dismissed the sign-in page before any checkout was shown, so let them open it again. It isn't proof that nothing happened: a session URL that was already opened elsewhere opens as a copy that reports no events to your page, so a close-only sequence on a reused URL is an unknown outcome. Create a fresh session for each attempt.


Anchor to Step 4: Listen to and respond to eventsStep 4: Listen to and respond to events

A Universal Checkout session contains a checkout for each participating merchant. Subscribe to start, update, complete, error, close, and blocked on the element to keep your app in sync while the buyer checks out. Lifecycle events are batched: event.detail is an array of entries, each with a context (sessionId, revision, shopId, and an optional status) and a full checkout snapshot for that shop. A batch doesn't have to contain every shop in the session, and the element keeps no state for you to read back: keep the latest entry per shop from start, update, and complete, and from error entries that carry a context. Events don't bubble, so attach them to the element.

Anchor to Decide how your app should respondDecide how your app should respond

EventWhen it firesevent.detail
startThe child checkouts that became interactiveInitial full snapshots, {context, checkout}[]
updateShops whose state changed, including context.status changes. It can introduce a shop, even before start. Shops it omits are unchanged.Full snapshots for the changed shops
completeShops that completed. A session can emit more than one complete, and sibling shops can keep going. Ejected shops aren't repeated here. An ejection is reported by the entry that carries context.status ejected, in update or in error; an error entry includes the shop's final snapshot only when one exists and can arrive before any snapshot. Treat that entry as the shop's terminal state, and handle it idempotently if both arrive.{context, checkout}[]
errorA shop failed, the session failed, or the kit couldn't present (for example an unsupported browser or an invalid URL).{context?, checkout?, error}[]; a resource error can include that shop's final snapshot
closeThe presentation closednull
blockedThe browser blocked the checkout window. The built-in overlay offers a retry; dismissing it emits close. Call open() again from a user action; calls made from this listener are ignored.{code: 'popup_blocked'}

A resource context.status of active means use the child's checkout.status; completed marks a completed shop; failed is a retryable failure that doesn't emit error; ejected means the shop no longer participates. Unknown statuses are reported as unknown and never mean success. These states never overwrite the child snapshot's own checkout.status.

Anchor to Handle partial outcomesHandle partial outcomes

complete and error are outcomes, and complete doesn't by itself end the session: a merchant's order arrives in a complete batch while sibling shops can keep going, and the kit ends the session once at least one shop has completed and every other shop has completed, failed terminally, or been ejected. A session-level error ends it immediately. close and blocked are separate presentation events and say nothing about success.

  • Terminal outcomes are yours to render. A resource error doesn't close or fail the other shops, and one failed shop doesn't mean the session failed. Don't close the presentation on the first complete unless you mean to stop the remaining shops. A close after complete leaves your confirmation state as is.
  • Custom overlay. If you supply slot="overlay", you replace the built-in focus and close controls and must provide your own.

A multi-shop session can end in more states than a single checkout. Handle each of the following:

  • A buyer who closes the presentation before signing in (close only)
  • A shop introduced by an update before start
  • Partial completion: some shops complete while others remain active
  • A retryable failed status that returns to active, with no error
  • One shop ejected while the others continue
  • A session-level error that ends the session as a whole
  • A blocked popup (blocked), retried from the overlay or by a fresh buyer gesture

Anchor to Handle incompatible cartsHandle incompatible carts

Some carts can't be combined, such as carts in different currencies. During early access, a combined checkout includes every cart, or fails when any of the carts are incompatible. When the carts can't share a checkout, the response doesn't include a top-level continue_url. It returns each cart's separate checkout continue_url and a message that explains why.

Anchor to Choose carts that can share a checkoutChoose carts that can share a checkout

For the most reliable results, build carts from products that you find in the Global Catalog. create_checkout checks the carts' compatibility when it creates the checkout, and might return an error if a cart doesn't support a shared currency.

Anchor to Recognize the responseRecognize the response

ResponseHow to recognize itRecommended action
Combined checkout createducp.status is success and the top-level continue_url is present.Render the top-level continue_url with Checkout Kit.
Separate checkoutsucp.status is error, the top-level continue_url is missing, and carts is present.Link to each cart's separate checkout continue_url. Refer to Checkout errors.
Request rejectedThe tool result has isError: true, and the response includes no carts.Fix the request, or create new carts, based on the message code.

The following example shows a response where the carts use different currencies:

Response: separate checkouts

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"structuredContent": {
"ucp": { "version": "draft", "capabilities": { … }, "status": "error" },
"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": [
{
"type": "error",
"code": "currency_mismatch",
"content": "Carts must use the same currency to join a universal checkout.",
"severity": "recoverable"
}
]
}
}
}

The following example shows a rejected request, where a cart expired or its key didn't match:

Response: request rejected

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"isError": true,
"structuredContent": {
"ucp": { "version": "draft", "capabilities": { … }, "status": "error" },
"messages": [
{
"type": "error",
"code": "cart_unavailable",
"content": "One or more carts are unavailable.",
"severity": "unrecoverable"
}
]
}
}
}

If cart_ids is missing, empty, has more than 15 entries, or contains a value that isn't a string, then the endpoint returns a JSON-RPC error instead of a tool result.

Note

Log the message codes that you receive, but don't log buyer information, cart keys, or continue_url values. If you receive checkout_timeout, then don't retry automatically, because a checkout might already exist. Send the buyer to each cart's continue_url instead.



Was this page helpful?