Skip to main content

🔗 x402

Introduction

x402 is a payment protocol that enables pay-per-use APIs, allowing developers to charge users for each API request using on-chain payments. This tutorial demonstrates how to upload files to Walrus via Lighthouse using the x402 payment system, where you pay only for what you use instead of maintaining a subscription or API key balance.

Traditional file upload APIs typically require users to maintain API key balances or subscriptions, which can be cumbersome for occasional users or applications with variable usage patterns. The x402 protocol eliminates these barriers by enabling micro-payments for each upload, making it ideal for pay-as-you-go scenarios.

Why Use x402 for File Uploads?

The x402 payment protocol offers several advantages over traditional API key-based systems:

  • Pay-as-you-go: Only pay for actual file uploads, no subscription fees
  • No balance management: No need to pre-fund API key balances
  • Transparent pricing: Dynamic pricing based on file size
  • Blockchain-native: Uses USDC on Base mainnet
  • Automatic payment handling: The x402-fetch library handles the entire payment flow

How it works

You                          x402 Client                     Server
│ │ │
│ npm run upload -- file.png │ │
│ ─────────────────────────────►│ │
│ │ POST /api/upload + file │
│ │ ────────────────────────────►│
│ │ ◄──────── 402 (price + payTo)│
│ │ │
│ │ pay USDC on Base (auto) │
│ │ │
│ │ POST /api/upload + payment │
│ │ ────────────────────────────►│
│ │ ◄──────── 200 { cid, ipfsUrl}│
│ │ │
│ ◄── CID: QmXoypiz… │ │

The x402 client library handles the entire payment flow automatically. You just run the upload command — it sends the file, receives the price, pays USDC on-chain, and retries with payment proof.

Upload File Function

You can import the same pattern in your own Node.js / TypeScript project:

