Skip to content

Commit 3038bb7

Browse files
[SDK] Add useFetchWithPayment React hook for x402 payment handling
1 parent a3c76d5 commit 3038bb7

File tree

18 files changed

+1130
-92
lines changed

18 files changed

+1130
-92
lines changed

.changeset/wet-maps-play.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
# New `useFetchWithPayment()` React Hook
6+
7+
Added a new React hook that wraps the native fetch API to automatically handle 402 Payment Required responses using the x402 payment protocol.
8+
9+
## Features
10+
11+
- **Automatic Payment Handling**: Automatically detects 402 responses, creates payment headers, and retries requests
12+
- **Built-in UI**: Shows an error modal with retry and fund wallet options when payment fails
13+
- **Sign In Flow**: Prompts users to connect their wallet if not connected, then automatically retries the payment
14+
- **Insufficient Funds Flow**: Integrates BuyWidget to help users top up their wallet directly in the modal
15+
- **Customizable**: Supports theming, custom payment selectors, BuyWidget customization, and ConnectModal customization
16+
- **Opt-out Modal**: Can disable the modal to handle errors manually
17+
18+
## Basic Usage
19+
20+
The hook automatically parses JSON responses by default.
21+
22+
```tsx
23+
import { useFetchWithPayment } from "thirdweb/react";
24+
import { createThirdwebClient } from "thirdweb";
25+
26+
const client = createThirdwebClient({ clientId: "your-client-id" });
27+
28+
function MyComponent() {
29+
const { fetchWithPayment, isPending } = useFetchWithPayment(client);
30+
31+
const handleApiCall = async () => {
32+
// Response is automatically parsed as JSON by default
33+
const data = await fetchWithPayment(
34+
"https://api.example.com/paid-endpoint"
35+
);
36+
console.log(data);
37+
};
38+
39+
return (
40+
<button onClick={handleApiCall} disabled={isPending}>
41+
{isPending ? "Loading..." : "Make Paid API Call"}
42+
</button>
43+
);
44+
}
45+
```
46+
47+
## Customize Response Parsing
48+
49+
By default, responses are parsed as JSON. You can customize this with the `parseAs` option:
50+
51+
```tsx
52+
// Parse as text instead of JSON
53+
const { fetchWithPayment } = useFetchWithPayment(client, {
54+
parseAs: "text",
55+
});
56+
57+
// Or get the raw Response object
58+
const { fetchWithPayment } = useFetchWithPayment(client, {
59+
parseAs: "raw",
60+
});
61+
```
62+
63+
## Customize Theme & Payment Options
64+
65+
```tsx
66+
const { fetchWithPayment } = useFetchWithPayment(client, {
67+
maxValue: 5000000n, // 5 USDC in base units
68+
theme: "light",
69+
paymentRequirementsSelector: (requirements) => {
70+
// Custom logic to select preferred payment method
71+
return requirements[0];
72+
},
73+
});
74+
```
75+
76+
## Customize Fund Wallet Widget
77+
78+
```tsx
79+
const { fetchWithPayment } = useFetchWithPayment(client, {
80+
fundWalletOptions: {
81+
title: "Add Funds",
82+
description: "You need more tokens to complete this payment",
83+
buttonLabel: "Get Tokens",
84+
},
85+
});
86+
```
87+
88+
## Customize Connect Modal
89+
90+
```tsx
91+
const { fetchWithPayment } = useFetchWithPayment(client, {
92+
connectOptions: {
93+
wallets: [inAppWallet(), createWallet("io.metamask")],
94+
title: "Sign in to continue",
95+
showThirdwebBranding: false,
96+
},
97+
});
98+
```
99+
100+
## Disable Modal (Handle Errors Manually)
101+
102+
```tsx
103+
const { fetchWithPayment, error } = useFetchWithPayment(client, {
104+
showErrorModal: false,
105+
});
106+
107+
// Handle the error manually
108+
if (error) {
109+
console.error("Payment failed:", error);
110+
}
111+
```

apps/playground-web/src/app/x402/components/X402RightSection.tsx

Lines changed: 28 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
"use client";
22

3-
import { useMutation } from "@tanstack/react-query";
43
import { Badge } from "@workspace/ui/components/badge";
54
import { CodeClient } from "@workspace/ui/components/code/code.client";
65
import { CodeIcon, LockIcon } from "lucide-react";
76
import { usePathname } from "next/navigation";
87
import { useState } from "react";
9-
import {
10-
ConnectButton,
11-
useActiveAccount,
12-
useActiveWallet,
13-
} from "thirdweb/react";
14-
import { wrapFetchWithPayment } from "thirdweb/x402";
8+
import { ConnectButton, useFetchWithPayment } from "thirdweb/react";
159
import { Button } from "@/components/ui/button";
1610
import { Card } from "@/components/ui/card";
1711
import { THIRDWEB_CLIENT } from "@/lib/client";
@@ -31,55 +25,42 @@ export function X402RightSection(props: { options: X402PlaygroundOptions }) {
3125
window.history.replaceState({}, "", `${pathname}?tab=${tab}`);
3226
}
3327

