A pay-per-request proxy in front of the Brave Search API. Instead of an API key, each request carries a stablecoin micropayment, making the signed payment act as the credential.
bx402 is bx (Brave Search CLI) + 402, the HTTP Payment Required status.
The 402 is the shared mechanism, not a rail, so x402 (USDC
on Base) and MPP (pathUSD on Tempo) are equally first-class.
| Spec | https://gist.github.com/onyb/a1d620ba1e6ded2577a2998f2ecb0f61 |
|---|
- A request with no payment receives one
402 Payment Requiredthat advertises both rails. - The client retries with the header matching its wallet, either x402 or MPP.
- On a valid payment the request is forwarded to Brave Search and the result is returned with the settlement receipt.
One hostname serves both rails. The rail is chosen by the client's payment header.
You need Node.js 24 or newer with corepack enable (pnpm
comes from the packageManager field), and a Brave Search API key (free tier at
brave.com/search/api).
This runs bx402 in Docker against the public
x402.org facilitator, which verifies each
payment and settles it on chain. It serves testnet only and pays the settlement gas
itself, so nothing on the Brave side needs funding. Production points
X402_FACILITATOR_URL at the
Coinbase-hosted facilitator
instead, with CDP_API_KEY_ID and CDP_API_KEY_SECRET set beside it; the service
signs every facilitator call with that key and refuses to send credentials to any
other facilitator host.
| Party | Owner | Requires | Faucet |
|---|---|---|---|
| Payer wallet | Agent | Base Sepolia USDC | Circle |
| Treasury address | Brave | nothing | — |
- Clone, then write your Brave Search API key to
.env:git clone git@github.com:brave-experiments/bx402.git cd bx402 echo "BRAVE_SEARCH_API_KEY=<your-key>" >> .env
- Start the stack:
docker compose up --build -d
- Create a payer wallet and fund it with USDC on Base Sepolia (
brew install stripe/purl/purlif needed):purl wallet add --type evm purl balance --network base-sepolia
- Pay for a search:
The server returns the settled tx hash in the
purl inspect 'http://localhost:8080/res/v1/web/search?q=rust' purl -v --max-amount 10000 'http://localhost:8080/res/v1/web/search?q=rust'
PAYMENT-RESPONSEresponse header, but purl does not print it. Look the payer's USDC transfer up on sepolia.basescan.org instead.
No facilitator here: the server talks to a Tempo RPC endpoint directly, and verifying an MPP credential is what settles it. Only the payer needs funding.
- Write the config to
.envand start the server (ENABLED_RAILS=mppruns the MPP rail alone, so no facilitator config is needed;ALLOW_TESTNETis required because Moderato is a testnet):echo "BRAVE_SEARCH_API_KEY=<your-key>" >> .env echo "ENABLED_RAILS=mpp" >> .env echo "MPP_RPC_URL=https://rpc.moderato.tempo.xyz" >> .env echo "MPP_SECRET_KEY=$(openssl rand -hex 32)" >> .env echo "ALLOW_TESTNET=true" >> .env pnpm install && pnpm build && pnpm start
- Create a throwaway payer and fund it from the faucet RPC method:
export MPPX_PRIVATE_KEY="0x$(openssl rand -hex 32)" curl -s -X POST https://rpc.moderato.tempo.xyz -H 'content-type: application/json' \ -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tempo_fundAddress\",\"params\":[\"<payer>\"]}"
new_payerin.github/scripts/e2e/mpp/lib.shdoes this end to end, deriving the address and waiting for the balance to land. - Pay for a search:
MPP clients print the
npx mppx --network testnet -i 'http://localhost:8080/res/v1/web/search?q=rust'Payment-Receiptheader, so look the transaction up athttps://explore.testnet.tempo.xyz/receipt/<transaction>.
Every endpoint below is payable on both rails. Prices track Brave's published rates at cost, in base units (5000 = $0.005). Brave's rate card names a price for Web Search and LLM Context, Autosuggest, and Spellcheck; the other search endpoints are charged the Web rate.
| Endpoint | Base units | Brave rate |
|---|---|---|
/res/v1/web/search |
5000 | $5/1k |
/res/v1/llm/context |
5000 | $5/1k |
/res/v1/news/search |
5000 | $5/1k |
/res/v1/videos/search |
5000 | $5/1k |
/res/v1/images/search |
5000 | $5/1k |
/res/v1/summarizer/search |
5000 | $5/1k |
/res/v1/local/place_search |
5000 | $5/1k |
/res/v1/local/pois |
5000 | $5/1k |
/res/v1/local/descriptions |
5000 | $5/1k |
/res/v1/suggest/search |
500 | $5/10k |
/res/v1/spellcheck/search |
500 | $5/10k |
A path outside this table is a 404, never a payable 402, so the proxy forwards only
the endpoints it sells. The Answers API (/res/v1/chat/completions) is not among them: it
bills per query and per token, which one fixed price in a 402 cannot express.
A payment is checked against the price of the path it is sent to, so a credential bought for Autosuggest does not pay for a web search.
Both rails charge the same price for the same endpoint, to the same treasury, in base units of a six decimal token. See Endpoints for the per-endpoint price.
| Rail | Mainnet | Testnet | Asset |
|---|---|---|---|
| x402 | Base, eip155:8453 |
Base Sepolia, eip155:84532 |
USDC |
| MPP | Tempo, chain 4217 | Moderato, chain 42431 | pathUSD |
ALLOW_TESTNET=true admits testnets. x402 then advertises the Base Sepolia offer first, so
a client taking the first offer it supports pays with faucet money. MPP discovers its chain
from MPP_RPC_URL at startup and refuses to start on a testnet without the variable.
ENABLED_RAILS picks which rails the deployment serves, as a comma-separated subset of
x402,mpp; unset enables both. A disabled rail's variables are not read, its challenge is
not advertised, and a payment attempt on it gets the plain 402 naming the rails that remain.
ENABLED_RAILS=none serves no rails at all: the service stays up and answers every payment
attempt with a 402 offering nothing, which suspends payments without taking the proxy down.
x402 verifies (a dry run that moves nothing), runs the search, then settles, so a failed search is never charged and a returned result always means the payment settled. An MPP credential is a signed transaction, so verifying it settles it, before the search runs.
| Client | x402 (Base Sepolia) | MPP (Moderato) |
|---|---|---|
purl |
✅ | — |
@x402/fetch |
✅ | — |
mppx |
✅ | ✅ |
tempo request |
— | ✅ |
mppx pays either rail. It reaches x402 through its own protocol adapter, which reads the
PAYMENT-REQUIRED header and answers in PAYMENT-SIGNATURE, so that leg settles through the
facilitator like any other x402 payment. The mppx entry in extensions carries the route
binding it requires before it will sign.
The proxy can refuse payments from prohibited addresses by checking each payer against a
restricted-address list kept in a private S3 bucket (one HeadObject per lookup).
Screening is off by default and is controlled by a single variable:
RESTRICTED_ADDRESS_S3_BUCKET(optional): the bucket holding the list. Unset or empty turns screening off. When set, the service probes the bucket once at startup and refuses to start if it cannot reach it, so it never serves traffic with a broken screener.
AWS credentials and region come from the standard AWS resolution chain (AWS_PROFILE,
AWS_REGION, ~/.aws, or an attached IAM role), so no AWS keys live in the app config.
The startup banner reports the outcome:
restricted address screening: ✓ enabled (bucket=<name>)
restricted address screening: ✗ disabled (RESTRICTED_ADDRESS_S3_BUCKET not set)
Prometheus metrics are served on port 8090, on a listener of its own; 8080 keeps
serving traffic and GET /health. Keep 8090 inside the network, since the
exposition names every paid endpoint, how often payments are refused and why, and how
much has been charged. Scrape localhost:8090/metrics for the full set: every series is
prefixed bx402_, and one with nothing recorded yet is absent rather than zero.