Quickstart
Run a complete TypeScript example to pay for an image upload and recover interrupted requests.
This guide uploads a local image with a TypeScript script. It includes the imports, wallet setup, x402 client, file handling, and saved state needed to resume an upload. No Naven account, project, or API key is required.
Before you start
- Install Bun to run TypeScript directly.
- Use a wallet with at least 0.005 USDG on Robinhood Chain (
eip155:4663). - Choose a static JPEG, PNG, or WebP image, up to 5,000,000 bytes and 16 megapixels.
- Run this example locally or on your own server. It uses a private key to sign payments; do not include that key in browser code.
Running the script authorizes one upload payment after checking the offer's network, token, and exact amount. The recipient and signing details come from Naven's HTTPS payment challenge. Each successful upload includes 365 days of storage and image delivery.
1. Install dependencies
In a new directory for your upload script, run:
bun init -y
bun add @x402/core@2.18.0 @x402/evm@2.18.0 viem| Package | Purpose |
|---|---|
@x402/core | Read the HTTP 402 challenge and encode the signed payment header |
@x402/evm | Register the EVM exact payment scheme |
viem | Create a signing account from your wallet's private key |
fetch, File, and FormData are built into Bun. The node: imports below are
also available in Bun and do not require additional packages.
2. Configure your wallet
Create a .env file and replace the placeholder with your wallet's private key:
X402_PRIVATE_KEY=0xYOUR_64_HEX_CHARACTER_PRIVATE_KEYBun loads .env automatically. X402_PRIVATE_KEY belongs to your paying wallet;
it is not a Naven API key. Add the following entries to your .gitignore:
.env
*.naven-upload.jsonThe script saves a private recovery file next to your image. Keep that file and the original image: together they let you resume the same upload after a timeout or restart.
3. Create the upload script
Save the complete example below as upload.ts:
import { createHash, randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { basename, extname } from "node:path";
import { x402Client, x402HTTPClient } from "@x402/core/client";
import { registerExactEvmScheme } from "@x402/evm/exact/client";
import { isAddress } from "viem";
import { privateKeyToAccount } from "viem/accounts";
const api = "https://api.naven.network/v1/ipfs";
const network = "eip155:4663";
const usdg = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168";
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type UploadState = {
key: string;
sha256: string;
mimeType: string;
orderId?: string;
paymentHeaders?: Record<string, string>;
};
async function readData(response: Response) {
const body = await response.json();
if (!response.ok || body.code !== 0) {
throw new Error(`HTTP ${response.status}: ${body.message ?? "Request failed"}`);
}
return body.data;
}
async function main() {
const imagePath = process.argv[2];
const privateKey = process.env.X402_PRIVATE_KEY;
if (!imagePath) throw new Error("Usage: bun run upload.ts ./image.png");
if (!privateKey || !/^0x[0-9a-f]{64}$/i.test(privateKey)) {
throw new Error("Set X402_PRIVATE_KEY to a 0x-prefixed private key in .env");
}
// Create the signer and enable x402 v2 exact payments on Robinhood Chain.
const signer = privateKeyToAccount(privateKey as `0x${string}`);
const client = new x402Client();
registerExactEvmScheme(client, { signer, networks: [network] });
const httpClient = new x402HTTPClient(client);
const config = await readData(await fetch(`${api}/config`));
if (!config.available) throw new Error("IPFS uploads are temporarily unavailable");
// Read the actual file; the API also validates its contents and dimensions.
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
const mimeType = mimeTypes[extname(imagePath).toLowerCase()];
if (!mimeType) throw new Error("Choose a .jpg, .jpeg, .png, or .webp image");
const bytes = new Uint8Array(await readFile(imagePath));
if (bytes.length === 0 || bytes.length > config.upload.maxFileBytes) {
throw new Error(`Image must be 1–${config.upload.maxFileBytes} bytes`);
}
const file = new File([bytes], basename(imagePath), { type: mimeType });
const sha256 = createHash("sha256").update(bytes).digest("hex");
// Generate the key only for a new upload. Never replace it during a retry.
const statePath = `${imagePath}.naven-upload.json`;
let state: UploadState;
try {
state = JSON.parse(await readFile(statePath, "utf8"));
} catch (error) {
if ((error as { code?: string }).code !== "ENOENT") throw error;
state = { key: randomUUID(), sha256, mimeType };
await writeFile(statePath, JSON.stringify(state), { mode: 0o600, flag: "wx" });
}
if (state.sha256 !== sha256 || state.mimeType !== mimeType) {
throw new Error("This saved order belongs to a different image. Restore the original file.");
}
const saveState = () =>
writeFile(statePath, JSON.stringify(state), { mode: 0o600 });
// Rebuild multipart data for every request so the file can be sent again.
function sendUpload(paymentHeaders: Record<string, string> = {}) {
const form = new FormData();
form.set("file", file);
return fetch(`${api}/upload`, {
method: "POST",
headers: { "Idempotency-Key": state.key, ...paymentHeaders },
body: form,
redirect: "error",
signal: AbortSignal.timeout(120_000),
});
}
// This is the configured paidFetch: check recovery state, then handle one 402.
async function paidFetch(): Promise<Response> {
if (state.orderId) {
const response = await fetch(`${api}/orders/${state.orderId}`, {
headers: { "Idempotency-Key": state.key },
});
const order = await readData(response.clone());
if (order.status === "fulfilled") return response;
if (order.status === "paid") return sendUpload();
if (order.status !== "awaiting_payment") {
throw new Error(`Order ${order.id} is ${order.status}. Check recovery instructions before continuing.`);
}
}
// If a prior request was interrupted, reuse its exact signed authorization.
const response = await sendUpload(state.paymentHeaders);
if (response.status !== 402 || state.paymentHeaders) return response;
const offer = httpClient.getPaymentRequiredResponse(
(name) => response.headers.get(name),
await response.clone().json(),
);
const terms = offer.accepts[0];
const resource = new URL(offer.resource.url);
const orderId = resource.searchParams.get("orderId");
if (
offer.x402Version !== 2 || offer.accepts.length !== 1 ||
terms?.scheme !== "exact" || terms.network !== network ||
terms.asset.toLowerCase() !== usdg.toLowerCase() ||
terms.amount !== "5000" || !isAddress(terms.payTo) ||
resource.origin !== new URL(api).origin ||
resource.pathname !== "/v1/ipfs/upload" ||
resource.username || resource.password || resource.hash ||
!orderId || !uuid.test(orderId) ||
(state.orderId && state.orderId !== orderId)
) {
throw new Error("Unexpected upload payment terms; no new payment was signed");
}
// Save the order ID before signing, then the payment before sending it.
state.orderId = orderId;
await saveState();
const payload = await httpClient.createPaymentPayload(offer);
state.paymentHeaders = httpClient.encodePaymentSignatureHeader(payload);
await saveState();
return sendUpload(state.paymentHeaders);
}
const result = await readData(await paidFetch());
if (result.status !== "fulfilled") {
throw new Error(`Upload is ${result.status}; keep the saved state for recovery`);
}
console.log(JSON.stringify(result, null, 2));
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : "Upload failed");
process.exitCode = 1;
});The x402 imports and signing flow use the public
@x402/core client API
and EVM client.
The example pins the SDK versions so the imports and behavior are reproducible.
4. Run the upload
Put an image named image.png in the same directory, then run:
bun run upload.ts ./image.pngOn the first run, the script creates image.png.naven-upload.json before the first
upload request. It then validates the server's offer, signs a payment for 5,000
atomic units of USDG (0.005 USDG), and repeats the multipart request with
PAYMENT-SIGNATURE. No separate token transfer or manually encoded payment header
is needed.
On success, the script prints the order data. Save these fields:
| Field | Use |
|---|---|
id | Query the upload order later with its saved key |
cid | Identify the image's content on IPFS |
ipfsUri | Reference the image using ipfs://… |
url | Share or embed the public image URL |
expiresAt | Track when the included storage period ends |
transactionHash | Identify the confirmed upload payment |
The HTTP response wraps these values in { "code": 0, "message": "ok", "data": ... }.
The readData helper checks the HTTP status and unwraps data.
How the request works
GET /configchecks availability and the current upload limits. It does not require payment.POST /uploadsends the file and its savedIdempotency-Key.- After validating the image, the API returns HTTP 402 with
PAYMENT-REQUIRED. The challenge contains the exact payment terms and a resource URL with the order ID. paidFetchuses the EVM signer to authorize that challenge, saves the encoded payment, and sends the same image and key withPAYMENT-SIGNATURE.- A successful request returns HTTP 200, the completed order, and a
PAYMENT-RESPONSEsettlement header.
Do not set Content-Type yourself for an upload. FormData supplies the multipart
boundary. Keep the challenge's resource URL intact when creating the payment payload;
the server binds payment to that exact order URL.
Resume an interrupted upload
Run the same command with the same image and recovery file. Do not run multiple processes against the same recovery file at once. The script checks a saved order before continuing:
| Order status | What happens next |
|---|---|
fulfilled | Prints the existing result without another upload or payment |
paid | Retries storage with the original image and key, without another signature |
awaiting_payment | Reuses a saved payment if present; otherwise handles the original quote |
payment_pending | Stops so you can query or reconcile the existing payment |
A 502 can mean storage failed after payment. Keep the saved state and rerun the command; a paid order remains retryable. A 409 requires inspecting the order: payment may be pending, or another upload may still be running.
For payment_pending, follow the order recovery examples.
Reconciliation confirms a previous payment; it does not upload the image. After the
order becomes paid, rerun the upload command to finish storage.
If a saved payment returns 402, the script stops instead of generating a new authorization. Check the order and the payment failure first. An unpaid quote lasts one hour; an expired quote returns 410. Only start a new order after confirming that the old one was never paid and has no uncertain settlement.
The recovery file contains a secret capability and may contain a signed payment. Do not publish it, log it, or delete it to work around a failed upload. For an application, store the equivalent state durably per upload and serialize retries for that order.
Browser integration
For a browser application, get file from a file input or drag-and-drop event and
use your connected wallet's EVM signer instead of privateKeyToAccount. Persist
the image, key, order ID, and signed payment in durable browser storage such as IndexedDB
before sending the corresponding requests. The HTTP flow and recovery rules are the same.
To upload without writing code, use the IPFS Storage uploader.