Skip to content

Resumable Upload (TUS 1.0)

For files larger than ~10 MB or unstable network conditions, Picora exposes a TUS 1.0 endpoint at /v1/uploads. Resume after disconnects, drop a fingerprint-based dedup before transfer starts, and avoid re-uploading bytes you already sent.

Protocol overview

MethodPathPurpose
OPTIONS/v1/uploadsCapability discovery — returns Tus-Version, Tus-Max-Size, Tus-Extension. No auth needed.
POST/v1/uploadsCreate a session. Returns Location: /v1/uploads/{sessionId}. Quota is pre-reserved for the full file size.
HEAD/v1/uploads/{id}Query current Upload-Offset for resume after disconnect.
PATCH/v1/uploads/{id}Append a chunk. Final chunk triggers finalize: sha256 + dedup + DB INSERT + R2 commit.
DELETE/v1/uploads/{id}Abort session — quota is refunded, temp R2 object cleaned.

Sessions expire after 24 hours. Expired in-flight sessions are cleaned by an hourly cron — quota is refunded, you can safely create a new session.

Quick start — tus-js-client (browser / Node)

Terminal window
npm install tus-js-client
import * as tus from 'tus-js-client';
const file = /* File from <input type="file"> */;
const upload = new tus.Upload(file, {
endpoint: 'https://api.picora.me/v1/uploads',
headers: {
Authorization: 'Bearer sk_live_YOUR_KEY',
'X-Picora-Client': 'web-tus',
},
// 5 MiB chunks — minimum for non-final chunks (smaller chunks → 413 CHUNK_TOO_SMALL)
chunkSize: 5 * 1024 * 1024,
// Persist upload URL in localStorage so user can resume after page reload
storeFingerprintForResuming: true,
fingerprint: async (file) =>
`picora-${file.name}-${file.size}-${file.lastModified}`,
metadata: {
filename: file.name,
contentType: file.type,
// Picora-specific: which resource bucket to land into when finalized
resourceType: file.type.startsWith('video/') ? 'video'
: file.type.startsWith('audio/') ? 'audio'
: 'image',
},
retryDelays: [0, 1000, 3000, 5000, 10000], // exponential backoff
onError: (err) => console.error('TUS upload failed', err),
onProgress: (sent, total) => {
const pct = ((sent / total) * 100).toFixed(1);
console.log(`uploaded ${pct}%`);
},
onSuccess: () => {
// Picora returns the final resource via X-Picora-Resource-Id header on the
// last PATCH response. tus-js-client doesn't expose response headers
// directly — use the `Upload.url` to derive session id and call
// GET /v1/images/{id} (or videos/audio) if you need the public URL.
console.log('done:', upload.url);
},
});
// Optional: detect previous interrupted upload
const previous = await upload.findPreviousUploads();
if (previous.length > 0) {
upload.resumeFromPreviousUpload(previous[0]);
}
upload.start();

Picora-specific gotchas

  • Chunk size: non-final chunks must be ≥ 5 MiB. The final chunk can be smaller. This matches R2 / S3 multipart minimum part size.
  • Upload-Length is required at session creation. Picora does not support Upload-Defer-Length (deferred length) in v0.37.0.
  • Quota is reserved upfront — if your file would exceed your plan’s storage quota, POST /v1/uploads returns 403 QUOTA_EXCEEDED. Cancel old sessions or upgrade before retrying.
  • Hash dedup on completion — if another upload in your library already has the same sha256, the finalize step skips the R2 commit and returns the existing resource id. Your client should treat this as success.

iOS — TUSKit (Swift / Objective-C)

Repo: github.com/tus/TUSKit

import TUSKit
let storageDirectory = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask).first!
.appendingPathComponent("tus")
let tusClient = try TUSClient(
server: URL(string: "https://api.picora.me/v1/uploads")!,
sessionIdentifier: "picora-upload",
storageDirectory: storageDirectory,
chunkSize: 5 * 1024 * 1024
)
tusClient.delegate = self
// Upload a file
let fileURL: URL = /* photo from PHPhotoLibrary */
try tusClient.uploadFileAt(
filePath: fileURL,
customHeaders: [
"Authorization": "Bearer sk_live_YOUR_KEY",
"X-Picora-Client": "ios-tus",
],
context: [
"filename": fileURL.lastPathComponent,
"resourceType": "image", // or "video" / "audio"
]
)

TUSKit persists pending uploads to disk and resumes automatically across app launches — ideal for background photo sync.

Android — tus-android-client

Repo: github.com/tus/tus-android-client

import io.tus.android.client.TusAndroidUpload
import io.tus.android.client.TusPreferencesURLStore
import io.tus.java.client.TusClient
import io.tus.java.client.TusUpload
import java.net.URL
val client = TusClient().apply {
uploadCreationURL = URL("https://api.picora.me/v1/uploads")
enableResuming(TusPreferencesURLStore(applicationContext.getSharedPreferences("tus", 0)))
headers = mapOf(
"Authorization" to "Bearer sk_live_YOUR_KEY",
"X-Picora-Client" to "android-tus"
)
}
val upload = TusAndroidUpload(contentUri, applicationContext).apply {
setMetadata(mapOf(
"filename" to displayName,
"resourceType" to "video"
))
}
val executor = object : TusExecutor() {
override fun makeAttempt() {
val uploader = client.resumeOrCreateUpload(upload)
uploader.chunkSize = 5 * 1024 * 1024
while (uploader.uploadChunk() > -1) { /* progress callback */ }
uploader.finish()
}
}
executor.makeAttempts()

Server response headers worth knowing

HeaderWhenMeaning
Upload-OffsetHEAD / PATCHCurrent confirmed byte offset. Compare to file size to detect completion.
Tus-Resumableevery responseProtocol version, always 1.0.0.
X-Picora-Resource-Idfinal PATCHnanoid of the created img_images / vid_videos / med_media row.
X-Picora-Duplicatefinal PATCHtrue if the upload was hash-deduped against an existing resource (R2 commit skipped).
Upload-ExpiresHEAD / POSTISO 8601 timestamp when this session auto-expires (creation + 24h).

Error codes

HTTPMeaningClient action
400Invalid TUS headers (e.g. missing Tus-Resumable)Fix headers
401API key missing / revokedRegenerate at center.picora.me/api-keys
403Quota would be exceededCancel old sessions (DELETE /v1/uploads/{id}) or upgrade plan
404Session not found / expired (24h)Create new session
409Upload-Offset mismatch — out-of-order chunkRe-fetch offset via HEAD, resume from there
412Tus-Resumable not 1.0.0Upgrade client to TUS 1.0
413Final upload size > plan max-file-size, or non-final chunk < 5 MiBSplit into larger chunks
415Wrong Content-Type on PATCH (must be application/offset+octet-stream)Fix Content-Type

When NOT to use TUS

  • Small images (< 5 MB) — the 2-request overhead is worse than direct POST /v1/images.
  • Idempotent retry of one-shot uploadsPOST /v1/images + Idempotency-Key header is simpler and supports automatic dedup.
  • Server-to-server transfers — if you control both sides, the regular POST is easier to reason about.

For most mobile photo sync, plain POST /v1/images is sufficient. TUS earns its complexity when you’re shipping multi-GB videos or unreliable network scenarios.

Reference