x402 Payments
x402 Facilitator & Bazaar
Vellar runs a hosted x402 facilitator for Stellar with Bazaar discovery:
https://vellar-facilitator-testnet-production.up.railway.app
A facilitator is the verify/settle service between a buyer and a seller in an x402 payment. The seller's server never touches Soroban directly, and the buyer never needs XLM: the facilitator re-simulates the signed payment to verify it, submits it on-chain, and sponsors the network fee.
Status: pre-production. Open for anyone to build against, hosted on Railway and running continuously. Two independent deployments exist — testnet and mainnet — and everything in these docs defaults to testnet. See Deployments for both base URLs and the kill switch.
The catalog does not survive a service restart (see Limits), and the spending-limit policy contract has not had a mainnet security audit. The facilitator review is complete; the policy contract is separate work. Source: Vellar-Wallet/vellar-facilitator.
Bring your own payment asset
Read this before trying anything else on this page — it's the step most likely to stop you.
The facilitator settles in whatever SEP-41 asset a resource names. There is
no canonical asset, no built-in test token, and no faucet. To try any
flow below you need your own: an issuer, a Stellar Asset Contract, a
merchant trustlined to it, and a funded payer. (This is stellar:testnet
only — testnet assets are not money, so there's nothing to keep safe here.)
git clone https://github.com/Vellar-Wallet/vellar-facilitator
cd vellar-facilitator/examples && npm install
node provision-testnet.mjs
Creates all four in roughly 40 seconds to 3 minutes and prints a
paste-ready env block. Pass it an AGENT_PUBLIC to also provision a Vellar
smart-account wallet for the buyer side — see Agent keys
for generating that keypair without the secret ever touching a command line
or a file.
One old Bazaar entry, X402TST (CDYCX4PE…), cannot be acquired by
anyone. Its issuer keypair was generated in-process by a throwaway script,
and the secret no longer exists — nobody can mint more of it, including us.
If you find that contract id in /discovery/resources, don't spend time
trying to get a balance of it. This warning is about that entry only —
the deployed demo seller itself now charges real testnet USDC and is payable
by anyone; see the next section.
Paying the deployed demo seller
Want to test against a live seller without running your own?
https://vellar-seller-demo-testnet-production.up.railway.app/quote charges 0.1 real testnet
USDC (USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5,
Circle's official testnet issuer) with sponsored fees. Testnet USDC is
freely obtainable with no faucet form: Friendbot an account, then buy USDC
on the testnet DEX with the Friendbot XLM — the same two steps the
playground performs when it funds a
session wallet:
import {
Asset,
Horizon,
Keypair,
Networks,
Operation,
TransactionBuilder,
} from "@stellar/stellar-sdk";
const USDC = new Asset("USDC", "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
const horizon = new Horizon.Server("https://horizon-testnet.stellar.org");
const payer = Keypair.random();
await fetch(`https://friendbot.stellar.org?addr=${payer.publicKey()}`);
const account = await horizon.loadAccount(payer.publicKey());
const tx = new TransactionBuilder(account, { fee: "1000", networkPassphrase: Networks.TESTNET })
.addOperation(Operation.changeTrust({ asset: USDC }))
.addOperation(
Operation.pathPaymentStrictReceive({
sendAsset: Asset.native(),
sendMax: "1000", // XLM you are willing to spend
destination: payer.publicKey(),
destAsset: USDC,
destAmount: "0.5", // USDC you receive
}),
)
.setTimeout(60)
.build();
tx.sign(payer);
await horizon.submitTransaction(tx);
console.log("payer:", payer.publicKey(), "secret:", payer.secret());
Then pay the seller with that keypair (classic flow, from examples/):
RESOURCE_URL="https://vellar-seller-demo-testnet-production.up.railway.app/quote?topic=perseverance" \
PAYER_SECRET=S... # the secret the script printed
node buyer-classic.mjs
Both steps are verified working end to end. For the smart-account/agent flow you still provision your own seller (below): a fresh smart account holds no USDC, and the budget-policy story needs an asset your policies are scoped to.
Why it exists
Policy-governed smart-account payments (the x402 agent flow)
run the spending-policy contract inside __check_auth, which raises the fee.
Hosted facilitators default to a 50,000-stroop sponsorship ceiling and reject
those payments with fee_exceeds_maximum, even though the payment is valid and
policy-approved. The Vellar facilitator ships with a 500,000-stroop ceiling. A
policy-governed payment bids roughly 130,000 stroops, and the bid is what the
ceiling compares against: see
Fees and Sponsorship for the distinction between bid and
charge. The ceiling is raisable via MAX_TX_FEE_STROOPS, so agent payments
bounded by an on-chain budget settle instead of being refused. Both classic
keypairs and Soroban smart accounts are supported.
Endpoints
| Endpoint | Purpose |
|---|---|
POST /verify | Verify a payment by re-simulation (runs the payer's __check_auth, including any policy) |
POST /settle | Submit on-chain, fee-sponsored |
GET /supported | Advertised scheme, network, extensions, signer addresses |
GET /discovery/resources | List cataloged x402 resources — full reference |
GET /discovery/search | Hybrid search, lexical and semantic arms fused by RRF (see Search and Retrieval for the pipeline and quality figures); full reference |
GET /health | Liveness; also reports catalogFrozen if the catalog has stopped accepting writes |
Wire-compatible with the canonical x402 clients — HTTPFacilitatorClient
and the withBazaar extension work unmodified.
areFeesSponsored
areFeesSponsored: true in a kind's extra object means the facilitator pays
the Stellar network fee from its own sponsor account. The buyer needs no XLM —
this is what makes a zero-XLM smart account payable.
Both schemes on the Vellar facilitator advertise areFeesSponsored: true.
Confirmed from the live /supported response:
{"x402Version":2,"scheme":"exact","network":"stellar:testnet",
"extra":{"areFeesSponsored":true}}
{"x402Version":2,"scheme":"upto","network":"stellar:testnet",
"extra":{"uptoContract":"CCZL7CTRS…4YQAN","areFeesSponsored":true}}
The SDK reads this during option selection and throws
NoUsablePaymentOptionError if no option advertises it. Clients building their
own transport must perform the same check.
GET /discovery/resources
| Parameter | Type | Default | Notes |
|---|---|---|---|
type | string | — | |
payTo | string | — | |
scheme | string | — | exact or upto |
network | string | — | |
extensions | string | — | |
asset | string | — | max 56 chars |
verified_only | "true" | — | 400 if no verdict source |
limit | number | 20 | max 100 |
offset | number | 0 |
Response:
{
"x402Version": 2,
"items": [...],
"pagination": {"limit":20,"offset":0,"total":8}
}
GET /discovery/search
Same filters except no offset and no asset. query is required — an empty
string returns 400. Cursor-paginated.
Response:
{
"x402Version": 2,
"resources": [...],
"partialResults": true,
"pagination": {"limit":20,"cursor":"<base64url or null>"}
}
Pass pagination.cursor as cursor in the next request. A null cursor means no
more results. The cursor is invalidated if filters change between pages.
/verify and /settle accept two schemes: exact (price known and signed
upfront — what everything on this page assumes) and the experimental
upto (buyer signs a ceiling, facilitator settles the metered actual,
enforced on-ledger) — see upto — Metered Payments.
Want to see real settlements instead of trusting this page? explorer.vellar.xyz is a public transaction explorer for this facilitator.
For sellers
Point your resource server's facilitator client at the URL and your API gains x402 payments with no Stellar plumbing:
import { HTTPFacilitatorClient } from "@x402/core/http";
import { x402ResourceServer } from "@x402/core/server";
import { ExactStellarScheme } from "@x402/stellar/exact/server";
const server = new x402ResourceServer(
new HTTPFacilitatorClient({ url: "https://vellar-facilitator-testnet-production.up.railway.app" }),
).register("stellar:testnet", new ExactStellarScheme());
Adding a gate to an endpoint you already have? The VS Code extension injects this wiring into a route you pick, in one command — same boilerplate, without writing it by hand.
Declare the bazaar discovery extension on a route and your resource is cataloged automatically after its first settled payment — no registration step — making it findable by agents:
import { declareDiscoveryExtension, bazaarResourceServerExtension } from "@x402/extensions/bazaar";
server.registerExtension(bazaarResourceServerExtension);
// route config:
// extensions: declareDiscoveryExtension({
// input: { topic: "perseverance" },
// inputSchema: { properties: { topic: { type: "string" } } },
// output: { example: { quote: "..." } },
// })
Route templates
A routeTemplate declares the URL shape of a parameterized route so agents can
construct a call rather than replay a fixed URL:
extensions: declareDiscoveryExtension({
routeTemplate: "/inspect/{address}",
input: { address: "GABC..." },
inputSchema: {
properties: {
address: { type: "string", description: "Stellar address" }
}
},
output: { example: { balance: "10.0" } },
})
Validation is handled by extractDiscoveryInfo from @x402/extensions. Invalid
or unsafe templates are dropped silently — cataloging never affects settlement.
A dropped template surfaces as schema_validation_failed in the
extension-responses header.
Templated routes are kept in the catalog but are permanently
ownerVerified: false — they are not fetchable URLs, so Layer 2 verification
cannot confirm them.
Listing metadata is sanitized at ingest (matching the upstream
@x402/extensions rules): serviceName must be printable ASCII, max 64
chars — a non-ASCII name (non-Latin characters, emoji) is silently
dropped, not transliterated — descriptions are clamped to 256 chars, and
tags follow the same ASCII rule.
Your payTo account needs a trustline to the payment asset you declare,
or a payment verifies successfully and then fails at settlement with an
on-chain error that reads exactly like a spend control refusing it — worth
checking before debugging anything else.
Cataloging happens on settle, not on verify: a resource shows up in discovery only after a real payment for it succeeds. Verify-only traffic (a client checking a payload without submitting) catalogs nothing.
For buyers and agents
vellar-sdk's wallet.x402.fetch() works against any compliant
facilitator the seller chose — nothing to configure on the buyer side. The
difference this facilitator makes: if the paying account carries a
spending-limit policy, the payment settles here where other facilitators
reject it on the fee ceiling.
Building your own buyer instead of using the SDK? Echo required.extensions
into your payment payload — that echo is what tells the facilitator to
catalog the resource. Skip it and the payment settles fine, but nothing gets
listed, with no error on either side.
extension-responses header
On a successful /settle, the facilitator returns a lowercase
extension-responses header. Its value is a JSON object keyed by extension
name:
{"bazaar":{"cataloged":true}}
{"bazaar":{"cataloged":false,"reason":"unbound_payto"}}
| Field | Type | Meaning |
|---|---|---|
bazaar.cataloged | boolean | Whether the resource entered the catalog |
bazaar.reason | string (omitted when cataloged) | Why cataloging was skipped |
Reason values: no_discovery_extension, invalid_payto,
ownership_tombstone_mismatch, unbound_payto, schema_validation_failed,
binding_refused, invalid_tool_name, cataloging_error.
The header is absent on non-settle paths (400s, 402 challenges). Clients that
echoed required.extensions should read this header to confirm cataloging
happened — a settlement can succeed while cataloging fails.
Discovery (Bazaar)
Agents can find payable resources instead of being hardcoded with URLs. Each result carries everything needed to call and pay: URL, method, input schema, price, asset, and recipient.
import { HTTPFacilitatorClient } from "@x402/core/http";
import { withBazaar } from "@x402/extensions/bazaar";
const bazaar = withBazaar(
new HTTPFacilitatorClient({ url: "https://vellar-facilitator-testnet-production.up.railway.app" }),
).extensions.bazaar;
const { items } = await bazaar.listResources({ network: "stellar:testnet" });
const { resources } = await bazaar.search({ query: "weather data api" });
MCP discovery server
The facilitator ships vellar-facilitator-discovery, an MCP stdio server
exposing Bazaar as agent tools. AI agents can search for payable resources
without hardcoded URLs.
{
"mcpServers": {
"vellar-x402-discovery": {
"command": "npx",
"args": ["tsx", "src/mcp.ts"],
"cwd": "/path/to/vellar-facilitator",
"env": {
"FACILITATOR_URL": "https://vellar-facilitator-testnet-production.up.railway.app"
}
}
}
}
x402_list_resources — list cataloged resources. Parameters: type
(http | mcp), payTo, network, limit (1–100), verified_only,
offset.
x402_search_resources — keyword search. Parameters: query (required),
the same filters, and cursor for pagination.
Paying for what you find is a separate server that holds a key — see the MCP payer.
Running the full loop
Getting from "I have a wallet" to "I paid for a resource and it's discoverable," using this page alone:
# 0. From "Bring your own payment asset" above — you already have this repo
# cloned and examples/ installed.
# 1. Provision an asset + funded accounts (~40s–3min)
node provision-testnet.mjs
# 2. Start a seller advertising it, with the PAYTO/ASSET it just printed.
# Heads up: with a localhost URL and the SHARED facilitator, seller.mjs
# REFUSES to start (a localhost resource would enter the public Bazaar
# permanently, unverifiable). For local testing add
# ALLOW_UNVERIFIABLE_ON_SHARED=1, or run your own facilitator and set
# FACILITATOR_URL — the refusal message walks through both.
PAYTO=G... ASSET=C... PRICE_ATOMIC=1000000 node seller.mjs
# 3. Pay it — classic keypair, no extra dependencies. No second funded
# account needed: the official client simulates from the SDK's own null
# account, so the payer is never the transaction source.
RESOURCE_URL=http://127.0.0.1:4031/quote \
PAYER_SECRET=S... \
node buyer-classic.mjs
# (or buyer.mjs, for a Vellar smart-account payer with an on-chain budget —
# see Agent keys for generating its session key)
That settles a real payment and catalogs the resource — check
GET /discovery/resources afterward and it's there. Expect to retry step 3
sometimes (see Limits below); nothing is
spent on a failed attempt.
This writes to the hosted instance's shared catalog, permanently — read
this before you run step 3. A localhost seller URL can never pass
ownership verification, and there's no self-service (or supported operator)
removal, so it stays listed as an unreachable entry for every other agent
reading the catalog. Nothing breaks and your payment is unaffected — the
cost is borne by everyone else. To avoid leaving one, run your own
facilitator instead (one command and a local database — see guide.md
below) and only point at the hosted instance once your seller has a public
URL.
For the complete merchant/buyer split — ownership verification in full,
every rough edge on the hosted instance — read
docs/using-it.md
(pointing at a running facilitator) and
docs/guide.md
(running your own). This page summarizes; those are the full reference.
Trust signals
Each catalog entry's payment options carry a trust block so agents can weigh a resource before paying:
settlements— count of observed on-chain settlements for this resource.uniquePayers— how many distinct accounts have paid it.observedSettlements/statsSource— provenance: whether the stats were observed live by this process or restored from persistence.verification/acceptsVerification— always"unknown", on every deployment. These read from an external attestation service that is deployed nowhere — that's architectural, not an outage, and it will not change on its own. Don't filter on?verified_only=true— it filters on this field, and since the field can never be anything but"unknown"here, the facilitator refuses the filter outright rather than silently hand back an empty list:400 { "error": "verified_only_unavailable", "reason": "no_verdict_source_configured" }, withownerVerifiednamed in the response as the signal that does work.ownerVerified— a different, working field, computed by the facilitator itself with no external dependency.trueonly when the facilitator fetched your resource's own URL and found yourpayToin its 402 challenge — the signal that a listing isn't a squat. On the hosted instance it's lost on every restart (no persistent disk — see Limits), but it self-heals: your next settlement re-runs the check after a 15-minute cooldown, with no operator involved.
Getting ownerVerified: true needs five things to be true about your
resource URL, checked in this order — any one failing gives unverifiable:
| # | Requirement | Why |
|---|---|---|
| 1 | https and publicly resolvable | http is rejected before a socket opens; so are loopback, private ranges, and cloud-metadata addresses |
| 2 | An unauthenticated GET returns 402 | The verifier sends no payment — a 200, a 401, or anything else is unverifiable |
| 3 | Carries a PAYMENT-REQUIRED header ≤ 64 KiB | The verdict comes entirely from the header; your body is never downloaded |
| 4 | The challenge's accepts[].payTo includes your address | This is the actual check |
| 5 | Answers within 3 seconds, with no redirect | Redirects aren't followed — 301 /quote → /quote/ reads as unverifiable |
Two things that catch people: advertise your public URL, not
localhost — a loopback address can never verify — and the canonical key
strips a trailing slash, so a server that only answers …/quote/ and 404s
on …/quote fails verification against the URL it's actually checked at.
Deployments
Two independent facilitator deployments, each pinned to one network with its own sponsor account, channel accounts and asset configuration. They never share Stellar credentials.
| Network | Facilitator | Seller demo |
|---|---|---|
stellar:testnet | https://vellar-facilitator-testnet-production.up.railway.app | https://vellar-seller-demo-testnet-production.up.railway.app |
stellar:pubnet (mainnet) | https://vellar-facilitator-production.up.railway.app | https://vellar-seller-demo-production.up.railway.app |
Every tool in these docs defaults to the testnet facilitator, and every example uses the testnet seller demo. A testnet call moves Friendbot-funded test USDC and costs nothing real.
⚠️ The mainnet pair moves real funds. The mainnet seller demo charges 1 real USDC per call against the live USDC contract
CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75. Point anything at the mainnet facilitator deliberately, never by copying a testnet command.
Each seller demo is wired at deploy time to exactly one facilitator on exactly
one network — FACILITATOR_URL, ASSET, NETWORK and HORIZON_URL all have to
agree. There is no runtime toggle, so the testnet demo has no path to real money.
The kill switch
The facilitator ships a kill switch (/admin/kill-switch, gated by that
service's own ADMIN_SECRET). When engaged, /settle returns
503 service_paused before any balance or spend logic runs; the process stays up
and healthy and simply refuses new settlements. The admin console's toggle calls
this — it does not restart the service or change environment variables.
The two deployments have two separate switches. Pausing mainnet has no effect on testnet.
Note: A paused facilitator is not detectable from
/health, which still reportsstatus: okbecause the process is healthy by design. It is also not detectable by posting a malformed/settlebody: payload validation runs first, so a bad request returns400 invalid_payloadon a paused service just as it does on a running one.503 service_pausedappears only for a request that would otherwise have settled. Treat the admin console as the source of truth for switch state, and handle503 service_pausedas a retryable condition in client code rather than probing for it.
Limits and operational caveats
Things a developer building against the hosted instance should know up front:
- Settlement can still fail on testnet — retry, don't debug.
/settleoccasionally returns an emptytransactionfield with one of two reason codes:settle_exact_stellar_transaction_submission_failedorsettle_exact_stellar_transaction_failed. Both mean the same thing — the transaction was never submitted, so nothing was spent and a retry cannot double-pay. Sign a fresh payload and retry (signatures expire in ledgers, not wall-clock, so a cached one won't work anyway). Root cause: the underlying Soroban RPC occasionally answersTRY_AGAIN_LATERto a perfectly valid transaction, for reasons it doesn't state (not sponsor contention, not sequence numbers — seediagnosis-settle-failures.mdin the repo for the ruled-out list). Since 2026-08-15 the facilitator retries this itself before giving up (two attempts, 6s apart, plus a separate one-retry guard for a related ledger-skew failure on/verifyand/settle) — so you should see this less often than earlier sessions did, though not never: an automated probe that ships with the retry, running a controlled comparison (identical conditions, with and without the retry) every few hours, has recorded zero settlement failures in either arm across its full run history so far — meaning the RPC hasn't been misbehaving during that window in a way this measurement caught, not that the underlying issue is confirmed gone. Earlier, pre-retry sessions saw failure rates as high as 1-in-3. Keep "sign fresh, retry once" as the correct client-side handling regardless — it costs nothing when nothing fails. - Catalog entries are durable;
ownerVerifiedis not. The hosted instance hasCATALOG_DB_URLconfigured (libSQL/Turso), so catalog entries and URL ownership bindings survive a restart — checkstatsSourceon a resource'strustblock:"persisted"means those stats were restored rather than observed live by the current process.ownerVerifiedis the one field that still resets on every restart and self-heals from the next settled payment (subject to a 15-minute cooldown); it is computed fresh rather than stored, by design. Settlement itself is unaffected either way: a payment still settles on-chain even against an empty or freshly-reset catalog. - URL ownership is trust-on-first-use. The first settled payment binds a
resource URL to its
payTo(then verified against the URL's own 402 challenge). A differentpayTosettling the same URL is refused from the catalog. WithCATALOG_DB_URLconfigured, that binding now survives a restart on the hosted instance — the first-settler race no longer reopens the way it used to on an in-memory catalog. - Rate and size limits. 60 requests/min per IP;
/verifyand/settlebodies are capped at 32 KiB;/healthis exempt from the rate limit. - Settlement can be refused.
/settlereturns503 { error: "settlement_refused", reason }.sponsor_balance_low(sponsor under its hard balance floor) refuses on every network. Four spend-policy reasons —rate_limited_payto,rate_limited_url,spend_ceiling,unbound_pool_exhausted— refuse on pubnet; on testnet they are logged as would-reject and the settlement proceeds, so you cannot test your handling of a real one there. - Debug a "not working" paid route with
GET, notHEAD.curl -Ireturns a plain200on a paid route — HEAD doesn't carry the payment challenge, so a correctly wired route looks broken. UseGET. /health'sunverifiableEntriesis absent when zero, not0. A healthy catalog doesn't carry the key at all — check for its presence, not its value, or "no such field" reads as "the endpoint doesn't report this" when it actually means everything is fine./healthalso reportsreverifyPending— the count of ownership re-verification checks still in flight after a restart (seeownerVerifiedabove: it's rebuilt on the next settlement after any restart, not stored).0means the catalog's trust state is settled; anything higher means check back shortly rather than treat what you just read as final.- The service runs continuously. It is hosted on Railway with no idle
sleep, so there is no cold start to plan around and no warming request to
send.
/healthremains rate-limit-exempt if you want a cheap liveness check.
Proven end to end
The full loop is live-verified on testnet with on-chain settlement hashes: a
policy-governed Vellar smart account paid a Bazaar-declared resource through
the hosted facilitator, fees were sponsored by the facilitator's own
account, and the resource became searchable automatically. Details, hashes,
and runnable seller/buyer examples:
docs/decisions.md
and
examples/
in the repo — or skip the hashes and browse real settlements yourself at
explorer.vellar.xyz, including upto ones.
