diff --git a/.changeset/wet-maps-play.md b/.changeset/wet-maps-play.md
new file mode 100644
index 00000000000..faae07ea7d6
--- /dev/null
+++ b/.changeset/wet-maps-play.md
@@ -0,0 +1,111 @@
+---
+"thirdweb": minor
+---
+
+# New `useFetchWithPayment()` React Hook
+
+Added a new React hook that wraps the native fetch API to automatically handle 402 Payment Required responses using the x402 payment protocol.
+
+## Features
+
+- **Automatic Payment Handling**: Automatically detects 402 responses, creates payment headers, and retries requests
+- **Built-in UI**: Shows an error modal with retry and fund wallet options when payment fails
+- **Sign In Flow**: Prompts users to connect their wallet if not connected, then automatically retries the payment
+- **Insufficient Funds Flow**: Integrates BuyWidget to help users top up their wallet directly in the modal
+- **Customizable**: Supports theming, custom payment selectors, BuyWidget customization, and ConnectModal customization
+- **Opt-out Modal**: Can disable the modal to handle errors manually
+
+## Basic Usage
+
+The hook automatically parses JSON responses by default.
+
+```tsx
+import { useFetchWithPayment } from "thirdweb/react";
+import { createThirdwebClient } from "thirdweb";
+
+const client = createThirdwebClient({ clientId: "your-client-id" });
+
+function MyComponent() {
+ const { fetchWithPayment, isPending } = useFetchWithPayment(client);
+
+ const handleApiCall = async () => {
+ // Response is automatically parsed as JSON by default
+ const data = await fetchWithPayment(
+ "https://api.example.com/paid-endpoint"
+ );
+ console.log(data);
+ };
+
+ return (
+
+ );
+}
+```
+
+## Customize Response Parsing
+
+By default, responses are parsed as JSON. You can customize this with the `parseAs` option:
+
+```tsx
+// Parse as text instead of JSON
+const { fetchWithPayment } = useFetchWithPayment(client, {
+ parseAs: "text",
+});
+
+// Or get the raw Response object
+const { fetchWithPayment } = useFetchWithPayment(client, {
+ parseAs: "raw",
+});
+```
+
+## Customize Theme & Payment Options
+
+```tsx
+const { fetchWithPayment } = useFetchWithPayment(client, {
+ maxValue: 5000000n, // 5 USDC in base units
+ theme: "light",
+ paymentRequirementsSelector: (requirements) => {
+ // Custom logic to select preferred payment method
+ return requirements[0];
+ },
+});
+```
+
+## Customize Fund Wallet Widget
+
+```tsx
+const { fetchWithPayment } = useFetchWithPayment(client, {
+ fundWalletOptions: {
+ title: "Add Funds",
+ description: "You need more tokens to complete this payment",
+ buttonLabel: "Get Tokens",
+ },
+});
+```
+
+## Customize Connect Modal
+
+```tsx
+const { fetchWithPayment } = useFetchWithPayment(client, {
+ connectOptions: {
+ wallets: [inAppWallet(), createWallet("io.metamask")],
+ title: "Sign in to continue",
+ showThirdwebBranding: false,
+ },
+});
+```
+
+## Disable Modal (Handle Errors Manually)
+
+```tsx
+const { fetchWithPayment, error } = useFetchWithPayment(client, {
+ showErrorModal: false,
+});
+
+// Handle the error manually
+if (error) {
+ console.error("Payment failed:", error);
+}
+```
diff --git a/apps/playground-web/src/app/x402/components/X402RightSection.tsx b/apps/playground-web/src/app/x402/components/X402RightSection.tsx
index 0c8efc375a6..432891e576a 100644
--- a/apps/playground-web/src/app/x402/components/X402RightSection.tsx
+++ b/apps/playground-web/src/app/x402/components/X402RightSection.tsx
@@ -1,17 +1,11 @@
"use client";
-import { useMutation } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { CodeClient } from "@workspace/ui/components/code/code.client";
import { CodeIcon, LockIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useState } from "react";
-import {
- ConnectButton,
- useActiveAccount,
- useActiveWallet,
-} from "thirdweb/react";
-import { wrapFetchWithPayment } from "thirdweb/x402";
+import { ConnectButton, useFetchWithPayment } from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { THIRDWEB_CLIENT } from "@/lib/client";
@@ -31,55 +25,42 @@ export function X402RightSection(props: { options: X402PlaygroundOptions }) {
window.history.replaceState({}, "", `${pathname}?tab=${tab}`);
}
- const activeWallet = useActiveWallet();
- const activeAccount = useActiveAccount();
+ const { fetchWithPayment, isPending, data, error, isError } =
+ useFetchWithPayment(THIRDWEB_CLIENT);
- const paidApiCall = useMutation({
- mutationFn: async () => {
- if (!activeWallet) {
- throw new Error("No active wallet");
- }
- const fetchWithPay = wrapFetchWithPayment(
- fetch,
- THIRDWEB_CLIENT,
- activeWallet,
- );
- const searchParams = new URLSearchParams();
- searchParams.set("chainId", props.options.chain.id.toString());
- searchParams.set("payTo", props.options.payTo);
- searchParams.set("amount", props.options.amount);
- searchParams.set("tokenAddress", props.options.tokenAddress);
- searchParams.set("decimals", props.options.tokenDecimals.toString());
- searchParams.set("waitUntil", props.options.waitUntil);
+ const handlePayClick = async () => {
+ const searchParams = new URLSearchParams();
+ searchParams.set("chainId", props.options.chain.id.toString());
+ searchParams.set("payTo", props.options.payTo);
+ searchParams.set("amount", props.options.amount);
+ searchParams.set("tokenAddress", props.options.tokenAddress);
+ searchParams.set("decimals", props.options.tokenDecimals.toString());
+ searchParams.set("waitUntil", props.options.waitUntil);
- const url =
- "/api/paywall" +
- (searchParams.size > 0 ? `?${searchParams.toString()}` : "");
- const response = await fetchWithPay(url.toString());
- return response.json();
- },
- });
+ const url =
+ "/api/paywall" +
+ (searchParams.size > 0 ? `?${searchParams.toString()}` : "");
- const handlePayClick = async () => {
- paidApiCall.mutate();
+ await fetchWithPayment(url);
};
const clientCode = `import { createThirdwebClient } from "thirdweb";
-import { wrapFetchWithPayment } from "thirdweb/x402";
-import { useActiveWallet } from "thirdweb/react";
+import { useFetchWithPayment } from "thirdweb/react";
const client = createThirdwebClient({ clientId: "your-client-id" });
export default function Page() {
- const wallet = useActiveWallet();
+ const { fetchWithPayment, isPending } = useFetchWithPayment(client);
const onClick = async () => {
- const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
- const response = await fetchWithPay('/api/paid-endpoint');
+ const data = await fetchWithPayment('/api/paid-endpoint');
+ console.log(data);
}
return (
-
+
);
}`;
@@ -204,7 +185,7 @@ export async function POST(request: Request) {
onClick={handlePayClick}
className="w-full mb-4"
size="lg"
- disabled={paidApiCall.isPending || !activeAccount}
+ disabled={isPending}
>
Access Premium Content
@@ -218,19 +199,12 @@ export async function POST(request: Request) {
API Call Response
- {paidApiCall.isPending && (
-
Loading...
- )}
- {paidApiCall.isError && (
-
- Error: {paidApiCall.error.message}
-
+ {isPending && Loading...
}
+ {isError && (
+ Error: {error?.message}
)}
- {paidApiCall.data && (
-
+ {!!data && (
+
)}
diff --git a/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts b/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts
index 8350ca208d4..69d9f50cb1e 100644
--- a/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts
+++ b/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts
@@ -13,6 +13,7 @@ const tagsToGroup = {
"@appURI": "App URI",
"@auth": "Auth",
"@bridge": "Payments",
+ "@x402": "x402",
"@chain": "Chain",
"@claimConditions": "Claim Conditions",
"@client": "Client",
@@ -64,6 +65,7 @@ const sidebarGroupOrder: TagKey[] = [
"@transaction",
"@insight",
"@engine",
+ "@x402",
"@bridge",
"@nebula",
"@social",
diff --git a/apps/portal/src/app/wallets/server/send-transactions/page.mdx b/apps/portal/src/app/wallets/server/send-transactions/page.mdx
index 4fbcf8ee1ec..0aef431b012 100644
--- a/apps/portal/src/app/wallets/server/send-transactions/page.mdx
+++ b/apps/portal/src/app/wallets/server/send-transactions/page.mdx
@@ -80,7 +80,7 @@ Send, monitor, and manage transactions. Send transactions from user or server wa
- For user wallets in React applications that use the SDK, you can obtain the user wallet auth token (JWT) with the [`useAuthToken()`](/references/typescript/v5/useAuthToken) hook.
- For user wallets in TypeScript applications, you can get it by calling `wallet.getAuthToken()` on a connected [`inAppWallet()`](/references/typescript/v5/inAppWallet) or [`ecosystemWallet()`](/references/typescript/v5/ecosystemWallet).
-
+
diff --git a/apps/portal/src/app/x402/client/page.mdx b/apps/portal/src/app/x402/client/page.mdx
index dc26407de0d..193e31271ae 100644
--- a/apps/portal/src/app/x402/client/page.mdx
+++ b/apps/portal/src/app/x402/client/page.mdx
@@ -1,5 +1,5 @@
import { Tabs, TabsList, TabsTrigger, TabsContent, OpenApiEndpoint, createMetadata } from "@doc";
-import { TypeScriptIcon, EngineIcon } from "@/icons";
+import { TypeScriptIcon, ReactIcon, EngineIcon } from "@/icons";
export const metadata = createMetadata({
image: {
@@ -27,6 +27,10 @@ The client library wraps the native `fetch` API and handles:
TypeScript
+
+
+ React
+
HTTP API
@@ -48,12 +52,10 @@ The client library wraps the native `fetch` API and handles:
await wallet.connect({ client })
// Wrap fetch with payment handling
- // maxValue is the maximum payment amount in base units (defaults to 1 USDC = 1_000_000)
const fetchWithPay = wrapFetchWithPayment(
fetch,
client,
wallet,
- BigInt(1 * 10 ** 6) // max 1 USDC
);
// Make a request that may require payment
@@ -66,7 +68,9 @@ The client library wraps the native `fetch` API and handles:
- `fetch` - The fetch function to wrap (typically `globalThis.fetch`)
- `client` - The thirdweb client used to access RPC infrastructure
- `wallet` - The wallet used to sign payment messages
- - `maxValue` - (Optional) The maximum allowed payment amount in base units (defaults to 1 USDC = 1,000,000)
+ - `options` - (Optional) Configuration object:
+ - `maxValue` - Maximum allowed payment amount in base units
+ - `paymentRequirementsSelector` - Custom function to select payment requirements from available options
### Reference
@@ -74,6 +78,60 @@ The client library wraps the native `fetch` API and handles:
+
+ ## Using `useFetchWithPayment`
+
+ The `useFetchWithPayment` hook is a React-specific wrapper that automatically handles 402 Payment Required responses with built-in UI for payment errors, insufficient funds, and wallet connection.
+
+ ```tsx
+ import { useFetchWithPayment } from "thirdweb/react";
+ import { createThirdwebClient } from "thirdweb";
+
+ const client = createThirdwebClient({ clientId: "your-client-id" });
+
+ function MyComponent() {
+ const { fetchWithPayment, isPending } = useFetchWithPayment(client);
+
+ const handleApiCall = async () => {
+ // Handle wallet connection, funding, and payment errors automatically
+ // Response is parsed as JSON by default
+ const data = await fetchWithPayment('https://api.example.com/paid-endpoint');
+ console.log(data);
+ };
+
+ return (
+
+ );
+ }
+ ```
+
+ ### Features
+
+ - **Automatic Payment Handling**: Detects 402 responses and creates payment headers automatically
+ - **Wallet Connection**: Prompts users to connect their wallet if not connected
+ - **Funding**: Integrates BuyWidget to help users top up their wallet directly when needed
+ - **Built-in error handling UI**: Shows error modals with retry and fund wallet options
+ - **Response Parsing**: Automatically parses responses as JSON by default
+
+ ### Parameters
+
+ - `client` - The thirdweb client used to access RPC infrastructure
+ - `options` - (Optional) Configuration object:
+ - `maxValue` - Maximum allowed payment amount in base units
+ - `parseAs` - How to parse the response: "json" (default), "text", or "raw"
+ - `theme` - Theme for the payment error modal: "dark" (default) or "light"
+ - `uiEnabled` - Whether to show the UI for connection, funding or payment retries (defaults to true)
+ - `fundWalletOptions` - Customize the BuyWidget for topping up
+ - `connectOptions` - Customize the ConnectModal for wallet connection
+
+ ### Reference
+
+ For full API documentation, see the [TypeScript Reference](/references/typescript/v5/useFetchWithPayment).
+
+
+
## Fetch with Payment
diff --git a/apps/portal/src/app/x402/page.mdx b/apps/portal/src/app/x402/page.mdx
index 06fdffebdcb..c0be637314b 100644
--- a/apps/portal/src/app/x402/page.mdx
+++ b/apps/portal/src/app/x402/page.mdx
@@ -1,4 +1,4 @@
-import { ArticleIconCard, createMetadata } from "@doc";
+import { ArticleIconCard, createMetadata, Tabs, TabsList, TabsTrigger, TabsContent } from "@doc";
import { ReactIcon, TypeScriptIcon, EngineIcon } from "@/icons";
export const metadata = createMetadata({
@@ -33,24 +33,69 @@ The payment token must support either:
## Client Side
-`wrapFetchWithPayment` wraps the native fetch API to automatically handle `402 Payment Required` responses from any x402-compatible API:
-
-```typescript
-import { wrapFetchWithPayment } from "thirdweb/x402";
-import { createThirdwebClient } from "thirdweb";
-import { createWallet } from "thirdweb/wallets";
-
-const client = createThirdwebClient({ clientId: "your-client-id" });
-const wallet = createWallet("io.metamask");
-await wallet.connect({ client })
-
-const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
-
-// Make a request that may require payment
-const response = await fetchWithPay('https://api.example.com/paid-endpoint');
-```
-
-You can also use the thirdweb API to fetch any x402 compatible endpoint and pay for it with the authenticated wallet. See the [client side docs](/x402/client) for more details.
+
+
+
+
+ TypeScript
+
+
+
+ React
+
+
+
+
+ `wrapFetchWithPayment` wraps the native fetch API to automatically handle `402 Payment Required` responses from any x402-compatible API:
+
+ ```typescript
+ import { wrapFetchWithPayment } from "thirdweb/x402";
+ import { createThirdwebClient } from "thirdweb";
+ import { createWallet } from "thirdweb/wallets";
+
+ const client = createThirdwebClient({ clientId: "your-client-id" });
+ const wallet = createWallet("io.metamask");
+ await wallet.connect({ client })
+
+ const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
+
+ // Make a request that may require payment
+ const response = await fetchWithPay('https://api.example.com/paid-endpoint');
+ ```
+
+ You can also use the thirdweb API to fetch any x402 compatible endpoint and pay for it with the authenticated wallet. See the [client side docs](/x402/client) for more details.
+
+
+
+ `useFetchWithPayment` is a React hook that automatically handles `402 Payment Required` responses with built-in UI for payment errors and wallet connection:
+
+ ```tsx
+ import { useFetchWithPayment } from "thirdweb/react";
+ import { createThirdwebClient } from "thirdweb";
+
+ const client = createThirdwebClient({ clientId: "your-client-id" });
+
+ function MyComponent() {
+ const { fetchWithPayment, isPending } = useFetchWithPayment(client);
+
+ const handleApiCall = async () => {
+ // Handle wallet connection, funding, and payment errors automatically
+ // Response is parsed as JSON by default
+ const data = await fetchWithPayment('https://api.example.com/paid-endpoint');
+ console.log(data);
+ };
+
+ return (
+
+ );
+ }
+ ```
+
+ The hook automatically shows modals for wallet connection, funding and payment errors. See the [client side docs](/x402/client) for more details.
+
+
## Server Side
diff --git a/packages/thirdweb/src/exports/react.native.ts b/packages/thirdweb/src/exports/react.native.ts
index eeba1b18f07..9723c3a09c0 100644
--- a/packages/thirdweb/src/exports/react.native.ts
+++ b/packages/thirdweb/src/exports/react.native.ts
@@ -114,6 +114,11 @@ export { useAutoConnect } from "../react/native/hooks/wallets/useAutoConnect.js"
export { useLinkProfile } from "../react/native/hooks/wallets/useLinkProfile.js";
export { useProfiles } from "../react/native/hooks/wallets/useProfiles.js";
export { useUnlinkProfile } from "../react/native/hooks/wallets/useUnlinkProfile.js";
+// x402
+export {
+ type UseFetchWithPaymentOptions,
+ useFetchWithPayment,
+} from "../react/native/hooks/x402/useFetchWithPayment.js";
export { ThirdwebProvider } from "../react/native/providers/thirdweb-provider.js";
// Components
export { AutoConnect } from "../react/native/ui/AutoConnect/AutoConnect.js";
diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts
index 48e07836492..47e57990081 100644
--- a/packages/thirdweb/src/exports/react.ts
+++ b/packages/thirdweb/src/exports/react.ts
@@ -131,6 +131,11 @@ export { useAutoConnect } from "../react/web/hooks/wallets/useAutoConnect.js";
export { useLinkProfile } from "../react/web/hooks/wallets/useLinkProfile.js";
export { useProfiles } from "../react/web/hooks/wallets/useProfiles.js";
export { useUnlinkProfile } from "../react/web/hooks/wallets/useUnlinkProfile.js";
+// x402
+export {
+ type UseFetchWithPaymentOptions,
+ useFetchWithPayment,
+} from "../react/web/hooks/x402/useFetchWithPayment.js";
export { ThirdwebProvider } from "../react/web/providers/thirdweb-provider.js";
export { AutoConnect } from "../react/web/ui/AutoConnect/AutoConnect.js";
export type { BuyOrOnrampPrepareResult } from "../react/web/ui/Bridge/BuyWidget.js";
diff --git a/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts b/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts
new file mode 100644
index 00000000000..e40812fd201
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts
@@ -0,0 +1,160 @@
+"use client";
+
+import { useMutation } from "@tanstack/react-query";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
+import { wrapFetchWithPayment } from "../../../../x402/fetchWithPayment.js";
+import type { RequestedPaymentRequirements } from "../../../../x402/schemas.js";
+import type { PaymentRequiredResult } from "../../../../x402/types.js";
+import { useActiveWallet } from "../wallets/useActiveWallet.js";
+
+export type UseFetchWithPaymentOptions = {
+ maxValue?: bigint;
+ paymentRequirementsSelector?: (
+ paymentRequirements: RequestedPaymentRequirements[],
+ ) => RequestedPaymentRequirements | undefined;
+ parseAs?: "json" | "text" | "raw";
+};
+
+type ShowErrorModalCallback = (data: {
+ errorData: PaymentRequiredResult["responseBody"];
+ onRetry: () => void;
+ onCancel: () => void;
+}) => void;
+
+type ShowConnectModalCallback = (data: {
+ onConnect: (wallet: Wallet) => void;
+ onCancel: () => void;
+}) => void;
+
+/**
+ * Core hook for fetch with payment functionality.
+ * This is the platform-agnostic implementation used by both web and native versions.
+ * @internal
+ */
+export function useFetchWithPaymentCore(
+ client: ThirdwebClient,
+ options?: UseFetchWithPaymentOptions,
+ showErrorModal?: ShowErrorModalCallback,
+ showConnectModal?: ShowConnectModalCallback,
+) {
+ const wallet = useActiveWallet();
+
+ const mutation = useMutation({
+ mutationFn: async ({
+ input,
+ init,
+ }: {
+ input: RequestInfo;
+ init?: RequestInit;
+ }) => {
+ // Recursive function that handles fetch + 402 error + retry
+ const executeFetch = async (currentWallet = wallet): Promise => {
+ if (!currentWallet) {
+ // If a connect modal handler is provided, show the connect modal
+ if (showConnectModal) {
+ return new Promise((resolve, reject) => {
+ showConnectModal({
+ onConnect: async (newWallet) => {
+ // After connection, retry the fetch with the newly connected wallet
+ try {
+ const result = await executeFetch(newWallet);
+ resolve(result);
+ } catch (error) {
+ reject(error);
+ }
+ },
+ onCancel: () => {
+ reject(new Error("Wallet connection cancelled by user"));
+ },
+ });
+ });
+ }
+
+ // If no connect modal handler, throw an error
+ throw new Error(
+ "No wallet connected. Please connect your wallet to make paid API calls.",
+ );
+ }
+
+ const wrappedFetch = wrapFetchWithPayment(
+ globalThis.fetch,
+ client,
+ currentWallet,
+ options,
+ );
+
+ const response = await wrappedFetch(input, init);
+
+ // Check if we got a 402 response (payment error)
+ if (response.status === 402) {
+ try {
+ const errorBody =
+ (await response.json()) as PaymentRequiredResult["responseBody"];
+
+ // If a modal handler is provided, show the modal and handle retry/cancel
+ if (showErrorModal) {
+ return new Promise((resolve, reject) => {
+ showErrorModal({
+ errorData: errorBody,
+ onRetry: async () => {
+ // Retry the entire fetch+error handling logic recursively
+ try {
+ const result = await executeFetch();
+ resolve(result);
+ } catch (error) {
+ reject(error);
+ }
+ },
+ onCancel: () => {
+ reject(new Error("Payment cancelled by user"));
+ },
+ });
+ });
+ }
+
+ // If no modal handler, throw the error with details
+ throw new Error(
+ errorBody.errorMessage || `Payment failed: ${errorBody.error}`,
+ );
+ } catch (_parseError) {
+ // If we can't parse the error body, throw a generic error
+ throw new Error("Payment failed with status 402");
+ }
+ }
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `Payment failed with status ${response.status} ${response.statusText} - ${errorText || "Unknown error"}`,
+ );
+ }
+
+ const parseAs = options?.parseAs ?? "json";
+ return parseResponse(response, parseAs);
+ };
+
+ // Start the fetch process
+ return executeFetch();
+ },
+ });
+
+ return {
+ fetchWithPayment: async (input: RequestInfo, init?: RequestInit) => {
+ return mutation.mutateAsync({ input, init });
+ },
+ ...mutation,
+ };
+}
+
+function parseResponse(response: Response, parseAs: "json" | "text" | "raw") {
+ if (parseAs === "json") {
+ return response.json();
+ } else if (parseAs === "text") {
+ return response.text();
+ } else if (parseAs === "raw") {
+ return response;
+ } else {
+ throw new Error(`Invalid parseAs option: ${parseAs}`);
+ }
+}
diff --git a/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts b/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts
new file mode 100644
index 00000000000..d5436d0c35c
--- /dev/null
+++ b/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts
@@ -0,0 +1,96 @@
+"use client";
+
+import type { ThirdwebClient } from "../../../../client/client.js";
+import {
+ type UseFetchWithPaymentOptions,
+ useFetchWithPaymentCore,
+} from "../../../core/hooks/x402/useFetchWithPaymentCore.js";
+
+export type { UseFetchWithPaymentOptions };
+
+/**
+ * A React hook that wraps the native fetch API to automatically handle 402 Payment Required responses
+ * using the x402 payment protocol with the currently connected wallet.
+ *
+ * This hook enables you to make API calls that require payment without manually handling the payment flow.
+ * Responses are automatically parsed as JSON by default (can be customized with `parseAs` option).
+ *
+ * When a 402 response is received, it will automatically:
+ * 1. Parse the payment requirements
+ * 2. Verify the payment amount is within the allowed maximum
+ * 3. Create a payment header using the connected wallet
+ * 4. Retry the request with the payment header
+ *
+ * Note: This is the React Native version which does not include modal UI.
+ * Payment errors will be thrown and should be handled by your application.
+ *
+ * @param client - The thirdweb client used to access RPC infrastructure
+ * @param options - Optional configuration for payment handling
+ * @param options.maxValue - The maximum allowed payment amount in base units
+ * @param options.paymentRequirementsSelector - Custom function to select payment requirements from available options
+ * @param options.parseAs - How to parse the response: "json" (default), "text", or "raw"
+ * @returns An object containing:
+ * - `fetchWithPayment`: Function to make fetch requests with automatic payment handling (returns parsed data)
+ * - `isPending`: Boolean indicating if a request is in progress
+ * - `error`: Any error that occurred during the request
+ * - `data`: The parsed response data (JSON by default, or based on `parseAs` option)
+ * - Other mutation properties from React Query
+ *
+ * @example
+ * ```tsx
+ * import { useFetchWithPayment } from "thirdweb/react";
+ * import { createThirdwebClient } from "thirdweb";
+ *
+ * const client = createThirdwebClient({ clientId: "your-client-id" });
+ *
+ * function MyComponent() {
+ * const { fetchWithPayment, isPending, error } = useFetchWithPayment(client);
+ *
+ * const handleApiCall = async () => {
+ * try {
+ * // Response is automatically parsed as JSON
+ * const data = await fetchWithPayment('https://api.example.com/paid-endpoint');
+ * console.log(data);
+ * } catch (err) {
+ * // Handle payment errors manually in React Native
+ * console.error("Payment failed:", err);
+ * }
+ * };
+ *
+ * return (
+ *
+ * );
+ * }
+ * ```
+ *
+ * ### Customize response parsing
+ * ```tsx
+ * const { fetchWithPayment } = useFetchWithPayment(client, {
+ * parseAs: "text", // Get response as text instead of JSON
+ * });
+ *
+ * const textData = await fetchWithPayment('https://api.example.com/endpoint');
+ * ```
+ *
+ * ### Customize payment options
+ * ```tsx
+ * const { fetchWithPayment } = useFetchWithPayment(client, {
+ * maxValue: 5000000n, // 5 USDC in base units
+ * paymentRequirementsSelector: (requirements) => {
+ * // Custom logic to select preferred payment method
+ * return requirements[0];
+ * }
+ * });
+ * ```
+ *
+ * @x402
+ */
+export function useFetchWithPayment(
+ client: ThirdwebClient,
+ options?: UseFetchWithPaymentOptions,
+) {
+ // Native version doesn't show modal, errors bubble up naturally
+ return useFetchWithPaymentCore(client, options);
+}
diff --git a/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx b/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx
new file mode 100644
index 00000000000..1dcc2e116dd
--- /dev/null
+++ b/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useContext } from "react";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
+import type { Theme } from "../../../core/design-system/index.js";
+import {
+ type UseFetchWithPaymentOptions,
+ useFetchWithPaymentCore,
+} from "../../../core/hooks/x402/useFetchWithPaymentCore.js";
+import { SetRootElementContext } from "../../../core/providers/RootElementContext.js";
+import type { BuyWidgetProps } from "../../ui/Bridge/BuyWidget.js";
+import {
+ type UseConnectModalOptions,
+ useConnectModal,
+} from "../../ui/ConnectWallet/useConnectModal.js";
+import { PaymentErrorModal } from "../../ui/x402/PaymentErrorModal.js";
+import { SignInRequiredModal } from "../../ui/x402/SignInRequiredModal.js";
+
+export type { UseFetchWithPaymentOptions };
+
+type UseFetchWithPaymentConfig = UseFetchWithPaymentOptions & {
+ /**
+ * Whether to show the UI for connection, funding or payment retries.
+ * If false, no UI will be shown and errors will have to be handled manually.
+ * @default true
+ */
+ uiEnabled?: boolean;
+ /**
+ * Theme for the payment error modal
+ * @default "dark"
+ */
+ theme?: Theme | "light" | "dark";
+ /**
+ * Options to customize the BuyWidget that appears when the user needs to fund their wallet.
+ * These options will be merged with default values.
+ */
+ fundWalletOptions?: Partial<
+ Omit<
+ BuyWidgetProps,
+ "client" | "chain" | "tokenAddress" | "onSuccess" | "onCancel" | "theme"
+ >
+ >;
+ /**
+ * Options to customize the ConnectModal that appears when the user needs to sign in.
+ * These options will be merged with the client, theme, and chain from the hook.
+ */
+ connectOptions?: Omit;
+};
+
+/**
+ * A React hook that wraps the native fetch API to automatically handle 402 Payment Required responses
+ * using the x402 payment protocol with the currently connected wallet.
+ *
+ * This hook enables you to make API calls that require payment without manually handling the payment flow.
+ * Responses are automatically parsed as JSON by default (can be customized with `parseAs` option).
+ *
+ * When a 402 response is received, it will automatically:
+ * 1. Parse the payment requirements
+ * 2. Verify the payment amount is within the allowed maximum
+ * 3. Create a payment header using the connected wallet
+ * 4. Retry the request with the payment header
+ *
+ * If payment fails (e.g. insufficient funds), a modal will be shown to help the user resolve the issue.
+ * If no wallet is connected, a sign-in modal will be shown to connect a wallet.
+ *
+ * @param client - The thirdweb client used to access RPC infrastructure
+ * @param options - Optional configuration for payment handling
+ * @param options.maxValue - The maximum allowed payment amount in base units
+ * @param options.paymentRequirementsSelector - Custom function to select payment requirements from available options
+ * @param options.parseAs - How to parse the response: "json" (default), "text", or "raw"
+ * @param options.uiEnabled - Whether to show the UI for connection, funding or payment retries (defaults to true). Set to false to handle errors yourself
+ * @param options.theme - Theme for the payment error modal (defaults to "dark")
+ * @param options.fundWalletOptions - Customize the BuyWidget shown when user needs to fund their wallet
+ * @param options.connectOptions - Customize the ConnectModal shown when user needs to sign in
+ * @returns An object containing:
+ * - `fetchWithPayment`: Function to make fetch requests with automatic payment handling (returns parsed data)
+ * - `isPending`: Boolean indicating if a request is in progress
+ * - `error`: Any error that occurred during the request
+ * - `data`: The parsed response data (JSON by default, or based on `parseAs` option)
+ * - Other mutation properties from React Query
+ *
+ * @example
+ * ```tsx
+ * import { useFetchWithPayment } from "thirdweb/react";
+ * import { createThirdwebClient } from "thirdweb";
+ *
+ * const client = createThirdwebClient({ clientId: "your-client-id" });
+ *
+ * function MyComponent() {
+ * const { fetchWithPayment, isPending } = useFetchWithPayment(client);
+ *
+ * const handleApiCall = async () => {
+ * // Response is automatically parsed as JSON
+ * const data = await fetchWithPayment('https://api.example.com/paid-endpoint');
+ * console.log(data);
+ * };
+ *
+ * return (
+ *
+ * );
+ * }
+ * ```
+ *
+ * ### Customize response parsing
+ * ```tsx
+ * const { fetchWithPayment } = useFetchWithPayment(client, {
+ * parseAs: "text", // Get response as text instead of JSON
+ * });
+ *
+ * const textData = await fetchWithPayment('https://api.example.com/endpoint');
+ * ```
+ *
+ * ### Customize payment options
+ * ```tsx
+ * const { fetchWithPayment } = useFetchWithPayment(client, {
+ * maxValue: 5000000n, // 5 USDC in base units
+ * theme: "light",
+ * paymentRequirementsSelector: (requirements) => {
+ * // Custom logic to select preferred payment method
+ * return requirements[0];
+ * }
+ * });
+ * ```
+ *
+ * ### Customize the fund wallet widget
+ * ```tsx
+ * const { fetchWithPayment } = useFetchWithPayment(client, {
+ * fundWalletOptions: {
+ * title: "Add Funds",
+ * description: "You need more tokens to complete this payment",
+ * buttonLabel: "Get Tokens",
+ * }
+ * });
+ * ```
+ *
+ * ### Customize the connect modal
+ * ```tsx
+ * const { fetchWithPayment } = useFetchWithPayment(client, {
+ * connectOptions: {
+ * wallets: [inAppWallet(), createWallet("io.metamask")],
+ * title: "Sign in to continue",
+ * }
+ * });
+ * ```
+ *
+ * ### Disable the UI and handle errors yourself
+ * ```tsx
+ * const { fetchWithPayment, error } = useFetchWithPayment(client, {
+ * uiEnabled: false,
+ * });
+ *
+ * // Handle the error manually
+ * if (error) {
+ * console.error("Payment failed:", error);
+ * }
+ * ```
+ *
+ * @x402
+ */
+export function useFetchWithPayment(
+ client: ThirdwebClient,
+ options?: UseFetchWithPaymentConfig,
+) {
+ const setRootEl = useContext(SetRootElementContext);
+ const { connect } = useConnectModal();
+ const theme = options?.theme || "dark";
+ const showModal = options?.uiEnabled !== false; // Default to true
+
+ const showErrorModal = showModal
+ ? (data: {
+ errorData: Parameters[0]["errorData"];
+ onRetry: () => void;
+ onCancel: () => void;
+ }) => {
+ setRootEl(
+ {
+ setRootEl(null);
+ data.onCancel();
+ }}
+ onRetry={() => {
+ setRootEl(null);
+ data.onRetry();
+ }}
+ theme={theme}
+ fundWalletOptions={options?.fundWalletOptions}
+ paymentRequirementsSelector={options?.paymentRequirementsSelector}
+ />,
+ );
+ }
+ : undefined;
+
+ const showConnectModal = showModal
+ ? (data: { onConnect: (wallet: Wallet) => void; onCancel: () => void }) => {
+ // First, show the SignInRequiredModal
+ setRootEl(
+ {
+ // Close the SignInRequiredModal
+ setRootEl(null);
+
+ // Open the ConnectModal
+ try {
+ const connectedWallet = await connect({
+ client,
+ theme,
+ ...options?.connectOptions,
+ });
+
+ // On successful connection, trigger onConnect callback with the wallet
+ data.onConnect(connectedWallet);
+ } catch (_error) {
+ // User cancelled the connection
+ data.onCancel();
+ }
+ }}
+ onCancel={() => {
+ setRootEl(null);
+ data.onCancel();
+ }}
+ />,
+ );
+ }
+ : undefined;
+
+ return useFetchWithPaymentCore(
+ client,
+ options,
+ showErrorModal,
+ showConnectModal,
+ );
+}
diff --git a/packages/thirdweb/src/react/web/ui/components/basic.tsx b/packages/thirdweb/src/react/web/ui/components/basic.tsx
index 1e282ca668c..7caa4593b91 100644
--- a/packages/thirdweb/src/react/web/ui/components/basic.tsx
+++ b/packages/thirdweb/src/react/web/ui/components/basic.tsx
@@ -17,7 +17,7 @@ export const ScreenBottomContainer = /* @__PURE__ */ StyledDiv((_) => {
borderTop: `1px solid ${theme.colors.separatorLine}`,
display: "flex",
flexDirection: "column",
- gap: spacing.lg,
+ gap: spacing.md,
padding: spacing.lg,
};
});
diff --git a/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx b/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx
new file mode 100644
index 00000000000..4737ef27501
--- /dev/null
+++ b/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx
@@ -0,0 +1,261 @@
+"use client";
+import { useState } from "react";
+import { getCachedChain } from "../../../../chains/utils.js";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import {
+ extractEvmChainId,
+ networkToCaip2ChainId,
+ type RequestedPaymentRequirements,
+} from "../../../../x402/schemas.js";
+import type { PaymentRequiredResult } from "../../../../x402/types.js";
+import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProvider.js";
+import type { Theme } from "../../../core/design-system/index.js";
+import { spacing } from "../../../core/design-system/index.js";
+import { useActiveWallet } from "../../../core/hooks/wallets/useActiveWallet.js";
+import { BuyWidget, type BuyWidgetProps } from "../Bridge/BuyWidget.js";
+import {
+ Container,
+ ModalHeader,
+ ScreenBottomContainer,
+} from "../components/basic.js";
+import { Button } from "../components/buttons.js";
+import { Modal } from "../components/Modal.js";
+import { Text } from "../components/text.js";
+
+type PaymentErrorModalProps = {
+ client: ThirdwebClient;
+ errorData: PaymentRequiredResult["responseBody"];
+ onRetry: () => void;
+ onCancel: () => void;
+ theme: Theme | "light" | "dark";
+ fundWalletOptions?: Partial<
+ Omit<
+ BuyWidgetProps,
+ | "client"
+ | "chain"
+ | "tokenAddress"
+ | "amount"
+ | "onSuccess"
+ | "onCancel"
+ | "theme"
+ >
+ >;
+ paymentRequirementsSelector?: (
+ paymentRequirements: RequestedPaymentRequirements[],
+ ) => RequestedPaymentRequirements | undefined;
+};
+
+type Screen = "error" | "buy-widget";
+
+/**
+ * @internal
+ */
+export function PaymentErrorModal(props: PaymentErrorModalProps) {
+ const {
+ client,
+ errorData,
+ onRetry,
+ onCancel,
+ theme,
+ fundWalletOptions,
+ paymentRequirementsSelector,
+ } = props;
+ const [screen, setScreen] = useState("error");
+ const isInsufficientFunds = errorData.error === "insufficient_funds";
+ const wallet = useActiveWallet();
+
+ // Extract chain and token info from errorData for BuyWidget
+ const getBuyWidgetConfig = () => {
+ if (!errorData.accepts || errorData.accepts.length === 0) {
+ return null;
+ }
+
+ // Get payment requirements from errorData
+ const parsedPaymentRequirements = errorData.accepts;
+
+ // Get the current chain from wallet
+ const currentChain = wallet?.getChain();
+ const currentChainId = currentChain?.id;
+
+ // Select payment requirement using the same logic as wrapFetchWithPayment
+ const selectedRequirement = paymentRequirementsSelector
+ ? paymentRequirementsSelector(parsedPaymentRequirements)
+ : defaultPaymentRequirementsSelector(
+ parsedPaymentRequirements,
+ currentChainId,
+ "exact",
+ errorData.error,
+ );
+
+ if (!selectedRequirement) return null;
+
+ const caip2ChainId = networkToCaip2ChainId(selectedRequirement.network);
+ const chainId = extractEvmChainId(caip2ChainId);
+
+ if (!chainId) return null;
+
+ const chain = getCachedChain(chainId);
+ const tokenAddress = selectedRequirement.asset as `0x${string}`;
+
+ return {
+ chain,
+ tokenAddress,
+ amount: undefined,
+ };
+ };
+
+ const buyWidgetConfig = isInsufficientFunds ? getBuyWidgetConfig() : null;
+
+ if (screen === "buy-widget" && buyWidgetConfig) {
+ return (
+
+ {
+ if (!open) {
+ onCancel();
+ }
+ }}
+ size="compact"
+ title="Top up your wallet"
+ crossContainerStyles={{
+ position: "absolute",
+ right: spacing.lg,
+ top: spacing.lg,
+ zIndex: 1,
+ }}
+ >
+ {/* BuyWidget without padding */}
+ {
+ // Close modal and retry the payment
+ onRetry();
+ }}
+ onCancel={() => {
+ // Go back to error screen
+ setScreen("error");
+ }}
+ />
+
+
+ );
+ }
+
+ // Error screen (default)
+ return (
+
+ {
+ if (!open) {
+ onCancel();
+ }
+ }}
+ size="compact"
+ title="Payment Failed"
+ >
+
+
+
+
+ {/* Error Message */}
+
+ {isInsufficientFunds
+ ? "Your wallet doesn't have enough funds to complete this payment. Please top up your wallet and try again."
+ : errorData.errorMessage ||
+ "An error occurred while processing your payment."}
+
+
+
+
+ {/* Action Buttons */}
+
+ {isInsufficientFunds && buyWidgetConfig ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+// Default payment requirement selector - same logic as in fetchWithPayment.ts
+function defaultPaymentRequirementsSelector(
+ paymentRequirements: RequestedPaymentRequirements[],
+ chainId: number | undefined,
+ scheme: "exact",
+ _error?: string,
+): RequestedPaymentRequirements | undefined {
+ if (!paymentRequirements.length) {
+ return undefined;
+ }
+
+ // If we have a chainId, find matching payment requirements
+ if (chainId !== undefined) {
+ const matchingPaymentRequirements = paymentRequirements.find(
+ (x) =>
+ extractEvmChainId(networkToCaip2ChainId(x.network)) === chainId &&
+ x.scheme === scheme,
+ );
+
+ if (matchingPaymentRequirements) {
+ return matchingPaymentRequirements;
+ }
+ }
+
+ // If no matching payment requirements, use the first payment requirement
+ const firstPaymentRequirement = paymentRequirements.find(
+ (x) => x.scheme === scheme,
+ );
+ return firstPaymentRequirement;
+}
diff --git a/packages/thirdweb/src/react/web/ui/x402/SignInRequiredModal.tsx b/packages/thirdweb/src/react/web/ui/x402/SignInRequiredModal.tsx
new file mode 100644
index 00000000000..3aa312d4c3f
--- /dev/null
+++ b/packages/thirdweb/src/react/web/ui/x402/SignInRequiredModal.tsx
@@ -0,0 +1,75 @@
+"use client";
+import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProvider.js";
+import type { Theme } from "../../../core/design-system/index.js";
+import { spacing } from "../../../core/design-system/index.js";
+import {
+ Container,
+ ModalHeader,
+ ScreenBottomContainer,
+} from "../components/basic.js";
+import { Button } from "../components/buttons.js";
+import { Modal } from "../components/Modal.js";
+import { Text } from "../components/text.js";
+
+type SignInRequiredModalProps = {
+ theme: Theme | "light" | "dark";
+ onSignIn: () => void;
+ onCancel: () => void;
+};
+
+/**
+ * @internal
+ */
+export function SignInRequiredModal(props: SignInRequiredModalProps) {
+ const { theme, onSignIn, onCancel } = props;
+
+ return (
+
+ {
+ if (!open) {
+ onCancel();
+ }
+ }}
+ size="compact"
+ title="Sign in required"
+ >
+
+
+
+
+ {/* Description */}
+
+ Account required to complete payment, please sign in to continue.
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/thirdweb/src/x402/facilitator.ts b/packages/thirdweb/src/x402/facilitator.ts
index 31ad4c27faa..599c9dbefc7 100644
--- a/packages/thirdweb/src/x402/facilitator.ts
+++ b/packages/thirdweb/src/x402/facilitator.ts
@@ -107,7 +107,7 @@ const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
* ```
*
- * @bridge x402
+ * @x402
*/
export function facilitator(
config: ThirdwebX402FacilitatorConfig,
diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts
index 20a4ec58f9e..207cfed5fec 100644
--- a/packages/thirdweb/src/x402/fetchWithPayment.ts
+++ b/packages/thirdweb/src/x402/fetchWithPayment.ts
@@ -23,7 +23,7 @@ import { createPaymentHeader } from "./sign.js";
* @param fetch - The fetch function to wrap (typically globalThis.fetch)
* @param client - The thirdweb client used to access RPC infrastructure
* @param wallet - The wallet used to sign payment messages
- * @param maxValue - The maximum allowed payment amount in base units (defaults to 1 USDC)
+ * @param maxValue - The maximum allowed payment amount in base units
* @returns A wrapped fetch function that handles 402 responses automatically
*
* @example
@@ -46,13 +46,18 @@ import { createPaymentHeader } from "./sign.js";
* @throws {Error} If a payment has already been attempted for this request
* @throws {Error} If there's an error creating the payment header
*
- * @bridge x402
+ * @x402
*/
export function wrapFetchWithPayment(
fetch: typeof globalThis.fetch,
client: ThirdwebClient,
wallet: Wallet,
- maxValue?: bigint,
+ options?: {
+ maxValue?: bigint;
+ paymentRequirementsSelector?: (
+ paymentRequirements: RequestedPaymentRequirements[],
+ ) => RequestedPaymentRequirements | undefined;
+ },
) {
return async (input: RequestInfo, init?: RequestInit) => {
const response = await fetch(input, init);
@@ -78,12 +83,14 @@ export function wrapFetchWithPayment(
"Wallet not connected. Please connect your wallet to continue.",
);
}
- const selectedPaymentRequirements = defaultPaymentRequirementsSelector(
- parsedPaymentRequirements,
- chain.id,
- "exact",
- error,
- );
+ const selectedPaymentRequirements = options?.paymentRequirementsSelector
+ ? options.paymentRequirementsSelector(parsedPaymentRequirements)
+ : defaultPaymentRequirementsSelector(
+ parsedPaymentRequirements,
+ chain.id,
+ "exact",
+ error,
+ );
if (!selectedPaymentRequirements) {
throw new Error(
@@ -92,11 +99,11 @@ export function wrapFetchWithPayment(
}
if (
- maxValue &&
- BigInt(selectedPaymentRequirements.maxAmountRequired) > maxValue
+ options?.maxValue &&
+ BigInt(selectedPaymentRequirements.maxAmountRequired) > options.maxValue
) {
throw new Error(
- `Payment amount exceeds maximum allowed (currently set to ${maxValue} in base units)`,
+ `Payment amount exceeds maximum allowed (currently set to ${options.maxValue} in base units)`,
);
}
diff --git a/packages/thirdweb/src/x402/settle-payment.ts b/packages/thirdweb/src/x402/settle-payment.ts
index bd1e46aa238..8c2df77ebcf 100644
--- a/packages/thirdweb/src/x402/settle-payment.ts
+++ b/packages/thirdweb/src/x402/settle-payment.ts
@@ -122,7 +122,7 @@ import {
*
* @public
* @beta
- * @bridge x402
+ * @x402
*/
export async function settlePayment(
args: SettlePaymentArgs,
diff --git a/packages/thirdweb/src/x402/verify-payment.ts b/packages/thirdweb/src/x402/verify-payment.ts
index 3feebdee6da..73d7abeefc0 100644
--- a/packages/thirdweb/src/x402/verify-payment.ts
+++ b/packages/thirdweb/src/x402/verify-payment.ts
@@ -70,7 +70,7 @@ import {
*
* @public
* @beta
- * @bridge x402
+ * @x402
*/
export async function verifyPayment(
args: PaymentArgs,
diff --git a/packages/thirdweb/tsdoc.json b/packages/thirdweb/tsdoc.json
index d9524f9b198..3324b5da455 100644
--- a/packages/thirdweb/tsdoc.json
+++ b/packages/thirdweb/tsdoc.json
@@ -5,6 +5,7 @@
"@auth": true,
"@beta": true,
"@bridge": true,
+ "@x402": true,
"@chain": true,
"@client": true,
"@component": true,
@@ -132,6 +133,10 @@
{
"syntaxKind": "block",
"tagName": "@engine"
+ },
+ {
+ "syntaxKind": "block",
+ "tagName": "@x402"
}
]
}