The complete drop-in checkout for any x402 paid endpoint — wallet connect, sign, settle, receipt.
A drop-in payment modal for any x402 paid endpoint. The
browser client is one ES module with zero runtime dependencies; it turns an HTTP
402 Payment Required challenge into a polished checkout: wallet connect (Phantom
/ Solflare / Backpack on Solana, MetaMask / any EVM wallet on Base via EIP-3009),
the 402 → sign → settle flow, optional SIWX re-entry, client-side spending caps,
and a receipt. It ships a server checkout adapter for the Solana rail
(./server, ./server/express, ./server/vercel) and a React wrapper
(./react) — pick only the pieces you need. Vanilla JS, no bundler required.
<script type="module" src="https://unpkg.com/@three-ws/x402-payment-modal"></script>
<button
data-x402-endpoint="https://api.example.com/paid/summarize"
data-x402-method="POST"
data-x402-body='{"text":"hello"}'
data-x402-merchant="Acme"
data-x402-action="Summarize">
Pay & Run
</button>That's the whole integration. Clicking the button opens the modal, runs the
payment, calls your endpoint with the signed X-PAYMENT header, and fires an
x402:result event with the response.
x402 lets any HTTP endpoint charge a micropayment per call: the server answers an
unpaid request with 402 and a list of accepted payments; the client pays and
retries with an X-PAYMENT header. The protocol is simple — the wallet UX is
not. This package is the missing front end: a single, framework-agnostic modal
that handles wallet detection, chain switching, signing, settlement, errors,
throttle retries, and the receipt, so you ship a paid endpoint in minutes.
- Zero dependencies, zero build. Plain ES module. Drop in a
<script>tag orimportit. The Solana/EVM crypto helpers are loaded lazily from a CDN only when a payment is actually attempted. - Solana + EVM. Phantom (Solana) and any injected EVM wallet (Base USDC
via EIP-3009
transferWithAuthorization). The modal picks the right path from the 402 challenge. - USDC by default, multi-token if you want it. USDC is the always-on default
on Solana. Optionally offer a second SPL token (the modal ships a built-in
THREEopt-in) and the buyer gets a token picker — each token is recognized on sight (symbol, decimals). See Accepting multiple Solana tokens. - SIWX re-entry. If a wallet already paid for a resource, it can sign back in instead of paying again. See docs/siwx.md.
- Spending caps. Optional per-call / hourly / daily caps enforced in the browser. See docs/spending-caps.md.
- Themeable. Light + automatic dark mode, a full
--x402-*design-token surface, runtime brand-matching, and a header logo. See docs/theming.md. - Accessible. Focus trap + restore,
aria-modal,aria-livestep announcements,:focus-visiblerings,prefers-reduced-motion, keyboardEscto close. - Server + React adapters in the box. Framework-agnostic Solana checkout
(
./server) with Express and Vercel adapters, and a first-class React wrapper (./react) — all behind separate export subpaths, all optional.
This package has a lighter sibling, @three-ws/x402-modal.
Both render a drop-in modal; choose by how much you need.
@three-ws/x402-payment-modal (this) |
@three-ws/x402-modal |
|
|---|---|---|
| Goal | Full checkout SDK: client + server + React | Minimal client-only modal |
| Chains | Solana and EVM (Base/Arbitrum/Optimism) | Client modal, EVM-leaning |
| Solana server adapter | ✅ ./server + Express + Vercel |
❌ bring your own |
| React wrapper | ✅ ./react (X402Button, useX402) |
❌ |
| SIWX re-entry | ✅ | — |
| Client spending caps | ✅ per-call / hour / day | — |
| Multi-token picker (USDC + opt-in SPL) | ✅ | — |
| Builder-code (ERC-8021) echo | ✅ | — |
| Footprint | Larger (more surface) | Smaller, fewer knobs |
Rule of thumb: taking real Solana payments, want a React component, or need
caps/SIWX → use this package. Just need a tiny EVM paywall button and nothing
else → the lighter @three-ws/x402-modal is enough.
Import only what you use — each subpath is independent and its peer deps are optional.
| Subpath | Import | Needs |
|---|---|---|
. |
@three-ws/x402-payment-modal |
nothing (browser) |
./min |
@three-ws/x402-payment-modal/min |
nothing — pre-minified bundle |
./server |
@three-ws/x402-payment-modal/server |
@solana/web3.js, @solana/spl-token |
./server/express |
@three-ws/x402-payment-modal/server/express |
+ express |
./server/vercel |
@three-ws/x402-payment-modal/server/vercel |
@solana/web3.js, @solana/spl-token |
./react |
@three-ws/x402-payment-modal/react |
react |
Via CDN (no build step):
<script type="module" src="https://unpkg.com/@three-ws/x402-payment-modal"></script>Via npm (bundler / framework):
npm install @three-ws/x402-payment-modalimport { pay, configure } from '@three-ws/x402-payment-modal';The core client has no runtime dependencies. The other subpaths declare optional peer deps — install them only when you use that subpath:
| You use… | Install |
|---|---|
./server or ./server/vercel (Solana checkout) |
@solana/web3.js @solana/spl-token |
./server/express |
@solana/web3.js @solana/spl-token express |
./react |
react (you already have it) |
# Solana checkout server
npm install @solana/web3.js @solana/spl-token
# …and express if you use the Express adapter
npm install expressEVM-only sites need nothing server-side: the wallet signs EIP-3009 typed-data entirely in the browser. The Solana/EVM crypto helpers used by the client are loaded lazily from a CDN only when a payment is attempted, so they are not in your bundle.
Any element with data-x402-endpoint is auto-bound on load and re-scanned as the
DOM changes:
<button
data-x402-endpoint="/api/paid/translate"
data-x402-method="POST"
data-x402-body='{"text":"bonjour","to":"en"}'
data-x402-merchant="Acme Translate"
data-x402-action="Translate">
Translate ($0.01)
</button>
<script type="module" src="https://unpkg.com/@three-ws/x402-payment-modal"></script>
<script>
document.querySelector('button').addEventListener('x402:result', (e) => {
console.log('paid + result:', e.detail.result);
});
</script>import { pay } from '@three-ws/x402-payment-modal';
try {
const { result, payment } = await pay({
endpoint: '/api/paid/translate',
method: 'POST',
body: { text: 'bonjour', to: 'en' },
merchant: 'Acme Translate',
action: 'Translate',
});
console.log(result, 'tx:', payment?.transaction);
} catch (err) {
if (err.code !== 'cancelled') console.error(err);
}See the runnable examples/ — plain HTML, React, and an Express server.
Three independent pieces, each optional except the client:
- Client (
././min) — renders the modal, detects wallets, drives the402 → sign → settleflow. Zero runtime deps. - Server adapter (
./server*) — Solana only. Builds the SPL transfer Phantom signs and wraps it into theX-PAYMENTenvelope. EVM never touches it. - React wrapper (
./react) —X402Button+useX402, SSR-safe.
flowchart TD
A[User clicks pay] --> B[Client: GET/POST endpoint]
B --> C{HTTP 402 + accepts?}
C -->|no, 200| X[Error: not an x402 endpoint]
C -->|yes| D[discover: show price + network]
D --> E[connect: pick & connect wallet]
E --> F{network}
F -->|EVM| G[sign EIP-3009 typed-data\nbrowser only, no server]
F -->|Solana| H[POST ./server ?action=prepare]
H --> I[Phantom signs the v0 tx]
I --> J[POST ./server ?action=encode → X-PAYMENT]
G --> K[verify: retry with X-PAYMENT]
J --> K
K --> L{settled?}
L -->|429 throttle| K
L -->|2xx| M[receipt + result → resolve PayResult]
L -->|4xx/5xx| N[error + roll back any cap reservation]
Full walkthrough in docs/architecture.md.
| Export | Signature | Purpose |
|---|---|---|
pay |
pay(opts) => Promise<PayResult> |
Open the modal and run a payment. |
configure |
configure(opts) => Config |
Set checkout origin, branding, builder-code, CDN URLs. |
init |
init() => void |
Bind [data-x402-endpoint] elements (auto-called). |
version |
string |
Library version. |
Also exposed as window.X402.{ pay, init, configure, version } for inline
scripts.
pay(opts) — opts:
| Field | Type | Notes |
|---|---|---|
endpoint |
string |
Required. The paid (x402) URL. |
method |
string |
Default GET, or POST when body is set. |
body |
object | string |
Objects are JSON-stringified. |
headers |
object |
Extra headers merged into the paid call. |
merchant |
string |
Shown in the modal header. |
action |
string |
Shown in the modal header. |
autoConnect |
boolean |
Skip the picker when exactly one wallet is detected. |
caps |
SpendingCaps |
{ maxPerCall, maxPerHour, maxPerDay } in atomic micro-USD. |
Resolves to PayResult: { ok: true, result, payment?: { network, transaction, payer }, siwx?: { address, network }, response: { status, headers } }. Rejects
with an Error whose .code === 'cancelled' when the user closes the modal.
DOM events (bubbling CustomEvents on the clicked element):
x402:result (detail = PayResult), x402:error (detail = { error }),
x402:siwx-signed (detail = { address, network }).
Full reference: docs/api-reference.md.
Defaults work out of the box. Override host-specific bits with configure():
import { configure } from '@three-ws/x402-payment-modal';
configure({
checkoutOrigin: 'https://pay.acme.com', // where your Solana checkout endpoint lives
brand: { name: 'Acme', url: 'https://acme.com' },
footerNote: 'Acme Pay',
});…or with data-* attributes on the script tag (no inline module needed):
<script type="module"
src="https://unpkg.com/@three-ws/x402-payment-modal"
data-x402-checkout-origin="https://pay.acme.com"
data-x402-brand-name="Acme"
data-x402-brand-url="https://acme.com"></script>| Option | data-* attribute |
Default |
|---|---|---|
checkoutOrigin |
data-x402-checkout-origin |
the script's own origin |
checkoutPath |
data-x402-checkout-path |
/api/x402-checkout |
footerNote |
data-x402-footer-note |
x402 · onchain settled |
brand.name / brand.url |
data-x402-brand-name / -brand-url |
null (footer link hidden) |
brand.logo |
— | null (no header logo) |
theme |
— | 'auto' (follow the OS) |
cssVars |
— | null (use built-in tokens) |
builderCode.wallet / .service |
data-x402-builder-wallet / -builder-service |
'' (no self-attribution) |
esm.solanaWeb3 / esm.nobleHashesSha3 |
— | pinned esm.sh URLs |
theme is 'auto' | 'light' | 'dark'; cssVars is a flat map of --x402-*
design tokens (e.g. { '--x402-accent': '#ff5c00', '--x402-radius': '8px' }) for
runtime brand-matching — see docs/theming.md. Repoint esm.*
at a self-hosted mirror if a strict Content-Security-Policy blocks esm.sh. (The
EVM/Base path needs no third-party code at all.)
Phantom signs serialized transactions but doesn't build instructions, so the
Solana path needs a tiny endpoint to build the SPL transfer and wrap the signed
tx into the X-PAYMENT envelope. The package ships it.
Express:
import express from 'express';
import { x402CheckoutRouter } from '@three-ws/x402-payment-modal/server/express';
const app = express();
app.use(express.json());
app.use('/api/x402-checkout', x402CheckoutRouter({ rpcUrl: process.env.SOLANA_RPC_URL }));
app.listen(3000);Vercel / Next.js — api/x402-checkout.js:
export { default } from '@three-ws/x402-payment-modal/server/vercel';Pass rpcUrls: [...] (an ordered list, tried with failover) instead of a single
rpcUrl for production — the public RPC is rate-limited and warns once at
startup. Lower-level helpers (prepareSolanaCheckout, encodeX402Payment,
handleCheckout, CheckoutError, solanaAccept) are exported from
@three-ws/x402-payment-modal/server. Full guide:
docs/server-setup.md.
The ./react subpath ships a hook and a button. The browser-only core is
dynamically imported on first use, so both are SSR-safe — nothing runs during
render or on the server.
import { X402Button, useX402 } from '@three-ws/x402-payment-modal/react';
// Drop-in button:
<X402Button
endpoint="/api/paid/summarize"
method="POST"
body={{ url: 'https://en.wikipedia.org/wiki/x402' }}
merchant="Acme"
action="Summarize"
label="Summarize for $0.01"
onResult={(r) => console.log('paid', r.payment)}
onError={(e) => console.error(e)}
/>;
// …or drive it yourself with the hook:
function Buy() {
const { pay, isPaying, result, error } = useX402({ merchant: 'Acme' });
return (
<button disabled={isPaying} onClick={() => pay({ endpoint: '/api/paid/summarize' })}>
{isPaying ? 'Processing…' : 'Pay'}
</button>
);
}useX402(defaults) returns { pay, status, result, error, reset, isPaying }
where status is idle | paying | done | error. Full reference:
docs/react.md.
USDC is the default settlement asset, but an x402 challenge can list more than one
accepted payment. List a USDC accept and a second SPL-token accept on Solana,
and the modal renders a token picker: the buyer chooses which to pay in, the
headline price and the transaction follow the choice. No client wiring — the
checkout endpoint already builds an SPL transfer for whatever mint the chosen
accept names. The package ships a built-in THREE opt-in token as a ready
example, but any mint works.
Build the accepts with the solanaAccept helper (no hardcoded mints):
import { solanaAccept } from '@three-ws/x402-payment-modal/server';
// feePayer is your facilitator's sponsor account (pays the SOL network fee).
const accepts = [
solanaAccept({ token: 'usdc', uiAmount: 0.25, payTo, feePayer }), // $0.25 in USDC
solanaAccept({ token: 'three', uiAmount: 1000, payTo, feePayer }), // or 1,000 THREE
];
// Return them in your 402 body / PAYMENT-REQUIRED envelope:
// { x402Version: 2, accepts }solanaAccept takes token: 'usdc' | 'three' (or an explicit mint for any
SPL token), the price as uiAmount (human units) or amount (atomic string),
and emits a spec-shaped accept. The constants THREE_MINT, USDC_MINT_SOLANA,
and WELL_KNOWN_SOLANA_TOKENS are exported too. On the client, the same mints
are exposed at window.X402.tokens for inline merchants.
Non-stable tokens float against the dollar. The modal can't dollar-denominate a floating-price token for browser-side spending caps — so enforce caps for any non-stable token (like
THREE) server-side. USDC and other stablecoin caps work in the browser as usual.
- Architecture — the full payment lifecycle.
- Client API reference
- Server setup
- React reference —
X402Button+useX402. - Theming
- SIWX re-entry
- Spending caps
- Examples — runnable plain-HTML, React, and Express samples.
- Tutorial — build a paid endpoint end-to-end.
- Contributing
Modern evergreen browsers (ES2020, BigInt, dynamic import(), fetch,
crypto.getRandomValues). The Solana wallet path requires
Phantom; the EVM path requires an injected wallet
(window.ethereum).
- Payments are signed in the user's wallet — this package never sees a private key. The Solana checkout server only builds an unsigned transaction and base64- wraps the user-signed one; it cannot move funds.
- SIWX re-entry is a gasless CAIP-122 signature, never a transfer. The client detects the offer, signs, and submits the proof; the server must verify the nonce/domain/expiry/signature and check the entitlement — see docs/siwx.md.
- Spending caps are a client-side guardrail, not a security boundary — a user
can clear
localStorage. Enforce real limits server-side. - Pin the script to a version or self-host it for production (the lazily-loaded
crypto helpers can be repointed at a mirror via
configure({ esm })to satisfy a strict CSP).
The modal opens but says "Couldn't confirm the price."
Discovery hit a response that wasn't an x402 challenge. The endpoint must answer
the unpaid request with HTTP 402 (or an MCP-style 401) carrying an accepts
array — in the JSON body or a base64 payment-required header. A 200 means the
endpoint isn't paywalled; the modal surfaces that instead of silently succeeding.
Do I need a server?
Only for Solana. EVM (Base/Arbitrum/Optimism USDC) signs EIP-3009 typed-data
entirely in the browser. For Solana, mount ./server so the modal can build the
SPL transfer Phantom signs — see docs/server-setup.md.
Solana payments fail with an RPC error under load.
You're on the public RPC (it warns once at startup). Pass a dedicated endpoint —
x402CheckoutRouter({ rpcUrls: ['https://your-helius-url', ...] }) — and the
adapter rotates across the list on transient failures.
A Token-2022 mint (e.g. THREE) wouldn't settle in an older version.
Fixed in 1.2.0 — the server now detects each mint's owning program (legacy SPL
Token vs Token-2022) and derives ATAs and transferChecked against the right one.
esm.sh is blocked by my Content-Security-Policy.
The client lazy-loads @solana/web3.js and @noble/hashes from a CDN only when a
payment is attempted. Repoint them at a self-hosted mirror:
configure({ esm: { solanaWeb3: '/vendor/solana-web3.js', nobleHashesSha3: '/vendor/noble-sha3.js' } }).
EVM-only sites load no third-party code at all.
Why did pay() reject?
A user dismissing the modal rejects with err.code === 'cancelled' — treat it as
a no-op, not a failure. Any other rejection is a real error. The declarative
auto-binder swallows cancellation and only fires x402:error for genuine failures.
My spending caps don't apply to a non-stablecoin. Browser caps are denominated in micro-USD and the dependency-free client doesn't fetch live prices, so caps only bind stablecoins (USDC/USDT/DAI). Enforce caps for floating-price tokens server-side — see docs/spending-caps.md.
Can the user pick which token to pay in?
Yes — if the 402 challenge lists more than one Solana accept, the modal renders
a token picker. See Accepting multiple Solana tokens.
@three-ws/x402-modal— the lighter client-only sibling. See the comparison above.- x402 protocol — the open "HTTP 402 Payment Required" micropayment standard this package implements (v2 envelope).
All rights reserved. See LICENSE.
If x402-payment-modal saves you time, star it on GitHub. Stars are how other developers and AI agents find the repositories worth trusting, and they cost you one click.
Know someone who would use it? Post on X · Share on Bluesky · Share on LinkedIn · Submit to Hacker News · Share on Reddit
Coding agents and LLM tooling can read this repo directly: AGENTS.md, llms.txt, llms-full.txt. Point an agent at https://github.com/nirholas/x402-payment-modal and it has the context it needs.
- All repositories by nirholas: the full catalog, grouped by topic
- three.ws: the platform for 3D AI agents with Solana wallets, a skill marketplace and x402 payments
- Questions or ideas: open an issue or start a discussion