An x402 payment scheme for webcash — bearer-token e-cash settled by an atomic-replacement issuer.
This repo contains:
specs/scheme_webcash.md— the formal scheme specification, written against the x402 v2 scheme template. Intended to be proposed upstream tocoinbase/x402.src/— a TypeScript reference facilitator implementingPOST /verify,POST /settle, andGET /supportedfor thewebcashscheme, plus an Express middleware and a client-side scheme handler.src/client/— a transport-agnostic client (also exported asx402-webcash/client):FileWallet,buildWebcashHeader, and awrapFetchWithWebcashfetch adapter that auto-settles 402s.src/mcp-settler.ts—webcashSettler(facilitator), an adapter that plugs this library'sFacilitatorinto@feldmannn/x402-mcpso MCP tools can be paywalled in webcash.examples/express-server.ts— a tiny Express resource server that paywalls an endpoint using this facilitator.examples/fetch-client.ts— a client that spends a webcash secret to call the paywalled endpoint above.
For exposing webcash payments to AI agents over MCP, see the separate webcash-mcp project — a general-purpose MCP server that wraps this library.
x402 is the emerging standard for HTTP-native, agent-friendly payments. Today its only scheme is exact (EIP-3009 stablecoin transfers). Webcash is a different shape — bearer secrets, issuer-enforced replay prevention, no signatures — but the x402 framing (scheme + network + payload + facilitator verify/settle) accommodates it cleanly.
Adding webcash as an x402 scheme means any x402-aware client gains the ability to pay in webcash, and any service already accepting webcash (e.g., harmoniis) becomes reachable to the broader x402 ecosystem with a thin adapter.
1.0 — feature-complete reference implementation, spec ready for upstream proposal to coinbase/x402. 122 tests covering every documented failure mode.
What's in 1.0:
- Facilitator + Express paywall + transport-agnostic client.
Facilitator.verify/Facilitator.settle/GET /supportedfor x402 v2; Expresspaywallmiddleware;FileWallet+buildWebcashHeader+wrapFetchWithWebcashon the client side. paywallLocal(facilitator, opts)— in-process facilitator paywall. No HTTP hop, no third-party trust boundary; combined with a caller-suppliedmintOutputSecret, the resource server controls every step of settlement.- SPKI certificate pinning.
pinnedSpkiHashesis accepted onFacilitator,paywall,splitToMatch, andwrapFetchWithWebcash'sautoSplit. Pinning is additive (default CA + hostname validation runs first); a mismatch fails at TLS handshake before any bearer secret is transmitted. RFC 7469 pin format.createPinnedFetch({ pinnedSpkiHashes })is exported for callers using custom transports. - Recipient binding (buyer-derived outputs). The 402 challenge can advertise an X25519 public key + nonce; the buyer derives the output secret via ECDH+HKDF; the facilitator is contractually constrained to use exactly that secret in
/replace; the resource server verifies the returned secret against its private key post-settlement. Closes the facilitator-substitution attack on remote facilitator deployments. Seespecs/scheme_webcash.md"Recipient binding". - Issuer URL allowlist.
FacilitatorOptions.issuerAllowlist(orWEBCASH_ISSUER_ALLOWLIST=url1,url2). Canonical webcash.org issuers are always included;extra.issuerUrloutside the allowlist is rejected at verify withinvalid_network. - HTTPS enforcement. Facilitator, paywall middleware, and the client-side splitter all reject non-HTTPS URLs that are not loopback. Opt-out (
allowHttpIssuer/allowHttpFacilitator/WEBCASH_ALLOW_HTTP_ISSUER=1) is reserved for test rigs. - Concurrency-safe FileWallet. In-process mutex serializes wallet operations so concurrent
takeExactcalls cannot double-spend or clobber writes. Not safe across processes — use SQLite/keychain-backed wallets for multi-process deployments. - Pre-flight journal hook.
wrapFetchWithWebcashaccepts ajournalcallback that fires after a secret is taken from the wallet but before the request is sent, so a process crash mid-request can be reconciled. - Full failure-mode coverage for: settlement integrity (server-side), ambiguous response (client-side), split rejection vs split ambiguity, persistence-failure recovery hooks, binding-substitution detection, and
[x402-webcash][CRITICAL]stderr breadcrumbs on every fund-loss path. - MCP bridge.
webcashSettler(facilitator)adapts the facilitator to@feldmannn/x402-mcp'sSettlerinterface.
See specs/scheme_webcash.md for the full protocol and security model, including the recipient-binding race-window analysis. See SECURITY.md for the trust model and how to report vulnerabilities.
npm install
npm run build
npm run facilitator # starts the facilitator on :4021
npm run example # starts the paywalled Express server on :4020Then from a client:
# Probe (no payment) — should return 402 with PaymentRequired body
curl -i http://localhost:4020/premium
# Retry with a webcash secret in X-PAYMENT (base64-encoded PaymentPayload)
curl -i -H "X-PAYMENT: $PAYLOAD_B64" http://localhost:4020/premiumOr via the TypeScript client (handles the 402 + retry automatically):
echo '{"secrets":["e0.3:secret:<your-hex>"]}' > ./client-wallet.json
npm run example:clientimport { FileWallet, wrapFetchWithWebcash } from "x402-webcash/client";
const wallet = new FileWallet("./wallet.json");
const pay = wrapFetchWithWebcash(fetch, { wallet });
const res = await pay("https://api.example.com/premium");
// On 402 advertising webcash, `pay` takes a matching secret from the wallet,
// retries with X-PAYMENT, and returns the 200. Schemes other than webcash
// pass through unchanged so you can chain other handlers.For non-fetch transports (axios, undici Dispatcher, MCP transports), call
buildWebcashHeader(body, wallet) directly — it returns the base64 header
string for the X-PAYMENT field plus the secret you took, so you can wire the
retry into whatever client you already have.
By default, the wallet must hold an unspent secret of exactly the amount
the resource server demands. Pass autoSplit to derive an exact-amount
secret on demand by asking the issuer to atomically replace a larger one
with [required, change]:
const pay = wrapFetchWithWebcash(fetch, { wallet, autoSplit: {} });The issuer URL is read from the 402 challenge itself (extra.issuerUrl or
payTo). On clean rejection the input secret is returned to the wallet;
on a network failure the outcome is ambiguous, the input is NOT returned
(it may have been spent at the issuer), and both newly-minted output
secrets are logged to stderr with the [x402-webcash][CRITICAL] marker
so an operator can recover them. See splitToMatch for the full
failure-mode contract.
To accept webcash for your own MCP tools, pair this library's facilitator
with @feldmannn/x402-mcp:
import { Facilitator, webcashSettler, decimalToWats, type WebcashOutput } from "x402-webcash";
import { createPaywall } from "@feldmannn/x402-mcp";
const facilitator = new Facilitator({
issuerAllowlist: ["https://webcash.org"],
});
const paywall = createPaywall<WebcashOutput>({
settler: webcashSettler(facilitator),
scheme: "webcash",
asset: "webcash",
network: "webcash:mainnet",
payTo: "https://webcash.org",
onSettled: async (output) => {
// Persist `output.secret` to your wallet — or you lose the funds.
},
});
// Then wrap any MCP tool handler:
server.registerTool("premium_search", schema, paywall.gate(
{
amount: decimalToWats("0.001").toString(), // 0.001 webcash in wats (x402 v2 is string)
resourceUrl: "mcp://your-server/premium_search",
},
async (args, extra) => {
return { content: [{ type: "text", text: "result" }] };
},
));webcashSettler enforces the same integrity gates as the Express
middleware: success responses without a parseable output secret, or with
an output secret whose embedded amount disagrees with the buyer's
requirements, are converted into non-retriable failures and logged to
stderr with [x402-webcash][CRITICAL] so an operator can audit the
facilitator. Mint failures map to retriable: true (the input was never
sent to the issuer); all other failures map to retriable: false.
There are three ways to paywall a resource with this library, in order of increasing facilitator trust required:
The resource server runs the facilitator itself. No third party is in the trust set. The resource server controls the output secret directly via mintOutputSecret.
import { Facilitator, paywallLocal } from "x402-webcash";
import express from "express";
const facilitator = new Facilitator({
issuerAllowlist: ["https://webcash.org"],
mintOutputSecret: (amountDecimal) => myWallet.newSecret(amountDecimal),
});
const app = express();
app.get(
"/premium",
paywallLocal(facilitator, {
amountWats: 30_000_000n,
onSettled: (output) => myWallet.put(output.secret),
}),
(_req, res) => res.json({ ok: true }),
);A separate facilitator service settles for you, but it cannot substitute its own output secret — the buyer derives one via ECDH against your X25519 public key, and you verify the returned secret against your private key. The facilitator briefly knows the secret (it has to, to call /replace); the residual risk is a race to spend in the gap between settlement and your refresh. Spend immediately in onSettled to minimize the window.
import { paywall, RecipientKey } from "x402-webcash";
const recipientKey = RecipientKey.generate(); // or RecipientKey.fromJwk(persistedJwk)
app.get(
"/premium",
paywall({
amountWats: 30_000_000n,
facilitatorUrl: "https://facilitator.example.com",
pinnedSpkiHashes: [/* current pin */, /* backup pin */],
recipientKey,
onSettled: async (output) => {
// Refresh immediately to close the race window.
await myWallet.refreshAndPut(output.secret);
},
}),
(_req, res) => res.json({ ok: true }),
);The classic deployment. The facilitator chooses the output secret; you trust it as part of your security perimeter. Useful when you have an operational relationship with the facilitator operator (e.g., self-hosted on the same VPC, or run by an org you trust).
import { paywall } from "x402-webcash";
app.get(
"/premium",
paywall({
amountWats: 30_000_000n,
facilitatorUrl: "https://facilitator.example.com",
pinnedSpkiHashes: [/* current pin */, /* backup pin */],
onSettled: (output) => myWallet.put(output.secret),
}),
(_req, res) => res.json({ ok: true }),
);Plain HTTPS trusts the public CA system. To defend against a CA-mis-issued cert on the facilitator↔issuer or paywall↔facilitator channel, configure SPKI pins on every HTTPS leg:
new Facilitator({
pinnedSpkiHashes: [
"NWny299lvjd0rPs5z5gb8Vq5tyjlt6vn5C4N6MF4Ltg=", // current
"AAAAAAAAbackupAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // backup
],
});Compute the pin for an HTTPS endpoint:
openssl s_client -connect webcash.org:443 < /dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary | base64Configure at least two pins (current + backup) so a planned key rotation is not an outage. Pinning is additive to default CA validation — it strengthens trust, never weakens it. A mismatch fails at TLS handshake with PinMismatchError before any bearer secret is transmitted.
This library writes [x402-webcash][CRITICAL] … lines to stderr on every fund-loss-adjacent code path. These are the deliberate last-resort witness: without them, a transient disk error during persistence, an ambiguous network failure mid-split, or a malformed facilitator response would silently destroy funds.
Several of these lines contain webcash secrets in plaintext. Anyone who reads the log line can spend them. Specifically:
middleware.ts→persistence_failure secret=…andrecovery_callback_also_failed secret=…when the seller'sonSettled/onSettledRecoveryhooks throw.client/split.ts→ both[required, change]output secrets when an auto-split's network outcome is ambiguous.client/fetch.ts→ wallet-restoration failures after a 402 retry path errors.
The mcp-settler.ts and integrity-gate paths log transaction=… and amount material only — not the secret itself — because the facilitator has already moved the funds by then.
Operator responsibilities:
- Do NOT ship stderr from a webcash facilitator, paywalled server, or splitter-using client to third-party log aggregators (Datadog, Loggly, Splunk Cloud, etc.) without redacting
secret=and the full output-secret line. Treat that stream like a.envfile. - Provide
onSettledRecoverycallbacks that write to a sink independent of your primary persistence (encrypted file on disk, secrets manager). If the recovery callback also fails, the secret is still in stderr — but a healthy operator should never need to grep for it. - Search by
transaction=first, notsecret=. The error responses returned to callers embedtransaction=<id>so you can correlate the failed call with the recovery line without grepping for secret material in shared incident channels.
If you want AI agents to be able to pay webcash-protected URLs and MCP
tools, use webcash-mcp —
a general-purpose stdio MCP server built on top of this library. It
exposes pay_fetch (HTTP-402), pay_tool (MCP-402, via
@feldmannn/x402-mcp), wallet_balance, wallet_import, and
wallet_status tools that any MCP client (Claude Desktop, etc.) can
call.
MIT