Live on Solana mainnet, small limits. Status page.Reviewed internally. Early. Real money. Use with caution.Report a problemState as of October 2026

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.