Guides

Quickstart

Provision a credential, upload an object across 15 providers, and read it back. Fifteen minutes, one file of code.

By the end of this page you will have stored an object as 15 encrypted shards on 15 independent providers and read it back from 10 of them.

Before you start

You need a runtime that can speak the storage transfer protocol to providers directly, because 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://api.PLACEHOLDER_DOMAIN.example"

1 — Provision a credential

This is the one endpoint that needs no authentication, because you do not have a credential yet.

curl -X POST "$TESSERA_API/auth/provision"
{
  "appID": "Aq3fZm9rLXRl",
  "appKey": "kR8vN2pQ...64-bytes-base64url-unpadded",
  "credential": "Aq3fZm9rLXRlc3QtY3JlZGVudGlhbC1oZXJlLW9r="
}

`appKey` is shown once

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. Provisioning is rate limited to 5 credentials per hour per source address.

export TESSERA_APPKEY="kR8vN2pQ..."
export TESSERA_CREDENTIAL="Aq3fZm9rLXRlc3QtY3JlZGVudGlhbC1oZXJlLW9r="

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: 15,
  minShards: 10,
})
{
  "hosts": [
    {
      "publicKey": "ed25519:9f3c1a...",
      "address": "provider-1.example:9984",
      "accountToken": "{\"hostKey\":\"ed25519:9f3c…\",\"account\":\"\",\"validUntil\":…,\"signature\":\"\"}",
      "contractID": "a1b2c3..."
    }
  ]
}

You get 15 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 = 15

// 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 + 5 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 15 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,                      // 15 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 15 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.

What to build next

  • Persist your placement records. GET /objects and GET /slabs/{id} give you everything needed to recover without us. Export on a schedule — see Recovery without Tessera.
  • Pack small objects into shared slabs. A slab has a 15-provider round trip; amortise it across many small objects using offset and length rather than paying it per object.
  • Keep a local index if you need listing or search, since the server cannot read your metadata.
  • Handle 429. The limit is 30 requests per minute per credential; respect Retry-After.