Build
seller.mjs, a small seller server
The code for step 3 of the developer hub. It is reference code, not a package: it keeps state in memory, serves one route and one process, and handles only GET.
Reference only. One route, one process, in-memory state. Do not run it as is in front of real traffic.
// seller.mjs: a minimal pay-per-call server. Node 20.18 or newer, no dependencies.
// Reference only: one route, one process, in-memory state. Do not run it as is in front of real traffic: this one takes real money.
import http from "node:http";
import { randomUUID } from "node:crypto";
const BASE = process.env.PAYZILLA_BASE_URL || "https://api-mainnet.payzilla.ai"; // the live service
const KEY = process.env.PAYZILLA_API_KEY; // shown once on the start page, sent exactly as shown (it starts with afk_live_)
const SERVICE_ID = process.env.PAYZILLA_SERVICE_ID; // the id of the service you created; its price is the price of the call
const PORT = Number(process.env.PORT || 8080);
if (!BASE || !KEY || !SERVICE_ID) { console.error("set PAYZILLA_BASE_URL, PAYZILLA_API_KEY and PAYZILLA_SERVICE_ID"); process.exit(1); }
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64");
const unb64 = (s) => JSON.parse(Buffer.from(s, "base64").toString("utf8"));
const issued = new Map(); // intent id (the memo) -> { requirements, state }, state: issued | in_flight | served
async function payzilla(path, body, extra = {}) {
const res = await fetch(BASE + path, {
method: "POST",
headers: { "x-api-key": KEY, "content-type": "application/json", ...extra },
body: JSON.stringify(body),
});
return { status: res.status, json: await res.json() };
}
http.createServer(async (req, res) => {
const send = (status, body, headers = {}) => {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers });
res.end(JSON.stringify(body));
};
let rec = null;
try {
if (req.method !== "GET") return send(405, { error: "method_not_allowed" });
const signature = req.headers["payment-signature"];
// 1. No payment yet: ask Payzilla for a payment challenge and answer 402.
if (!signature) {
const r = await payzilla("/v1/intents",
{ serviceId: SERVICE_ID, resourceUrl: `http://localhost:${PORT}${req.url}` },
{ "idempotency-key": randomUUID() });
if (r.status !== 200 && r.status !== 201) {
console.error("intent refused:", r.status, r.json?.error); // for example service_paused or service_not_found: for your own logs
return send(503, { error: "payments_unavailable" });
}
const challenge = r.json.paymentRequired;
issued.set(r.json.intentId, { requirements: challenge.accepts[0], state: "issued" });
return send(402, challenge, { "payment-required": b64(challenge) });
}
// 2. The buyer paid: check it is for a challenge we issued, then verify and settle.
const payload = unb64(String(signature));
const memo = payload?.accepted?.extra?.memo;
rec = issued.get(memo);
if (!rec) return send(409, { error: "unknown_intent" });
if (rec.state === "served") return send(409, { error: "already_served" });
if (rec.state === "in_flight") return send(409, { error: "already_in_progress" });
if (JSON.stringify(payload.accepted) !== JSON.stringify(rec.requirements)) return send(400, { error: "accepted_mismatch" });
rec.state = "in_flight"; // set BEFORE the first await, so a second request with the same signature cannot also get past the checks above
const body = { x402Version: 2, paymentPayload: payload, paymentRequirements: rec.requirements };
const v = await payzilla("/verify", body);
if (!v.json.isValid) {
rec.state = "issued";
console.error("verify refused:", v.json.invalidReason); // the reason is for your own logs, not for the buyer
return send(402, { error: "payment_invalid" });
}
const s = await payzilla("/settle", body);
// 3. Serve only when settlement succeeded. If it is still pending (or a repeat is in progress), do not serve and do not ask for a new payment.
if (s.json.success === true && s.json.transaction) {
rec.state = "served";
return send(200, { data: "your paid response" }, { "payment-response": b64(s.json) });
}
rec.state = "issued"; // not paid yet: back to the previous state
const pending = s.json.errorReason === "settlement_pending" || s.json.errorReason === "duplicate_settlement";
if (pending) return send(402, { error: "settlement_pending" }, { "retry-after": "2" });
return send(402, { error: "payment_failed" });
} catch {
if (rec && rec.state === "in_flight") rec.state = "issued";
return send(503, { error: "temporarily_unavailable" });
}
}).listen(PORT);
Run it with PAYZILLA_API_KEY=<YOUR_KEY> PAYZILLA_SERVICE_ID=<SERVICE_ID> node seller.mjs.