Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions .changeset/wet-maps-play.md
Original file line number Diff line number Diff line change
@@ -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 (
<button onClick={handleApiCall} disabled={isPending}>
{isPending ? "Loading..." : "Make Paid API Call"}
</button>
);
}
```

## 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);
}
```
82 changes: 28 additions & 54 deletions apps/playground-web/src/app/x402/components/X402RightSection.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<Button onClick={onClick}>Pay Now</Button>
<Button onClick={onClick} disabled={isPending}>
{isPending ? "Processing..." : "Pay Now"}
</Button>
);
}`;

Expand Down Expand Up @@ -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
</Button>
Expand All @@ -218,19 +199,12 @@ export async function POST(request: Request) {
<CodeIcon className="w-5 h-5 text-muted-foreground" />
<span className="text-lg font-medium">API Call Response</span>
</div>
{paidApiCall.isPending && (
<div className="text-center">Loading...</div>
)}
{paidApiCall.isError && (
<div className="text-center">
Error: {paidApiCall.error.message}
</div>
{isPending && <div className="text-center">Loading...</div>}
{isError && (
<div className="text-center">Error: {error?.message}</div>
)}
{paidApiCall.data && (
<CodeClient
code={JSON.stringify(paidApiCall.data, null, 2)}
lang="json"
/>
{!!data && (
<CodeClient code={JSON.stringify(data, null, 2)} lang="json" />
)}
</Card>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const tagsToGroup = {
"@appURI": "App URI",
"@auth": "Auth",
"@bridge": "Payments",
"@x402": "x402",
"@chain": "Chain",
"@claimConditions": "Claim Conditions",
"@client": "Client",
Expand Down Expand Up @@ -64,6 +65,7 @@ const sidebarGroupOrder: TagKey[] = [
"@transaction",
"@insight",
"@engine",
"@x402",
"@bridge",
"@nebula",
"@social",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<OpenApiEndpoint path="/v1/contracts/{chainId}/{address}/write" method="POST" />
<OpenApiEndpoint path="/v1/contracts/write" method="POST" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Verify the API endpoint path change.

The endpoint path changed from /v1/contracts/{chainId}/{address}/write to /v1/contracts/write, removing the path parameters. Please confirm this reflects the actual API structure, as it's a significant change to the documented endpoint.


🏁 Script executed:

#!/bin/bash
# Search for other references to this endpoint path in the codebase
rg -n "v1/contracts.*write" --type=md --type=ts --type=tsx -g '!node_modules'

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for endpoint references - fix file type flag
rg -n "v1/contracts.*write" --type=md --type=ts -g '!node_modules'

Length of output: 1042


🏁 Script executed:

#!/bin/bash
# Also search for the old endpoint pattern with path parameters
rg -n "chainId.*address.*write" -g '!node_modules'

Length of output: 414


🏁 Script executed:

#!/bin/bash
# Search for the new endpoint pattern
rg -n "/v1/contracts/write" -g '!node_modules'

Length of output: 1611



</TabsContent>

Expand Down
66 changes: 62 additions & 4 deletions apps/portal/src/app/x402/client/page.mdx
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -27,6 +27,10 @@ The client library wraps the native `fetch` API and handles:
<TypeScriptIcon className="size-4 mr-1.5" />
TypeScript
</TabsTrigger>
<TabsTrigger value="react" className="flex items-center [&>p]:mb-0">
<ReactIcon className="size-4 mr-1.5" />
React
</TabsTrigger>
<TabsTrigger value="http" className="flex items-center [&>p]:mb-0">
<EngineIcon className="size-4 mr-1.5" />
HTTP API
Expand All @@ -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
Expand All @@ -66,14 +68,70 @@ 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

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

</TabsContent>

<TabsContent value="react">
## 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 (
<button onClick={handleApiCall} disabled={isPending}>
{isPending ? 'Loading...' : 'Make Paid API Call'}
</button>
);
}
```

### 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).

</TabsContent>

<TabsContent value="http">
## Fetch with Payment

Expand Down
Loading
Loading