Upload & File Lifecycle
Every file in OpenTusk follows a storage pipeline — from upload, through a fast hot cache, to durable decentralized storage on the Walrus network. This guide covers the full journey: uploading files, paying for storage (PPU), encryption, status transitions, rehydration, and webhook notifications.
Uploading files
Section titled “Uploading files”Basic upload
Section titled “Basic upload”# Upload a single fileopentusk upload report.pdf --vault "My Vault"
# Upload multiple filesopentusk upload file1.txt file2.txt --vault "My Vault"
# Upload a directory recursivelyopentusk upload ./docs --vault "My Vault" --recursive
# Upload to a specific folder within a vaultopentusk upload invoice.pdf --vault "My Vault" --folder <folder-id>
# Upload inline text content (no file on disk needed)opentusk upload --content "Hello world" --name notes.txt --vault "My Vault"
# Upload from stdinecho "pipeline output" | opentusk upload --stdin --name output.txt --vault "My Vault"import { OpenTuskClient } from '@opentusk/sdk';import { readFile } from 'fs/promises';
const opentusk = new OpenTuskClient({ apiKey: 'otk_your_key' });
const data = await readFile('./report.pdf');const file = await opentusk.files.upload({ name: 'report.pdf', mimeType: 'application/pdf', vaultId: '<vault-id>', data,});
console.log(file.id); // file UUIDconsole.log(file.status); // "hot"How uploads work under the hood
Section titled “How uploads work under the hood”OpenTusk uses a presigned URL flow — file bytes never touch the API server. They go directly to S3-compatible object storage.
-
Request upload — Your client sends file metadata (name, size, MIME type, vault) to the API. The API returns a presigned URL and a file ID.
-
Upload bytes — Your client PUTs the raw file bytes directly to the presigned URL.
-
Confirm — Your client tells the API the upload is complete. The API verifies the file exists in storage and sets the status to
hot.
The SDK and CLI handle all three steps automatically. If you’re building a custom integration, here’s the manual flow:
# Step 1: Request presigned URLcurl -X POST https://api.opentusk.ai/api/files/upload \ -H "Authorization: Bearer otk_your_key" \ -H "Content-Type: application/json" \ -d '{"name": "report.pdf", "mimeType": "application/pdf", "sizeBytes": 204800, "vaultId": "vault-uuid"}'# → {"fileId": "file-uuid", "uploadUrl": "https://...?X-Amz-Signature=..."}
# Step 2: PUT file bytes to presigned URLcurl -X PUT "<uploadUrl>" \ -H "Content-Type: application/pdf" \ --data-binary @report.pdf
# Step 3: Confirm uploadcurl -X POST https://api.opentusk.ai/api/files/file-uuid/confirm \ -H "Authorization: Bearer otk_your_key"# → {"id": "file-uuid", "status": "hot", ...}Pay-per-upload (402 payment flow)
Section titled “Pay-per-upload (402 payment flow)”Pay-per-upload (PPU) lets the caller pay for a single file in WAL on the Sui blockchain. It is a per-upload mode, not a plan: enabled on plans where plan.ppuEnabled === true (currently just Free) and opted into per-upload via paymentType: 'ppu' / CLI --ppu. Paid plans don’t expose PPU — storage is included. When the API receives an upload request with PPU semantics on a plan that allows it, it returns a 402 Payment Required response with a cost quote instead of a presigned URL. If the user’s plan doesn’t allow PPU, the API returns 403 Forbidden instead.
How it works
Section titled “How it works”Client API Sui Blockchain │ │ │ ├─ POST /files/upload ────────►│ │ │ ├─ Calculate cost │ │◄─ 402 + cost quote ─────────┤ │ │ │ │ ├─ Display cost to user │ │ ├─ Sign WAL transfer ─────────────────────────────────────────►│ │◄─ Transaction digest ───────────────────────────────────────┤│ │ │ │ ├─ POST /files/upload ────────►│ │ │ + quoteId + txDigest ├─ Verify payment on-chain ─────►│ │ │◄─ Confirmed ──────────────────┤│ │◄─ 200 + presigned URL ──────┤ │ │ │ │ ├─ PUT bytes to URL │ │ ├─ POST /confirm │ │The 402 response
Section titled “The 402 response”When the API returns 402, it includes a full cost breakdown:
{ "status": 402, "quoteId": "quote-uuid", "walAmountFrost": "6700000", "walAmountFormatted": "0.0067 WAL", "walPriceUsd": 0.10, "usdEquivalent": "$0.00067", "recipient": "0x...", "expiresAt": "2026-04-05T12:00:00Z", "breakdown": { "walrusStorage": "0.004 WAL", "walrusUpload": "0.001 WAL", "doSpaces": "0.0005 WAL", "egress": "0.0002 WAL", "suiGas": "0.005 SUI", "platformFee": "0.001 WAL" }}Quotes expire 5 minutes after issuance. If the client doesn’t sign and retry within that window, the next upload attempt receives a new 402 with a fresh quote — there’s no penalty, but quoteId is single-use against a single txDigest.
If the API receives quoteId + txDigest but on-chain verification fails (wrong recipient, wrong amount, transaction not found yet, etc.), it returns another 402 with a fresh quote rather than a 4xx — the client should display the new cost and retry, not surface this as a hard error.
CLI payment flow
Section titled “CLI payment flow”Pass --ppu to opt a single file into PPU. Without --ppu, the upload is billed against the plan’s storage quota (and on Free, that’s a 10 MB cap and hot-only storage). With --ppu, the server returns 402 with a quote; the CLI displays the cost and prompts to sign.
# Free plan — small file, billed against plan quotaopentusk upload small.txt --vault "My Vault"
# Any plan with ppuEnabled (today: Free) — opt this upload into PPUopentusk upload large-file.zip --vault "My Vault" --ppu --epochs 30# → Cost: 0.0067 WAL (~$0.00067)# → Approve payment? (y/n)
# Auto-approve for scripts and automationopentusk upload large-file.zip --vault "My Vault" --ppu --yesThe CLI signs a Sui transaction to transfer WAL tokens to OpenTusk’s recipient address, then retries the upload request with the quoteId and txDigest as proof of payment.
SDK payment flow
Section titled “SDK payment flow”The SDK returns the 402 as a PaymentRequired object instead of throwing. Pass paymentType: 'ppu' to opt a single upload into PPU regardless of plan:
const result = await opentusk.files.requestUpload({ name: 'report.pdf', mimeType: 'application/pdf', sizeBytes: 204800, vaultId: '<vault-id>', paymentType: 'ppu', // required to opt this upload into PPU; omit for plan-quota billing epochs: 30,});
if ('status' in result && result.status === 402) { // PaymentRequired — sign and pay, then retry console.log(result.walAmountFormatted); // "0.0067 WAL" console.log(result.recipient); // Sui address console.log(result.breakdown); // cost breakdown
// After signing the Sui transaction: const retry = await opentusk.files.requestUpload({ name: 'report.pdf', mimeType: 'application/pdf', sizeBytes: 204800, vaultId: '<vault-id>', paymentType: 'ppu', epochs: 30, quoteId: result.quoteId, txDigest: '<sui-tx-digest>', }); // retry now has fileId + uploadUrl}Subscription uploads
Section titled “Subscription uploads”Without paymentType: 'ppu', subscription accounts (Developer, Scale, Enterprise) get a presigned URL directly — no 402 flow, no on-chain payment. Storage counts against the plan’s quota and hot-retention is governed by hotRetentionDays. Subscription accounts can still mix PPU files in alongside subscription files by passing paymentType: 'ppu' on the uploads they want billed on-chain.
Batch uploads (bundles)
Section titled “Batch uploads (bundles)”Small files can be packed into a single Walrus blob — a bundle, backed by a Walrus Quilt — which reduces Sui gas and storage cost substantially.
- PPU uploads (any plan) can opt in per request via the “Bundle files” toggle in the web uploader (shown when 2+ files are selected) or via
POST /api/files/batch-upload. One aggregated 402 quote, one Sui payment, one Walrus write. - Subscription uploads get automatic async batching: files under 10 MB accumulate in a pending pool and flush when total ≥ 100 MB or oldest file is > 1 hour old.
Quilted files have a quiltPatchId instead of a walrusBlobId and cannot be individually renewed or deleted. Downloads work identically — opentusk download <file-id> handles the patch lookup automatically. See the Bundles guide for constraints, SEAL behavior, and the POST /api/files/batch-upload / batch-confirm endpoints.
Encryption
Section titled “Encryption”OpenTusk handles encryption differently depending on vault visibility.
Public vaults
Section titled “Public vaults”No encryption. Files are stored and served as plaintext.
Shared vaults (SEAL protocol)
Section titled “Shared vaults (SEAL protocol)”Files uploaded to shared vaults are encrypted client-side before upload using the SEAL protocol. The server never sees plaintext.
The CLI, SDK (via MCP server), and web app handle this automatically:
- The client fetches the vault’s SEAL identity and key server configuration
- The file is encrypted with AES-256-GCM using a random data key
- The data key is encrypted with SEAL IBE (identity-based encryption) tied to the vault’s on-chain Whitelist
- The encrypted file bytes and SEAL metadata are uploaded together
- On download, the client’s Sui key proves membership on-chain, SEAL key servers release the decryption shares, and the file is decrypted locally
# Encryption is automatic — just upload to a shared vaultopentusk upload secrets.pdf --vault "Team Vault"# → SEAL-encrypted before upload
# Download decrypts automatically (requires Sui key)opentusk download <file-id> --output secrets.pdfThe MCP server handles SEAL encryption transparently. AI agents call opentusk_file_upload or opentusk_file_create and encryption happens automatically when the target vault is shared.
File statuses
Section titled “File statuses”Every file has a status that reflects where it is in the storage pipeline.
| Status | Hot cache | Walrus | Downloadable | Description |
|---|---|---|---|---|
uploading | Pending | No | No | Upload initiated, not yet confirmed |
hot | Yes | No | Yes (fast) | Confirmed, available in hot cache |
synced | Yes | Yes | Yes (fast) | Synced to Walrus, still in hot cache |
cold | No | Yes | After rehydration | Evicted from hot cache, Walrus only |
error | Yes | Failed | Yes (fast) | Walrus sync failed, file safe in hot cache |
Checking status
Section titled “Checking status”# Check status onceopentusk file status <file-id>
# Poll until synced or error (max 5 minutes)opentusk file status <file-id> --wait
# Watch all files in a vault (refreshes every 3 seconds)opentusk ls --followFor PPU files, the status output also includes payment type, total epochs paid (initial + extensions), the absolute expiry date, epochs remaining, and auto-renew state:
File: report.pdfStatus: syncedWalrus Blob: AoHT9eo4JpOvA6xW-ndMcpCg8GNhMtb0xKuJw0kv2hIPayment: PPUEpochs paid: 30Expires: 6/18/2026, 3:02:29 PM (29 epochs remaining)Auto-renew: On// Check onceconst file = await opentusk.files.get('<file-id>');console.log(file.status); // "hot", "synced", "cold", etc.
// PPU lifetime info — present on the file status responseconst status = await opentusk.files.getStatus('<file-id>');console.log(status.paymentType); // 'ppu' | 'subscription'console.log(status.epochsPaid); // total epochs of paid storage (PPU only)console.log(status.ppuEpochEnd); // ISO timestamp when paid storage endsconsole.log(status.walrusEpochEnd); // current Walrus lease expiry
// Wait for a target statusawait opentusk.files.waitForStatus('<file-id>', ['synced'], { timeout: 120_000, // 2 minutes});Lifecycle
Section titled “Lifecycle”uploading ──► hot ──► synced ──► cold │ │ │ rehydrate │ │ │ ◄────┘ │ error ◄── (any state on failure)Upload → hot
Section titled “Upload → hot”When you upload a file, it goes to the hot cache (S3-compatible object storage on DigitalOcean Spaces). It’s immediately available for download at full speed.
Walrus sync → synced
Section titled “Walrus sync → synced”A background worker picks up the file and publishes it to the Walrus decentralized storage network. This typically takes seconds to a few minutes depending on file size. Once confirmed, the status changes to synced.
The file now exists in two places — hot cache (fast) and Walrus (durable). Two identifiers are stored:
walrusBlobId— content-addressed hash, used to retrieve the file from any Walrus aggregatorwalrusBlobObjectId— Sui object ID, used for epoch renewals
# View Walrus metadataopentusk file info <file-id># → Walrus Blob ID: Bx7K9...abc# → Walrus Object ID: 0x1234...5678# → Status: syncedconst file = await opentusk.files.get('<file-id>');console.log(file.walrusBlobId); // content hashconsole.log(file.walrusBlobObjectId); // Sui object IDconsole.log(file.status); // "synced"Hot eviction → cold
Section titled “Hot eviction → cold”After the hot retention period expires, the hot eviction worker removes the file from the hot cache. The file is now cold — stored only on Walrus.
- Subscription plans have a fixed retention period (e.g., 7 days, 30 days)
- PPU files (on any plan) keep
hotUntil = ppuEpochEnd; extending epochs moves both forward
Rehydration → back to hot
Section titled “Rehydration → back to hot”When you download a cold file, OpenTusk automatically fetches it from Walrus back to the hot cache. There’s no separate rehydration step — requesting a download triggers it:
# Downloading a cold file triggers rehydration automatically# The CLI polls until the file is ready (up to 60 seconds)opentusk download <file-id> --output report.pdf
# Direct rehydration from Walrus by blob ID (no API key needed)opentusk file rehydrate Bx7K9...abc --output myfile.enc// The SDK handles rehydration automatically — polls until readyconst { url } = await opentusk.files.getDownloadUrl('<file-id>');Error → retry
Section titled “Error → retry”If Walrus sync fails after retries, the file status is set to error. The file remains safe in the hot cache — it just hasn’t been durably stored yet.
# Retry a specific fileopentusk file retry <file-id>
# Retry all files in error statusopentusk file retryawait opentusk.files.retrySync('<file-id>');Walrus epochs and renewal
Section titled “Walrus epochs and renewal”Walrus stores data for a set number of epochs (each ~24 hours). When epochs expire, the data is garbage collected permanently by the network.
OpenTusk automatically renews epoch leases before they expire via a background worker (walrusRenew). Renewals use the walrusBlobObjectId to call extendBlob() on the Sui blockchain.
PPU epoch management
Section titled “PPU epoch management”PPU files can be extended at any time before they expire via an on-chain 402 flow that mirrors the upload flow. Auto-renewal can be toggled separately:
# Purchase additional epochs (signs and pays on-chain, then enqueues the# extension job). Acknowledges 202 once the worker is queued.opentusk file extend <file-id> --epochs 30
# Enable auto-renewalopentusk file auto-renew <file-id> --on
# Disable auto-renewalopentusk file auto-renew <file-id> --off# 1) QuotePOST /api/files/:fileId/extend{ "epochs": 30 }
→ 402 { quoteId, walAmountFrost, recipient, expiresAt, breakdown, ... }
# 2) Pay on Sui, then submit proofPOST /api/files/:fileId/extend{ "epochs": 30, "quoteId": "...", "txDigest": "..." }
→ 202 { fileId, additionalEpochs: 30, status: "extending", traceId }The 202 response means the WAL payment is confirmed and a ppu-extend worker job has been enqueued. The worker calls Walrus extendBlob, then advances ppuEpochEnd, hotUntil, and walrusEpochEnd on the file. The file’s status doesn’t change — only its lifetime fields move forward.
Eligibility: file must be paymentType: 'ppu', have a walrusBlobObjectId (synced to Walrus), not be in uploading or error status, and not yet be past ppuEpochEnd. Otherwise the API returns 400/409 without taking payment.
Downloading files
Section titled “Downloading files”# Download to current directory (original filename)opentusk download <file-id>
# Download to specific pathopentusk download <file-id> --output ./downloads/report.pdf
# Stream to stdout (for pipes and sandboxed environments)opentusk download <file-id> --stdoutopentusk download <file-id> --stdout > report.pdf// Get a download URLconst { url } = await opentusk.files.getDownloadUrl('<file-id>');
// Fetch the file contentconst response = await fetch(url);const data = await response.arrayBuffer();Shared vault files are decrypted automatically by the CLI and MCP server (requires Sui key).
Automatic local indexing
Section titled “Automatic local indexing”When @opentusk/indexer is installed, the CLI and MCP tools notify it with plaintext bytes during upload and download — files get semantically indexed for local search as a side effect of normal workflows. No extra step is required. See the Semantic Search guide for details.
Who uploaded each file
Section titled “Who uploaded each file”Every file record carries an uploaderSuiAddress field — the Sui address of the identity that uploaded the file. In shared vaults, this lets members see who contributed each file. For personal vaults it usually matches the account owner’s Sui address; for files uploaded by an agent, it’s the agent’s address.
Managing files
Section titled “Managing files”Listing files
Section titled “Listing files”# List all filesopentusk ls
# List files in a specific vaultopentusk ls "My Vault"
# Sort by name, size, or dateopentusk ls --sort name --limit 100
# Filter by folderopentusk ls --folder <folder-id>const files = await opentusk.files.list({ vaultId: '<vault-id>' });
for (const f of files) { console.log(`${f.name} — ${f.status} — ${f.sizeBytes} bytes`);}Deleting files
Section titled “Deleting files”Files are soft-deleted (moved to trash) with a 7-day grace period before permanent deletion.
# Soft-delete (moves to trash)opentusk file delete <file-id>opentusk file delete <file-id> --yes # skip confirmation
# Delete multiple files at onceopentusk file delete <id1> <id2> <id3> --yesawait opentusk.files.delete('<file-id>');Trash operations
Section titled “Trash operations”# List trashed itemsopentusk trash list
# Restore from trashopentusk trash restore <id>
# Permanently delete one itemopentusk trash delete <id> --yes
# Empty all trashopentusk trash empty --yes// List trashconst items = await opentusk.trash.list();
// Restoreawait opentusk.trash.restore('<id>');
// Permanent deleteawait opentusk.trash.delete('<id>');Moving files between folders
Section titled “Moving files between folders”# Move to a folderopentusk file move <file-id> --folder <folder-id>
# Move back to vault rootopentusk file move <file-id> --rootWebhooks
Section titled “Webhooks”Webhooks notify your systems when files change status. Subscribe to the events you care about:
| Event | Triggered when |
|---|---|
file.hot | File confirmed and available in hot cache |
file.synced | File synced to Walrus |
file.cold | File evicted from hot cache |
file.error | Walrus sync or processing failed |
file.deleted | File soft-deleted (moved to trash) |
file.rehydrated | Cold file rehydrated back to hot cache |
# Create a webhookopentusk webhook create https://example.com/hook --events file.synced,file.error
# Test itopentusk webhook test <webhook-id>
# View deliveriesopentusk webhook deliveries <webhook-id>const webhook = await opentusk.webhooks.create({ url: 'https://example.com/hook', events: ['file.synced', 'file.error'],});
// Save the secret — used to verify webhook signaturesconsole.log(webhook.secret);Webhook payloads include the file ID, new status, vault ID, and timestamp. Verify signatures using the webhook secret with HMAC-SHA256. See the Webhooks guide for payload format and verification.
Quotas and limits
Section titled “Quotas and limits”All uploads are checked against your plan’s quotas:
| Check | What happens if exceeded |
|---|---|
| Storage quota | Upload rejected with 409 Storage quota exceeded |
| Max file size | Upload rejected with 413 File size limit exceeded |
| Max vaults | Vault creation rejected |
Quota is calculated on plaintext size — you’re not penalized for encryption overhead on shared vault files.
# Check your current usageopentusk account usage
# See plan limitsopentusk account plan