diff --git a/biome.json b/biome.json
index de10f4f8c..e1d196275 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
diff --git a/docs/docs/api-reference/use-checkout.md b/docs/docs/api-reference/use-checkout.md
new file mode 100644
index 000000000..d1d09c333
--- /dev/null
+++ b/docs/docs/api-reference/use-checkout.md
@@ -0,0 +1,374 @@
+---
+sidebar_position: 2
+---
+
+# useCheckout
+
+The `useCheckout` hook enables [Instant Checkout](https://platform.openai.com/docs/actions/monetization) in ChatGPT apps, allowing users to complete purchases directly within ChatGPT. It wraps [`window.openai.requestCheckout`](https://developers.openai.com/apps-sdk/build/chatgpt-ui#instant-checkout) with type-safe state management following the [ACP (Agentic Commerce Protocol) specification](https://developers.openai.com/commerce/specs/checkout).
+
+:::note
+Instant Checkout is currently in private beta and limited to select marketplace partners.
+:::
+
+## Basic usage
+
+```tsx
+import { useCheckout, CheckoutSessionRequest } from "skybridge/web";
+
+function CheckoutButton({ session }: { session: CheckoutSessionRequest }) {
+ const { requestCheckout, isPending, isSuccess, order } = useCheckout();
+
+ return (
+
+
+ {isSuccess && order &&
Order completed: {order.id}
}
+
+ );
+}
+```
+
+## Parameters
+
+```tsx
+const {
+ data,
+ error,
+ isError,
+ isIdle,
+ isPending,
+ isSuccess,
+ status,
+ order,
+ sessionId,
+ requestCheckout,
+ requestCheckoutAsync,
+} = useCheckout(options);
+
+requestCheckout(session, {
+ onError,
+ onSettled,
+ onSuccess,
+});
+
+await requestCheckoutAsync(session);
+```
+
+### `options`
+
+Optional configuration object:
+
+```tsx
+type UseCheckoutOptions = {
+ checkoutSessionIdGenerator?: () => string;
+};
+```
+
+- `checkoutSessionIdGenerator` - Function to generate unique checkout session IDs. Defaults to `crypto.randomUUID()`. Useful for custom ID formats or testing.
+
+## Returns
+
+### `requestCheckout`
+
+```tsx
+requestCheckout: (session: CheckoutSessionRequest, sideEffects?: CheckoutSideEffects) => void
+```
+
+Initiates the checkout flow. Opens the ChatGPT Instant Checkout UI with the provided session data.
+
+- `session: CheckoutSessionRequest`
+ - **Required**
+ - The checkout session configuration (see [Checkout Session](#checkout-session) below)
+- `sideEffects: CheckoutSideEffects`
+ - Optional callbacks:
+ - `onSuccess: (data: CheckoutSuccessResponse) => void` - Fires on successful checkout
+ - `onError: (error: CheckoutErrorResponse | Error) => void` - Fires on checkout failure
+ - `onSettled: (data, error) => void` - Fires when checkout completes (success or error)
+
+### `requestCheckoutAsync`
+
+```tsx
+requestCheckoutAsync: (session: CheckoutSessionRequest) => Promise
+```
+
+Same as `requestCheckout` but returns a promise for async/await usage.
+
+### `status`
+
+```tsx
+status: "idle" | "pending" | "success" | "error"
+```
+
+- `idle` - Initial state, no checkout initiated
+- `pending` - Checkout UI is open, awaiting user action
+- `success` - Checkout completed successfully
+- `error` - Checkout failed or was cancelled
+
+### `isIdle`, `isPending`, `isSuccess`, `isError`
+
+```tsx
+isIdle: boolean;
+isPending: boolean;
+isSuccess: boolean;
+isError: boolean;
+```
+
+Boolean flags derived from `status` for convenience.
+
+### `data`
+
+```tsx
+data: CheckoutSuccessResponse | undefined
+```
+
+The successful checkout response. Only available when `status` is `"success"`.
+
+```tsx
+type CheckoutSuccessResponse = {
+ id: string; // Checkout session ID
+ status: "completed";
+ currency: string;
+ order: {
+ id: string; // Order ID
+ checkout_session_id: string;
+ permalink_url?: string; // Link to order confirmation
+ created_at?: string;
+ status?: CheckoutOrderStatus;
+ };
+};
+```
+
+### `error`
+
+```tsx
+error: CheckoutErrorResponse | Error | undefined
+```
+
+The error if checkout failed. Only available when `status` is `"error"`.
+
+```tsx
+type CheckoutErrorResponse = {
+ code: CheckoutErrorCode; // "payment_declined" | "requires_3ds" | "cancelled" | ...
+ message: string;
+};
+```
+
+### `order`
+
+```tsx
+order: CheckoutOrder | undefined
+```
+
+Convenience accessor for the order details from the successful checkout. Equivalent to `data?.order`. Only available when `status` is `"success"`.
+
+```tsx
+type CheckoutOrder = {
+ id: string; // Order ID
+ checkout_session_id: string;
+ permalink_url?: string; // Link to order confirmation
+ created_at?: string;
+ status?: CheckoutOrderStatus;
+};
+```
+
+### `sessionId`
+
+```tsx
+sessionId: string | undefined
+```
+
+The checkout session ID used for the current or most recent checkout operation. This is either the ID provided in the `CheckoutSessionRequest` or auto-generated using the `checkoutSessionIdGenerator`.
+
+## Checkout Session
+
+The `CheckoutSessionRequest` defines what the user is purchasing:
+
+```tsx
+type CheckoutSessionRequest = {
+ id: string; // Unique session ID
+ payment_provider: {
+ provider: string; // PSP name (e.g., "stripe", "adyen")
+ merchant_id: string; // Your merchant ID from PSP
+ supported_payment_methods?: SupportedPaymentMethod[];
+ };
+ status: CheckoutSessionStatus; // Usually "ready_for_payment"
+ currency: string; // ISO 4217 code (e.g., "USD")
+ totals: CheckoutTotal[]; // Price breakdown
+ links?: CheckoutLink[]; // Legal/policy links
+ payment_mode?: "live" | "test"; // Use "test" for test cards
+ line_items?: CheckoutLineItem[]; // Items being purchased
+ merchant_name?: string;
+ order_reference?: string;
+};
+```
+
+## Examples
+
+### With Side Effects
+
+```tsx
+import { useCheckout, CheckoutSessionRequest } from "skybridge/web";
+
+function CheckoutWidget({ session }: { session: CheckoutSessionRequest }) {
+ const { requestCheckout, isPending, isError, error } = useCheckout();
+
+ const handleCheckout = () => {
+ requestCheckout(session, {
+ onSuccess: (data) => {
+ console.log("Order completed:", data.order.id);
+ },
+ onError: (error) => {
+ if ("code" in error) {
+ console.error("Checkout error:", error.code, error.message);
+ } else {
+ console.error("Unexpected error:", error.message);
+ }
+ },
+ });
+ };
+
+ return (
+
+
+ {isError &&
Error: {"message" in error ? error.message : String(error)}
}
+
+ );
+}
+```
+
+### Async/Await Pattern
+
+```tsx
+import { useCheckout, CheckoutSessionRequest } from "skybridge/web";
+
+function AsyncCheckout({ session }: { session: CheckoutSessionRequest }) {
+ const { requestCheckoutAsync, isPending } = useCheckout();
+
+ const handleCheckout = async () => {
+ try {
+ const result = await requestCheckoutAsync(session);
+ console.log("Order ID:", result.order.id);
+ // Redirect to confirmation or update UI
+ } catch (error) {
+ console.error("Checkout failed:", error);
+ }
+ };
+
+ return (
+
+ );
+}
+```
+
+### Building a Checkout Session
+
+```tsx
+import { useCheckout, CheckoutSessionRequest } from "skybridge/web";
+
+function ProductCheckout({ productId, price }: { productId: string; price: number }) {
+ const { requestCheckout, isPending } = useCheckout();
+
+ const handleCheckout = () => {
+ const session: CheckoutSessionRequest = {
+ id: `checkout_${Date.now()}`,
+ payment_provider: {
+ provider: "stripe",
+ merchant_id: "your_merchant_id",
+ supported_payment_methods: ["card", "apple_pay", "google_pay"],
+ },
+ status: "ready_for_payment",
+ currency: "USD",
+ totals: [
+ { type: "subtotal", display_text: "Subtotal", amount: price },
+ { type: "tax", display_text: "Tax", amount: Math.round(price * 0.08) },
+ { type: "total", display_text: "Total", amount: Math.round(price * 1.08) },
+ ],
+ links: [
+ { type: "terms_of_use", url: "https://example.com/terms" },
+ { type: "privacy_policy", url: "https://example.com/privacy" },
+ ],
+ payment_mode: "live",
+ };
+
+ requestCheckout(session);
+ };
+
+ return (
+
+ );
+}
+```
+
+### Test Mode
+
+Use `payment_mode: "test"` to test with test cards (e.g., 4242 4242 4242 4242):
+
+```tsx
+const testSession: CheckoutSessionRequest = {
+ // ...other fields
+ payment_mode: "test",
+};
+```
+
+### Using Order and Session ID
+
+```tsx
+import { useCheckout, CheckoutSessionRequest } from "skybridge/web";
+
+function CheckoutStatus({ session }: { session: CheckoutSessionRequest }) {
+ const { requestCheckout, isPending, isSuccess, order, sessionId } = useCheckout();
+
+ return (
+
+
+ {sessionId &&
Session ID: {sessionId}
}
+ {isSuccess && order && (
+
+ )}
+
+ );
+}
+```
+
+### Custom Session ID Generator
+
+```tsx
+import { useCheckout } from "skybridge/web";
+
+function CheckoutWithCustomId() {
+ const { requestCheckout, sessionId } = useCheckout({
+ checkoutSessionIdGenerator: () => `session_${Date.now()}_${Math.random().toString(36).slice(2)}`,
+ });
+
+ // sessionId will use your custom format
+ return (
+
+ );
+}
+```
+
+## Related
+
+- [OpenAI Monetization Docs](https://platform.openai.com/docs/actions/monetization)
+- [ACP Checkout Specification](https://developers.openai.com/commerce/specs/checkout)
+- [Stripe Agentic Commerce](https://docs.stripe.com/agentic-commerce/apps)
+- [Adyen Agentic Commerce](https://docs.adyen.com/online-payments/agentic-commerce)
diff --git a/packages/core/src/server/widgetsDevServer.ts b/packages/core/src/server/widgetsDevServer.ts
index da22fb7c7..a4f8d3f47 100644
--- a/packages/core/src/server/widgetsDevServer.ts
+++ b/packages/core/src/server/widgetsDevServer.ts
@@ -26,7 +26,7 @@ export const widgetsDevServer = async (): Promise => {
webAppRoot,
);
- const { build, preview, ...devConfig } = configResult?.config || {};
+ const { ...devConfig } = configResult?.config || {};
const vite = await createServer({
...devConfig,
diff --git a/packages/core/src/web/hooks/index.ts b/packages/core/src/web/hooks/index.ts
index 8893889d1..7868592c4 100644
--- a/packages/core/src/web/hooks/index.ts
+++ b/packages/core/src/web/hooks/index.ts
@@ -1,3 +1,4 @@
+export type { AsyncOperationState } from "./use-async-operation.js";
export {
type CallToolAsyncFn,
type CallToolFn,
@@ -5,6 +6,30 @@ export {
type SideEffects,
useCallTool,
} from "./use-call-tool.js";
+export {
+ type CheckoutErrorCode,
+ type CheckoutErrorResponse,
+ type CheckoutLineItem,
+ type CheckoutLink,
+ type CheckoutLinkType,
+ type CheckoutOrder,
+ type CheckoutOrderStatus,
+ type CheckoutPaymentMode,
+ type CheckoutPaymentProvider,
+ type CheckoutResponse,
+ type CheckoutSessionRequest,
+ type CheckoutSessionStatus,
+ type CheckoutSideEffects,
+ type CheckoutState,
+ type CheckoutSuccessResponse,
+ type CheckoutTotal,
+ type CheckoutTotalType,
+ type RequestCheckoutAsyncFn,
+ type RequestCheckoutFn,
+ type SupportedPaymentMethod,
+ type UseCheckoutOptions,
+ useCheckout,
+} from "./use-checkout.js";
export { useDisplayMode } from "./use-display-mode.js";
export { useFiles } from "./use-files.js";
export { type LayoutState, useLayout } from "./use-layout.js";
diff --git a/packages/core/src/web/hooks/use-async-operation.test.ts b/packages/core/src/web/hooks/use-async-operation.test.ts
new file mode 100644
index 000000000..9ade22deb
--- /dev/null
+++ b/packages/core/src/web/hooks/use-async-operation.test.ts
@@ -0,0 +1,411 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useAsyncOperation } from "./use-async-operation.js";
+
+describe("useAsyncOperation", () => {
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ describe("initial state", () => {
+ it("should start in idle state", () => {
+ const { result } = renderHook(() => useAsyncOperation());
+
+ expect(result.current.state.status).toBe("idle");
+ expect(result.current.state.isIdle).toBe(true);
+ expect(result.current.state.isPending).toBe(false);
+ expect(result.current.state.isSuccess).toBe(false);
+ expect(result.current.state.isError).toBe(false);
+ expect(result.current.state.data).toBeUndefined();
+ expect(result.current.state.error).toBeUndefined();
+ });
+ });
+
+ describe("successful execution", () => {
+ it("should transition from idle -> pending -> success", async () => {
+ const { result } = renderHook(() => useAsyncOperation());
+ const mockData = "success data";
+
+ let resolveOperation: (value: string) => void;
+ const operationPromise = new Promise((resolve) => {
+ resolveOperation = resolve;
+ });
+
+ // Start execution
+ act(() => {
+ result.current.execute(() => operationPromise);
+ });
+
+ // Should be in pending state
+ expect(result.current.state.status).toBe("pending");
+ expect(result.current.state.isPending).toBe(true);
+ expect(result.current.state.data).toBeUndefined();
+
+ // Resolve the operation
+ await act(async () => {
+ resolveOperation(mockData);
+ await operationPromise;
+ });
+
+ // Should be in success state
+ await waitFor(() => {
+ expect(result.current.state.status).toBe("success");
+ expect(result.current.state.isSuccess).toBe(true);
+ expect(result.current.state.data).toBe(mockData);
+ expect(result.current.state.error).toBeUndefined();
+ });
+ });
+
+ it("should return the result from execute", async () => {
+ const { result } = renderHook(() => useAsyncOperation());
+ const expectedValue = 42;
+
+ let actualValue: number | undefined;
+ await act(async () => {
+ actualValue = await result.current.execute(async () => expectedValue);
+ });
+
+ expect(actualValue).toBe(expectedValue);
+ });
+ });
+
+ describe("failed execution", () => {
+ it("should transition from idle -> pending -> error", async () => {
+ const { result } = renderHook(() => useAsyncOperation());
+ const mockError = new Error("operation failed");
+
+ let rejectOperation: (error: Error) => void;
+ const operationPromise = new Promise((_resolve, reject) => {
+ rejectOperation = reject;
+ });
+
+ // Start execution
+ act(() => {
+ result.current
+ .execute(() => operationPromise)
+ .catch(() => {
+ // Prevent unhandled rejection
+ });
+ });
+
+ // Should be in pending state
+ expect(result.current.state.status).toBe("pending");
+ expect(result.current.state.isPending).toBe(true);
+
+ // Reject the operation
+ await act(async () => {
+ rejectOperation(mockError);
+ try {
+ await operationPromise;
+ } catch {
+ // Expected
+ }
+ });
+
+ // Should be in error state
+ await waitFor(() => {
+ expect(result.current.state.status).toBe("error");
+ expect(result.current.state.isError).toBe(true);
+ expect(result.current.state.error).toBe(mockError);
+ expect(result.current.state.data).toBeUndefined();
+ });
+ });
+
+ it("should throw the error from execute", async () => {
+ const { result } = renderHook(() => useAsyncOperation());
+ const expectedError = new Error("test error");
+
+ await act(async () => {
+ await expect(
+ result.current.execute(async () => {
+ throw expectedError;
+ }),
+ ).rejects.toThrow(expectedError);
+ });
+ });
+ });
+
+ describe("deduplication", () => {
+ it("should not enable deduplication by default", async () => {
+ const { result } = renderHook(() => useAsyncOperation());
+
+ // Execute first operation
+ await act(async () => {
+ await result.current.execute(async () => "first");
+ });
+ expect(result.current.state.data).toBe("first");
+
+ // Execute second operation
+ await act(async () => {
+ await result.current.execute(async () => "second");
+ });
+ expect(result.current.state.data).toBe("second");
+ });
+
+ it("should deduplicate rapid calls when enabled", async () => {
+ const { result } = renderHook(() =>
+ useAsyncOperation({ enableDeduplication: true }),
+ );
+
+ let resolveFirst: (value: string) => void;
+ let resolveSecond: (value: string) => void;
+
+ const firstPromise = new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ const secondPromise = new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+
+ // Start first operation
+ act(() => {
+ result.current.execute(() => firstPromise);
+ });
+
+ // Start second operation (should supersede first)
+ act(() => {
+ result.current.execute(() => secondPromise);
+ });
+
+ expect(result.current.state.status).toBe("pending");
+
+ // Resolve first operation (should be ignored)
+ await act(async () => {
+ resolveFirst("first");
+ await firstPromise;
+ });
+
+ // State should still be pending because second is newer
+ expect(result.current.state.status).toBe("pending");
+ expect(result.current.state.data).toBeUndefined();
+
+ // Resolve second operation (should update state)
+ await act(async () => {
+ resolveSecond("second");
+ await secondPromise;
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.status).toBe("success");
+ expect(result.current.state.data).toBe("second");
+ });
+ });
+
+ it("should ignore errors from superseded operations", async () => {
+ const { result } = renderHook(() =>
+ useAsyncOperation({ enableDeduplication: true }),
+ );
+
+ let rejectFirst: (error: Error) => void;
+ let resolveSecond: (value: string) => void;
+
+ const firstPromise = new Promise((_resolve, reject) => {
+ rejectFirst = reject;
+ });
+ const secondPromise = new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+
+ // Start first operation
+ act(() => {
+ result.current
+ .execute(() => firstPromise)
+ .catch(() => {
+ // Prevent unhandled rejection
+ });
+ });
+
+ // Start second operation (should supersede first)
+ act(() => {
+ result.current.execute(() => secondPromise);
+ });
+
+ // Reject first operation (should be ignored)
+ await act(async () => {
+ rejectFirst(new Error("first error"));
+ try {
+ await firstPromise;
+ } catch {
+ // Expected
+ }
+ });
+
+ // State should still be pending (error was ignored)
+ expect(result.current.state.status).toBe("pending");
+ expect(result.current.state.error).toBeUndefined();
+
+ // Resolve second operation
+ await act(async () => {
+ resolveSecond("second");
+ await secondPromise;
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.status).toBe("success");
+ expect(result.current.state.data).toBe("second");
+ });
+ });
+
+ it("should handle multiple rapid calls with deduplication", async () => {
+ const { result } = renderHook(() =>
+ useAsyncOperation({ enableDeduplication: true }),
+ );
+
+ const resolvers: Array<() => void> = [];
+ const promises = [1, 2, 3, 4, 5].map(
+ (n) =>
+ new Promise((resolve) => {
+ resolvers.push(() => resolve(n));
+ }),
+ );
+
+ // Start all operations rapidly
+ act(() => {
+ promises.forEach((promise) => {
+ result.current.execute(() => promise);
+ });
+ });
+
+ expect(result.current.state.status).toBe("pending");
+
+ // Resolve all operations
+ await act(async () => {
+ for (const resolver of resolvers) {
+ resolver();
+ }
+ await Promise.all(promises);
+ });
+
+ // Only the last operation should update state
+ await waitFor(() => {
+ expect(result.current.state.status).toBe("success");
+ expect(result.current.state.data).toBe(5);
+ });
+ });
+ });
+
+ describe("state transitions", () => {
+ it("should clear previous data when starting new operation", async () => {
+ const { result } = renderHook(() => useAsyncOperation());
+
+ // First successful operation
+ await act(async () => {
+ await result.current.execute(async () => "first data");
+ });
+ expect(result.current.state.data).toBe("first data");
+
+ // Start second operation
+ let resolveSecond: (value: string) => void;
+ const secondPromise = new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+
+ act(() => {
+ result.current.execute(() => secondPromise);
+ });
+
+ // Data should be cleared in pending state
+ expect(result.current.state.status).toBe("pending");
+ expect(result.current.state.data).toBeUndefined();
+
+ // Complete second operation
+ await act(async () => {
+ resolveSecond("second data");
+ await secondPromise;
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.data).toBe("second data");
+ });
+ });
+
+ it("should clear previous error when starting new operation", async () => {
+ const { result } = renderHook(() => useAsyncOperation());
+
+ // First failed operation
+ await act(async () => {
+ try {
+ await result.current.execute(async () => {
+ throw new Error("first error");
+ });
+ } catch {
+ // Expected
+ }
+ });
+ expect(result.current.state.error).toBeDefined();
+
+ // Start second operation
+ let resolveSecond: (value: string) => void;
+ const secondPromise = new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+
+ act(() => {
+ result.current.execute(() => secondPromise);
+ });
+
+ // Error should be cleared in pending state
+ expect(result.current.state.status).toBe("pending");
+ expect(result.current.state.error).toBeUndefined();
+
+ // Complete second operation
+ await act(async () => {
+ resolveSecond("success");
+ await secondPromise;
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.data).toBe("success");
+ });
+ });
+ });
+
+ describe("type safety", () => {
+ it("should maintain correct types for data", async () => {
+ type CustomData = { id: string; value: number };
+ const { result } = renderHook(() => useAsyncOperation());
+
+ const testData: CustomData = { id: "test", value: 42 };
+
+ await act(async () => {
+ await result.current.execute(async () => testData);
+ });
+
+ // Type should be CustomData | undefined in success state
+ expect(result.current.state.data).toEqual(testData);
+ if (result.current.state.isSuccess) {
+ expect(result.current.state.data.id).toBe("test");
+ expect(result.current.state.data.value).toBe(42);
+ }
+ });
+
+ it("should maintain correct types for error", async () => {
+ type CustomError = { code: string; message: string };
+ const { result } = renderHook(() =>
+ useAsyncOperation(),
+ );
+
+ const testError: CustomError = {
+ code: "TEST_ERROR",
+ message: "Test error message",
+ };
+
+ await act(async () => {
+ try {
+ await result.current.execute(async () => {
+ throw testError;
+ });
+ } catch {
+ // Expected
+ }
+ });
+
+ // Type should be CustomError | undefined in error state
+ expect(result.current.state.error).toEqual(testError);
+ if (result.current.state.isError) {
+ expect(result.current.state.error.code).toBe("TEST_ERROR");
+ expect(result.current.state.error.message).toBe("Test error message");
+ }
+ });
+ });
+});
diff --git a/packages/core/src/web/hooks/use-async-operation.ts b/packages/core/src/web/hooks/use-async-operation.ts
new file mode 100644
index 000000000..db5e4ffe0
--- /dev/null
+++ b/packages/core/src/web/hooks/use-async-operation.ts
@@ -0,0 +1,144 @@
+import { useRef, useState } from "react";
+
+type AsyncOperationIdleState<_TData, _TError> = {
+ status: "idle";
+ isIdle: true;
+ isPending: false;
+ isSuccess: false;
+ isError: false;
+ data: undefined;
+ error: undefined;
+};
+
+type AsyncOperationPendingState<_TData, _TError> = {
+ status: "pending";
+ isIdle: false;
+ isPending: true;
+ isSuccess: false;
+ isError: false;
+ data: undefined;
+ error: undefined;
+};
+
+type AsyncOperationSuccessState = {
+ status: "success";
+ isIdle: false;
+ isPending: false;
+ isSuccess: true;
+ isError: false;
+ data: TData;
+ error: undefined;
+};
+
+type AsyncOperationErrorState<_TData, TError> = {
+ status: "error";
+ isIdle: false;
+ isPending: false;
+ isSuccess: false;
+ isError: true;
+ data: undefined;
+ error: TError;
+};
+
+export type AsyncOperationState =
+ | AsyncOperationIdleState
+ | AsyncOperationPendingState
+ | AsyncOperationSuccessState
+ | AsyncOperationErrorState;
+
+export type UseAsyncOperationConfig = {
+ enableDeduplication?: boolean;
+};
+
+const createState = (
+ status: "idle" | "pending" | "success" | "error",
+ data: TData | undefined,
+ error: TError | undefined,
+): AsyncOperationState => {
+ switch (status) {
+ case "idle":
+ return {
+ status: "idle",
+ data: undefined,
+ error: undefined,
+ isIdle: true,
+ isPending: false,
+ isSuccess: false,
+ isError: false,
+ };
+ case "pending":
+ return {
+ status: "pending",
+ data: undefined,
+ error: undefined,
+ isIdle: false,
+ isPending: true,
+ isSuccess: false,
+ isError: false,
+ };
+ case "success":
+ return {
+ status: "success",
+ data: data as TData,
+ error: undefined,
+ isIdle: false,
+ isPending: false,
+ isSuccess: true,
+ isError: false,
+ };
+ case "error":
+ return {
+ status: "error",
+ data: undefined,
+ error: error as TError,
+ isIdle: false,
+ isPending: false,
+ isSuccess: false,
+ isError: true,
+ };
+ }
+};
+
+export const useAsyncOperation = (
+ config?: UseAsyncOperationConfig,
+) => {
+ const [{ status, data, error }, setState] = useState<
+ Omit<
+ AsyncOperationState,
+ "isIdle" | "isPending" | "isSuccess" | "isError"
+ >
+ >({ status: "idle", data: undefined, error: undefined });
+
+ const callIdRef = useRef(0);
+
+ const execute = async (executeFn: () => Promise): Promise => {
+ const callId = config?.enableDeduplication ? ++callIdRef.current : 0;
+ setState({ status: "pending", data: undefined, error: undefined });
+
+ try {
+ const result = await executeFn();
+
+ if (!config?.enableDeduplication || callId === callIdRef.current) {
+ setState({ status: "success", data: result, error: undefined });
+ }
+
+ return result;
+ } catch (error) {
+ if (!config?.enableDeduplication || callId === callIdRef.current) {
+ setState({
+ status: "error",
+ data: undefined,
+ error: error as TError,
+ });
+ }
+ throw error;
+ }
+ };
+
+ const state = createState(status, data, error);
+
+ return {
+ state,
+ execute,
+ };
+};
diff --git a/packages/core/src/web/hooks/use-call-tool.ts b/packages/core/src/web/hooks/use-call-tool.ts
index 9e6d1aaca..c0a254de4 100644
--- a/packages/core/src/web/hooks/use-call-tool.ts
+++ b/packages/core/src/web/hooks/use-call-tool.ts
@@ -1,57 +1,14 @@
-import { useRef, useState } from "react";
-
import { useAdaptor } from "../bridges/hooks/use-adaptor.js";
import type {
CallToolArgs,
CallToolResponse,
HasRequiredKeys,
} from "../types.js";
-
-type CallToolIdleState = {
- status: "idle";
- isIdle: true;
- isPending: false;
- isSuccess: false;
- isError: false;
- data: undefined;
- error: undefined;
-};
-
-type CallToolPendingState = {
- status: "pending";
- isIdle: false;
- isPending: true;
- isSuccess: false;
- isError: false;
- data: undefined;
- error: undefined;
-};
-
-type CallToolSuccessState = {
- status: "success";
- isIdle: false;
- isPending: false;
- isSuccess: true;
- isError: false;
- data: TData;
- error: undefined;
-};
-
-type CallToolErrorState = {
- status: "error";
- isIdle: false;
- isPending: false;
- isSuccess: false;
- isError: true;
- data: undefined;
- error: unknown;
-};
+import type { AsyncOperationState } from "./use-async-operation.js";
+import { useAsyncOperation } from "./use-async-operation.js";
export type CallToolState =
- | CallToolIdleState
- | CallToolPendingState
- | CallToolSuccessState
- | CallToolErrorState;
+ AsyncOperationState;
export type SideEffects = {
onSuccess?: (data: ToolResponse, toolArgs: ToolArgs) => void;
@@ -103,38 +60,22 @@ export const useCallTool = <
) => {
type CombinedCallToolResponse = CallToolResponse & ToolResponse;
- const [{ status, data, error }, setCallToolState] = useState<
- Omit<
- CallToolState,
- "isIdle" | "isPending" | "isSuccess" | "isError"
- >
- >({ status: "idle", data: undefined, error: undefined });
+ const { state, execute: executeAsync } = useAsyncOperation<
+ CombinedCallToolResponse,
+ unknown
+ >({ enableDeduplication: true });
- const callIdRef = useRef(0);
const adaptor = useAdaptor();
const execute = async (
toolArgs: ToolArgs,
): Promise => {
- const callId = ++callIdRef.current;
- setCallToolState({ status: "pending", data: undefined, error: undefined });
-
- try {
- const data = await adaptor.callTool(
+ return executeAsync(async () => {
+ return adaptor.callTool(
name,
toolArgs,
);
- if (callId === callIdRef.current) {
- setCallToolState({ status: "success", data, error: undefined });
- }
-
- return data;
- } catch (error) {
- if (callId === callIdRef.current) {
- setCallToolState({ status: "error", data: undefined, error });
- }
- throw error;
- }
+ });
};
const callToolAsync = ((toolArgs?: ToolArgs) => {
@@ -173,18 +114,8 @@ export const useCallTool = <
});
}) as CallToolFn;
- const callToolState = {
- status,
- data,
- error,
- isIdle: status === "idle",
- isPending: status === "pending",
- isSuccess: status === "success",
- isError: status === "error",
- } as CallToolState;
-
return {
- ...callToolState,
+ ...state,
callTool,
callToolAsync,
};
diff --git a/packages/core/src/web/hooks/use-checkout.test.ts b/packages/core/src/web/hooks/use-checkout.test.ts
new file mode 100644
index 000000000..723b48eec
--- /dev/null
+++ b/packages/core/src/web/hooks/use-checkout.test.ts
@@ -0,0 +1,443 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ type Mock,
+ vi,
+} from "vitest";
+import type {
+ CheckoutErrorResponse,
+ CheckoutSessionRequest,
+ CheckoutSuccessResponse,
+} from "../types.js";
+import { useCheckout } from "./use-checkout.js";
+
+describe("useCheckout", () => {
+ let requestCheckoutMock: Mock;
+
+ const mockSession: CheckoutSessionRequest = {
+ id: "checkout_session_123",
+ payment_provider: {
+ provider: "stripe",
+ merchant_id: "merchant_456",
+ supported_payment_methods: ["card", "apple_pay"],
+ },
+ status: "ready_for_payment",
+ currency: "USD",
+ totals: [
+ { type: "subtotal", display_text: "Subtotal", amount: 1000 },
+ { type: "tax", display_text: "Tax", amount: 80 },
+ { type: "total", display_text: "Total", amount: 1080 },
+ ],
+ links: [
+ { type: "terms_of_use", url: "https://example.com/terms" },
+ { type: "privacy_policy", url: "https://example.com/privacy" },
+ ],
+ payment_mode: "live",
+ };
+
+ const mockSuccessResponse: CheckoutSuccessResponse = {
+ id: "checkout_session_123",
+ status: "completed",
+ currency: "USD",
+ order: {
+ id: "order_789",
+ checkout_session_id: "checkout_session_123",
+ permalink_url: "https://example.com/orders/789",
+ },
+ };
+
+ const mockErrorResponse: CheckoutErrorResponse = {
+ code: "payment_declined",
+ message: "Your card was declined.",
+ };
+
+ beforeEach(() => {
+ requestCheckoutMock = vi.fn();
+ vi.stubGlobal("openai", {
+ requestCheckout: requestCheckoutMock,
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.resetAllMocks();
+ });
+
+ describe("initial state", () => {
+ it("should start in idle state", () => {
+ const { result } = renderHook(() => useCheckout());
+
+ expect(result.current.status).toBe("idle");
+ expect(result.current.isIdle).toBe(true);
+ expect(result.current.isPending).toBe(false);
+ expect(result.current.isSuccess).toBe(false);
+ expect(result.current.isError).toBe(false);
+ expect(result.current.data).toBeUndefined();
+ expect(result.current.error).toBeUndefined();
+ });
+ });
+
+ describe("requestCheckout", () => {
+ it("should call window.openai.requestCheckout with correct session data", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ expect(requestCheckoutMock).toHaveBeenCalledWith(mockSession);
+ });
+
+ it("should transition to pending state while checkout is in progress", async () => {
+ let resolveCheckout: (value: CheckoutSuccessResponse) => void;
+ requestCheckoutMock.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveCheckout = resolve;
+ }),
+ );
+
+ const { result } = renderHook(() => useCheckout());
+
+ act(() => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ expect(result.current.status).toBe("pending");
+ expect(result.current.isPending).toBe(true);
+ expect(result.current.isIdle).toBe(false);
+
+ await act(async () => {
+ resolveCheckout?.(mockSuccessResponse);
+ });
+ });
+
+ it("should transition to success state on successful checkout", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ await waitFor(() => {
+ expect(result.current.status).toBe("success");
+ expect(result.current.isSuccess).toBe(true);
+ expect(result.current.data).toEqual(mockSuccessResponse);
+ expect(result.current.error).toBeUndefined();
+ });
+ });
+
+ it("should transition to error state on checkout error response", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockErrorResponse);
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ await waitFor(() => {
+ expect(result.current.status).toBe("error");
+ expect(result.current.isError).toBe(true);
+ expect(result.current.error).toEqual(mockErrorResponse);
+ expect(result.current.data).toBeUndefined();
+ });
+ });
+
+ it("should transition to error state on rejected promise", async () => {
+ const error = new Error("Network error");
+ requestCheckoutMock.mockRejectedValueOnce(error);
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ await waitFor(() => {
+ expect(result.current.status).toBe("error");
+ expect(result.current.isError).toBe(true);
+ expect(result.current.error).toEqual(error);
+ });
+ });
+ });
+
+ describe("requestCheckout with side effects", () => {
+ it("should call onSuccess callback on successful checkout", async () => {
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+ const onSettled = vi.fn();
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+
+ const { result } = renderHook(() => useCheckout());
+
+ act(() => {
+ result.current.requestCheckout(mockSession, {
+ onSuccess,
+ onError,
+ onSettled,
+ });
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledWith(mockSuccessResponse);
+ expect(onError).not.toHaveBeenCalled();
+ expect(onSettled).toHaveBeenCalledWith(mockSuccessResponse, undefined);
+ });
+ });
+
+ it("should call onError callback on checkout error", async () => {
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+ const onSettled = vi.fn();
+ requestCheckoutMock.mockResolvedValueOnce(mockErrorResponse);
+
+ const { result } = renderHook(() => useCheckout());
+
+ act(() => {
+ result.current.requestCheckout(mockSession, {
+ onSuccess,
+ onError,
+ onSettled,
+ });
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).not.toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledWith(mockErrorResponse);
+ expect(onSettled).toHaveBeenCalledWith(undefined, mockErrorResponse);
+ });
+ });
+
+ it("should call onError callback on rejected promise", async () => {
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+ const onSettled = vi.fn();
+ const error = new Error("Network error");
+ requestCheckoutMock.mockRejectedValueOnce(error);
+
+ const { result } = renderHook(() => useCheckout());
+
+ act(() => {
+ result.current.requestCheckout(mockSession, {
+ onSuccess,
+ onError,
+ onSettled,
+ });
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).not.toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledWith(error);
+ expect(onSettled).toHaveBeenCalledWith(undefined, error);
+ });
+ });
+ });
+
+ describe("requestCheckoutAsync", () => {
+ it("should return the successful response", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+ const { result } = renderHook(() => useCheckout());
+
+ const response = await act(async () => {
+ return result.current.requestCheckoutAsync(mockSession);
+ });
+
+ expect(response).toEqual(mockSuccessResponse);
+ });
+
+ it("should throw on checkout error response", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockErrorResponse);
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ await expect(
+ result.current.requestCheckoutAsync(mockSession),
+ ).rejects.toEqual(mockErrorResponse);
+ });
+ });
+
+ it("should throw on rejected promise", async () => {
+ const error = new Error("Network error");
+ requestCheckoutMock.mockRejectedValueOnce(error);
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ await expect(
+ result.current.requestCheckoutAsync(mockSession),
+ ).rejects.toEqual(error);
+ });
+ });
+ });
+
+ describe("when requestCheckout is not available", () => {
+ it("should throw an error if window.openai.requestCheckout is undefined", async () => {
+ vi.stubGlobal("openai", {});
+
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ await expect(
+ result.current.requestCheckoutAsync(mockSession),
+ ).rejects.toThrow("requestCheckout is not available in this host");
+ });
+ });
+
+ it("should call onError callback if requestCheckout is unavailable", async () => {
+ vi.stubGlobal("openai", {});
+ const onError = vi.fn();
+
+ const { result } = renderHook(() => useCheckout());
+
+ act(() => {
+ result.current.requestCheckout(mockSession, { onError });
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalled();
+ const errorArg = onError.mock.calls[0]?.[0];
+ expect(errorArg).toBeInstanceOf(Error);
+ expect((errorArg as Error).message).toBe(
+ "requestCheckout is not available in this host",
+ );
+ });
+ });
+ });
+
+ describe("test payment mode", () => {
+ it("should support test payment mode in session", async () => {
+ const testSession: CheckoutSessionRequest = {
+ ...mockSession,
+ payment_mode: "test",
+ };
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ result.current.requestCheckout(testSession);
+ });
+
+ expect(requestCheckoutMock).toHaveBeenCalledWith(testSession);
+ });
+ });
+
+ describe("session ID generation", () => {
+ it("should generate session ID automatically if not provided", async () => {
+ const sessionWithoutId: CheckoutSessionRequest = {
+ ...mockSession,
+ id: undefined as unknown as string,
+ };
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ result.current.requestCheckout(sessionWithoutId);
+ });
+
+ // Verify that requestCheckout was called with a session that has an ID
+ expect(requestCheckoutMock).toHaveBeenCalled();
+ const calledSession = requestCheckoutMock.mock.calls[0]?.[0];
+ expect(calledSession).toBeDefined();
+ expect(calledSession.id).toBeDefined();
+ expect(typeof calledSession.id).toBe("string");
+ });
+
+ it("should preserve provided session ID", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ expect(requestCheckoutMock).toHaveBeenCalledWith(mockSession);
+ const calledSession = requestCheckoutMock.mock.calls[0]?.[0];
+ expect(calledSession.id).toBe("checkout_session_123");
+ });
+
+ it("should use custom session ID generator", async () => {
+ const customIdGenerator = vi.fn(() => "custom_id_999");
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+
+ const sessionWithoutId: CheckoutSessionRequest = {
+ ...mockSession,
+ id: undefined as unknown as string,
+ };
+
+ const { result } = renderHook(() =>
+ useCheckout({ checkoutSessionIdGenerator: customIdGenerator }),
+ );
+
+ await act(async () => {
+ result.current.requestCheckout(sessionWithoutId);
+ });
+
+ expect(customIdGenerator).toHaveBeenCalled();
+ const calledSession = requestCheckoutMock.mock.calls[0]?.[0];
+ expect(calledSession.id).toBe("custom_id_999");
+ });
+
+ it("should expose sessionId in return value", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+
+ const { result } = renderHook(() => useCheckout());
+
+ expect(result.current.sessionId).toBeUndefined();
+
+ await act(async () => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ await waitFor(() => {
+ expect(result.current.sessionId).toBe("checkout_session_123");
+ });
+ });
+ });
+
+ describe("direct order access", () => {
+ it("should expose order directly in return value", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse);
+
+ const { result } = renderHook(() => useCheckout());
+
+ expect(result.current.order).toBeUndefined();
+
+ await act(async () => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ await waitFor(() => {
+ expect(result.current.order).toEqual(mockSuccessResponse.order);
+ expect(result.current.order?.id).toBe("order_789");
+ });
+ });
+
+ it("should have order as undefined when status is not success", async () => {
+ const { result } = renderHook(() => useCheckout());
+
+ expect(result.current.order).toBeUndefined();
+ expect(result.current.status).toBe("idle");
+ });
+
+ it("should have order as undefined on error", async () => {
+ requestCheckoutMock.mockResolvedValueOnce(mockErrorResponse);
+
+ const { result } = renderHook(() => useCheckout());
+
+ await act(async () => {
+ result.current.requestCheckout(mockSession);
+ });
+
+ await waitFor(() => {
+ expect(result.current.status).toBe("error");
+ expect(result.current.order).toBeUndefined();
+ });
+ });
+ });
+});
diff --git a/packages/core/src/web/hooks/use-checkout.ts b/packages/core/src/web/hooks/use-checkout.ts
new file mode 100644
index 000000000..bf48df5cc
--- /dev/null
+++ b/packages/core/src/web/hooks/use-checkout.ts
@@ -0,0 +1,127 @@
+import { useState } from "react";
+import type {
+ CheckoutErrorResponse,
+ CheckoutResponse,
+ CheckoutSessionRequest,
+ CheckoutSuccessResponse,
+} from "../types.js";
+import type { AsyncOperationState } from "./use-async-operation.js";
+import { useAsyncOperation } from "./use-async-operation.js";
+
+export type {
+ CheckoutErrorCode,
+ CheckoutErrorResponse,
+ CheckoutLineItem,
+ CheckoutLink,
+ CheckoutLinkType,
+ CheckoutOrder,
+ CheckoutOrderStatus,
+ CheckoutPaymentMode,
+ CheckoutPaymentProvider,
+ CheckoutResponse,
+ CheckoutSessionRequest,
+ CheckoutSessionStatus,
+ CheckoutSuccessResponse,
+ CheckoutTotal,
+ CheckoutTotalType,
+ SupportedPaymentMethod,
+} from "../types.js";
+
+export type CheckoutState = AsyncOperationState<
+ CheckoutSuccessResponse,
+ CheckoutErrorResponse | Error
+>;
+
+export type CheckoutSideEffects = {
+ onSuccess?: (data: CheckoutSuccessResponse) => void;
+ onError?: (error: CheckoutErrorResponse | Error) => void;
+ onSettled?: (
+ data: CheckoutSuccessResponse | undefined,
+ error: CheckoutErrorResponse | Error | undefined,
+ ) => void;
+};
+
+export type RequestCheckoutFn = {
+ (session: CheckoutSessionRequest): void;
+ (session: CheckoutSessionRequest, sideEffects: CheckoutSideEffects): void;
+};
+
+export type RequestCheckoutAsyncFn = (
+ session: CheckoutSessionRequest,
+) => Promise;
+
+export type UseCheckoutOptions = {
+ checkoutSessionIdGenerator?: () => string;
+};
+
+function isCheckoutErrorResponse(
+ response: CheckoutResponse,
+): response is CheckoutErrorResponse {
+ return "code" in response && "message" in response;
+}
+
+export const useCheckout = (options?: UseCheckoutOptions) => {
+ const { state, execute: executeAsync } = useAsyncOperation<
+ CheckoutSuccessResponse,
+ CheckoutErrorResponse | Error
+ >({ enableDeduplication: true });
+
+ const [sessionId, setSessionId] = useState();
+
+ const generateSessionId =
+ options?.checkoutSessionIdGenerator ?? (() => crypto.randomUUID());
+
+ const execute = async (
+ session: CheckoutSessionRequest,
+ ): Promise => {
+ return executeAsync(async () => {
+ if (!window.openai?.requestCheckout) {
+ throw new Error("requestCheckout is not available in this host");
+ }
+
+ // Auto-inject session ID if not provided
+ const sessionWithId = session.id
+ ? session
+ : { ...session, id: generateSessionId() };
+
+ setSessionId(sessionWithId.id);
+
+ const response = await window.openai.requestCheckout(sessionWithId);
+
+ if (isCheckoutErrorResponse(response)) {
+ throw response;
+ }
+
+ return response;
+ });
+ };
+
+ const requestCheckoutAsync: RequestCheckoutAsyncFn = (
+ session: CheckoutSessionRequest,
+ ) => {
+ return execute(session);
+ };
+
+ const requestCheckout: RequestCheckoutFn = ((
+ session: CheckoutSessionRequest,
+ sideEffects?: CheckoutSideEffects,
+ ) => {
+ execute(session)
+ .then((data) => {
+ sideEffects?.onSuccess?.(data);
+ sideEffects?.onSettled?.(data, undefined);
+ })
+ .catch((error: CheckoutErrorResponse | Error) => {
+ sideEffects?.onError?.(error);
+ sideEffects?.onSettled?.(undefined, error);
+ });
+ }) as RequestCheckoutFn;
+
+ return {
+ ...state,
+ order: state.data?.order,
+ sessionId,
+ requestCheckout,
+ requestCheckoutAsync,
+ };
+};
diff --git a/packages/core/src/web/types.ts b/packages/core/src/web/types.ts
index 32d091f88..9595003fd 100644
--- a/packages/core/src/web/types.ts
+++ b/packages/core/src/web/types.ts
@@ -129,8 +129,175 @@ export type OpenAiMethods = {
* Only files uploaded by the same connector instance can be downloaded.
*/
downloadFile: (file: FileMetadata) => Promise<{ downloadUrl: string }>;
+
+ /**
+ * Opens the Instant Checkout UI for payment processing.
+ * This is part of the ChatGPT Apps Monetization (private beta).
+ *
+ * The promise resolves with the order result after successful payment,
+ * or rejects on error/cancellation.
+ *
+ * @see https://developers.openai.com/commerce/specs/checkout
+ */
+ requestCheckout?: <
+ TSession extends CheckoutSessionRequest = CheckoutSessionRequest,
+ TResponse extends CheckoutResponse = CheckoutResponse,
+ >(
+ session: TSession,
+ ) => Promise;
+};
+
+/**
+ * Supported payment methods for instant checkout.
+ */
+export type SupportedPaymentMethod = "card" | "apple_pay" | "google_pay";
+
+/**
+ * Payment provider configuration for instant checkout.
+ */
+export type CheckoutPaymentProvider = {
+ provider: string;
+ merchant_id: string;
+ supported_payment_methods?: SupportedPaymentMethod[];
};
+/**
+ * Line item in the checkout session.
+ */
+export type CheckoutLineItem = {
+ id?: string;
+ name: string;
+ description?: string;
+ quantity: number;
+ unit_amount: number;
+ image_url?: string;
+};
+
+/**
+ * Type of total line in the checkout summary.
+ */
+export type CheckoutTotalType =
+ | "subtotal"
+ | "tax"
+ | "discount"
+ | "fee"
+ | "shipping"
+ | "total";
+
+/**
+ * Total line in the checkout summary.
+ */
+export type CheckoutTotal = {
+ type: CheckoutTotalType;
+ display_text: string;
+ amount: number;
+};
+
+/**
+ * Type of legal/policy link for checkout.
+ */
+export type CheckoutLinkType =
+ | "terms_of_use"
+ | "privacy_policy"
+ | "refund_policy"
+ | "shipping_policy"
+ | "merchant_terms";
+
+/**
+ * Legal/policy link for checkout.
+ */
+export type CheckoutLink = {
+ type: CheckoutLinkType;
+ url: string;
+};
+
+/**
+ * Checkout session status.
+ */
+export type CheckoutSessionStatus =
+ | "pending"
+ | "ready_for_payment"
+ | "processing"
+ | "completed"
+ | "cancelled"
+ | "failed";
+
+/**
+ * Payment mode for the checkout session.
+ */
+export type CheckoutPaymentMode = "live" | "test";
+
+/**
+ * Checkout session request payload following the ACP specification.
+ */
+export type CheckoutSessionRequest = {
+ id: string;
+ payment_provider: CheckoutPaymentProvider;
+ status: CheckoutSessionStatus;
+ currency: string;
+ totals: CheckoutTotal[];
+ links?: CheckoutLink[];
+ payment_mode?: CheckoutPaymentMode;
+ line_items?: CheckoutLineItem[];
+ merchant_name?: string;
+ order_reference?: string;
+};
+
+/**
+ * Order status after checkout completion.
+ */
+export type CheckoutOrderStatus =
+ | "pending"
+ | "confirmed"
+ | "shipped"
+ | "delivered"
+ | "cancelled";
+
+/**
+ * Order details returned after successful checkout.
+ */
+export type CheckoutOrder = {
+ id: string;
+ checkout_session_id: string;
+ permalink_url?: string;
+ created_at?: string;
+ status?: CheckoutOrderStatus;
+};
+
+/**
+ * Successful checkout response.
+ */
+export type CheckoutSuccessResponse = {
+ id: string;
+ status: "completed";
+ currency: string;
+ order: CheckoutOrder;
+};
+
+/**
+ * Error code returned from checkout.
+ */
+export type CheckoutErrorCode =
+ | "payment_declined"
+ | "requires_3ds"
+ | "invalid_session"
+ | "expired_session"
+ | "cancelled"
+ | "unknown_error";
+
+/**
+ * Error response from checkout.
+ */
+export type CheckoutErrorResponse = {
+ code: CheckoutErrorCode;
+ message: string;
+};
+
+/**
+ * Checkout response - either success or error.
+ */
+export type CheckoutResponse = CheckoutSuccessResponse | CheckoutErrorResponse;
+
// Dispatched when any global changes in the host page
export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals";
export class SetGlobalsEvent extends CustomEvent<{
diff --git a/packages/create-skybridge/biome.json b/packages/create-skybridge/biome.json
index b733015e1..67d8ef7cd 100644
--- a/packages/create-skybridge/biome.json
+++ b/packages/create-skybridge/biome.json
@@ -1,6 +1,6 @@
{
"root": false,
- "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"extends": "//",
"linter": {
"rules": {