Uploads
Upload up to 100 files per batch, send bytes straight to storage or through the API, complete each session safely, and track finalization and scanning by polling or live events.
On this page
This page is for developers adding files to SteadyLink from their own code. An upload is a short conversation: you announce the files, send each file's bytes, tell SteadyLink you are done, and wait while it verifies, scans, and publishes them. At the end every file has an asset ID and a stable link.
To publish new bytes for a file that already exists, keep its link and use Replace a file instead.
The JavaScript examples assume const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! }) and the Python examples assume client = SteadyLink(api_key=os.environ["STEADYLINK_API_KEY"]).
How an upload works#
Create a batch
POST /api/upload-batcheswith the bucket and up to 100 files. SteadyLink reserves storage for them and returns one upload session per file, each with a presigneduploadUrl.Send the bytes
PUTeach file to itsuploadUrl. The bytes go straight to object storage, not through the API, so large files do not tie up your API connection or count against API request limits.Complete each session
POST /api/upload-sessions/{session_id}/completewith anIdempotency-Key. SteadyLink queues finalization and answers202immediately.Wait for a final state
Poll the batch or listen to the event stream until each session is
ready,blocked,failed, orcancelled. Areadysession has anobjectAssetId: the file's stable link ishttps://cdn.steadylink.io/a/{objectAssetId}.
If you use the SDK or CLI, one call does all four steps:
import { fileFromPath } from "@steadylink/sdk/node";
const [hero] = await steadylink.upload(
"7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
await fileFromPath("./campaign-hero.webp"),
{ folder: "campaign/launch", visibility: "public" },
);
console.log(hero.status, hero.url); // "ready", https://cdn.steadylink.io/a/3f2a9c1e-...from pathlib import Path
data = Path("campaign-hero.webp").read_bytes()
session = client.upload_file(
"7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
filename="campaign-hero.webp",
body=data,
path="campaign/launch/",
)
print(session["status"]) # "committing"; poll the batch for the final statesteadylink upload ./campaign-hero.webp --bucket marketing --folder campaign/launch --publicThe Python helper returns as soon as the session is completed, before finalization. Poll Get an upload batch with the session's batchId to get the asset ID. Leave its content_type at the default: presigned URLs are signed for application/octet-stream, and the stored type is detected from the bytes.
Upload states#
Every upload session has a status:
| Status | Final | Meaning |
|---|---|---|
created | No | The session exists and is waiting for bytes. Direct storage uploads stay created until you complete the session, because storage does not notify the API. |
uploaded | No | Bytes were received through Upload bytes through the API. |
committing | No | You completed the session and finalization is queued or running: size checks, type detection, bucket policy, and revision creation. If finalization hits a temporary problem, the session stays here with the error code commit_retrying and is retried automatically. |
scanning | No | The revision exists and the malware scan is running. objectAssetId and revisionNumber are already set, but the link is not served until the scan is clean. |
ready | Yes | The file is published under its visibility. Its link works. |
blocked | Yes | Malware was found or the bucket's upload policy rejected the file. Nothing was published. |
failed | Yes | Finalization could not finish. Upload the file again in a new batch. |
cancelled | Yes | The session was cancelled before it finished. |
When a session is blocked or failed, its error explains why:
error.code | Status | Cause and fix |
|---|---|---|
malware_detected | blocked | The scanner found malware. The bytes are discarded. |
policy | blocked | The bucket's max_upload_mb, allowed_mime_prefixes, or allowed_extensions rejected the file. See Update bucket settings. |
upload_expired | failed | The session was completed after expiresAt. Create a new batch. |
upload_missing | failed | No bytes were found when finalization ran. The PUT never happened, failed, or the temporary bytes expired. |
upload_size_mismatch | failed | The uploaded bytes do not match the declared size. Declare the exact size. |
upload_too_large | failed | The uploaded bytes exceed the upload limit that applied when the batch was created. |
A batch has its own status: active while any session is not final, complete when every session is final and none is blocked or failed, and partial when every session is final and at least one is blocked or failed. A batch with only ready and cancelled sessions is complete.
Operations#
Create an upload batch#
/api/upload-batchesassets:writeCreates one upload session per file and returns presigned upload URLs. All files go into one bucket; each can have its own folder.
Body
bucketIduuidRequired- Destination bucket. A file asset ID returns
400 Uploads require a bucket. filesobject[]Required- 1 to 100 files. Split larger sets into several batches.
files[].filenamestringRequired- File name, 1 to 512 characters, for example
campaign-hero.webp. Any directory part is removed:a/b/c.pngbecomesc.png. Usepathfor folders. files[].sizeintegerRequired- Exact size in bytes, at least 1. Missing returns
422with the codeupload_size_required. Empty files cannot be uploaded. files[].pathstringDefault""- Folder inside the bucket, up to 1024 characters, for example
campaign/launch/. Backslashes become slashes, empty and.segments are dropped, and a trailing slash is added. A..segment returns422 Invalid upload path. files[].contentTypestringDefaultapplication/octet-stream- Declared type, up to 128 characters, recorded on the session. The type stored with the revision is detected from the bytes during finalization, so a wrong value here does not mislabel the file.
curl -X POST "https://api.steadylink.io/api/upload-batches" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"files": [
{ "filename": "campaign-hero.webp", "path": "campaign/launch/", "contentType": "image/webp", "size": 2841024 },
{ "filename": "price-list.pdf", "path": "docs/", "contentType": "application/pdf", "size": 418230 }
]
}'const batch = await steadylink.createUploadBatch("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", [
{ filename: "campaign-hero.webp", path: "campaign/launch/", contentType: "image/webp", size: 2841024 },
{ filename: "price-list.pdf", path: "docs/", contentType: "application/pdf", size: 418230 },
]);from steadylink import UploadFile
batch = client.create_upload_batch("7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15", [
UploadFile("campaign-hero.webp", 2841024, "image/webp", "campaign/launch/"),
UploadFile("price-list.pdf", 418230, "application/pdf", "docs/"),
]){
"id": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
"status": "active",
"totalFiles": 2,
"files": [
{
"id": "9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64",
"batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"objectAssetId": null,
"filename": "campaign-hero.webp",
"path": "campaign/launch/",
"contentType": "image/webp",
"expectedSize": 2841024,
"status": "created",
"revisionNumber": null,
"error": null,
"expiresAt": "2026-10-09T14:22:31.482913",
"createdAt": "2026-10-08T14:22:31.482913",
"updatedAt": "2026-10-08T14:22:31.482913",
"uploadUrl": "https://storage-endpoint.example/uploads/temp/0c7f3a92-...?X-Amz-Signature=..."
},
{
"id": "2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396",
"batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"objectAssetId": null,
"filename": "price-list.pdf",
"path": "docs/",
"contentType": "application/pdf",
"expectedSize": 418230,
"status": "created",
"revisionNumber": null,
"error": null,
"expiresAt": "2026-10-09T14:22:31.482913",
"createdAt": "2026-10-08T14:22:31.482913",
"updatedAt": "2026-10-08T14:22:31.482913",
"uploadUrl": "https://storage-endpoint.example/uploads/temp/5a1e9c08-...?X-Amz-Signature=..."
}
]
}files is in the same order as the request. Two clocks matter:
uploadUrlexpires 15 minutes after the batch is created. Send the bytes promptly. If the URL expires first, cancel the session and create a new batch.expiresAtis when the session itself expires, by default 24 hours after creation. Completing a session after that marks itfailedwithupload_expired.
The batch is checked as a whole before anything is created:
| Status | Code | Cause |
|---|---|---|
413 | upload_size_limit | A file is larger than the upload limit: the smaller of the plan's API ceiling and the workspace upload limit. The body's limit is the effective limit in bytes. |
413 | usage_limit_exceeded | The batch would put the workspace over its storage allowance. Storage is reserved when the batch is created and released if a session is cancelled, fails, or expires. |
422 | upload_size_required | A file has no size. |
429 | api_expensive_operation_rate_exceeded | Creating batches counts as an expensive operation. |
Upload the bytes#
{uploadUrl}Send the file's raw bytes to the session's uploadUrl. This request goes to object storage, so storage answers it, not the SteadyLink API.
Headers
Content-TypestringRequired- Exactly
application/octet-stream, whatever the file's real type. The URL is signed for this value, and any other value fails with403from storage. The stored type is detected from the bytes later.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/octet-stream" \
--data-binary @campaign-hero.webpimport { readFile } from "node:fs/promises";
const session = batch.files[0];
await steadylink.sendUploadBytes(session, await readFile("./campaign-hero.webp"), {
onProgress: (loaded, total) => console.log(`${Math.round((loaded / total) * 100)}%`),
});from pathlib import Path
session = batch["files"][0]
client.upload_to_url(session["uploadUrl"], Path("campaign-hero.webp").read_bytes())A successful PUT returns 200 from storage with an empty body. The session stays created: storage does not tell SteadyLink about the upload, so you must complete the session next.
Storage errors come back as XML from storage, not as SteadyLink JSON. 403 with SignatureDoesNotMatch almost always means the wrong Content-Type header; 403 with AccessDenied or Request has expired means the URL is older than 15 minutes.
From a browser, the same PUT works with fetch or XMLHttpRequest, which can report progress. Create the batch and complete the session on your server so the API key never reaches the browser. See Browser uploads.
Upload bytes through the API#
/api/upload-sessions/{session_id}/contentassets:writeStreams the bytes through the SteadyLink API instead of straight to storage. Use it only when a network blocks the storage host, for example a strict corporate proxy. It authenticates like any API request and moves the session to uploaded.
Headers
Content-Typestringapplication/octet-stream.Content-Lengthinteger- If sent, it must equal the session's
expectedSize, or the request returns400 Uploaded file size does not match.
curl -X PUT "https://api.steadylink.io/api/upload-sessions/9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64/content" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @campaign-hero.webpawait steadylink.sendUploadBytes(session, await readFile("./campaign-hero.webp"), { via: "api" });
// Or for the whole upload() helper
await steadylink.upload(bucketId, files, { via: "api" });steadylink upload ./campaign-hero.webp --bucket marketing --via apiThe response is the session object with status: "uploaded". Other outcomes:
| Status | Cause |
|---|---|
400 | The byte count does not match expectedSize, or Content-Length is invalid. |
409 | The session is past created or uploaded, for example already committing. |
410 | The session has expired. |
502 | Storage could not store the bytes. Retry the request. |
This route counts against your API rate limits and the request stays open for the whole transfer, so prefer presigned uploads when they work.
Complete an upload session#
/api/upload-sessions/{session_id}/completeassets:writeTells SteadyLink the bytes are in place. The session moves to committing and finalization is queued: SteadyLink checks the size, detects the type, applies the bucket's policy, creates the file and its first revision (or a new revision if a file already exists at the same key), and starts the malware scan.
Headers
Idempotency-Keystring- Any string unique to this session, for example
complete-9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64. Reuse the same value when you retry.
curl -X POST "https://api.steadylink.io/api/upload-sessions/9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64/complete" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Idempotency-Key: complete-9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64"// Sends Idempotency-Key: complete-{session_id} and retries safely
const session = await steadylink.completeUpload("9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64");session = client.complete_upload("9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64"){
"id": "9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64",
"batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"objectAssetId": null,
"filename": "campaign-hero.webp",
"path": "campaign/launch/",
"contentType": "image/webp",
"expectedSize": 2841024,
"status": "committing",
"revisionNumber": null,
"error": null,
"expiresAt": "2026-10-09T14:22:31.482913",
"createdAt": "2026-10-08T14:22:31.482913",
"updatedAt": "2026-10-08T14:23:02.917440"
}How retries behave:
- The session is already final (
ready,blocked,failed,cancelled): the request returns the session as it is, with no new work. Retrying after success is harmless. - The session is
committing: the request returns409 Upload is already committing, even with the sameIdempotency-Key. Treat this as "your earlier request went through" and poll for the result. - A different
Idempotency-Keythan the one recorded for the session returns409 Idempotency key mismatch. - The session expired: the session is marked
failedwithupload_expired, its storage reservation is released, and the request returns410.
Completing a session before the bytes are uploaded does not fail immediately. Finalization runs, finds nothing, and the session ends failed with upload_missing.
Get an upload batch#
/api/upload-batches/{batch_id}assets:readReturns the batch with counts and every session's current state. Poll it until the sessions you care about are final.
curl "https://api.steadylink.io/api/upload-batches/b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13" \
-H "X-API-Key: $STEADYLINK_API_KEY"// One read
const batch = await steadylink.getUploadBatch("b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13");
// Or poll until every session is final (750 ms, backing off to 3 s, 5-minute timeout)
const done = await steadylink.waitForUploads("b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13");import time
while True:
batch = client.request("GET", "/api/upload-batches/b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13")
if all(f["status"] in {"ready", "blocked", "failed", "cancelled"} for f in batch["files"]):
break
time.sleep(1.5){
"id": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
"status": "partial",
"totalFiles": 2,
"completedFiles": 1,
"failedFiles": 1,
"files": [
{
"id": "9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64",
"batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"objectAssetId": "3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71",
"filename": "campaign-hero.webp",
"path": "campaign/launch/",
"contentType": "image/webp",
"expectedSize": 2841024,
"status": "ready",
"revisionNumber": 1,
"error": null,
"expiresAt": "2026-10-09T14:22:31.482913",
"createdAt": "2026-10-08T14:22:31.482913",
"updatedAt": "2026-10-08T14:23:09.204118"
},
{
"id": "2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396",
"batchId": "b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13",
"bucketId": "7c1e4b2a-5d3f-4e8a-9b61-2f0c8d4a7e15",
"objectAssetId": null,
"filename": "price-list.pdf",
"path": "docs/",
"contentType": "application/pdf",
"expectedSize": 418230,
"status": "blocked",
"revisionNumber": null,
"error": { "code": "policy", "message": "File type not allowed by bucket policy" },
"expiresAt": "2026-10-09T14:22:31.482913",
"createdAt": "2026-10-08T14:22:31.482913",
"updatedAt": "2026-10-08T14:23:04.660271"
}
]
}Response fields
completedFilesinteger- Sessions that are
ready. failedFilesinteger- Sessions that are
blockedorfailed. files[].objectAssetIduuid | null- The file's asset ID, set once the revision is created (from
scanningon). files[].revisionNumberinteger | null- The revision this upload created.
1for a new file; higher when the upload landed on an existing key and added a revision to it.
Uploading to a key that already has a file does not create a second file. It adds a revision to the existing file, and the stable link starts serving the new bytes when the session is ready.
Stream upload events#
/api/events/streamassets:readOpens a server-sent events stream of upload state changes for the whole workspace. Use it instead of polling when you track many uploads or show live progress.
curl -N "https://api.steadylink.io/api/events/stream" \
-H "X-API-Key: $STEADYLINK_API_KEY"event: upload.updated
data: {"type":"upload.updated","upload":{"id":"9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64","batchId":"b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13","status":"scanning","objectAssetId":"3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71","revisionNumber":1,"error":null}}
: keepalive
event: upload.updated
data: {"type":"upload.updated","upload":{"id":"9e4c2a71-6b3d-4f8e-a0c5-1d7b3e9f2a64","batchId":"b5d0e8f2-1a3c-4e6b-8d9f-0c2a4e6b8d13","status":"ready","objectAssetId":"3f2a9c1e-8b4d-4c7a-a1e2-6d5f0b9c3e71","revisionNumber":1,"error":null}}The upload object has the same fields as a session in Get an upload batch; the example above is shortened.
- Every session in the workspace is included, from every user and integration. Filter by
batchIdorid. - Changes are checked about every 1.5 seconds. A connection starts with changes from the last few seconds, so it does not replay history; read the batch once after connecting to catch up.
- A
: keepalivecomment arrives about every 15 seconds when nothing changes. If you see neither events nor keepalives for longer than that, reconnect. - The browser
EventSourceAPI cannot send anX-API-Keyheader. Consume the stream on your server, or usefetchwith a streaming body.
Cancel an upload session#
/api/upload-sessions/{session_id}assets:writeCancels a session that has not finished, deletes any bytes already uploaded, and releases its storage reservation.
curl -X DELETE "https://api.steadylink.io/api/upload-sessions/2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396" \
-H "X-API-Key: $STEADYLINK_API_KEY"await steadylink.cancelUpload("2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396");client.cancel_upload("2b8f6d14-9a3e-4c71-b0d5-e7f2a1c8b396")The response is the session with status: "cancelled". Sessions that are already ready, blocked, or failed return 409 Completed uploads cannot be cancelled; to remove a finished file, delete it. Cancel sessions you abandon, such as when a user closes the page, so their storage reservation is released right away.
Limits#
| Limit | Value |
|---|---|
| Files per batch | 100 |
| File size | At least 1 byte; at most the plan's upload limit, from 100 MiB on Free. See Plan access. |
| Bucket limit | A bucket's max_upload_mb can lower the limit further. Checked during finalization. |
| Presigned URL lifetime | 15 minutes |
| Session lifetime | expiresAt, by default 24 hours |
| Storage | Reserved at batch creation. Sessions that are cancelled, fail, or expire give it back. |
| Rate | Every write under /api/upload-batches and /api/upload-sessions, including cancelling, counts as a write and as an expensive operation. The PUT to a presigned URL does not count. |