import "dotenv/config";
import { statSync, openAsBlob } from "fs";
import { basename } from "path";
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
import { registerExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";
import { log, logDebug, logError, createDebugFetch, DEBUG } from "./log.js";

// ── Config ───────────────────────────────────────────────────────────────────

const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`;
const SERVER_URL = process.env.SERVER_URL || "https://x402-walrus.lighthouse.storage";

if (!PRIVATE_KEY) {
console.error("Error: PRIVATE_KEY is not set in .env");
process.exit(1);
}

// ── x402 Client Setup ────────────────────────────────────────────────────────

const signer = privateKeyToAccount(PRIVATE_KEY);
const client = new x402Client();
registerExactEvmScheme(client, { signer });

const baseFetch = DEBUG ? createDebugFetch() : fetch;
const fetchWithPayment = wrapFetchWithPayment(baseFetch, client);

// ── Upload ───────────────────────────────────────────────────────────────────

async function upload(filePath: string): Promise<void> {
const fileName = basename(filePath);
const stats = statSync(filePath);
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);

log("Starting upload", {
fileName,
fileSizeBytes: stats.size,
fileSizeMB: sizeMB,
serverUrl: SERVER_URL,
wallet: signer.address,
debug: DEBUG,
});

const fileBlob = await openAsBlob(filePath);

log("Uploading (x402 payment will be handled automatically)…");

const start = Date.now();
const response = await fetchWithPayment(`${SERVER_URL}/api/upload`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(stats.size),
"x-file-name": fileName,
},
body: fileBlob,
});
const duration = Date.now() - start;

const responseText = await response.text();

log(`Response received`, {
status: response.status,
durationMs: duration,
});

logDebug("Response body", { body: responseText });

if (!response.ok) {
logError(`Upload failed with status ${response.status}`, {
body: responseText,
});

if (response.status === 402) {
const paymentHeader = response.headers.get("PAYMENT-REQUIRED");
if (paymentHeader) {
logError(
"Payment was required but x402 flow did not complete. PAYMENT-REQUIRED header present.",
);
try {
const decoded = JSON.parse(
Buffer.from(paymentHeader, "base64").toString(),
);
logError("Payment requirements:", decoded);
} catch {
logError("Could not decode PAYMENT-REQUIRED header");
}
} else {
logError(
"Got 402 but no PAYMENT-REQUIRED header — server may not be configured correctly",
);
}
}
process.exit(1);
}

let result: Record<string, unknown>;
try {
result = JSON.parse(responseText);
} catch {
logError("Failed to parse response as JSON", { body: responseText });
process.exit(1);
}

if (!result.success) {
logError("Server returned an error", {
error: result.error,
message: result.message,
});
process.exit(1);
}

// Decode the PAYMENT-RESPONSE header to get the settlement tx hash.
// The x402 facilitator settles the payment on-chain AFTER the upload
// handler completes, and returns the tx hash in this response header.
let txHash = "";
let payer = "";
const paymentResponseHeader =
response.headers.get("PAYMENT-RESPONSE") ||
response.headers.get("X-PAYMENT-RESPONSE");
if (paymentResponseHeader) {
try {
const settlement = JSON.parse(
Buffer.from(paymentResponseHeader, "base64").toString(),
);
txHash = settlement.transaction || "";
payer = settlement.payer || "";
logDebug(
"Settlement response (decoded PAYMENT-RESPONSE header):",
settlement,
);
} catch {
logDebug("Could not decode PAYMENT-RESPONSE header");
}
}

const networkId = "8453"; // Base Mainnet — change to 84532 for Sepolia
const explorerBase =
networkId === "8453"
? "https://basescan.org"
: "https://sepolia.basescan.org";

log("Upload successful!", {
id: result.id,
cid: result.cid,
fileName: result.fileName,
mimeType: result.mimeType,
fileSizeBytes: result.fileSizeBytes,
storagePeriodDays: result.storagePeriodDays,
expiresAt: result.expiresAt,
publicKey: result.publicKey,
ipfsUrl: result.ipfsUrl,
...(payer && { payer }),
...(txHash && { txHash }),
...(txHash && { txUrl: `${explorerBase}/tx/${txHash}` }),
});

log("Save your file ID for renewals:", { id: result.id });
}

// ── CLI ──────────────────────────────────────────────────────────────────────

const filePath = process.argv[2];

if (!filePath) {
console.log("Usage: npm run upload -- <file-path>\n");
console.log("Example:");
console.log(" npm run upload -- ./photo.png");
console.log(" npm run upload -- /path/to/document.pdf\n");
console.log("Enable debug logging:");
console.log(" DEBUG=true npm run upload -- ./photo.png\n");
process.exit(1);
}

upload(filePath).catch((err) => {
logError("Unhandled error", {
message: err.message || String(err),
stack: err.stack,
});
process.exit(1);
});

Refer: lighthouse_x402_client (Walrus)

Renew File Function

Files stored via x402 have a one-year storage period. You must renew the file before its expiry date to keep it stored on Walrus. The renew endpoint accepts x402 payment just like uploads — the client library handles the payment flow automatically.

You can add the following renew functions alongside the upload code:

// ── Renew ────────────────────────────────────────────────────────────────────

async function renew(fileId: string): Promise<void> {
log("Starting renewal", {
fileId,
serverUrl: SERVER_URL,
wallet: signer.address,
debug: DEBUG,
});

log("Renewing (x402 payment will be handled automatically)…");

const start = Date.now();
const response = await fetchWithPayment(`${SERVER_URL}/api/renew`, {
method: "POST",
headers: {
"x-file-id": fileId,
},
});
const duration = Date.now() - start;

const responseText = await response.text();

log(`Response received`, {
status: response.status,
durationMs: duration,
});

logDebug("Response body", { body: responseText });

if (!response.ok) {
logError(`Renewal failed with status ${response.status}`, {
body: responseText,
});
process.exit(1);
}

let result: Record<string, unknown>;
try {
result = JSON.parse(responseText);
} catch {
logError("Failed to parse response as JSON", { body: responseText });
process.exit(1);
}

if (!result.success) {
logError("Server returned an error", {
error: result.error,
message: result.message,
});
process.exit(1);
}

let txHash = "";
let payer = "";
const paymentResponseHeader =
response.headers.get("PAYMENT-RESPONSE") ||
response.headers.get("X-PAYMENT-RESPONSE");
if (paymentResponseHeader) {
try {
const settlement = JSON.parse(
Buffer.from(paymentResponseHeader, "base64").toString(),
);
txHash = settlement.transaction || "";
payer = settlement.payer || "";
logDebug(
"Settlement response (decoded PAYMENT-RESPONSE header):",
settlement,
);
} catch {
logDebug("Could not decode PAYMENT-RESPONSE header");
}
}

const networkId = "8453";
const explorerBase =
networkId === "8453"
? "https://basescan.org"
: "https://sepolia.basescan.org";

log("Renewal successful!", {
id: result.id,
cid: result.cid,
fileName: result.fileName,
fileSizeBytes: result.fileSizeBytes,
previousExpiresAt: result.previousExpiresAt,
expiresAt: result.expiresAt,
storagePeriodDays: result.storagePeriodDays,
publicKey: result.publicKey,
...(payer && { payer }),
...(txHash && { txHash }),
...(txHash && { txUrl: `${explorerBase}/tx/${txHash}` }),
});
}

// ── Check Renewal Price ──────────────────────────────────────────────────────

async function checkRenewPrice(fileId: string): Promise<void> {
const response = await fetch(`${SERVER_URL}/api/renew/price?id=${fileId}`);

if (!response.ok) {
const errorBody = await response.text();
logError(`Price check failed (${response.status})`, { body: errorBody });
process.exit(1);
}

const data = await response.json();
console.log(data);
console.log(`\nRenewal price quote from ${SERVER_URL}\n`);
console.log(` File ID: ${fileId}`);
console.log(
` File size: ${data.fileSizeBytes} bytes (${data.fileSizeMB} MiB)`,
);
console.log(
` Encoded size: ${data.encodedSizeMiB} MiB (${data.encodedSizeBytes} bytes)`,
);
console.log(` Billable: ${data.billableMiB} MiB`);
console.log(
` Rate: ${data.pricePerMB} / MiB / ${data.storagePeriodDays} days`,
);
console.log(` Facilitator fee: ${data.facilitatorFee}`);
console.log(` Total price: ${data.totalPrice} USDC`);
console.log(
` Current expires: ${data.currentExpiresAt ? new Date(data.currentExpiresAt).toISOString() : "N/A"}`,
);
console.log(` Network: ${data.network}`);
console.log(` Pay to: ${data.payTo}\n`);
}

// ── CLI ──────────────────────────────────────────────────────────────────────

const fileId = process.argv[2];
const priceOnly = process.argv.includes("--price");

if (!fileId) {
console.log("Usage: npm run renew -- <file-id>\n");
console.log("Options:");
console.log(" --price Only check the renewal price (no payment)\n");
console.log("Examples:");
console.log(" npm run renew -- 8f0c1e2a-1234-5678-abcd-1234567890ab");
console.log(
" npm run renew -- 8f0c1e2a-1234-5678-abcd-1234567890ab --price\n",
);
process.exit(1);
}

const action = priceOnly ? checkRenewPrice(fileId) : renew(fileId);

action.catch((err) => {
logError("Unhandled error", {
message: err.message || String(err),
stack: err.stack,
});
process.exit(1);
});
Renew Before Expiry

Files stored via x402 have a one-year storage period. You must renew your file before the expiry date to avoid losing access to your data. You can check the current expiry date using the --price flag.

Pricing

The x402 API uses dynamic pricing based on file size. The price quoted is for one year of storage:

  • Price: 0.0005$ per mb per year
  • Payment: USDC on Base mainnet