Vellar SDK
ExplorerGitHubOpen Vellar Wallet

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

EndpointPurpose
POST /verifyVerify a payment by re-simulation (runs the payer's __check_auth, including any policy)
POST /settleSubmit on-chain, fee-sponsored
GET /supportedAdvertised scheme, network, extensions, signer addresses
GET /discovery/resourcesList cataloged x402 resources — full reference
GET /discovery/searchHybrid search, lexical and semantic arms fused by RRF (see Search and Retrieval for the pipeline and quality figures); full reference
GET /healthLiveness; 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

ParameterTypeDefaultNotes
typestring—
payTostring—
schemestring—exact or upto
networkstring—
extensionsstring—
assetstring—max 56 chars
verified_only"true"—400 if no verdict source
limitnumber20max 100
offsetnumber0

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"}}
FieldTypeMeaning
bazaar.catalogedbooleanWhether the resource entered the catalog
bazaar.reasonstring (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" }, with ownerVerified named in the response as the signal that does work.
  • ownerVerified — a different, working field, computed by the facilitator itself with no external dependency. true only when the facilitator fetched your resource's own URL and found your payTo in 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:

#RequirementWhy
1https and publicly resolvablehttp is rejected before a socket opens; so are loopback, private ranges, and cloud-metadata addresses
2An unauthenticated GET returns 402The verifier sends no payment — a 200, a 401, or anything else is unverifiable
3Carries a PAYMENT-REQUIRED header ≤ 64 KiBThe verdict comes entirely from the header; your body is never downloaded
4The challenge's accepts[].payTo includes your addressThis is the actual check
5Answers within 3 seconds, with no redirectRedirects 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.

NetworkFacilitatorSeller demo
stellar:testnethttps://vellar-facilitator-testnet-production.up.railway.apphttps://vellar-seller-demo-testnet-production.up.railway.app
stellar:pubnet (mainnet)https://vellar-facilitator-production.up.railway.apphttps://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 reports status: ok because the process is healthy by design. It is also not detectable by posting a malformed /settle body: payload validation runs first, so a bad request returns 400 invalid_payload on a paused service just as it does on a running one. 503 service_paused appears only for a request that would otherwise have settled. Treat the admin console as the source of truth for switch state, and handle 503 service_paused as 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. /settle occasionally returns an empty transaction field with one of two reason codes: settle_exact_stellar_transaction_submission_failed or settle_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 answers TRY_AGAIN_LATER to a perfectly valid transaction, for reasons it doesn't state (not sponsor contention, not sequence numbers — see diagnosis-settle-failures.md in 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 /verify and /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; ownerVerified is not. The hosted instance has CATALOG_DB_URL configured (libSQL/Turso), so catalog entries and URL ownership bindings survive a restart — check statsSource on a resource's trust block: "persisted" means those stats were restored rather than observed live by the current process. ownerVerified is 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 different payTo settling the same URL is refused from the catalog. With CATALOG_DB_URL configured, 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; /verify and /settle bodies are capped at 32 KiB; /health is exempt from the rate limit.
  • Settlement can be refused. /settle returns 503 { 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, not HEAD. curl -I returns a plain 200 on a paid route — HEAD doesn't carry the payment challenge, so a correctly wired route looks broken. Use GET.
  • /health's unverifiableEntries is absent when zero, not 0. 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.
  • /health also reports reverifyPending — the count of ownership re-verification checks still in flight after a restart (see ownerVerified above: it's rebuilt on the next settlement after any restart, not stored). 0 means 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. /health remains 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.