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
| Method | Path | Purpose |
|---|---|---|
OPTIONS | /v1/uploads | Capability discovery — returns Tus-Version, Tus-Max-Size, Tus-Extension. No auth needed. |
POST | /v1/uploads | Create 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)
npm install tus-js-clientimport * 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 uploadconst 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-Lengthis required at session creation. Picora does not supportUpload-Defer-Length(deferred length) in v0.37.0.- Quota is reserved upfront — if your file would exceed your plan’s storage quota,
POST /v1/uploadsreturns403 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 filelet 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.TusAndroidUploadimport io.tus.android.client.TusPreferencesURLStoreimport io.tus.java.client.TusClientimport io.tus.java.client.TusUploadimport 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
| Header | When | Meaning |
|---|---|---|
Upload-Offset | HEAD / PATCH | Current confirmed byte offset. Compare to file size to detect completion. |
Tus-Resumable | every response | Protocol version, always 1.0.0. |
X-Picora-Resource-Id | final PATCH | nanoid of the created img_images / vid_videos / med_media row. |
X-Picora-Duplicate | final PATCH | true if the upload was hash-deduped against an existing resource (R2 commit skipped). |
Upload-Expires | HEAD / POST | ISO 8601 timestamp when this session auto-expires (creation + 24h). |
Error codes
| HTTP | Meaning | Client action |
|---|---|---|
| 400 | Invalid TUS headers (e.g. missing Tus-Resumable) | Fix headers |
| 401 | API key missing / revoked | Regenerate at center.picora.me/api-keys |
| 403 | Quota would be exceeded | Cancel old sessions (DELETE /v1/uploads/{id}) or upgrade plan |
| 404 | Session not found / expired (24h) | Create new session |
| 409 | Upload-Offset mismatch — out-of-order chunk | Re-fetch offset via HEAD, resume from there |
| 412 | Tus-Resumable not 1.0.0 | Upgrade client to TUS 1.0 |
| 413 | Final upload size > plan max-file-size, or non-final chunk < 5 MiB | Split into larger chunks |
| 415 | Wrong 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 uploads —
POST /v1/images+Idempotency-Keyheader 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
- TUS specification: tus.io/protocols/resumable-upload.html
- Picora endpoint contracts: API Explorer → /v1/uploads
- Mobile sync overview: Mobile Sync (iOS Shortcuts & Android)