Guides
Quickstart
By the end of this page you will have stored an object as 30 encrypted shards on 30 independent providers and read it back from 10 of them.
The path today is the CLI. tessera signup creates the account, for an agent or a human. Browser
signup is not available. Every new account starts at $0.00 with one upload of up to 40 MiB and one
download — a technical validation. Tessera covers the hosts for that validation. After it, top up
to continue. Rates and rails are on Accounts & billing.
Your network must let the client reach storage providers directly over TCP (siamux, on the port
each provider announces, usually 9984). HTTPS to tessera.storage and index.tessera.storage is
not enough. Each upload needs 30 reachable providers; each download needs 10 of the providers
holding the file. See Transfer errors.
CLI
# install — see https://tessera.storage/install.sh
tessera signup --name "my-agent"
tessera upload ./hello.txt
tessera download <object-id> ./hello.txt
tessera signup prints a recovery phrase and an API key once. Keep both. On a new machine,
tessera login "<12 words>" restores the same account. Tessera cannot recover a lost phrase.
The CLI is the supported path. The walkthrough below is the same calls the CLI makes, if you are writing your own client.
Before you start
The raw API needs a runtime that can speak the storage transfer protocol to providers directly. Tessera does not proxy object bytes. That means a server-side runtime — Bun, Node, Go, or Python — not a browser.
bun add @noble/hashes @noble/curves
export TESSERA_API="https://index.tessera.storage"
1 — Get a credential
Use tessera signup (above) to obtain app_id and app_key — there is no separate provisioning
endpoint for the raw API; it uses the same credential your CLI config.json holds.
export TESSERA_APPKEY="<your 128-hex app_key>"
export TESSERA_CREDENTIAL="<your app_id>"
`app_key` is yours alone
It is your ed25519 private key. We do not store a recoverable copy, so if you lose it, everything stored under it is unreachable — by you and by us. Put it in a secret manager now, before you write any data.
2 — Sign a request
Every other endpoint needs three signed query parameters. Copy signRequest from
Authentication — the construction has enough sharp edges that reading that
page is genuinely faster than guessing.
import { signRequest } from './sign'
const API = new URL(process.env.TESSERA_API!)
const cred = {
appKey: process.env.TESSERA_APPKEY!,
credential: process.env.TESSERA_CREDENTIAL!,
}
async function call(method: string, path: string, body?: unknown) {
const raw = body === undefined ? null : new TextEncoder().encode(JSON.stringify(body))
const params = signRequest(cred, method, API.host, path, raw)
const res = await fetch(`${API.origin}${path}?${params}`, {
method,
headers: raw ? { 'content-type': 'application/json' } : undefined,
body: raw,
})
const text = await res.text()
if (!res.ok) throw new Error(`${method} ${path} → ${res.status}: ${text}`)
// Some endpoints answer 200 with a JSON error object rather than an error status.
const parsed = text ? JSON.parse(text) : null
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
throw new Error(`${method} ${path} → ${parsed.error}`)
}
return parsed
}
Confirm it works before going further:
await call('GET', '/auth/check') // resolves on 204, throws on 401
3 — Ask where to write
const { hosts } = await call('POST', '/prepare-write', {
totalShards: 30,
minShards: 10,
})
{
"hosts": [
{
"publicKey": "ed25519:9f3c1a...",
"address": "provider-1.example:9984",
"accountToken": "{\"hostKey\":\"ed25519:9f3c…\",\"account\":\"…\",\"validUntil\":…,\"signature\":\"…\"}",
"contractID": "a1b2c3..."
}
]
}
You get 30 entries, each a different provider, with a funded contract and an accountToken
that authorises your writes. Treat the token as opaque and hand it to the transfer protocol
unmodified.
4 — Encrypt, split, and write
Encryption first, then erasure coding. In that order — coding ciphertext means a provider holds a fragment of ciphertext; coding first and encrypting after would defeat the point.
import { createCipheriv, randomBytes } from 'node:crypto'
import { ReedSolomon } from 'your-rs-library'
const MIN_SHARDS = 10
const TOTAL_SHARDS = 30
// 4a — encrypt
const dataKey = randomBytes(32)
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-ctr', dataKey, iv)
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
// 4b — erasure code: 10 data shards + 20 parity
const shardSize = Math.ceil(ciphertext.length / MIN_SHARDS)
const shards: Uint8Array[] = []
for (let i = 0; i < MIN_SHARDS; i++) {
const shard = new Uint8Array(shardSize)
shard.set(ciphertext.subarray(i * shardSize, (i + 1) * shardSize))
shards.push(shard)
}
for (let i = 0; i < TOTAL_SHARDS - MIN_SHARDS; i++) shards.push(new Uint8Array(shardSize))
const rs = new ReedSolomon(MIN_SHARDS, TOTAL_SHARDS - MIN_SHARDS)
rs.encode(shards)
// 4c — write shard i directly to provider i, and keep the sector root it returns
const sectors = await Promise.all(
shards.map(async (shard, i) => ({
host: hosts[i].publicKey,
root: await writeSectorToProvider(hosts[i], shard), // your transfer client
contractID: hosts[i].contractID,
})),
)
Write failures are expected, and cheap
Providers time out. When a shard write fails, do not retry it forever — request another provider
from /prepare-write and place the shard there instead. You need 30 successful placements out of
however many attempts it takes; which specific providers they land on does not matter.
5 — Register the slab
Now tell Tessera what you did. This is what makes the data findable later.
const [slabID] = await call('POST', '/slabs', [
{
version: 1,
minShards: MIN_SHARDS,
encryptionKey: iv32Base64, // 32 bytes, standard base64
sectors, // 30 entries, one per provider
},
])
The response is an array of hex slab IDs, in the same order as your request. The ID is a digest of the slab's parameters, so registering identical parameters twice returns the same ID rather than creating a duplicate.
6 — Register the object
const objectKey = sha256Hex(plaintext) // your choice; a content hash gives free dedup
await call('POST', '/objects', {
id: objectKey,
encryptedDataKey: wrap(dataKey), // wrapped by your key — we cannot unwrap it
slabs: [{ id: slabID, offset: 0, length: ciphertext.length }],
encryptedMetadata: encrypt({ name: 'hello.txt', size: plaintext.length }),
encryptedMetadataKey: wrap(metadataKey),
dataSignature: signOverData,
metadataSignature: signOverMetadata,
})
The object is now stored. encryptedMetadata is where filenames live — the API has no field for
one, and never will.
7 — Read it back
// 7a — placement record
const obj = await call('GET', `/objects/${objectKey}`)
// 7b — for each slab, the providers and sector roots
const slab = await call('GET', `/slabs/${obj.slabs[0].id}`)
// 7c — fetch any 10 of the 30 shards, directly from providers
const fetched = await fetchAnyN(slab.sectors, slab.minShards)
// 7d — reconstruct, then decrypt
const rs = new ReedSolomon(slab.minShards, slab.sectors.length - slab.minShards)
rs.reconstruct(fetched)
const plaintext = decrypt(join(fetched).subarray(0, obj.slabs[0].length), unwrap(obj.encryptedDataKey))
Ten shards is the threshold, but fetch a couple extra in parallel and take the first ten to return — it costs nothing and removes the slowest provider from your critical path.
8 — Clean up
curl -X DELETE "$TESSERA_API/objects/$OBJECT_KEY?$AUTH" # 204
curl -X POST "$TESSERA_API/slabs/prune?$AUTH" # {"pruned": 1}
Deleting an object does not delete its slabs, because another object may reference them. prune
reclaims slabs nothing references any more.
Persist your placement records if you need to recover without Tessera — see Recovery.