NavenDocs
NavenDocs
Introduction
IPFS StorageQuickstartAPI reference
Back to Naven Network
IPFS Storage

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
PackagePurpose
@x402/coreRead the HTTP 402 challenge and encode the signed payment header
@x402/evmRegister the EVM exact payment scheme
viemCreate 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_KEY

Bun 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.json

The 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.png

On 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:

FieldUse
idQuery the upload order later with its saved key
cidIdentify the image's content on IPFS
ipfsUriReference the image using ipfs://…
urlShare or embed the public image URL
expiresAtTrack when the included storage period ends
transactionHashIdentify 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

  1. GET /config checks availability and the current upload limits. It does not require payment.
  2. POST /upload sends the file and its saved Idempotency-Key.
  3. 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.
  4. paidFetch uses the EVM signer to authorize that challenge, saves the encoded payment, and sends the same image and key with PAYMENT-SIGNATURE.
  5. A successful request returns HTTP 200, the completed order, and a PAYMENT-RESPONSE settlement 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 statusWhat happens next
fulfilledPrints the existing result without another upload or payment
paidRetries storage with the original image and key, without another signature
awaiting_paymentReuses a saved payment if present; otherwise handles the original quote
payment_pendingStops 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.

IPFS Storage

Upload an image, pay once, and get a public IPFS URI and a shareable image URL.

API reference

Public x402 image uploads, response fields, and runnable order recovery examples.

On this page

Before you start1. Install dependencies2. Configure your wallet3. Create the upload script4. Run the uploadHow the request worksResume an interrupted uploadBrowser integration