diff --git a/platforms/web/README.md b/platforms/web/README.md
index b935eeac1..3375cee4b 100644
--- a/platforms/web/README.md
+++ b/platforms/web/README.md
@@ -15,9 +15,9 @@ present the world's highest converting, customizable, one-page checkout. The
presented experience is a fully-featured checkout that preserves all of the
store customizations: Checkout UI extensions, Functions, branding, and more. It
also provides web idiomatic defaults such as opening checkout in a popup or
-new tab, a transient overlay scrim while the popup is open, and convenient
-developer APIs to embed, customize, and follow the lifecycle of the checkout
-experience via the
+new tab, a transient overlay scrim while the popup is open, and
+convenient developer APIs to embed, customize, and follow the lifecycle of the
+checkout experience via the
[Embedded Checkout Protocol](https://ucp.dev/2026-04-08/specification/embedded-checkout/).
Check out our blog to
@@ -33,6 +33,8 @@ Check out our blog to
- [Usage with the Shopify Storefront API](#usage-with-the-shopify-storefront-api)
- [Configuration](#configuration)
- [`src`](#src)
+ - [`presentation`](#presentation)
+ - [Authentication](#authentication)
- [`target`](#target)
- [`debug`](#debug)
- [Popup dimensions](#popup-dimensions)
@@ -97,7 +99,7 @@ checkout:
Buy now
@@ -112,9 +114,9 @@ checkout:
```
-The element has no visible layout of its own beyond a transient ``
-scrim that appears over the host page while the popup is open. It can sit
-anywhere in your DOM.
+For popup and auto presentations, the element has no visible layout of its own
+beyond a transient `` scrim that appears over the host page while the
+popup is open.
See [usage with the Storefront API](#usage-with-the-shopify-storefront-api)
below for details on how to obtain a checkout URL.
@@ -129,7 +131,7 @@ import type {ShopifyCheckout} from '@shopify/checkout-kit';
const checkout = document.createElement('shopify-checkout') as ShopifyCheckout;
checkout.src = 'https://your-store.myshopify.com/checkouts/cn/abc123';
-checkout.target = 'popup';
+checkout.presentation = 'popup';
document.body.append(checkout);
checkout.addEventListener('ec.complete', (event) => {
@@ -192,7 +194,7 @@ export function BuyNowButton({checkoutUrl}: {checkoutUrl: string}) {
return (
<>
-
+
checkoutRef.current?.open()}>Buy now
>
);
@@ -216,7 +218,12 @@ declare module 'react' {
'shopify-checkout': DetailedHTMLProps<
HTMLAttributes,
ShopifyCheckout
- > & {src?: string; target?: string; debug?: boolean};
+ > & {
+ src?: string;
+ presentation?: string;
+ target?: string;
+ debug?: boolean;
+ };
}
}
}
@@ -323,19 +330,64 @@ checkout: `ec_version` (Embedded Checkout Protocol version),
`ec_delegate` (which capabilities the host delegates), and `ck_version`
(the Checkout Kit version).
+### `presentation`
+
+How checkout is presented when `open()` is called. Defaults to `"auto"`.
+
+| Value | Behavior |
+| ---------- | --------------------------------------------------------------------- |
+| `"auto"` | Opens checkout in a new browser tab or named window based on `target`. |
+| `"popup"` | Opens checkout in a popup window sized and centered over the page. |
+
+For popup integrations, prefer `presentation="popup"`:
+
+```html
+
+```
+
+```ts
+checkout.presentation = 'popup';
+checkout.open();
+```
+
+For new-tab integrations, use `presentation="auto"` with `target="_blank"`.
+For compatibility, omitting `presentation` and setting `target="popup"` still
+opens a popup, but prefer `presentation="popup"` for new code:
+
+```html
+
+```
+
+### Authentication
+
+Authentication for embedded checkout is not available in Checkout Kit yet.
+[Authenticated checkout](https://ucp.dev/latest/specification/embedded-checkout/#authentication)
+via UCP flows will be documented once they are supported.
+
### `target`
-Where the checkout is presented. Defaults to `"auto"`.
+The browser context target used when `presentation="auto"`. Defaults to
+`"auto"`.
| Value | Behavior |
| ---------- | ------------------------------------------------------------------- |
| `"auto"` | Opens checkout in a new browser tab (default). |
-| `"popup"` | Opens checkout in a popup window sized and centered over the page. |
-| `"_blank"` | Synonym for `"auto"` — new tab. |
-| _(string)_ | Any other value is treated as a named window target, the same as the [`target` parameter of `window.open()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#target). |
+| `"popup"` | Legacy alias for `presentation="popup"` only when `presentation` is omitted. |
+| `"_blank"` | Opens checkout in a new browser tab/window. |
+| _(string)_ | Passed as the [`target` parameter of `window.open()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#target). |
+
+When `presentation="auto"` is set explicitly, `target` is passed to
+`window.open()` as the browser context target.
```html
-
+
```
> [!NOTE]
@@ -354,9 +406,9 @@ and event handlers; turn it off in production.
### Popup dimensions
-When `target="popup"`, the popup is centered over the host window. Defaults
-are `600 × 600`, capped at 90% of the host window. Override via CSS custom
-properties:
+When `presentation="popup"`, the popup is centered over the host window.
+Defaults are `600 × 600`, capped at 90% of the host window. Override via CSS
+custom properties:
```css
shopify-checkout {
@@ -394,7 +446,7 @@ exactly the fields relevant to that moment.
| `ec.complete` | `{checkout, order}` | The buyer completed the order successfully. |
| `ec.close` | _(none)_ | The popup was dismissed (by the buyer, by `close()`, or by `focus` loss). |
| `ec.error` | `{error}` | Session-level fatal error — tear down the embedded context. |
-| `ec.line_items.change` | `{checkout, lineItems}` | The cart's line items changed (item added/removed/quantity updated). |
+| `ec.line_items.change` | `{checkout, lineItems}` | The cart's line items changed (item added/removed/quantity updated). |
| `ec.totals.change` | `{checkout, totals}` | The cart totals changed (subtotal, tax, shipping, discounts, total). |
| `ec.messages.change` | `{checkout, messages}` | Checkout-level warnings/errors/info shown inside the checkout changed. |
diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md
index cb6c77823..63c564fc8 100644
--- a/platforms/web/sample/README.md
+++ b/platforms/web/sample/README.md
@@ -14,16 +14,17 @@ pnpm sample
Vite serves at `http://localhost:5173`. The page has three panels:
-- **Options** — form for `src`, `target` (`auto` | `popup`), and `debug`,
- plus buttons for `open()`, `close()`, and `focus()`.
+- **Options** — form for `src`, `presentation`, `target`, and `debug`, plus
+ buttons for `open()`, `close()`, and `focus()`.
- **Demo Storefront** — a mocked product card with **Buy now** calling
- `checkout.open()`. The button stays disabled until a checkout URL is set.
- The collapsible readout shows `checkout`, `error`, `target`, and `debug`.
+ `checkout.open()`. The button stays disabled until a checkout URL is set. The
+ collapsible readout shows `checkout`, `error`, `presentation`, `target`, and
+ `debug`.
- **Events** — log of dispatched events with a JSON snapshot of state at fire
time.
-The element is mounted on ``. For `popup` / `auto`, the visible UI is
-mostly the overlay scrim while checkout is open in a separate window or tab.
+Popup / auto presentations mostly show the overlay scrim while checkout is open
+in a separate window or tab.
## Build
diff --git a/platforms/web/sample/index.html b/platforms/web/sample/index.html
index e28f0a624..63781925d 100644
--- a/platforms/web/sample/index.html
+++ b/platforms/web/sample/index.html
@@ -34,11 +34,19 @@ Options
/>
+
+ presentation
+
+ popup
+ auto (new tab)
+
+
+
target
- popup (window)
- auto (new tab)
+ _blank
+ checkout-window
@@ -111,6 +119,8 @@ Studio Plant Pot
+
+
Component state
@@ -118,6 +128,8 @@ Studio Plant Pot
—
error
—
+ presentation
+ —
target
—
debug
diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts
index f0cd48afd..2c054901a 100644
--- a/platforms/web/sample/main.ts
+++ b/platforms/web/sample/main.ts
@@ -36,10 +36,12 @@ const eventLog = $("#event-log");
const clearLogButton = $("#clear-log");
const buyNowButton = $("#buy-now");
const buyHint = $("#buy-hint");
+const checkoutHost = $("#checkout-host");
const stateNodes = {
checkout: $("#state-checkout"),
error: $("#state-error"),
+ presentation: $("#state-presentation"),
target: $("#state-target"),
debug: $("#state-debug"),
};
@@ -47,13 +49,11 @@ const stateNodes = {
// ───── Mount the component (off-layout) ───────────────────────────────────
//
// In real merchant integrations the element lives wherever
-// it makes sense in the page. For popup / auto targets it has no visible UI
-// of its own beyond a transient dialog scrim that appears when open() is
-// called, so we attach it to and leave the storefront panel free for
-// the merchant's product UI.
+// it makes sense in the page. Popup / auto presentations have no visible UI of
+// their own beyond the transient dialog scrim.
const checkout = document.createElement("shopify-checkout") as ShopifyCheckout;
-document.body.append(checkout);
+checkoutHost.append(checkout);
const checkoutEl: HTMLElement = checkout;
@@ -70,6 +70,7 @@ function setStringAttribute(el: HTMLElement, name: string, value: FormDataEntryV
function syncAttributes(): void {
const data = new FormData(form);
setStringAttribute(checkout, "src", data.get("src"));
+ setStringAttribute(checkout, "presentation", data.get("presentation"));
setStringAttribute(checkout, "target", data.get("target"));
if (data.has("debug")) {
checkout.setAttribute("debug", "");
@@ -78,6 +79,7 @@ function syncAttributes(): void {
}
refreshBuyButton(data.get("src"));
+ refreshState();
}
function refreshBuyButton(src: FormDataEntryValue | null): void {
@@ -153,6 +155,7 @@ function snapshotState(): Record {
return {
checkout: checkout.checkout,
error: checkout.error,
+ presentation: checkout.presentation,
target: checkout.target,
debug: checkout.debug,
};
@@ -161,6 +164,7 @@ function snapshotState(): Record {
function refreshState(): void {
stateNodes.checkout.textContent = formatValue(checkout.checkout);
stateNodes.error.textContent = formatValue(checkout.error);
+ stateNodes.presentation.textContent = formatValue(checkout.presentation);
stateNodes.target.textContent = formatValue(checkout.target);
stateNodes.debug.textContent = formatValue(checkout.debug);
}
diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css
index b847da344..2165e8094 100644
--- a/platforms/web/sample/styles.css
+++ b/platforms/web/sample/styles.css
@@ -250,6 +250,15 @@ button:disabled {
box-shadow: var(--shadow-md);
}
+.checkout-host {
+ width: 100%;
+}
+
+.checkout-host shopify-checkout {
+ display: block;
+ width: 100%;
+}
+
.product-image {
aspect-ratio: 4 / 3;
display: grid;
diff --git a/platforms/web/src/checkout.styles.ts b/platforms/web/src/checkout.styles.ts
index b83490252..88c182c08 100644
--- a/platforms/web/src/checkout.styles.ts
+++ b/platforms/web/src/checkout.styles.ts
@@ -12,6 +12,14 @@ export const STYLES: SafeMarkup = css`
border: none;
}
+ #checkout-frame {
+ display: block;
+ inline-size: 100%;
+ block-size: 100%;
+ border: 0;
+ background: hsl(0, 0%, 100%);
+ }
+
:host {
@media (prefers-reduced-motion: reduce) {
--shopify-checkout-overlay-transition-duration: 1ms;
@@ -122,4 +130,10 @@ export const STYLES: SafeMarkup = css`
fill: currentColor;
}
}
+
+ :host([presentation="iframe"]) {
+ display: block;
+ block-size: var(--shopify-checkout-iframe-height, 640px);
+ min-block-size: var(--shopify-checkout-iframe-min-height, 640px);
+ }
`;
diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts
index f6bc1461e..e87460daf 100644
--- a/platforms/web/src/checkout.test.ts
+++ b/platforms/web/src/checkout.test.ts
@@ -45,6 +45,30 @@ describe("", () => {
expect(checkout.src).toBe(newSrc);
});
});
+
+ describe("presentation", () => {
+ it("changing the presentation attribute reflects to the presentation property", () => {
+ const checkout = renderCheckout();
+ checkout.setAttribute("presentation", "popup");
+
+ expect(checkout.presentation).toBe("popup");
+ });
+
+ it('does not derive the presentation property from target="popup"', () => {
+ const checkout = renderCheckout({ target: "popup" });
+
+ expect(checkout.presentation).toBe("auto");
+ });
+ });
+
+ describe("auth", () => {
+ it("changing the auth attribute reflects to the auth property", () => {
+ const checkout = renderCheckout();
+ checkout.setAttribute("auth", "test-auth-token");
+
+ expect(checkout.auth).toBe("test-auth-token");
+ });
+ });
});
describe("target", () => {
@@ -104,6 +128,15 @@ describe("", () => {
});
});
+ describe("auth", () => {
+ it("changing the auth property reflects to the auth attribute", () => {
+ const checkout = renderCheckout();
+ checkout.auth = "test-auth-token";
+
+ expect(checkout.getAttribute("auth")).toBe("test-auth-token");
+ });
+ });
+
describe("target", () => {
it("changing the target property reflects to the target attribute", () => {
const checkout = renderCheckout();
@@ -113,6 +146,15 @@ describe("", () => {
});
});
+ describe("presentation", () => {
+ it("changing the presentation property reflects to the presentation attribute", () => {
+ const checkout = renderCheckout();
+ checkout.presentation = "popup";
+
+ expect(checkout.getAttribute("presentation")).toBe("popup");
+ });
+ });
+
describe("debug", () => {
it("sets the debug attribute when assigned true", () => {
const checkout = renderCheckout();
@@ -190,6 +232,21 @@ describe("", () => {
expect(url.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION);
});
+ it("passes auth to checkout as ec_auth", () => {
+ const checkout = renderCheckout({
+ auth: "test-auth-token",
+ src: "https://example.com/checkout?ec_auth=should-not-propagate&keep=1",
+ });
+
+ const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow());
+
+ checkout.open();
+
+ const url = new URL(expectWindowOpenArgs(windowOpenSpy)[0] as string);
+ expect(url.searchParams.get("ec_auth")).toBe("test-auth-token");
+ expect(url.searchParams.get("keep")).toBe("1");
+ });
+
it("handles invalid src URL gracefully", () => {
const checkout = renderCheckout();
checkout.src = "invalid-url";
@@ -251,6 +308,19 @@ describe("", () => {
});
});
+ describe('when presentation="popup"', () => {
+ it("opens in a popup window regardless of the window target", () => {
+ const checkout = renderCheckout({ presentation: "popup", target: "_blank" });
+ const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow());
+
+ checkout.open();
+
+ const call = expectWindowOpenArgs(windowOpenSpy);
+ expect(call[1]).toBe("");
+ expect(call[2]).toEqual(expect.stringContaining("scrollbars=yes"));
+ });
+ });
+
describe('when target="popup"', () => {
it("shows the checkout in a popup window", () => {
POPUP_TARGETS.forEach((target) => {
@@ -727,26 +797,72 @@ describe("", () => {
describe("it subscribes to checkout-protocol events", () => {
describe("ec.ready handshake", () => {
- it("auto-responds with an empty result and does not dispatch a DOM event", async () => {
+ it("dispatches a respondable DOM event and posts a successful response", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onReadySpy = vi.fn();
- // ec.ready is no longer a public event; cast through `never` to verify
- // that the component does not dispatch one.
- checkout.addEventListener("ec.ready" as never, onReadySpy as EventListener);
+ checkout.addEventListener("ec.ready", onReadySpy);
simulateProtocolMessageEvent(
checkout,
"ec.ready",
- { delegate: [] },
+ { delegate: ["window.open"], auth: { type: "jwt" } },
{ id: "ready-1", source: mockCheckoutWindow },
);
await Promise.resolve();
+ expect(onReadySpy).toHaveBeenCalledOnce();
+ const event = onReadySpy.mock.calls[0]![0] as CustomEvent<{
+ delegate: readonly string[];
+ auth?: { type?: string };
+ }>;
+ expect(event.detail).toEqual({
+ delegate: ["window.open"],
+ auth: { type: "jwt" },
+ });
+ expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith(
+ {
+ jsonrpc: "2.0",
+ id: "ready-1",
+ result: {
+ ucp: {
+ version: EMBED_PROTOCOL_VERSION,
+ status: "success",
+ },
+ },
+ },
+ { targetOrigin: new URL(checkout.src).origin },
+ );
+ });
+
+ it("uses respondWith() to include a ready credential", async () => {
+ const { checkout, mockCheckoutWindow } = openPopupCheckout();
+
+ checkout.addEventListener("ec.ready", (event) => {
+ event.respondWith({ credential: "ready-token" });
+ });
+
+ simulateProtocolMessageEvent(
+ checkout,
+ "ec.ready",
+ { delegate: [], auth: { type: "oauth" } },
+ { id: "ready-credential", source: mockCheckoutWindow },
+ );
+ await Promise.resolve();
+
expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith(
- { jsonrpc: "2.0", id: "ready-1", result: {} },
- new URL(checkout.src).origin,
+ {
+ jsonrpc: "2.0",
+ id: "ready-credential",
+ result: {
+ ucp: {
+ version: EMBED_PROTOCOL_VERSION,
+ status: "success",
+ },
+ credential: "ready-token",
+ },
+ },
+ { targetOrigin: new URL(checkout.src).origin },
);
- expect(onReadySpy).not.toHaveBeenCalled();
});
it("does not post a response when id is missing", () => {
@@ -763,6 +879,65 @@ describe("", () => {
});
});
+ describe("ec.auth", () => {
+ it("uses respondWith() to return a credential", async () => {
+ const { checkout, mockCheckoutWindow } = openPopupCheckout();
+
+ checkout.addEventListener("ec.auth", (event) => {
+ expect(event.detail.type).toBe("jwt");
+ event.respondWith({ credential: "refreshed-token" });
+ });
+
+ simulateProtocolMessageEvent(
+ checkout,
+ "ec.auth",
+ { type: "jwt" },
+ { id: "auth-1", source: mockCheckoutWindow },
+ );
+ await Promise.resolve();
+
+ expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith(
+ {
+ jsonrpc: "2.0",
+ id: "auth-1",
+ result: {
+ ucp: {
+ version: EMBED_PROTOCOL_VERSION,
+ status: "success",
+ },
+ credential: "refreshed-token",
+ },
+ },
+ { targetOrigin: new URL(checkout.src).origin },
+ );
+ });
+
+ it("posts an error when ec.auth has no credential responder", async () => {
+ const { checkout, mockCheckoutWindow } = openPopupCheckout();
+ checkout.addEventListener("ec.auth", () => {});
+
+ simulateProtocolMessageEvent(
+ checkout,
+ "ec.auth",
+ { type: "jwt" },
+ { id: "auth-missing", source: mockCheckoutWindow },
+ );
+ await Promise.resolve();
+
+ expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith(
+ {
+ jsonrpc: "2.0",
+ id: "auth-missing",
+ error: {
+ code: -32000,
+ message: "No credential response was provided for ec.auth",
+ },
+ },
+ { targetOrigin: new URL(checkout.src).origin },
+ );
+ });
+ });
+
describe("unsupported protocol methods", () => {
it("posts method-not-found for unsupported requests", () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
@@ -795,7 +970,7 @@ describe("", () => {
it("ignores unsupported requests with invalid request ids", () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
- for (const id of [{}, null, true]) {
+ for (const id of [{}, true]) {
simulateRawMessageEvent(
checkout,
{
diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts
index e8a02f99c..ebdefa62f 100644
--- a/platforms/web/src/checkout.ts
+++ b/platforms/web/src/checkout.ts
@@ -1,10 +1,14 @@
import { createTemplate, html } from "./utils";
import type {
CheckoutAttributes,
+ CheckoutCredentialResponse,
CheckoutMethods,
CheckoutProperties,
+ CheckoutPresentation,
CheckoutProtocolMessageMap,
+ CheckoutReadyResponse,
CheckoutTarget,
+ JsonRpcRequestId,
TypedEventListener,
CheckoutProtocolMessageData,
Checkout,
@@ -54,13 +58,18 @@ const SHADOW_TEMPLATE = createTemplate(html`
`);
/**
- * An element that renders a Shopify Checkout. Checkout opens in a popup or browser tab/window
- * (see `target`). To use, create a `shopify-checkout` element, set the `src` attribute to the
- * checkout URL (typically retrieved from the `cart.checkoutUrl` field), and then call `open()`.
+ * An element that renders a Shopify Checkout. Checkout opens according to the configured
+ * `presentation` and `target`. To use, create a `shopify-checkout` element, set the `src`
+ * attribute to the checkout URL (typically retrieved from the `cart.checkoutUrl` field),
+ * and then call `open()`.
*
* @attribute src - The URL of the checkout to load.
- * @attribute target - Where the checkout is presented (auto, popup, new tab, or a named window).
+ * @attribute presentation - How checkout is presented (auto or popup).
+ * @attribute auth - Checkout-bound authentication token.
+ * @attribute target - The browsing context target for non-popup window presentations.
*
+ * @event ec.ready - Dispatched when checkout initiates the ECP handshake
+ * @event ec.auth - Dispatched when checkout requests an ECP credential
* @event ec.start - Dispatched when the checkout has started
* @event ec.complete - Dispatched when the checkout was successfully completed
* @event ec.error - Dispatched on a session-level fatal error
@@ -71,10 +80,11 @@ const SHADOW_TEMPLATE = createTemplate(html`
*
* @example
* ```js
- * // Popup target (default)
+ * // Popup presentation
* const cart = await fetchCart();
* const checkout = document.createElement("shopify-checkout");
* checkout.setAttribute("src", cart.checkoutUrl);
+ * checkout.setAttribute("presentation", "popup");
* document.body.append(checkout);
* checkout.open();
* ```
@@ -83,7 +93,7 @@ export class ShopifyCheckout
extends HTMLElement
implements CheckoutAttributes, CheckoutMethods, CheckoutProperties
{
- static observedAttributes = ["src", "target"] as const;
+ static observedAttributes = ["src", "auth", "presentation", "target"] as const;
constructor() {
super();
@@ -130,9 +140,12 @@ export class ShopifyCheckout
}
if (url.protocol !== "https:") return undefined;
- // Drop ec_auth if present on src (e.g. prepared checkout URLs); this build
- // does not support passing auth via query string.
+ // Drop ec_auth if present on src (e.g. prepared checkout URLs); the
+ // explicit auth attribute is the only supported source for this value.
url.searchParams.delete("ec_auth");
+ if (this.auth) {
+ url.searchParams.set("ec_auth", this.auth);
+ }
url.searchParams.set("ec_version", EMBED_PROTOCOL_VERSION);
if (EMBED_DELEGATIONS.length > 0) {
@@ -142,6 +155,14 @@ export class ShopifyCheckout
return url;
}
+ get auth(): string {
+ return this.getAttribute("auth") ?? "";
+ }
+
+ set auth(value: string | undefined) {
+ this.#setAttribute("auth", value);
+ }
+
/**
* Whether the component should log diagnostic messages to the console.
*/
@@ -165,6 +186,18 @@ export class ShopifyCheckout
}
}
+ get presentation(): CheckoutPresentation {
+ const presentation = this.getAttribute("presentation");
+ if (presentation === "auto" || presentation === "popup" || presentation === "iframe") {
+ return presentation;
+ }
+ return "auto";
+ }
+
+ set presentation(value: CheckoutPresentation | undefined) {
+ this.#setAttribute("presentation", value);
+ }
+
get target(): CheckoutTarget | string {
return this.getAttribute("target") ?? "auto";
}
@@ -183,6 +216,107 @@ export class ShopifyCheckout
}
}
+ #resolvedPresentation(): CheckoutPresentation {
+ const presentation = this.getAttribute("presentation");
+
+ switch (presentation) {
+ case "auto":
+ case "popup":
+ case "iframe":
+ return presentation;
+ case null:
+ return this.target === "popup" ? "popup" : "auto";
+ default:
+ this.#debugWarn(`presentation="${presentation}" is invalid; falling back to "auto"`);
+ return "auto";
+ }
+ }
+
+ async #handleReadyRequest(message: CheckoutProtocolMessage): Promise {
+ if (!this.#isRespondableRequest(message)) return;
+
+ const session = this.#currentOpen;
+ const body = message.body as CheckoutProtocolMessageMap["ec.ready"] | undefined;
+ const readyEvent = new ShopifyCheckoutReadyEvent({
+ delegate: Array.isArray(body?.delegate)
+ ? body.delegate.filter((delegation): delegation is string => typeof delegation === "string")
+ : [],
+ auth: body?.auth,
+ });
+ this.dispatchEvent(readyEvent);
+
+ let response: CheckoutReadyResponse | undefined;
+ const responsePromise = getRespondWithResponse(readyEvent);
+ try {
+ response = responsePromise ? await responsePromise : undefined;
+ } catch {
+ if (this.#isCurrentCheckoutSession(session)) {
+ postProtocolError(message, -32000, "ec.ready response rejected");
+ }
+ return;
+ }
+
+ if (!this.#isCurrentCheckoutSession(session)) return;
+
+ postProtocolResult(message, {
+ ucp: ucpSuccess(),
+ ...normalizeReadyResponse(response),
+ });
+ }
+
+ async #handleAuthRequest(message: CheckoutProtocolMessage): Promise {
+ if (!this.#isRespondableRequest(message)) return;
+
+ const session = this.#currentOpen;
+ const body = message.body;
+ if (!isObjectRecord(body)) {
+ postProtocolError(message, -32602, "Invalid params: expected object");
+ return;
+ }
+ if ("type" in body && body.type != null && typeof body.type !== "string") {
+ postProtocolError(message, -32602, "Invalid params: type must be a string");
+ return;
+ }
+
+ const auth = body as CheckoutProtocolMessageMap["ec.auth"];
+ const authEvent = new ShopifyCheckoutAuthEvent({
+ type: typeof auth.type === "string" ? auth.type : undefined,
+ auth,
+ });
+ this.dispatchEvent(authEvent);
+
+ const responsePromise = getRespondWithResponse(authEvent);
+ if (!responsePromise) {
+ if (this.#isCurrentCheckoutSession(session)) {
+ postProtocolError(message, -32000, "No credential response was provided for ec.auth");
+ }
+ return;
+ }
+
+ let response: CheckoutCredentialResponse;
+ try {
+ response = await responsePromise;
+ } catch {
+ if (this.#isCurrentCheckoutSession(session)) {
+ postProtocolError(message, -32000, "ec.auth response rejected");
+ }
+ return;
+ }
+
+ if (!this.#isCurrentCheckoutSession(session)) return;
+
+ const credential = normalizeCredentialResponse(response);
+ if (!credential) {
+ postProtocolError(message, -32000, "No credential response was provided for ec.auth");
+ return;
+ }
+
+ postProtocolResult(message, {
+ ucp: ucpSuccess(),
+ credential,
+ });
+ }
+
/* ------------------------------------------------------------
* Read-only properties (populated by checkout protocol events)
* ------------------------------------------------------------
@@ -233,6 +367,10 @@ export class ShopifyCheckout
return this.shadowRoot?.querySelector("#overlay-link") ?? undefined;
}
+ get #iframeElement(): HTMLIFrameElement | undefined {
+ return this.shadowRoot?.querySelector("#checkout-frame") ?? undefined;
+ }
+
get #targetElement(): HTMLDivElement | undefined {
return this.shadowRoot?.querySelector(".Shopify-target") ?? undefined;
}
@@ -243,13 +381,15 @@ export class ShopifyCheckout
*/
/**
- * Reveals checkout in the target.
+ * Reveals checkout using the configured presentation.
*/
open(): void {
const { target } = this;
- const src = this.#srcAsURL()?.href;
+ const presentation = this.#resolvedPresentation();
+ const url = this.#srcAsURL();
+ const src = url?.href;
- if (!src) {
+ if (!url || !src) {
// eslint-disable-next-line no-console
console.warn("``: src property is empty or invalid, cannot open checkout");
return;
@@ -260,9 +400,14 @@ export class ShopifyCheckout
this.close();
}
+ if (presentation === "iframe") {
+ this.#openIframeCheckout(url);
+ return;
+ }
+
let checkoutWindow: WindowProxy | null = null;
- switch (target) {
+ switch (presentation) {
case "popup": {
const features = this.#getPopupFeatures();
checkoutWindow = window.open(src, "", features);
@@ -270,7 +415,6 @@ export class ShopifyCheckout
}
case "auto":
- case "_blank":
default: {
if (target === "_self" || target === "_parent" || target === "_top") {
this.#debugWarn(
@@ -421,6 +565,55 @@ export class ShopifyCheckout
this.#targetElement?.classList.remove(`Shopify-target--${value}`);
}
+ #openIframeCheckout(url: URL) {
+ const iframe = this.#iframeElement ?? this.#createIframeElement();
+
+ const targetElement = this.#targetElement;
+ if (targetElement && iframe.parentElement !== targetElement) {
+ targetElement.appendChild(iframe);
+ }
+
+ iframe.src = url.href;
+ this.#checkoutWindow = iframe.contentWindow ?? null;
+
+ const abortController = new AbortController();
+ abortController.signal.addEventListener("abort", () => {
+ iframe.remove();
+ this.#checkoutWindow = null;
+ this.#currentOpen = null;
+ this.dispatchEvent(new ShopifyCheckoutCloseEvent());
+ });
+
+ this.#currentOpen = { controller: abortController };
+ }
+
+ #createIframeElement() {
+ const iframe = document.createElement("iframe");
+ iframe.id = "checkout-frame";
+ iframe.title = "Checkout";
+ iframe.setAttribute("part", "iframe");
+ iframe.setAttribute("allow", "publickey-credentials-get *; geolocation");
+ iframe.setAttribute(
+ "sandbox",
+ "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox",
+ );
+ return iframe;
+ }
+
+ #updateIframeSrc() {
+ const iframe = this.#iframeElement;
+ if (!iframe) return;
+
+ const url = this.#srcAsURL();
+ if (!url) {
+ this.close();
+ return;
+ }
+
+ this.close();
+ this.#openIframeCheckout(url);
+ }
+
#getPopupFeatures() {
const computedStyle = window.getComputedStyle(this);
const widthFromCustomProperty = computedStyle.getPropertyValue(
@@ -474,8 +667,12 @@ export class ShopifyCheckout
*/
#isRespondableRequest(
message: CheckoutProtocolMessage,
- ): message is CheckoutProtocolMessage & { id: string } {
- return message.id != null;
+ ): message is CheckoutProtocolMessage & { id: JsonRpcRequestId } {
+ return message.id !== undefined;
+ }
+
+ #isCurrentCheckoutSession(session: { controller: AbortController } | null): boolean {
+ return session != null && this.#currentOpen === session && !session.controller.signal.aborted;
}
#validateMessageOrigin(event: MessageEvent) {
@@ -536,12 +733,11 @@ export class ShopifyCheckout
switch (message.name) {
case "ec.ready": {
- if (this.#isRespondableRequest(message) && message.source) {
- (message.source as WindowProxy).postMessage(
- { jsonrpc: "2.0" as const, id: message.id, result: {} },
- message.origin,
- );
- }
+ void this.#handleReadyRequest(message);
+ break;
+ }
+ case "ec.auth": {
+ void this.#handleAuthRequest(message);
break;
}
case "ec.start": {
@@ -699,6 +895,15 @@ export class ShopifyCheckout
switch (name) {
case "src":
+ this.#updateOverlayLink();
+ this.#updateIframeSrc();
+ break;
+ case "presentation":
+ if (oldValue !== newValue && this.#currentOpen) {
+ this.close();
+ }
+ break;
+ case "auth":
this.#updateOverlayLink();
break;
case "target": {
@@ -719,6 +924,18 @@ export class ShopifyCheckout
* ------------------------------------------------------------
*/
// we overload these so that the consumer of the component can autocomplete the correct events
+ override addEventListener(
+ type: "ec.ready",
+ listener: TypedEventListener | null,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+
+ override addEventListener(
+ type: "ec.auth",
+ listener: TypedEventListener | null,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+
override addEventListener(
type: "ec.start",
listener: TypedEventListener | null,
@@ -776,6 +993,20 @@ export class ShopifyCheckout
* ------------------------------------------------------------
*/
+export interface ShopifyCheckoutReadyEventDetail {
+ /** Delegation types requested by checkout in `ec.ready.params.delegate`. */
+ delegate: readonly string[];
+ /** Authorization requested by checkout in `ec.ready.params.auth`, when present. */
+ auth?: CheckoutProtocolMessageMap["ec.ready"]["auth"];
+}
+
+export interface ShopifyCheckoutAuthEventDetail {
+ /** Authorization type requested by checkout, e.g. `oauth`, `api_key`, or `jwt`. */
+ type?: string;
+ /** Raw `ec.auth` params for integrations that need additional fields. */
+ auth: CheckoutProtocolMessageMap["ec.auth"];
+}
+
export interface ShopifyCheckoutStartEventDetail {
/** Initial checkout snapshot from the ECP `ec.start` notification. */
checkout: Checkout;
@@ -819,6 +1050,49 @@ export interface ShopifyCheckoutMessagesChangeEventDetail {
* ------------------------------------------------------------
*/
+const respondWithResponses = new WeakMap>();
+
+export class ShopifyCheckoutRespondableEvent extends CustomEvent {
+ respondWith(response: Promise | ResponsePayload): void {
+ if (respondWithResponses.has(this)) {
+ throw new DOMException(
+ `: respondWith() has already been called for this ${this.type} event`,
+ "InvalidStateError",
+ );
+ }
+
+ respondWithResponses.set(this, Promise.resolve(response));
+ }
+}
+
+function getRespondWithResponse(
+ event: Event,
+): Promise | undefined {
+ return respondWithResponses.get(event) as Promise | undefined;
+}
+
+export class ShopifyCheckoutReadyEvent extends ShopifyCheckoutRespondableEvent<
+ ShopifyCheckoutReadyEventDetail,
+ CheckoutReadyResponse
+> {
+ declare type: "ec.ready";
+
+ constructor(detail: ShopifyCheckoutReadyEventDetail) {
+ super("ec.ready", { detail, bubbles: true });
+ }
+}
+
+export class ShopifyCheckoutAuthEvent extends ShopifyCheckoutRespondableEvent<
+ ShopifyCheckoutAuthEventDetail,
+ CheckoutCredentialResponse
+> {
+ declare type: "ec.auth";
+
+ constructor(detail: ShopifyCheckoutAuthEventDetail) {
+ super("ec.auth", { detail, bubbles: true });
+ }
+}
+
export class ShopifyCheckoutStartEvent extends CustomEvent {
declare type: "ec.start";
@@ -881,6 +1155,7 @@ export class ShopifyCheckoutMessagesChangeEvent extends CustomEvent & { id?: string },
+ { method, params, id }: CheckoutProtocolMessageData,
{ source, origin }: { source: MessageEventSource | null; origin: string },
) {
this.protocol = { version: "2026-04-08" };
@@ -932,6 +1207,65 @@ function isEcErrorParams(params: unknown): params is CheckoutProtocolMessageMap[
return params != null && typeof params === "object" && "error" in params;
}
+function isObjectRecord(value: unknown): value is Record {
+ return value != null && typeof value === "object" && !Array.isArray(value);
+}
+
+function postProtocolResult(
+ message: CheckoutProtocolMessage & { id: JsonRpcRequestId },
+ result: unknown,
+): void {
+ message.source?.postMessage(
+ {
+ jsonrpc: "2.0" as const,
+ id: message.id,
+ result,
+ },
+ { targetOrigin: message.origin },
+ );
+}
+
+function postProtocolError(
+ message: CheckoutProtocolMessage & { id: JsonRpcRequestId },
+ code: number,
+ errorMessage: string,
+): void {
+ message.source?.postMessage(
+ {
+ jsonrpc: "2.0" as const,
+ id: message.id,
+ error: {
+ code,
+ message: errorMessage,
+ },
+ },
+ { targetOrigin: message.origin },
+ );
+}
+
+function ucpSuccess() {
+ return {
+ version: EMBED_PROTOCOL_VERSION,
+ status: "success" as const,
+ };
+}
+
+function normalizeReadyResponse(response: CheckoutReadyResponse | undefined): {
+ readonly credential?: string;
+} {
+ if (typeof response === "string") {
+ return { credential: response };
+ }
+ if (response == null) {
+ return {};
+ }
+ return response.credential ? { credential: response.credential } : {};
+}
+
+function normalizeCredentialResponse(response: CheckoutCredentialResponse): string | undefined {
+ return typeof response === "string" ? response : response.credential;
+}
+
function respondToUnsupportedProtocolRequest(event: MessageEvent) {
const request = parseUnsupportedProtocolRequest(event.data);
if (!request) return;
@@ -946,7 +1280,7 @@ function respondToUnsupportedProtocolRequest(event: MessageEvent) {
);
}
-function parseUnsupportedProtocolRequest(data: unknown): { id: string | number } | undefined {
+function parseUnsupportedProtocolRequest(data: unknown): { id: JsonRpcRequestId } | undefined {
if (
data == null ||
typeof data !== "object" ||
@@ -964,7 +1298,8 @@ function parseUnsupportedProtocolRequest(data: unknown): { id: string | number }
return { id: data.id };
}
-function isJsonRpcRequestId(id: unknown): id is string | number {
+function isJsonRpcRequestId(id: unknown): id is JsonRpcRequestId {
+ if (id === null) return true;
if (typeof id === "string") return true;
if (typeof id === "number" && Number.isFinite(id)) return true;
return false;
@@ -977,6 +1312,7 @@ function isCheckoutProtocolMessage(data: unknown): data is CheckoutProtocolMessa
"jsonrpc" in data &&
data.jsonrpc === "2.0" &&
"method" in data &&
- CHECKOUT_PROTOCOL_MESSAGES.includes(data.method as keyof CheckoutProtocolMessageMap)
+ CHECKOUT_PROTOCOL_MESSAGES.includes(data.method as keyof CheckoutProtocolMessageMap) &&
+ (!("id" in data) || isJsonRpcRequestId(data.id))
);
}
diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts
index 39b42c763..c390dd63b 100644
--- a/platforms/web/src/checkout.types.ts
+++ b/platforms/web/src/checkout.types.ts
@@ -5,6 +5,8 @@ import type {
Checkout,
CheckoutLineItem,
CheckoutMessage,
+ EcAuthParams,
+ EcAuthRequest,
EcReadyParams,
OrderConfirmation,
Total,
@@ -29,24 +31,29 @@ import type {
// Documentation-safe types:
+export type CheckoutPresentation = "auto" | "popup" | "iframe";
+
export type CheckoutTarget = "auto" | "popup" | "_blank";
+export type JsonRpcRequestId = string | number | null;
+
export interface CheckoutAttributes {
src?: string;
+ presentation?: CheckoutPresentation;
+ auth?: string;
target?: CheckoutTarget | string;
debug?: boolean | string;
}
export interface CheckoutMethods {
/**
- * Opens the checkout in a popup window by default, but can be configured
- * to open in a new tab or named window using the `target` property.
+ * Opens checkout using the configured `presentation`.
*/
open?: () => void;
/**
- * Closes the checkout popup.
- * Can be used after checkout completion or to cancel the checkout process
+ * Closes the active checkout presentation.
+ * Can be used after checkout completion or to cancel the checkout process.
*/
close?: () => void;
}
@@ -62,10 +69,31 @@ export interface CheckoutProperties {
src?: string;
/**
- * The mode in which to display the checkout when opened. Defaults to `'auto'`.
- * - `'popup'`: Opens checkout in a popup window
- * - `'_blank' | `'auto'`: Opens checkout in a new tab (default)
- * - `string`: Opens checkout in a new named window
+ * The presentation mode used when checkout is opened. Defaults to `'auto'`.
+ * - `'auto'`: Opens checkout in a new tab or named window based on `target`
+ * - `'popup'`: Opens checkout in a popup window sized and centered over the page
+ *
+ * This property is automatically reflected to the `presentation` attribute,
+ * so you can use the `presentation` attribute or this property interchangeably.
+ */
+ presentation?: CheckoutPresentation;
+
+ /**
+ * Checkout-bound authentication token.
+ *
+ * This property is automatically reflected to the `auth` attribute. When set,
+ * Checkout Kit sends it to checkout as the `ec_auth` query parameter on the
+ * generated checkout URL.
+ */
+ auth?: string;
+
+ /**
+ * The browser context target used when `presentation` is `'auto'`. Defaults to `'auto'`.
+ * - `'auto'`: Opens checkout in a new tab (default)
+ * - `'popup'`: Legacy alias for `presentation='popup'`; prefer the presentation property
+ * for new integrations
+ * - `'_blank'`: Opens checkout in a new tab/window
+ * - `string`: Passed to `window.open()` as the target
*
* For more details on window targets, see the [`Window.open()` `target` parameter](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#target)
*
@@ -90,6 +118,18 @@ export interface CheckoutProperties {
// exist so the generated API docs show what's on `event.detail` directly,
// without dragging in the full `CustomEvent`/`Event` documentation.
export interface CheckoutEvents {
+ /**
+ * Dispatched when checkout sends the ECP `ec.ready` handshake. Listeners can
+ * call `respondWith()` synchronously when checkout requests a credential.
+ */
+ "ec.ready": CheckoutReadyEvent;
+
+ /**
+ * Dispatched when checkout sends an ECP `ec.auth` credential refresh request.
+ * Listeners should call `respondWith()` synchronously with a credential.
+ */
+ "ec.auth": CheckoutAuthEvent;
+
/**
* Dispatched when checkout has started.
*/
@@ -189,6 +229,48 @@ export interface CheckoutMessagesChangeEvent {
};
}
+export type CheckoutCredentialResponse = string | { readonly credential: string };
+
+export type CheckoutReadyResponse =
+ | string
+ | {
+ readonly credential?: string;
+ };
+
+export interface RespondableCheckoutEvent {
+ type: string;
+ detail: Detail;
+ respondWith(response: Promise | ResponsePayload): void;
+}
+
+export interface CheckoutReadyEvent extends RespondableCheckoutEvent<
+ CheckoutReadyResponse,
+ CheckoutReadyEventDetail
+> {
+ type: "ec.ready";
+}
+
+export interface CheckoutReadyEventDetail {
+ /** Delegation types requested by checkout in `ec.ready.params.delegate`. */
+ delegate: readonly string[];
+ /** Authorization requested by checkout in `ec.ready.params.auth`, when present. */
+ auth?: EcAuthRequest;
+}
+
+export interface CheckoutAuthEvent extends RespondableCheckoutEvent<
+ CheckoutCredentialResponse,
+ CheckoutAuthEventDetail
+> {
+ type: "ec.auth";
+}
+
+export interface CheckoutAuthEventDetail {
+ /** Authorization type requested by checkout, e.g. `oauth`, `api_key`, or `jwt`. */
+ type?: string;
+ /** Raw `ec.auth` params for integrations that need additional fields. */
+ auth: EcAuthParams;
+}
+
export type TypedEventListener =
| ((event: Event) => void)
| {
@@ -209,6 +291,7 @@ export interface CheckoutProtocolMessageData<
T extends keyof CheckoutProtocolMessageMap = keyof CheckoutProtocolMessageMap,
> {
jsonrpc: "2.0";
+ id?: JsonRpcRequestId;
method: T;
params?: CheckoutProtocolMessageMap[T];
}
@@ -232,6 +315,7 @@ export interface EcErrorParams {
*/
export interface CheckoutProtocolMessageMap {
"ec.ready": EcReadyParams;
+ "ec.auth": EcAuthParams;
"ec.start": CheckoutPayload;
"ec.complete": CheckoutPayload;
"ec.error": EcErrorParams;
@@ -246,6 +330,8 @@ export type {
Checkout,
CheckoutLineItem,
CheckoutMessage,
+ EcAuthParams,
+ EcAuthRequest,
EcReadyParams,
OrderConfirmation,
Total,
diff --git a/platforms/web/src/ucp-embed-types.ts b/platforms/web/src/ucp-embed-types.ts
index 65d93ae65..e6e904bed 100644
--- a/platforms/web/src/ucp-embed-types.ts
+++ b/platforms/web/src/ucp-embed-types.ts
@@ -150,12 +150,20 @@ export interface FulfillmentAvailableMethod {
readonly [key: string]: unknown;
}
+export interface EcAuthRequest {
+ readonly type?: string;
+ readonly [key: string]: unknown;
+}
+
/** `ec.ready` request params (handshake). */
export interface EcReadyParams {
readonly delegate: readonly string[];
+ readonly auth?: EcAuthRequest;
readonly [key: string]: unknown;
}
+export interface EcAuthParams extends EcAuthRequest {}
+
/** Populated on completed checkout (`checkout.order`). */
export interface OrderConfirmation {
readonly id: string;