Upload and replace files
Upload files in batches, follow them through finalization and malware scanning, and publish new versions behind the same link from the dashboard, API, SDKs, or CLI.
On this page
- How an upload works
- Upload from the dashboard
- Upload with the API, SDKs, or CLI
- Limits to plan for
- Uploading to a name that already exists
- What the upload states mean
- Completing safely
- Replace a file
- In the dashboard
- With the API, SDKs, or CLI
- What a replacement keeps and changes
- Timing and failure modes
- Roll back a replacement
- Next steps
This guide is for anyone who publishes files with SteadyLink and needs to update them later: a marketing team swapping a price list that is linked from a hundred emails, or a developer shipping a new installer behind a download button. By the end you will know how uploads move from your machine to a live link, what each upload state means, and how to replace a file so every existing link serves the new version.
How an upload works#
Every upload, whether it starts in the dashboard, an SDK, the CLI, or a widget on your website, goes through the same three phases:
- Transfer. The bytes go straight from the sender to object storage through a short-lived presigned URL. They never pass through a SteadyLink API server, so large files upload as fast as the sender's connection allows.
- Finalize. SteadyLink checks the stored bytes against what was declared (size, bucket rules, content type), creates the file's revision, and links it to its path in the bucket.
- Scan. The new revision is scanned for malware. Only a clean revision is served from the link. An infected one is blocked and never delivered.
The result is a file with a permanent asset ID and a link of the form https://cdn.steadylink.io/a/{asset_id}.
Upload from the dashboard#
- Choose Upload from anywhere in the dashboard, or drag files onto an open bucket in Files.
- Pick the Destination bucket and, optionally, a folder. If your workspace has no buckets yet, one called My files is created for you.
- Choose who can open the link: Anyone with the link (public) or Private. The default comes from the bucket, then from your workspace setting New files start as.
- Choose Upload.
Files transfer one after another, each with its own progress bar. You can close the dialog while they upload: the Activity panel takes over, shows each file's progress, lets you Cancel the rest of the batch, and offers Retry on any file that failed. When the dialog is still open at the end, it lists one link per file with Copy link (or Copy private link for private files, which creates a signed link valid for seven days).
Views of your own files inside the dashboard are not counted as public delivery traffic.
Upload with the API, SDKs, or CLI#
Outside the dashboard, uploads are grouped into batches of up to 100 files. Each file in a batch gets its own upload session with its own presigned URL, so files transfer in parallel and fail independently.
curl -X POST https://api.steadylink.io/api/upload-batches \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bucketId": "7c1d5e2a-4b8f-4f3a-9e61-2d0c8a5b3f17",
"files": [
{ "filename": "price-list.pdf", "path": "docs/", "contentType": "application/pdf", "size": 482113 },
{ "filename": "hero.webp", "path": "campaign/launch/", "contentType": "image/webp", "size": 284102 }
]
}'
# Then, for each file: PUT the bytes to files[i].uploadUrl with
# Content-Type: application/octet-stream, and POST
# /api/upload-sessions/{files[i].id}/complete with an Idempotency-Key.import { SteadyLink } from "@steadylink/sdk";
import { fileFromPath } from "@steadylink/sdk/node";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
const bucket = await steadylink.findBucket("marketing");
const results = await steadylink.upload(bucket!.id, [
await fileFromPath("./price-list.pdf", { path: "docs/" }),
await fileFromPath("./hero.webp", { path: "campaign/launch/" }),
], {
concurrency: 4,
onProgress: ({ filename, loaded, total }) => console.log(filename, Math.round((loaded / total) * 100)),
});
for (const file of results) console.log(file.key, file.status, file.url);from pathlib import Path
# upload_file creates a one-file batch, sends the bytes, and completes the session.
for name, folder in [("price-list.pdf", "docs/"), ("hero.webp", "campaign/launch/")]:
session = client.upload_file(bucket_id, filename=name, body=Path(name).read_bytes(), path=folder)
print(name, session["status"], session["batchId"])# Files and whole folders. Folder structure is kept; symbolic links are skipped.
steadylink upload ./price-list.pdf --bucket marketing --folder docs
steadylink upload ./public/media --bucket marketing --folder campaign/launchThe TypeScript SDK and the CLI split any number of files into batches of 100, stream large files from disk without loading them into memory, complete each session with an idempotency key, and by default wait until every file reaches a final state so each result carries its asset ID and link. The CLI exits with code 4 when some files failed and the rest succeeded. The full request and response shapes are in the Uploads API.
Limits to plan for#
| Limit | Value |
|---|---|
| Files per batch | 100. Larger requests are rejected with 400; the SDK and CLI split them for you. |
| Declared size | Required for every file. A missing size returns 422 with code upload_size_required. |
| Largest file | Set by your plan (100 MB on Free). A larger declared size returns 413 with code upload_size_limit. API keys can have a lower ceiling than the dashboard; see Usage and limits. |
| Storage | The declared size is reserved against your workspace storage when the batch is created and released if the upload fails or is cancelled. |
| Presigned URL lifetime | 15 minutes from batch creation. Start the transfer promptly; create a new batch if the URL expires. |
| Session lifetime | Shown in each session's expiresAt. Completing an expired session returns 410 and marks it failed. |
| Filename and path | Filename up to 512 characters, folder path up to 1,024. Path segments of .. are rejected. |
A bucket can be stricter than your plan. Bucket settings in the dashboard can set a Maximum upload size (MB), and the bucket settings API can also restrict a bucket to certain file types. A file that breaks one of these rules ends as blocked with error code policy. SteadyLink also compares each file's contents with its declared type and rejects a clear mismatch, such as an executable renamed to photo.jpg; turning off Block disguised files in Bucket settings disables that check for the bucket.
Uploading to a name that already exists#
A path inside a bucket holds one file. If you upload hero.webp into campaign/launch/ and a file with that key already exists, the upload does not create a copy: it becomes the next revision of the existing file, and that file's link starts serving it. This is often exactly what you want from a deploy script, and it is why re-running the same CLI command is safe. When you want a separate file instead, give it a different name or folder.
What the upload states mean#
Each session reports a status. The batch reports active while any file is still in progress, then complete (every file ready or cancelled) or partial (at least one file blocked or failed).
| Status | Meaning | What to do |
|---|---|---|
created | The session exists and is waiting for bytes. | PUT the bytes to uploadUrl, then complete. |
uploaded | Bytes arrived through the API fallback route instead of the presigned URL. | Complete the session. |
committing | Completion was accepted and finalization is queued or running. If error.code is commit_retrying, a transient failure is being retried automatically. | Keep polling. |
scanning | The revision exists and has an asset ID, and the malware scan is running. The link returns 423 Locked until it finishes. | Keep polling, or treat the asset ID as known. |
ready | Finalized and clean. The link serves it under the file's visibility. | Done. |
blocked | Rejected by the malware scanner (malware_detected) or a bucket rule (policy). Nothing is served. | Do not retry the same file. |
failed | Finalization could not complete. Common codes: upload_size_mismatch (bytes do not match the declared size), upload_too_large, upload_missing (the bytes never arrived or the temporary copy expired), upload_expired, commit_failed. | Fix the cause and upload again in a new batch. |
cancelled | Cancelled before completion. Its storage reservation is released. | None. |
ready, blocked, failed, and cancelled are final. Poll GET /api/upload-batches/{batch_id} with a growing interval (the SDK starts at 750 ms and backs off to 3 seconds), or open the server-sent event stream at GET /api/events/stream, which emits an upload.updated event for every state change in the workspace and a keepalive comment about every 15 seconds.
To stop an upload that has not finished, cancel its session with DELETE /api/upload-sessions/{session_id}. A session that is already ready, blocked, or failed cannot be cancelled and returns 409.
Completing safely#
The complete call is the one write that is safe to retry. Send an Idempotency-Key header and reuse the same value on every retry of the same session. Completing a session that already reached a final state returns its final state again instead of an error; sending a different key for a session that already has one returns 409. The SDKs use complete-{session_id} as the key.
Replace a file#
Replacing publishes new bytes as the next revision of an existing file. The asset ID and the link stay the same, so every website, email, app, and QR code that uses the link starts serving the new version without being edited.
The blog has step-by-step versions of this for specific cases, such as replacing an image without changing its URL and replacing a file while keeping the same URL.
In the dashboard#
Open the file and choose Replace, then pick the new file. The same button is on the Version history tab. Progress appears in the Activity panel, and when it finishes you see "File replaced. Its link is unchanged." Uploading a file with the same name into the same folder has the same effect.
With the API, SDKs, or CLI#
# 1. Get a temporary upload URL in the file's bucket.
TEMP=$(curl -s -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/upload-temp?size=503221&content_type=application/pdf" \
-H "X-API-Key: $STEADYLINK_API_KEY")
# 2. PUT the new bytes.
curl -X PUT "$(echo "$TEMP" | jq -r '.uploadUrl')" \
-H "Content-Type: application/octet-stream" \
--data-binary @price-list-2026.pdf
# 3. Publish them as the next revision of docs/price-list.pdf.
curl -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/replace?key=docs/price-list.pdf&upload_temp_key=$(echo "$TEMP" | jq -r '.tempKey')&original_filename=price-list-2026.pdf" \
-H "X-API-Key: $STEADYLINK_API_KEY"// By asset ID or link
const result = await steadylink.replace("3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b", await fileFromPath("./price-list-2026.pdf"));
// Or by bucket and key
await steadylink.replace({ bucketId, key: "docs/price-list.pdf" }, await fileFromPath("./price-list-2026.pdf"));
console.log(result.version, result.url);body = Path("price-list-2026.pdf").read_bytes()
result = client.replace_file(bucket_id, "docs/price-list.pdf", filename="price-list-2026.pdf", body=body)
print(result["version"])steadylink replace marketing:docs/price-list.pdf ./price-list-2026.pdf
# or by asset ID or link
steadylink replace 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b ./price-list-2026.pdf{ "replaced": true, "version": 4 }Unlike an upload batch, the replace call finalizes synchronously: when it returns, revision 4 exists and is current. The temporary URL from upload-temp is valid for 15 minutes and the size you pass there is checked against your plan's upload limit.
What a replacement keeps and changes#
| Stays the same | Changes |
|---|---|
| Asset ID and link | Revision number (increments by one) |
| Bucket, folder, and key | Bytes, size, and content type (detected from the new bytes) |
| Visibility (public or private) | Stored filename, if you pass a different original_filename. Browsers use it when saving the file. |
| Saved focal point for image crops | Image variants: each new revision gets freshly generated resized versions |
| Signed links that were not pinned to a revision | |
| Earlier revisions, which stay available until you delete them |
SteadyLink does not require the new file to be the same type as the old one. Replacing a PDF with a PNG works, and the link starts answering with image/png. If other systems depend on the type, check it before you publish.
Timing and failure modes#
- The five-minute cache. The stable link is cacheable for five minutes, with a background refresh window after that. A viewer or edge that fetched the old version recently can see it until that copy expires. Pinned links such as
?v=4are separate cache entries and show the new revision immediately. See How stable links work. - The scan window. The new revision becomes current as soon as it is published, then it is scanned. Until the scan finishes, requests that reach SteadyLink get
423 Lockedrather than the old bytes. Replace during a quiet moment if that matters for a high-traffic file. - A replacement that fails the scan. If the infection is caught before commit (small files, known hashes, cached verdicts), the replacement is rejected and the previous revision keeps serving. If the background scan catches it after the new revision went current, that revision is blocked and the file is left with no current revision: the link returns
404until you restore an earlier clean revision. Restore one from Version history right away. See The scan window after a replacement. - Retries. The replace call has no idempotency key. If it fails after the bytes were sent, request a new
upload-tempURL rather than reusing the old temporary key.
Roll back a replacement#
Every revision is kept, so undoing a replacement is a matter of making an earlier revision current again. In the dashboard, open the file, go to Version history, and choose Restore on the revision you want. With code:
curl -X POST "https://api.steadylink.io/api/assets/$ASSET_ID/versions/3/promote" \
-H "X-API-Key: $STEADYLINK_API_KEY"await steadylink.rollback(assetId, 3);steadylink versions 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b
steadylink rollback 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b 3Only a clean revision can be restored; restoring one that is still being scanned or was blocked returns 409. Restoring does not create a new revision number: revision 3 simply becomes current again, and the same five-minute cache applies. Revisions covers labels, retention, and deleting revisions.
Next steps#
How revisions are numbered, retained, labeled, pinned, and deleted.
Every upload route with request and response examples.
Let someone outside your workspace send the new version for you to approve.
Let your users upload straight to storage from your own web app.