@z402-net/server
The provider-side payment scheme for the x402 resource server, the start-up check that an API key and an address belong together, and its error type.
On this page
@z402-net/server is the provider side of the z402 payment scheme. A provider's resource server registers PrepaidVoucherServerScheme with the stock x402 x402ResourceServer, and calls checkProvider once before it listens.
npm install --save-exact @z402-net/server @z402-net/protocol @x402/core@2.28.0The package has one entry point, @z402-net/server. It depends on @z402-net/protocol, which is installed with it, and takes @x402/core as a peer dependency at exactly 2.28.0, so you install that yourself. The example below imports NETWORK_REGTEST from @z402-net/protocol as well, which is why the line names it.
| Export | Kind | What it is |
|---|---|---|
PrepaidVoucherServerScheme | class | The prepaid-voucher scheme, for x402ResourceServer. |
TERM_KEYS | constant | The five keys of a 402's extra that the facilitator owns |
checkProvider | function | Proves that an API key and an address belong together |
ProviderCheckError | class | What checkProvider throws, with field and retryable |
ProviderCheckField | type | "facilitatorToken", "payTo" or "facilitatorUrl" |
Use it
import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server";
import { NETWORK_REGTEST } from "@z402-net/protocol";
import { PrepaidVoucherServerScheme } from "@z402-net/server";
const payTo = "uregtest1...";
const server = new x402ResourceServer(new HTTPFacilitatorClient({ url: "https://facilitator.example" })).register(
NETWORK_REGTEST,
new PrepaidVoucherServerScheme({ payTo, network: NETWORK_REGTEST }),
);Sell an API builds a complete server on these two exports. @z402-net/service-kit wires them for you.
PrepaidVoucherServerScheme
class PrepaidVoucherServerScheme implements SchemeNetworkServer {
constructor(o: { payTo: string; network: string });
readonly scheme: "prepaid-voucher";
readonly defaultAssetTransferMethod: "default";
readonly paymentFlows: { default: { supported: ["authorization"]; default: "authorization" } };
readonly schemeHooks: SchemeServerHooks;
enrichPaymentRequiredResponse(ctx: SchemePaymentRequiredContext): Promise<void>;
parsePrice(price: Price, network: Network): Promise<AssetAmount>;
getAssetDecimals(asset: string, network: Network): number | undefined;
enhancePaymentRequirements(
paymentRequirements: PaymentRequirements,
supportedKind: SupportedKind,
facilitatorExtensions: string[],
): Promise<PaymentRequirements>;
validateFacilitatorSupport(
network: Network,
supportedKind: SupportedKind,
facilitatorExtensions: string[],
): string | void;
}Create one instance for each network and register it with register(network,. The x402 server calls the members itself. The constructor takes these options, and throws a plain Error for a bad one:
payTostringRequired- The shielded-only unified address of the provider this scheme serves. It must be valid for
network, or the constructor throwspayTo:.not a shielded- only unified address for <network> networkstringRequiredzcash:regtest,zcash:testnetorzcash:mainnet. Anything else throwsnetwork:.expected one of zcash: regtest, zcash: testnet, zcash: mainnet
Members
| Member | What it does |
|---|---|
scheme | The scheme id, prepaid-voucher |
defaultAssetTransferMethod | default |
paymentFlows | One flow, authorization: the facilitator verifies, your handler runs, then the facilitator settles |
parsePrice(price, | Accepts only { amount, asset: "ZEC" }, where amount is a whole number of zat as a string with no leading zero. It returns { amount, asset: "ZEC" }. Any other form, a number or a "$0.01" string included, throws prepaid- |
getAssetDecimals(asset, | 8 for ZEC, and undefined for any other asset |
enhancePaymentRequirements(requirements, | Copies the rail's terms into the requirement's extra, and nothing else. See the terms |
validateFacilitatorSupport(network, | Runs when the x402 server loads /supported. It returns a message when the facilitator's terms cannot be used. See the support check |
schemeHooks. | Keeps the session snapshot of a refused verify, for the corrective 402 |
enrichPaymentRequiredResponse(ctx) | Adds the session snapshot to the 402 that follows a refused voucher. See the corrective 402 |
A route sets its own price, and a price may be any whole number of zat from 1 to windowZat. The scheme checks only the form of a price. The facilitator checks the range.
accepts: {
scheme: "prepaid-voucher",
network: "zcash:regtest",
price: { amount: "250", asset: "ZEC" },
payTo,
maxTimeoutSeconds: 60,
}The terms
The facilitator owns the rail's trust terms and advertises them in the extra of its GET /supported entry. enhancePaymentRequirements copies those keys, when the facilitator advertises them, into the extra of every accepts entry of the 402. They are the same for every provider and every route.
| Key | Value | Meaning |
|---|---|---|
windowZat | decimal string | The size of one deposit, and the largest price of one call |
creditRatioBps | integer, 0 to 10,000 | Credit on confirmed deposits, in basis points. 2,500 is 25 percent |
creditCapZat | decimal string | The most credit one session can hold |
zeroConfMinExpiryBlocks | integer | An unconfirmed deposit counts only with at least this many blocks left before it expires |
termsVersion | 16 lowercase hex characters | Names the facilitator's terms. The client ignores it |
On the demo terms the window is 100,000 zat, the credit ratio 2,500, the credit cap 200,000 zat and the expiry margin 20 blocks. The facilitator's own payTo, if it advertises one, is never copied. TERM_KEYS lists exactly these five keys.
extra is static per route. The server matches the client's echo of it, so a value that changed per session would break the match.
The support check
validateFacilitatorSupport makes the x402 server refuse a facilitator whose terms the scheme cannot use. The server then throws a FacilitatorCapabilityError from initialize(), with each message as one of its problems. The check fails when:
- the terms do not parse.
parseTermsneedswindowZatof at least 1, acreditCapZat, acreditRatioBpsfrom 0 to 10,000 and azeroConfMinExpiryBlocks. The message isfacilitator terms are invalid (<reason>); termsVersionis not 16 lowercase hex characters. The message isfacilitator extra..termsVersion must be 16 lowercase hex characters
The corrective 402
When the facilitator refuses a voucher, the client needs to know where its session stands. The scheme adds the session snapshot to the 402 that the resource server sends back, in accepts[].extra.session.
onAfterVerify stores the snapshot of a refused verify in a WeakMap keyed by the request's payment payload, so it lives only as long as the request. enrichPaymentRequiredResponse then adds it to each accepts entry of this scheme and network. A request that was not refused gets the plain 402.
{
"windowZat": "100000",
"creditRatioBps": 2500,
"creditCapZat": "200000",
"zeroConfMinExpiryBlocks": 20,
"termsVersion": "<16 hex characters>",
"session": {
"sessionKey": "<64 hex characters>",
"cumulativeZat": "53000",
"limitZat": "125000",
"headroomZat": "72000",
"confirmedZat": "100000",
"pendingZat": "0",
"creditZat": "25000",
"tip": 4547331,
"deposits": []
}
}The snapshot has the fields of the wire contract's session snapshot, section 7.2 of the protocol. deposits lists each deposit that is not yet final, with its status.
TERM_KEYS
const TERM_KEYS: readonly ["windowZat", "creditRatioBps", "creditCapZat", "zeroConfMinExpiryBlocks", "termsVersion"];The facilitator-owned keys of an accepts entry's extra. A server that builds its own offers can use it to tell the rail's terms from its own keys.
checkProvider
function checkProvider(o: {
facilitatorUrl: string;
token?: string;
payTo: string;
network: string;
fetch?: typeof fetch;
timeoutMs?: number;
}): Promise<void>;Resolves when the provider that the API key belongs to is paid at payTo on network. Otherwise it throws a ProviderCheckError. Call it before anything that reads the facilitator's terms, so that a wrong key or address is named for what it is.
facilitatorUrlstringRequired- The facilitator's
http://orhttps://URL. Trailing slashes are ignored. It must not carry a user or a password. tokenstring- The API key, sent as
Authorization:. No header is sent when it is left out. It must be one token of letters, digits andBearer <key> -._~+/, which may end in=. payTostringRequired- The address your server tells clients to pay. It must be the one the key's provider has.
networkstringRequired- The network your server serves, for example
zcash:regtest. fetchtypeof fetch- The fetch to use. Defaults to the global
fetch. timeoutMsnumber- How long the request may take. Defaults to 5,000.
What it does
- It checks
facilitatorUrlandtokenbefore any request. A URL that is nothttp(s)or that carries credentials, or a token that breaks the token rule, throws at once. - It sends
GET <facilitatorUrl>/withproviders/ me accept: application/json, aborted aftertimeoutMs. - It reads at most 4,096 bytes of the answer. A longer body is not read.
- It requires a JSON object with the strings
providerId,networkandpayTo, and comparesnetworkandpayTowith its options.
A message never contains the token. It names the facilitator by its origin only, and a failed connection by the error's code or name, never by the error's message, which can quote a header.
Outcomes
Each message starts with <field>: . Callers branch on field and retryable and never on the message.
| Situation | field | retryable | Message after the prefix |
|---|---|---|---|
facilitatorUrl is not an http(s) URL | facilitatorUrl | no | expected an http: |
facilitatorUrl has a user or password | facilitatorUrl | no | must not carry credentials (use facilitatorToken) |
token is not one token | facilitatorToken | no | must be one token of letters, |
| The connection failed, or the body broke off | facilitatorUrl | yes | cannot reach the facilitator at <origin> (<code>) |
No answer within timeoutMs | facilitatorUrl | yes | the facilitator at <origin> did not answer / |
401 or 403, with a key | facilitatorToken | no | the facilitator at <origin> refused the API key (<status>) |
401 or 403, without a key | facilitatorToken | no | the facilitator at <origin> wants an API key (<status>) and none is set |
404 | facilitatorUrl | no | the facilitator at <origin> has no / |
5xx, 408 or 429 | facilitatorUrl | yes | the facilitator at <origin> answered / |
| Any other status | facilitatorUrl | no | The same message |
200, but a body that is over 4,096 bytes, is not JSON, or lacks the three strings | facilitatorUrl | no | the facilitator at <origin> answered / |
200, but another network | payTo | no | the facilitator at <origin> serves <network>, |
200, but another payTo | payTo | no | the facilitator at <origin> has this API key for another payTo than <payTo> |
A busy or slow facilitator, 408 and 429, answers later, so those count as retryable like 5xx.
import { checkProvider, ProviderCheckError } from "@z402-net/server";
try {
await checkProvider({ facilitatorUrl, token, payTo, network: "zcash:regtest" });
} catch (e) {
if (e instanceof ProviderCheckError) {
console.error(`${e.field} (retryable: ${e.retryable}): ${e.message}`);
}
throw e;
}A timeoutMs that no timer accepts throws the runtime's own error, as a bug of the caller, and not a ProviderCheckError.
ProviderCheckError
class ProviderCheckError extends Error {
readonly field: ProviderCheckField;
readonly retryable: boolean;
constructor(field: ProviderCheckField, retryable: boolean, reason: string);
}
type ProviderCheckField = "facilitatorToken" | "payTo" | "facilitatorUrl";name is ProviderCheckError and message is <field>: <reason>. field is the setting at fault, named as the options of checkProvider are. retryable is true when the facilitator may only be starting or briefly down, so asking again later can succeed. Retry only while it is true. Every other outcome is a mistake of yours, and asking again changes nothing.
@z402-net/service-kit maps field to the variable that sets it: facilitatorToken to Z402_FACILITATOR_TOKEN, payTo to Z402_PAY_TO and facilitatorUrl to Z402_FACILITATOR_URL. It retries while retryable is true.
The facilitator route
Names the provider that the API key belongs to. A resource server reads it at start-up to prove that its key and its address match.
GET /providers/me HTTP/1.1
Authorization: Bearer z402_...{
"providerId": "0f1e689e20871dd9",
"network": "zcash:regtest",
"payTo": "uregtest1..."
}An unknown key, and a key that was rotated out from the moment it was rotated, gets 401 with WWW-Authenticate: Bearer and {"error":"unauthorized"}. The Facilitator API lists every route.
References
packages/server/src/index.ts: the exports.packages/server/src/scheme.ts:PrepaidVoucherServerSchemeandTERM_KEYS.packages/server/src/provider.ts:checkProviderandProviderCheckError.apps/api/src/app.ts: a resource server that uses both.packages/service-kit/src/service.ts: the kit's start-up, which retries onretryable.packages/protocol/src/terms.ts:parseTermsand the demo terms.docs/spec/protocol.md: sections 2, 7 and 8, the offer, the session snapshot and the reason codes. Protocol v2.0 adds sections 16 and 17, providers with API keys and the Bazaar.docs/superpowers/specs/2026-10-28-z402-multi-provider-design.md: section 4.6, the server scheme andcheckProvider.- x402: the resource server and its scheme interface.
- RFC 6750: the bearer token format.