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
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
pull_request:

jobs:
test:
node:
strategy:
fail-fast: false
matrix:
Expand All @@ -25,3 +25,31 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test

bun:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile || bun install
- run: bun test
- run: bun bin/x402.js --help

deno:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
# Deno resolves the noble packages from node_modules next to package.json.
- run: pnpm install --frozen-lockfile
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno test -A --no-check test/
- run: deno run -A bin/x402.js --help
164 changes: 142 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
# @profullstack/x402-client

Pay an [x402](https://x402.org) `402 Payment Required` from Node with a local key. Sign the EIP-3009 authorization, retry with the proof, keep the pass.

A crawler, an agent, or a script that meets a paid site needs three things: to read the offer, to sign it with a key it holds, and to remember what it bought so the next thousand requests are free. That is the whole package: a `fetch` that does all three, a CLI for shells, and the header that lets Lightpanda, Puppeteer or curl through a site sold by [@profullstack/x402-gateway](https://github.com/profullstack/x402-gateway) or any other x402 v2 server.
[![npm](https://img.shields.io/npm/v/%40profullstack%2Fx402-client?logo=npm&label=npm)](https://www.npmjs.com/package/@profullstack/x402-client)
[![ci](https://github.com/profullstack/x402-client/actions/workflows/ci.yml/badge.svg)](https://github.com/profullstack/x402-client/actions/workflows/ci.yml)
[![node](https://img.shields.io/badge/node-%E2%89%A520.19-339933?logo=node.js&logoColor=white)](#runtimes)
[![bun](https://img.shields.io/badge/bun-tested-fbf0df?logo=bun&logoColor=black)](#runtimes)
[![deno](https://img.shields.io/badge/deno-tested-000000?logo=deno&logoColor=white)](#runtimes)
[![types](https://img.shields.io/badge/types-included-3178c6?logo=typescript&logoColor=white)](index.d.ts)
[![license](https://img.shields.io/npm/l/%40profullstack%2Fx402-client)](LICENSE)

Pay an [x402](https://x402.org) `402 Payment Required` from JavaScript with a local key. Sign the EIP-3009 authorization, retry with the proof, keep the pass.

A crawler, an agent, or a script that meets a paid site needs three things: to read the offer, to sign it with a key it holds, and to remember what it bought so the next thousand requests are free. That is the whole package. It ships as a `fetch`, a CLI, and adapters that make the tools you already crawl with pay as they go.

| You crawl with | Add | Docs |
| --- | --- | --- |
| `fetch` — Node, Bun, Deno, browser, `undici`, `node-fetch`, `ky`, `ofetch` | `wrapFetch(fetch)` | [fetch](#fetch) |
| **axios** | `attachX402(axios, client)` | [axios](#axios) |
| **got**, `got-scraping`, **Crawlee** | `got.extend(x402Hooks(client))` | [got](#got-and-crawlee) |
| **Puppeteer**, `puppeteer-core`, Lightpanda over CDP | `attachX402(page, client)` | [Puppeteer](#puppeteer) |
| **Playwright** | `await attachX402(context, client)` | [Playwright](#playwright) |
| **curl**, wget, HTTPie, Lightpanda's CLI, anything with a header flag | `-H "x-crawl-pass: $(x402 pass URL)"` | [CLI](#the-cli) |

```
npm install @profullstack/x402-client
npm install @profullstack/x402-client # node
bun add @profullstack/x402-client # bun
deno add npm:@profullstack/x402-client # deno
```

Two dependencies, `@noble/curves` and `@noble/hashes`, for one hash function and one signature. No ethers, no viem, no RPC: the payer never sends a transaction and never needs ETH. The facilitator broadcasts the authorization and pays the gas; the wallet holds USDC and signs.

## Fetch
## The client

```js
import { createClient } from '@profullstack/x402-client';
Expand All @@ -23,12 +42,10 @@ const again = await client.fetch('https://rssamplifier.com/another/page');
// presents the pass, pays nothing.
```

`fetch` behaves like the global one until it sees a 402 carrying an x402 offer. Then it picks the first option it can sign, signs an EIP-3009 `TransferWithAuthorization` for exactly the offered amount to exactly the offered address, and asks again with the proof in `X-PAYMENT` and `PAYMENT-SIGNATURE`. A gateway answers with a receipt naming a pass, which is filed and presented on every later request to that origin; a plain x402 resource answers with the resource, which is returned as it came.
`client.fetch` behaves like the global one until it sees a 402 carrying an x402 offer. Then it picks the first option it can sign, signs an EIP-3009 `TransferWithAuthorization` for exactly the offered amount to exactly the offered address, and asks again with the proof in `X-PAYMENT` and `PAYMENT-SIGNATURE`. A gateway answers with a receipt naming a pass, which is filed and presented on every later request to that origin; a plain x402 resource answers with the resource, which is returned as it came.

It pays once per request and never guesses: a second 402 after a proof means the payment was refused, and that is thrown as an `X402Error` with `code: 'rejected'` and the server's reason, not retried with a fresh signature.

## Pay, explicitly

```js
const result = await client.pay('https://rssamplifier.com/crawl');
result.payer; // 0x… the address that signed
Expand All @@ -37,36 +54,126 @@ result.network; // 'eip155:8453'
result.receipt.pass; // 'cp_…' the day pass
result.receipt.header; // 'x-crawl-pass' where to put it
result.receipt.expires; // ISO timestamp
```

`pay` asks the URL for its offer (as JSON), signs, and files whatever pass comes back. Give it `{ offer }` to skip the first request when you already hold one.
client.passFor(url); // the live pass filed for that site, or null
```

## Options
### Options

```js
createClient({
key, // 32-byte secp256k1 private key, hex. Or `wallet`.
maxUsd: 5, // refuse any single payment above this. Default 5.
key, // 32-byte secp256k1 private key, hex. Or `wallet`.
maxUsd: 5, // refuse any single payment above this. Default 5.
networks: ['eip155:8453'], // only pay on these chains, e.g. the one you funded
store: 'file', // file passes in ~/.config/x402-client/passes.json; default memory
store: 'file', // file passes in ~/.config/x402-client/passes.json; default memory
userAgent: 'MyBot/1.0 (+https://…)',
validForSeconds, // authorization lifetime; defaults to the offer's maxTimeoutSeconds
fetch, // your own fetch, for proxies and tests
validForSeconds, // authorization lifetime; defaults to the offer's maxTimeoutSeconds
fetch, // your own fetch, for proxies and tests
});
```

The ceiling is the one setting to think about. A client that holds a key and pays whatever it is asked is a client a hostile server can drain by asking. A day of crawling on a gateway costs a dollar; five is room for a dearer site and not for a mistake.
The ceiling is the one setting to think about. A client that holds a key and pays whatever it is asked is a client a hostile server can drain by asking. A day of crawling on a gateway costs a dollar; five is room for a dearer site and not for a mistake. Buying a week at once is `?days=7` on the sales page and `maxUsd: 7`.

`networks` matters because the server's `accepts` is in its order of preference, and the first entry may be a chain your wallet has no USDC on. A signed authorization on an empty wallet verifies and then fails to settle, and the server answers 402 as if you had never paid.

## Adapters

Every adapter takes a client, presents the filed pass on the way out, pays a 402 once, and replays the request with the pass. None of them import the library they adapt: you pass in the instance you already have, and the package stays two dependencies. The axios and got adapters are tested against the real libraries; the browser adapters are tested against library-shaped fakes in CI and were verified by hand with real Puppeteer on Chrome and on Lightpanda, and real Playwright on Chrome, each loading a gated page with exactly one payment.

A gateway answers a browser's 402 with an HTML sales page rather than the JSON offer, since the browser asked for HTML. Every adapter handles that: when the 402 carries no offer, the client asks the URL again as JSON and pays what it says.

### fetch

```js
import { wrapFetch, x402Fetch } from '@profullstack/x402-client/fetch';

const fetch = x402Fetch({ key }); // the global fetch, paying
const paying = wrapFetch(undici.fetch, { key }); // any fetch-shaped function

// Hand it to anything with a fetch option:
const api = ky.create({ fetch: paying });
const ai = new OpenAI({ fetch: paying });
```

Give several wrappers one `client` to share a wallet and a pass file:

```js
const client = createClient({ key, store: 'file' });
const a = wrapFetch(fetch, { client });
const b = wrapFetch(nodeFetch, { client });
```

### axios

```js
import axios from 'axios';
import { createClient } from '@profullstack/x402-client';
import { attachX402 } from '@profullstack/x402-client/axios';

const detach = attachX402(axios, createClient({ key })); // or an axios.create()
const { data } = await axios.get('https://site/paid/page'); // paid once, then free
```

A request interceptor puts the pass on requests to sites that have one; a response interceptor catches the 402, pays, and replays with the pass. Works whether the 402 arrives as a rejection (axios' default) or as a response (a widened `validateStatus`). `baseURL` and relative URLs resolve as axios resolves them. The one request that carries the proof goes through the client's own fetch, not through axios, so an axios proxy does not apply to it.

### got and Crawlee

```js
import got from 'got';
import { x402Hooks } from '@profullstack/x402-client/got';

const paying = got.extend(x402Hooks(client));
const { body } = await paying('https://site/paid/page');
```

`beforeRequest` presents the pass, `afterResponse` pays a 402 and asks got to retry with the pass merged in. Crawlee's `got-scraping` is got underneath and takes the same hooks; for its browser crawlers, use the Playwright or Puppeteer adapter on the page in `preNavigationHooks`.

### Puppeteer

```js
import puppeteer from 'puppeteer';
import { attachX402 } from '@profullstack/x402-client/puppeteer';

const page = await browser.newPage();
attachX402(page, client);
await page.goto('https://site/paid/page'); // 402 → paid from Node → loaded
```

Puppeteer's interception cannot retry a response, so the handshake lives at the navigation: `page.goto` presents the filed pass, and on a 402 pays, presents, and navigates again. `gotoPaid(page, url, client)` is the same without patching, `applyPass(page, url, client)` just sets the header. Against Lightpanda, `puppeteer.connect({ browserWSEndpoint: 'ws://127.0.0.1:9222' })` and nothing else changes; see [`examples/lightpanda.mjs`](examples/lightpanda.mjs). The pass is sent with `setExtraHTTPHeaders`, which is page-wide; it is a bearer token for one site and nothing else, and it expires, but clear it with `page.setExtraHTTPHeaders({})` if the crawl leaves the site.

### Playwright

```js
import { chromium } from 'playwright';
import { attachX402 } from '@profullstack/x402-client/playwright';

const context = await browser.newContext();
await attachX402(context, client); // or a single page
const page = await context.newPage();
await page.goto('https://site/paid/page'); // the page sees the paid answer
```

Playwright's routing makes the request itself, so this is the cleanest of the adapters: every document, XHR and fetch request goes out with the pass, a 402 is paid and the request made again, and the page receives the paid answer as if the site had never asked. Other resources continue untouched but for the header. `resourceTypes` and `pattern` are options; `gotoPaid` exists for symmetry. The browser never holds the key. See [`examples/playwright.mjs`](examples/playwright.mjs).

### Lightpanda

No plugin system, and none needed: pay once from Node, hand the browser the header.

```
lightpanda fetch --http-header "x-crawl-pass: $(x402 pass https://site/crawl)" https://site/page
```

Or connect Puppeteer or Playwright to `lightpanda serve` and use the adapter above.

## The CLI

```
npm install -g @profullstack/x402-client
npm install -g @profullstack/x402-client # or: npx / bunx / deno run -A npm:@profullstack/x402-client
export X402_PRIVATE_KEY=0x…

x402 address # fund this
x402 pay https://site/crawl # receipt as JSON
x402 pay "https://site/crawl?days=7" --max-usd 7
x402 pass https://site/crawl # the pass, buying one if none is on file
x402 fetch https://site/page # the page, paying if it asks
x402 passes # what is on file
Expand All @@ -75,13 +182,22 @@ x402 passes # what is on file
The key is read from the environment or `--key-file`, never from an argument. Passes are filed in `$XDG_CONFIG_HOME/x402-client/passes.json` with mode 0600, so one invocation buys and the rest present:

```
curl -H "x-crawl-pass: $(x402 pass https://site/crawl)" https://site/page
lightpanda fetch --http-header "x-crawl-pass: $(x402 pass https://site/crawl)" https://site/page
curl -H "x-crawl-pass: $(x402 pass https://site/crawl)" https://site/page
wget --header="x-crawl-pass: $(x402 pass https://site/crawl)" https://site/page
http https://site/page "x-crawl-pass:$(x402 pass https://site/crawl)"
```

## Lightpanda and Puppeteer
## Runtimes

Tested in CI on every push:

Lightpanda has no plugin system, and does not need one: a gateway sells time, not pages, so the browser only has to carry the header a payment bought. The shell line above is the whole integration. For a script, [`examples/lightpanda.mjs`](examples/lightpanda.mjs) connects Puppeteer to `lightpanda serve`, presents the filed pass, and on a 402 pays from Node and reloads. The browser never holds the key. Nothing in it is Lightpanda-specific; it works against Chrome.
| runtime | how | status |
| --- | --- | --- |
| Node 22, 24 (works on 20.19+) | `node --test` | [![ci](https://github.com/profullstack/x402-client/actions/workflows/ci.yml/badge.svg)](https://github.com/profullstack/x402-client/actions/workflows/ci.yml) |
| Bun | `bun test`, `bun bin/x402.js` | same workflow |
| Deno 2 | `deno test -A`, `deno run -A bin/x402.js` | same workflow |

Nothing in the package is runtime-specific: base64 goes through `atob`/`btoa` and the text encoders rather than `Buffer`, randomness through `globalThis.crypto`, and the pass file through `node:fs`, which all three provide. The CLI is a plain script: `bunx @profullstack/x402-client pass URL` and `deno run -A npm:@profullstack/x402-client pass URL` both work.

## What is signed

Expand All @@ -106,6 +222,10 @@ The signer is a port of the one in CoinPay Wallet, which was checked byte for by
| `too-expensive` | over `maxUsd`, or a token this client cannot price |
| `rejected` | the server refused the proof; `body` has its reason |

## Selling, not buying?

[@profullstack/x402-gateway](https://github.com/profullstack/x402-gateway) is the other half: one middleware that answers training crawlers with a 402, sells passes by the day, and keeps search crawlers welcome.

## License

MIT
19 changes: 19 additions & 0 deletions examples/axios.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* axios through a paid site.
*
* npm i axios @profullstack/x402-client
* X402_PRIVATE_KEY=0x… node examples/axios.mjs https://site/page
*/

import axios from 'axios';

import { createClient } from '@profullstack/x402-client';
import { attachX402 } from '@profullstack/x402-client/axios';

const [url = 'https://rssamplifier.com/'] = process.argv.slice(2);

const http = axios.create({ headers: { 'user-agent': 'MyAgent/1.0 (+https://example.test/bot)' } });
attachX402(http, createClient({ key: process.env.X402_PRIVATE_KEY, store: 'file', maxUsd: 2 }));

const res = await http.get(url);
console.log(res.status, String(res.data).slice(0, 500));
37 changes: 9 additions & 28 deletions examples/lightpanda.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,13 @@
*
* Lightpanda has no plugin system, and does not need one here: a gateway that
* sells crawl time hands back a pass after one payment, and the browser only
* has to present it. So the shape is
*
* 1. pay once, from Node, with this client
* 2. give the pass to the browser as a header
*
* Two ways to do step 2. From the shell, with Lightpanda's own header flag:
* has to present it. From the shell that is one flag:
*
* lightpanda fetch --http-header "x-crawl-pass: $(x402 pass https://site/crawl)" https://site/page
*
* Or from Puppeteer, connected to `lightpanda serve` (or to Chrome — nothing
* below is Lightpanda-specific), which is what this file does. The pass is
* set once per page, and a 402 that still comes through is paid and the page
* reloaded, once.
* From a script, connect Puppeteer to `lightpanda serve` and let the adapter
* make `page.goto` pay. Nothing below is Lightpanda-specific; it is the same
* against Chrome.
*
* npm i puppeteer-core @profullstack/x402-client
* lightpanda serve --host 127.0.0.1 --port 9222 &
Expand All @@ -25,6 +19,7 @@
import puppeteer from 'puppeteer-core';

import { createClient } from '@profullstack/x402-client';
import { attachX402 } from '@profullstack/x402-client/puppeteer';

const [url = 'https://rssamplifier.com/'] = process.argv.slice(2);

Expand All @@ -38,25 +33,11 @@ const client = createClient({
const browser = await puppeteer.connect({ browserWSEndpoint: 'ws://127.0.0.1:9222' });
const page = await browser.newPage();

/** Present whatever pass is on file for the page's site. */
async function presentPass(target) {
const pass = client.passFor(target);
if (pass) await page.setExtraHTTPHeaders({ [pass.header]: pass.token });
return Boolean(pass);
}

await presentPass(url);
let response = await page.goto(url, { waitUntil: 'networkidle0' });

if (response?.status() === 402) {
// The browser was refused. Pay from Node — the browser never sees the key —
// file the pass, hand it to the page, and go once more.
const paid = await client.pay(url);
console.error(`paid ${paid.amountUsd} USD on ${paid.network} from ${paid.payer}; pass good until ${paid.receipt.expires}`);
await presentPass(url);
response = await page.goto(url, { waitUntil: 'networkidle0' });
}
// page.goto now presents the filed pass, and on a 402 pays from Node, files
// the pass, and navigates again. The browser never holds the key.
attachX402(page, client);

const response = await page.goto(url, { waitUntil: 'networkidle0' });
console.log(`${response?.status()} ${url}`);
console.log((await page.content()).slice(0, 500));

Expand Down
Loading
Loading