ar.io Logoar.io Documentation

Upload and Verify

This is the smallest complete thing you can do on ar.io: put one file on the network, get it back from a gateway, and prove the bytes are the same. It is written so a script or an agent can follow it without a person: every step says what it costs, what it prints, and what to do when it does not.

Before you start

  • Node.js 18+ and npm install @ardrive/turbo-sdk bs58.
  • A wallet. The example uses a Solana keypair in the Solana CLI format, a JSON array of 64 numbers. Ethereum keys and Arweave wallets work too; see Signer setup.
  • Nothing else for a small file. Items within the free tier upload with no credits and no payment call. Larger items need credits.

Decide where you are running. Mainnet is permanent and costs real value above the free tier; the sandbox is free and purges data after about three days.

MainnetTestnet sandbox
Upload servicehttps://upload.ardrive.io (the SDK default)https://upload.services.ar-io.dev
Gateway to read fromhttps://turbo-gateway.com, or any ar.io gatewayhttps://ar-io.dev
Free per item105 KiB5 MiB
Free per wallet, lifetime10 MiB100 MiB
WalletSolana mainnetSolana devnet

GET /info on either upload service reports the current limits as freeTier.

What it costs

Ask before you upload. getUploadCosts returns the price in Turbo Credits (winc) for a size, and getFreeStatus returns how many free bytes the wallet has left.

const [{ winc: price }] = await turbo.getUploadCosts({ bytes: [size] });
const { bytesRemaining } = await turbo.getFreeStatus();
  • price is the list price for that size. It does not apply the free tier, so a small file still shows a price here.
  • bytesRemaining at or above size, and size within the per-item limit: the upload is free, and the receipt says winc: "0".
  • Otherwise the upload costs price, deducted from the wallet's credits. You pay once; nothing renews.
  • bytesRemaining is null for a wallet with an unlimited allowance and 0 where the free tier is off.

Step 1: Upload

const receipt = await turbo.uploadFile({
  fileStreamFactory: () => fs.createReadStream(path),
  fileSizeFactory: () => size,
  dataItemOpts: { tags: [{ name: "Content-Type", value: "text/markdown" }] },
});

The response is the receipt. Expect this shape:

{
  "id": "tCdA4ePIjMvKSapeQfe3zBUhODPKjXYERqjvEoOM3f8",
  "owner": "HbmimaUBGYtimZiYbeXLXQS8pKD9wBTMfMTaYmfsyBMj",
  "winc": "0",
  "timestamp": 1791483824036,
  "dataCaches": ["ar-io.dev"],
  "fastFinalityIndexes": ["ar-io.dev"],
  "version": "0.2.0",
  "deadlineHeight": 1234567,
  "public": "<the service's public key>",
  "signature": "<the service's signature>"
}

id is the data item's permanent identifier. winc is what you were charged. Save the whole object: Turbo does not keep receipts for you, and Receipts explains what each field proves and how to verify the signature later.

Set Content-Type yourself. Without it, a gateway serves the bytes as application/octet-stream.

Step 2: Fetch the bytes back

GET https://turbo-gateway.com/raw/<id>

Use the /raw/ path. A plain /<id> resolves manifests, so for a manifest it serves the index file rather than the manifest's own bytes. /raw/ always returns the data item's bytes as stored.

Expect 200 with these headers:

HeaderMeaning
x-ar-io-data-idThe id the gateway served, which must equal the one you asked for
content-digestsha-256=:<base64>:, the hash of the bytes the gateway served
x-ar-io-digestThe same hash, base64url
x-ar-io-verifiedtrue once the gateway has verified the item against the chain; false is normal for a fresh upload
x-ar-io-stabletrue once the item is in a block old enough to be considered final

A gateway that has not seen the item yet returns 404. Turbo's own gateway normally serves a new upload at once; other gateways serve it once they have indexed it. Retry a 404 with a pause between attempts, up to a limit you choose, and treat anything other than 200 or 404 as a stop.

Step 3: Compare

Hash the local file and the fetched bytes with SHA-256 and compare. That comparison is the proof: the bytes this gateway serves under the id are your file. x-ar-io-digest is the gateway's own hash of the item it holds. Comparing it as well catches a gateway that sent something other than what it indexed, but it arrives in the same response as the bytes, so it is a consistency check, not independent evidence.

const want = crypto.createHash("sha256").update(fs.readFileSync(path)).digest();
const got = crypto.createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest();
const match = got.equals(want) && response.headers.get("x-ar-io-digest") === want.toString("base64url");

Both must be true. A gateway then serves your file, byte for byte, under this id. Whether the gateway has also verified the item against the chain is what x-ar-io-verified reports, and it lags the upload.

When to stop

