Skip to content

Provider quickstart

A SubEtha provider is a normal x402 resource server: the official @x402/express middleware does the 402/verify/settle choreography, and the @subetha/provider plugin supplies the subetha-zerc20 scheme. The provider process holds no chain keys and never touches the chain — everything settlement-shaped is delegated to an external facilitator over HTTP. Everything on this page targets the local, non-production stack; the code mirrors the pinned protocol spec and the in-repo demo provider, and the expectations at the end are what those sources define.

Terminal window
npm install @subetha/provider @x402/core @x402/express express
  • @subetha/provider (Apache-2.0, ESM, Node.js >= 20) brings @subetha/x402-scheme with it as a regular dependency.
  • @x402/core and @x402/express are the official x402 packages; the pinned product commit builds its demo provider against ^2.17.0.
  • There is no facilitator package to install. @subetha/facilitator is private/workspace-internal — the facilitator is an external HTTP service you point a URL at, run from product-repo source (facilitator quickstart).
Terminal window
# Local facilitator from the facilitator quickstart (loopback, port 4032).
export FACILITATOR_URL=http://127.0.0.1:4032

SubethaFacilitatorClient implements the official FacilitatorClient interface over HTTP (/verify, /settle, /supported) plus SubEtha’s POST /challenge extension — the call that mints a fresh one-time burn address per offer. That extension exists because only the facilitator (the seed holder) can derive a burn address; a resource server cannot mint its own PaymentRequirements the way vanilla x402 sellers do.

This is the provider wiring the pinned docs/PROTOCOL.md §5 defines, arranged as a runnable Express app:

import express from "express";
import { paymentMiddleware } from "@x402/express";
import { x402ResourceServer } from "@x402/core/server";
import {
SubethaFacilitatorClient,
SubethaSchemeServer,
subethaAccepts,
subethaHandler,
} from "@subetha/provider";
const FACILITATOR_URL = process.env.FACILITATOR_URL ?? "http://127.0.0.1:4032";
const facilitator = new SubethaFacilitatorClient(FACILITATOR_URL);
const scheme = new SubethaSchemeServer(facilitator);
const server = new x402ResourceServer([facilitator]);
server.register("eip155:31337", scheme);
await server.initialize(); // hard-fails unless /supported advertises the kind
const app = express();
app.use(express.json());
app.use(
paymentMiddleware(
{
"/api/*": {
accepts: subethaAccepts(scheme, { network: "eip155:31337", price: "1000" }),
},
},
server,
),
);
// The handler returns a value and never touches `res` — subethaHandler makes
// the single response commit after the middleware settles the payment.
app.post("/api/complete", subethaHandler(async (req) => ({ body: { answer: 42 } })));
app.listen(4031);

Initialization notes from the pinned sources:

  • await server.initialize() syncs the facilitator’s /supported and fails fast when the subetha-zerc20 kind is not advertised — start the facilitator first.
  • price is the list price in token base units; the quoted amount (including any facilitator fee) comes from the facilitator per request.
  • subethaAccepts(schemeServer, { network, price }) builds the route’s accepts entry with a dynamic payTo: a fresh one-time burn address is minted per unpaid 402, and the client’s accepted.payTo is echoed (not re-minted) on the paying request so the middleware’s offer match is deterministic. SUBETHA_PLACEHOLDER_PAYTO exists only for static quote-only route configs — real payment routes use subethaAccepts.
  • A handler that throws becomes a 500 and the payment is not settled; on a failed settle the buffered success body is discarded and the client gets a 402 — nothing is served unpaid.

With the facilitator up, an unpaid POST /api/complete draws a 402 whose offer carries only the one-time burn address; a paying client’s retry settles (accepted — response served with the PAYMENT-RESPONSE header) and the proof-gated mint finalizes later, asynchronously (accepted vs finalized).

Limits stated at the pinned commit:

  • Single-process assumption. Live offers are cached in memory (maxCachedOffers default 5000); multi-instance deployments would need a shared offer cache. After a restart, in-flight offers fail the match and clients simply re-pay against a fresh 402.
  • Verify is point-in-time (TOCTOU). The middleware runs verify → handler → settle; a payer can invalidate its payment between verify and settle, in which case settle fails after your handler already ran. Funds are never at risk (the failed settle yields a 402, nothing is served), but expensive handlers deserve ordinary rate limiting — the pinned release supports only low-cost, non-streaming, side-effect-free handlers.
  • The provider holds no keys. The payout destination lives facilitator-side (settlement.treasury or the FeeSplit deployment); the route config carries no payout address or secret.