@three-ws/x402-modal
v0.3.0
Published
A drop-in, dependency-free payment modal for any x402 paid endpoint. One script tag turns a 402 challenge into a polished checkout: wallet connect (Phantom on Solana, MetaMask/EVM via EIP-3009), the 402 → sign → settle flow, SIWX re-entry, spending caps,
Maintainers
Readme
@three-ws/x402-modal
A drop-in payment modal for any x402 paid endpoint.
One script tag turns an HTTP 402 Payment Required into a polished checkout:
wallet connect (Phantom on Solana, MetaMask/EVM via EIP-3009), the
402 → sign → settle flow, SIWX re-entry, spending caps, and a receipt — all
in vanilla JS, with no bundler and no framework.
Quick start · How it works · API · Configuration · Backend · Tutorials · FAQ
Why
x402 revives HTTP 402 Payment Required as a real payment
rail: a server answers a request with a 402 whose body lists what it
accepts (asset, amount, network, pay-to), the client pays, and re-sends the
request with an X-PAYMENT header. It's perfect for pay-per-call APIs, agent
economies, and content paywalls — but every merchant ends up rebuilding the same
fiddly client: parse the challenge, connect a wallet, sign the right thing for
the right chain, retry, settle, show a receipt.
This package is that client, done once and done well. Point it at a 402
endpoint and it renders the entire flow. The EVM/Base path is 100%
client-side. The Solana path needs one small backend helper (see
The backend).
Quick start
1 — One script tag (zero JS)
<script type="module" src="https://unpkg.com/@three-ws/x402-modal/global"></script>
<button
data-x402-endpoint="https://api.example.com/paid/summarize"
data-x402-method="POST"
data-x402-body='{"text":"hello world"}'
data-x402-merchant="Acme"
data-x402-action="Summarize">
Pay & summarize
</button>Clicking the button opens the modal, runs the payment, calls the endpoint, and
fires an x402:result event on the button with { ok, result, payment, response }:
document.querySelector('button').addEventListener('x402:result', (e) => {
console.log('paid + got result:', e.detail.result);
});2 — Programmatic (full control)
import { pay } from '@three-ws/x402-modal';
const out = await pay({
endpoint: '/api/paid/summarize',
method: 'POST',
body: { text: 'hello world' },
merchant: 'Acme',
action: 'Summarize',
});
console.log(out.result); // the endpoint's response, after settlement
console.log(out.payment); // { network, payer, transaction }pay() resolves once the paid call returns 200, or rejects with an Error
whose .code === 'cancelled' if the user closes the modal.
3 — Self-hosted, fully branded
<script
type="module"
src="https://your.cdn/x402.global.js"
data-x402-api-origin="https://pay.your-company.com"
data-x402-brand-label="Powered by Acme"
data-x402-brand-href="https://acme.com"></script>or from JS, before the first pay():
import { configure } from '@three-ws/x402-modal';
configure({
apiOrigin: 'https://pay.your-company.com', // Solana checkout backend
brand: { label: 'Powered by Acme', href: 'https://acme.com' },
});No dependencies to install. The ESM build leaves the two optional wallet libraries (
@solana/web3.jsfor Solana, a keccak for EVM sign-in) as runtime CDN imports, fetched only when that wallet path actually runs. The/globalbuild is a single self-contained file.
How it works
pay({ endpoint })
│
├─ 1. discover GET/POST endpoint → 402 (or 401 + payment-required header)
│ parse `accepts[]` (asset · amount · network · payTo)
│
├─ 2. connect pick a wallet that can satisfy an accept:
│ Solana → Phantom EVM → MetaMask / window.ethereum
│
├─ 3. authorize Solana: backend builds the tx → Phantom signs it
│ EVM: wallet signs an EIP-3009 transferWithAuthorization
│ (no on-chain tx, no gas for the payer)
│
└─ 4. verify re-send the request with `X-PAYMENT` → endpoint runs the work,
settles on-chain, returns 200 + `x-payment-response` receiptEach step renders as a live row in the modal (spinner → check → error), with a
Try again affordance on failure, automatic retry on a 429 upstream
throttle (the payment isn't settled until the work succeeds, so re-sending can't
double-charge), and a receipt with an explorer link on success.
Networks
| Network | Wallet | Scheme | Needs a backend? |
|---|---|---|---|
| Base (eip155:8453) | MetaMask / any window.ethereum | EIP-3009 transferWithAuthorization | No — fully client-side |
| Base Sepolia, Arbitrum, Optimism | same | EIP-3009 | No |
| Solana (solana:*) | Phantom | exact (facilitator-settled) | Yes — prepare/encode helper |
When a 402 advertises more than one network the modal shows a wallet picker;
when it advertises exactly one, it goes straight there.
API
pay(options): Promise<PayResult>
| option | type | default | notes |
|---------------|-------------------------------|--------------------|-------|
| endpoint | string | — (required) | the x402-protected URL to pay for and call |
| method | string | GET / POST* | *POST when a body is set |
| body | object \| string | — | forwarded to the endpoint (object → JSON) |
| headers | Record<string,string> | — | merged into discovery + paid calls |
| merchant | string | Payment | shown in the modal header |
| action | string | Pay-per-call | shown in the modal header |
| caps | { maxPerCall, maxPerHour, maxPerDay } | — | µUSD spending caps (see Configuration) |
| autoConnect | boolean | false | skip the picker when exactly one wallet is detected |
| apiOrigin | string | global config | per-call override of the Solana checkout backend |
| brand | { label, href } | global config | per-call footer override |
Returns { ok: true, result, payment?, siwx?, response }. payment is present
on a fresh payment ({ network, payer, transaction }); siwx is present when
the user re-entered via sign-in instead of paying.
discover(options): Promise<PaymentChallenge>
Step 1 of pay() on its own: probe an endpoint and return its parsed 402
challenge without opening any UI. Takes endpoint (required), method, body
and headers; touches no DOM and no wallet, so it also runs on a server, in a
CLI, or inside an agent that has no modal at all.
import { discover } from '@three-ws/x402-modal';
const challenge = await discover({ endpoint: '/api/paid/summarize' });
for (const a of challenge.accepts) {
console.log(a.network, a.amount, a.extra?.name); // eip155:8453 1000 USDC
}accepts[] comes back normalized (spec-canonical maxAmountRequired coerced to
amount), read from the response body or the base64 payment-required header,
whichever carries it. It rejects when the endpoint answers with no readable
challenge, including a free 200: pointing it at an unpaid route is an error,
never a silent success.
configure(config): config · getConfig(): config
Set global defaults once at startup. See Configuration.
init(): void
Scan the document and bind every [data-x402-endpoint] element. The /global
build calls this automatically (and re-scans on DOM mutation); call it yourself
only when using the ESM build with declarative buttons.
DOM events (declarative usage)
Bound elements dispatch bubbling CustomEvents:
x402:result—detailis the fullPayResult.x402:error—detailis{ error: string }. (Cancellation does not fire this.)x402:siwx-signed—detailis{ address, network }, when re-entry was via SIWX.
data-* attributes (declarative usage)
data-x402-endpoint (required), data-x402-method, data-x402-body (JSON),
data-x402-headers (JSON), data-x402-caps (JSON), data-x402-api-origin,
data-x402-merchant, data-x402-action.
Configuration
All fields are optional; the defaults reproduce the hosted three.ws modal.
configure({
// Origin serving the Solana prepare/encode checkout helpers. Only the Solana
// path uses it; the EVM path needs no backend. null → resolve from the
// script's own origin; '' → same-origin.
apiOrigin: 'https://pay.example.com',
// Footer attribution.
brand: { label: 'Powered by Acme', href: 'https://acme.com' },
// ERC-8021 builder-code self-attribution, echoed back only when the 402
// challenge declares a builder code. null disables the echo.
builderCode: { wallet: 'acme', service: 'acme_checkout' },
// Override the on-demand CDN modules (e.g. to self-host under a strict CSP).
solanaWeb3Url: 'https://esm.sh/@solana/[email protected]?bundle',
nobleHashesUrl: 'https://esm.sh/@noble/[email protected]/sha3?bundle',
});Spending caps
Caps are enforced in localStorage, bucketed by rolling UTC hour and day, and
survive reloads. Amounts are micro-USD (1_000_000 = $1). A failed payment
rolls its reservation back.
await pay({
endpoint: '/api/paid/x',
caps: {
maxPerCall: 1_000_000, // $1.00 per call
maxPerHour: 10_000_000, // $10/hour
maxPerDay: 50_000_000, // $50/day
},
});Stablecoins (USDC, USDT, DAI) are converted to µUSD exactly. Non-stable assets pass through atomic in the browser (no price feed is fetched to keep the script dependency-free) — enforce those server-side.
The backend
EVM / Base needs no backend. The payer signs an EIP-3009
transferWithAuthorization in their wallet and the modal sends the signed
authorization straight to your merchant endpoint as X-PAYMENT. Your x402
server (and its facilitator) verify and settle it.
Solana needs one tiny helper, because building a Solana transfer transaction
requires RPC access and the facilitator's fee-payer. The modal expects two
actions at {apiOrigin}/api/x402-checkout:
| action | request | response |
|---|---|---|
| ?action=prepare | { accept, buyer } | { tx_base64 } — an unsigned/partially-signed VersionedTransaction |
| ?action=encode | { accept, signed_tx_base64, resource_url, builder_code? } | { x_payment } — the base64 X-PAYMENT value to send to the merchant |
apiOrigin defaults to the origin that served the script, so when you self-host
both the script and this helper there is nothing to configure. See
docs/BACKEND.md for the full contract and a reference
implementation, and examples/ for runnable code.
Install
npm i @three-ws/x402-modalimport { pay, configure } from '@three-ws/x402-modal'; // ESM, no side effectsor skip the install entirely and use the CDN /global build (auto-binds
[data-x402-endpoint], exposes window.X402).
Development
npm install # esbuild is the only devDependency
npm run build # → dist/x402-modal.mjs (ESM) + dist/x402.global.js (IIFE)
npm test # node --test, zero extra depsnpm test covers the protocol layer (challenge discovery against a real local
HTTP server, amount/network/caps helpers, the SIWX message format) and the
config surface. What only a browser can prove (the global build binding
data-x402-endpoint, the modal mounting, live discovery, cancellation,
script-tag config) runs from the monorepo root against the live demo endpoint:
npm --prefix x402-modal-sdk run build
node scripts/x402-modal-e2e.mjsIt reads a real 402 challenge and never signs or spends anything.
examples/index.html is the same demo page, for driving by hand: build, serve
this folder, and open it.
Security notes
- The modal never holds keys. Signing happens in the user's wallet; the signed payload goes to your endpoint.
- A
429from the merchant is retried with the same signed payment — safe, because x402 settles only after the work succeeds. - Upstream throttle/billing text is never relayed to the buyer verbatim.
- All endpoint-supplied strings are HTML-escaped before rendering.
- For the Solana path, the dynamic CDN import can be blocked by a strict
Content-Security-Policy; either allow it, repoint it via
solanaWeb3Url, or steer users to the dependency-free Base path.
FAQ
Do I need a wallet adapter / WalletConnect? No. Solana uses the injected
Phantom provider; EVM uses the injected window.ethereum.
Does the payer pay gas? On EVM, no — EIP-3009 is a gasless signed authorization your facilitator submits. On Solana the facilitator is the fee-payer.
Can I theme it? It ships a self-contained stylesheet with light/dark
(prefers-color-scheme) support. Override the .x402-* classes, or set
brand for the footer. The header reflects merchant / action.
Framework support? It's framework-agnostic. Import pay() and call it from
a React/Vue/Svelte handler, or drop the /global script and use data-*
buttons.
Where does this run in production? This is the same modal that powers payments on three.ws; the package is its standalone, configurable home.
License
All rights reserved, see LICENSE. Part of the three.ws platform for building, animating, rigging, and monetizing 3D AI agents.