You seeIt meansDo
402 with the header X-Free-Tier-Exhausted: true (the body is an x402 payment offer, or code: "FREE_TIER_EXHAUSTED")The free bytes for this wallet or this IP range are used up, or the item is over the free sizeStop. Top up credits, or pay just in time, then retry once
402 naming a network and USDCYou posted to an x402 endpoint, which only takes USDCWrong path. Use uploadFile or /v1/tx as above
413The item is over the service's maximum item size (10 MiB on the sandbox)Stop. Split the data into smaller items and tie them together with a manifest
404 from the gateway after your retry limitThe gateway has not indexed the itemStop and report the id. Try turbo-gateway.com, which serves Turbo uploads first
Hashes differThe bytes served are not the fileStop. Do not retry blindly; report both hashes and the id
x-ar-io-verified: falseThe gateway has not verified the item yetNot a failure. Verification follows indexing

Full script

Runs against mainnet by default and against the sandbox with SANDBOX=1. It stops, with a reason, at every condition in the table above.

// Upload one file through Turbo, fetch it back from an ar.io gateway, prove the bytes match.
// Usage: KEYFILE=./solana-keypair.json node upload-verify.mjs ./report.md
// Set SANDBOX=1 to run against the testnet sandbox instead of mainnet.
import fs from "node:fs";
import crypto from "node:crypto";
import { TurboFactory, HexSolanaSigner } from "@ardrive/turbo-sdk";
import bs58 from "bs58";

const sandbox = process.env.SANDBOX === "1";
const gateway = sandbox ? "https://ar-io.dev" : "https://turbo-gateway.com";
const path = process.argv[2];
if (!path) throw new Error("give the file to upload as the first argument");

// 1. Signer. A Solana CLI keypair file is a JSON array of 64 bytes.
const secretKey = Uint8Array.from(JSON.parse(fs.readFileSync(process.env.KEYFILE, "utf8")));
const signer = new HexSolanaSigner(bs58.encode(secretKey));
const turbo = TurboFactory.authenticated({
  signer,
  token: "solana",
  ...(sandbox && {
    gatewayUrl: "https://api.devnet.solana.com",
    uploadServiceConfig: { url: "https://upload.services.ar-io.dev" },
    paymentServiceConfig: { url: "https://payment.services.ar-io.dev" },
  }),
});

// 2. Cost before upload. winc "0" means the item fits the free tier.
const size = fs.statSync(path).size;
const [{ winc: price }] = await turbo.getUploadCosts({ bytes: [size] });
const { bytesRemaining } = await turbo.getFreeStatus();
const free = bytesRemaining === null || bytesRemaining >= size;
console.log(JSON.stringify({ size, price, bytesRemaining, free }));
if (!free && BigInt(price) > BigInt((await turbo.getBalance()).winc)) {
  throw new Error("stop: not free and the wallet's credits cannot cover it; top up first");
}

// 3. Upload. The response is the receipt; keep it.
const receipt = await turbo.uploadFile({
  fileStreamFactory: () => fs.createReadStream(path),
  fileSizeFactory: () => size,
  dataItemOpts: { tags: [{ name: "Content-Type", value: "text/markdown" }] },
});
fs.writeFileSync(`${path}.receipt.json`, JSON.stringify(receipt, null, 2));
console.log(JSON.stringify({ id: receipt.id, winc: receipt.winc, timestamp: receipt.timestamp }));

// 4. Fetch the raw bytes back and compare hashes. A gateway that has not seen the item yet returns 404.
const want = crypto.createHash("sha256").update(fs.readFileSync(path)).digest();
let response;
for (let attempt = 1; attempt <= 12; attempt++) {
  response = await fetch(`${gateway}/raw/${receipt.id}`);
  if (response.ok) break;
  if (response.status !== 404) throw new Error(`stop: gateway returned ${response.status}`);
  await new Promise((r) => setTimeout(r, 10_000));
}
if (!response.ok) throw new Error("stop: gateway still returns 404 after two minutes; report the id");
const got = crypto.createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest();
const servedDigest = response.headers.get("x-ar-io-digest"); // base64url of the sha-256 the gateway served
const match = got.equals(want) && servedDigest === want.toString("base64url");
console.log(JSON.stringify({
  fetched: `${gateway}/raw/${receipt.id}`,
  sha256Match: match,
  verified: response.headers.get("x-ar-io-verified"),
  stable: response.headers.get("x-ar-io-stable"),
}));
if (!match) throw new Error("stop: the bytes the gateway served differ from the file; do not retry blindly");

Output for a 27 KB file on the sandbox, as run on 2026-10-08, three lines of JSON. price is the list price; winc on the receipt is what was charged:

{"size":27018,"price":"353098377","bytesRemaining":104857600,"free":true}
{"id":"tCdA4ePIjMvKSapeQfe3zBUhODPKjXYERqjvEoOM3f8","winc":"0","timestamp":1791483824036}
{"fetched":"https://ar-io.dev/raw/tCdA4ePIjMvKSapeQfe3zBUhODPKjXYERqjvEoOM3f8","sha256Match":true,"verified":"false","stable":"false"}

Next steps

  • Receipts: what the receipt proves and how to verify its signature later.
  • Paying for Uploads: credits, just-in-time payment and giving a second wallet a budget.
  • Manifests: upload a folder so that paths resolve, the step before publishing a site.

How is this guide?