34-
const activeWallet = useActiveWallet();
35-
const activeAccount = useActiveAccount();
28+
const { fetchWithPayment, isPending, data, error, isError } =
29+
useFetchWithPayment(THIRDWEB_CLIENT);
3630

37-
const paidApiCall = useMutation({
38-
mutationFn: async () => {
39-
if (!activeWallet) {
40-
throw new Error("No active wallet");
41-
}
42-
const fetchWithPay = wrapFetchWithPayment(
43-
fetch,
44-
THIRDWEB_CLIENT,
45-
activeWallet,
46-
);
47-
const searchParams = new URLSearchParams();
48-
searchParams.set("chainId", props.options.chain.id.toString());
49-
searchParams.set("payTo", props.options.payTo);
50-
searchParams.set("amount", props.options.amount);
51-
searchParams.set("tokenAddress", props.options.tokenAddress);
52-
searchParams.set("decimals", props.options.tokenDecimals.toString());
53-
searchParams.set("waitUntil", props.options.waitUntil);
31+
const handlePayClick = async () => {
32+
const searchParams = new URLSearchParams();
33+
searchParams.set("chainId", props.options.chain.id.toString());
34+
searchParams.set("payTo", props.options.payTo);
35+
searchParams.set("amount", props.options.amount);
36+
searchParams.set("tokenAddress", props.options.tokenAddress);
37+
searchParams.set("decimals", props.options.tokenDecimals.toString());
38+
searchParams.set("waitUntil", props.options.waitUntil);
5439

55-
const url =
56-
"/api/paywall" +
57-
(searchParams.size > 0 ? `?${searchParams.toString()}` : "");
58-
const response = await fetchWithPay(url.toString());
59-
return response.json();
60-
},
61-
});
40+
const url =
41+
"/api/paywall" +
42+
(searchParams.size > 0 ? `?${searchParams.toString()}` : "");
6243

63-
const handlePayClick = async () => {
64-
paidApiCall.mutate();
44+
await fetchWithPayment(url);
6545
};
6646

6747
const clientCode = `import { createThirdwebClient } from "thirdweb";
68-
import { wrapFetchWithPayment } from "thirdweb/x402";
69-
import { useActiveWallet } from "thirdweb/react";
48+
import { useFetchWithPayment } from "thirdweb/react";
7049
7150
const client = createThirdwebClient({ clientId: "your-client-id" });
7251
7352
export default function Page() {
74-
const wallet = useActiveWallet();
53+
const { fetchWithPayment, isPending } = useFetchWithPayment(client);
7554
7655
const onClick = async () => {
77-
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
78-
const response = await fetchWithPay('/api/paid-endpoint');
56+
const data = await fetchWithPayment('/api/paid-endpoint');
57+
console.log(data);
7958
}
8059
8160
return (
82-
<Button onClick={onClick}>Pay Now</Button>
61+
<Button onClick={onClick} disabled={isPending}>
62+
{isPending ? "Processing..." : "Pay Now"}
63+
</Button>
8364
);
8465
}`;
8566

@@ -204,7 +185,7 @@ export async function POST(request: Request) {
204185
onClick={handlePayClick}
205186
className="w-full mb-4"
206187
size="lg"
207-
disabled={paidApiCall.isPending || !activeAccount}
188+
disabled={isPending}
208189
>
209190
Access Premium Content
210191
</Button>
@@ -218,19 +199,12 @@ export async function POST(request: Request) {
218199
<CodeIcon className="w-5 h-5 text-muted-foreground" />
219200
<span className="text-lg font-medium">API Call Response</span>
220201
</div>
221-
{paidApiCall.isPending && (
222-
<div className="text-center">Loading...</div>
223-
)}
224-
{paidApiCall.isError && (
225-
<div className="text-center">
226-
Error: {paidApiCall.error.message}
227-
</div>
202+
{isPending && <div className="text-center">Loading...</div>}
203+
{isError && (
204+
<div className="text-center">Error: {error?.message}</div>
228205
)}
229-
{paidApiCall.data && (
230-
<CodeClient
231-
code={JSON.stringify(paidApiCall.data, null, 2)}
232-
lang="json"
233-
/>
206+
{data && (
207+
<CodeClient code={JSON.stringify(data, null, 2)} lang="json" />
234208
)}
235209
</Card>
236210
</div>

apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const tagsToGroup = {
1313
"@appURI": "App URI",
1414
"@auth": "Auth",
1515
"@bridge": "Payments",
16+
"@x402": "x402",
1617
"@chain": "Chain",
1718
"@claimConditions": "Claim Conditions",
1819
"@client": "Client",
@@ -64,6 +65,7 @@ const sidebarGroupOrder: TagKey[] = [
6465
"@transaction",
6566
"@insight",
6667
"@engine",
68+
"@x402",
6769
"@bridge",
6870
"@nebula",
6971
"@social",

apps/portal/src/app/wallets/server/send-transactions/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Send, monitor, and manage transactions. Send transactions from user or server wa
8080
- 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.
8181
- 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).
8282

83-
<OpenApiEndpoint path="/v1/contracts/{chainId}/{address}/write" method="POST" />
83+
<OpenApiEndpoint path="/v1/contracts/write" method="POST" />
8484

8585
</TabsContent>
8686

apps/portal/src/app/x402/client/page.mdx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Tabs, TabsList, TabsTrigger, TabsContent, OpenApiEndpoint, createMetadata } from "@doc";
2-
import { TypeScriptIcon, EngineIcon } from "@/icons";
2+
import { TypeScriptIcon, ReactIcon, EngineIcon } from "@/icons";
33

44
export const metadata = createMetadata({
55
image: {
@@ -27,6 +27,10 @@ The client library wraps the native `fetch` API and handles:
2727
<TypeScriptIcon className="size-4 mr-1.5" />
2828
TypeScript
2929
</TabsTrigger>
30+
<TabsTrigger value="react" className="flex items-center [&>p]:mb-0">
31+
<ReactIcon className="size-4 mr-1.5" />
32+
React
33+
</TabsTrigger>
3034
<TabsTrigger value="http" className="flex items-center [&>p]:mb-0">
3135
<EngineIcon className="size-4 mr-1.5" />
3236
HTTP API
@@ -48,12 +52,10 @@ The client library wraps the native `fetch` API and handles:
4852
await wallet.connect({ client })
4953

5054
// Wrap fetch with payment handling
51-
// maxValue is the maximum payment amount in base units (defaults to 1 USDC = 1_000_000)
5255
const fetchWithPay = wrapFetchWithPayment(
5356
fetch,
5457
client,
5558
wallet,
56-
BigInt(1 * 10 ** 6) // max 1 USDC
5759
);
5860

5961
// Make a request that may require payment
@@ -66,14 +68,70 @@ The client library wraps the native `fetch` API and handles:
6668
- `fetch` - The fetch function to wrap (typically `globalThis.fetch`)
6769
- `client` - The thirdweb client used to access RPC infrastructure
6870
- `wallet` - The wallet used to sign payment messages
69-
- `maxValue` - (Optional) The maximum allowed payment amount in base units (defaults to 1 USDC = 1,000,000)
71+
- `options` - (Optional) Configuration object:
72+
- `maxValue` - Maximum allowed payment amount in base units
73+
- `paymentRequirementsSelector` - Custom function to select payment requirements from available options
7074

7175
### Reference
7276

7377
For full API documentation, see the [TypeScript Reference](/references/typescript/v5/x402/wrapFetchWithPayment).
7478

7579
</TabsContent>
7680

81+
<TabsContent value="react">
82+
## Using `useFetchWithPayment`
83+
84+
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.
85+
86+
```tsx
87+
import { useFetchWithPayment } from "thirdweb/react";
88+
import { createThirdwebClient } from "thirdweb";
89+
90+
const client = createThirdwebClient({ clientId: "your-client-id" });
91+
92+
function MyComponent() {
93+
const { fetchWithPayment, isPending } = useFetchWithPayment(client);
94+
95+
const handleApiCall = async () => {
96+
// Handle wallet connection, funding, and payment errors automatically
97+
// Response is parsed as JSON by default
98+
const data = await fetchWithPayment('https://api.example.com/paid-endpoint');
99+
console.log(data);
100+
};
101+
102+
return (
103+
<button onClick={handleApiCall} disabled={isPending}>
104+
{isPending ? 'Loading...' : 'Make Paid API Call'}
105+
</button>
106+
);
107+
}
108+
```
109+
110+
### Features
111+
112+
- **Automatic Payment Handling**: Detects 402 responses and creates payment headers automatically
113+
- **Wallet Connection**: Prompts users to connect their wallet if not connected
114+
- **Funding**: Integrates BuyWidget to help users top up their wallet directly when needed
115+
- **Built-in error handling UI**: Shows error modals with retry and fund wallet options
116+
- **Response Parsing**: Automatically parses responses as JSON by default
117+
118+
### Parameters
119+
120+
- `client` - The thirdweb client used to access RPC infrastructure
121+
- `options` - (Optional) Configuration object:
122+
- `maxValue` - Maximum allowed payment amount in base units (defaults to 1 USDC = 1,000,000)
123+
- `parseAs` - How to parse the response: "json" (default), "text", or "raw"
124+
- `theme` - Theme for the payment error modal: "dark" (default) or "light"
125+
- `showErrorModal` - Whether to show the error modal (defaults to true)
126+
- `fundWalletOptions` - Customize the BuyWidget for topping up
127+
- `connectOptions` - Customize the ConnectModal for wallet connection
128+
129+
### Reference
130+
131+
For full API documentation, see the [TypeScript Reference](/references/typescript/v5/useFetchWithPayment).
132+
133+
</TabsContent>
134+
77135
<TabsContent value="http">
78136
## Fetch with Payment
79137

0 commit comments

Comments
 (0)