Quickstart
Upload a file, get its permanent link, replace the file, and watch the same link serve the new version. About five minutes.
On this page
In this quickstart you upload one image through the API, copy its stable link, publish a new version of the image, and confirm that the same link now serves the new bytes. Every step shows curl, the TypeScript SDK, the Python SDK, and the CLI, so pick the tool you plan to use in production. If you would rather click than type, skip to Prefer the dashboard?.
You need a terminal and an image file. The examples use hero.webp and a second version called hero-v2.webp; any JPEG, PNG, WebP, or PDF works the same way.
Create an account
Sign up at steadylink.io/signup. A workspace is created for you. The Free plan includes one active API key, which is all this guide needs.
Create a scoped API key
Open Dashboard > Developers, choose New API key, give it a name such as
quickstart, and select two scopes:- Read files (
assets:read) to list buckets and check upload status. - Change files (
assets:write) to create buckets, upload, and replace.
The full key starts with
slk_and is shown once. Copy it into an environment variable in the terminal you will use:Terminal export STEADYLINK_API_KEY="slk_..."The key belongs to one workspace, so you never pass a workspace ID with it. Keep it on servers and in CI secrets; never put it in browser code. To let people upload from a web page, see Browser uploads.
- Read files (
Install a client (optional)
curl needs nothing else. The JSON parsing in the curl examples uses
jq.npm install @steadylink/sdkpip install steadylinknpm install -g @steadylink/cli steadylink login --api-key "$STEADYLINK_API_KEY"The CLI also reads
STEADYLINK_API_KEYdirectly, sologinis optional when the variable is set.
Find or create a bucket#
A bucket is the top-level container your files live in. Every upload names a destination bucket by its ID. List the buckets in your workspace first; a new workspace has none until you create one or upload from the dashboard (which creates My files).
curl https://api.steadylink.io/api/assets/ \
-H "X-API-Key: $STEADYLINK_API_KEY"import { SteadyLink } from "@steadylink/sdk";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
const { items } = await steadylink.listBuckets();
console.log(items.map((bucket) => `${bucket.id} ${bucket.slug ?? ""} ${bucket.name}`));import os
from steadylink import SteadyLink
client = SteadyLink(api_key=os.environ["STEADYLINK_API_KEY"])
for bucket in client.list_buckets()["items"]:
print(bucket["id"], bucket.get("slug"), bucket["name"])steadylink lsIf the list is empty, create a bucket. The slug is optional; the SDK and CLI let you refer to a bucket by ID, slug, or name.
curl -X POST https://api.steadylink.io/api/assets/ \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Marketing", "slug": "marketing"}'const { id: bucketId } = await steadylink.createBucket("Marketing", "marketing");bucket_id = client.request("POST", "/api/assets/", {"name": "Marketing", "slug": "marketing"})["id"]{ "id": "7c1d5e2a-4b8f-4f3a-9e61-2d0c8a5b3f17" }The CLI cannot create buckets; use one of the other tools or the dashboard. Save the ID for the next steps:
export BUCKET_ID="7c1d5e2a-4b8f-4f3a-9e61-2d0c8a5b3f17"Upload a file#
An upload has three parts: ask SteadyLink for an upload session, send the bytes straight to storage with the presigned URL it returns, then tell SteadyLink you are done so it can finalize the file and scan it for malware. The SDKs and CLI do all three for you.
# 1. Create an upload batch with one file. size is required.
SIZE=$(wc -c < hero.webp | tr -d ' ')
BATCH=$(curl -s -X POST https://api.steadylink.io/api/upload-batches \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"bucketId\": \"$BUCKET_ID\", \"files\": [{\"filename\": \"hero.webp\", \"contentType\": \"image/webp\", \"size\": $SIZE}]}")
BATCH_ID=$(echo "$BATCH" | jq -r '.id')
SESSION_ID=$(echo "$BATCH" | jq -r '.files[0].id')
UPLOAD_URL=$(echo "$BATCH" | jq -r '.files[0].uploadUrl')
# 2. PUT the raw bytes to storage. The URL is signed for this exact Content-Type.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/octet-stream" \
--data-binary @hero.webp
# 3. Complete the session. Reuse the same Idempotency-Key if you retry.
curl -X POST "https://api.steadylink.io/api/upload-sessions/$SESSION_ID/complete" \
-H "X-API-Key: $STEADYLINK_API_KEY" \
-H "Idempotency-Key: complete-$SESSION_ID"
# 4. Poll until the file is ready (finalized and scanned).
curl "https://api.steadylink.io/api/upload-batches/$BATCH_ID" \
-H "X-API-Key: $STEADYLINK_API_KEY"import { fileFromPath } from "@steadylink/sdk/node";
const [hero] = await steadylink.upload(bucketId, await fileFromPath("./hero.webp"), {
visibility: "public",
});
console.log(hero.status, hero.assetId, hero.url);
// ready 3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b https://cdn.steadylink.io/a/3f2a9c1e-...import time
from pathlib import Path
body = Path("hero.webp").read_bytes()
# Leave content_type at its default: presigned URLs are signed for application/octet-stream.
session = client.upload_file(bucket_id, filename="hero.webp", body=body)
while True:
batch = client.request("GET", f"/api/upload-batches/{session['batchId']}")
upload = batch["files"][0]
if upload["status"] in {"ready", "blocked", "failed", "cancelled"}:
break
time.sleep(1)
print(upload["status"], upload["objectAssetId"])steadylink upload ./hero.webp --bucket marketing --public
# hero.webp https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1bThe completion call returns 202 Accepted with the session in committing. Polling the batch shows it move through scanning to ready:
{
"id": "0b9e7d4c-2f1a-4c6b-8e3d-9a7f5b1c2d40",
"status": "complete",
"totalFiles": 1,
"completedFiles": 1,
"failedFiles": 0,
"files": [
{
"id": "5a8c3e1f-6d2b-4f9a-b7c0-1e2d3f4a5b6c",
"batchId": "0b9e7d4c-2f1a-4c6b-8e3d-9a7f5b1c2d40",
"bucketId": "7c1d5e2a-4b8f-4f3a-9e61-2d0c8a5b3f17",
"objectAssetId": "3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b",
"filename": "hero.webp",
"path": "",
"contentType": "image/webp",
"expectedSize": 284102,
"status": "ready",
"revisionNumber": 1,
"error": null,
"expiresAt": "2026-10-09T14:02:11.402913",
"createdAt": "2026-10-08T14:02:11.402913",
"updatedAt": "2026-10-08T14:02:15.118204"
}
]
}objectAssetId is the file's permanent identity. If the status ends at blocked or failed, read error.code: malware_detected and policy mean the file was rejected, upload_size_mismatch means the bytes you sent do not match the size you declared. Upload and replace files lists every state.
With curl and Python, make the file public now. The JavaScript visibility option and the CLI --public flag already did this.
export ASSET_ID="3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b"
curl -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/visibility?key=hero.webp&visibility=public" \
-H "X-API-Key: $STEADYLINK_API_KEY"client.request("POST", f"/api/assets/{bucket_id}/objects/visibility?key=hero.webp&visibility=public")Open the stable link#
The link is the delivery origin plus the asset ID:
https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1bOpen it in a browser or fetch the headers. No API key is involved: public delivery is anonymous.
curl -I "https://cdn.steadylink.io/a/$ASSET_ID"This is the address you put in your website, email template, app config, or QR code. It does not contain the filename, the bucket, or the folder, so renaming or moving the file later does not change it.
Replace the file#
Now publish hero-v2.webp as the next revision of the same file. A replacement never creates a new asset ID; it appends revision 2 and makes it current.
# 1. Ask for a temporary upload URL in the file's bucket.
SIZE=$(wc -c < hero-v2.webp | tr -d ' ')
TEMP=$(curl -s -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/upload-temp?size=$SIZE&content_type=image/webp" \
-H "X-API-Key: $STEADYLINK_API_KEY")
TEMP_KEY=$(echo "$TEMP" | jq -r '.tempKey')
# 2. PUT the new bytes.
curl -X PUT "$(echo "$TEMP" | jq -r '.uploadUrl')" \
-H "Content-Type: application/octet-stream" \
--data-binary @hero-v2.webp
# 3. Publish them as the next revision of the file at key hero.webp.
curl -X POST "https://api.steadylink.io/api/assets/$BUCKET_ID/objects/replace?key=hero.webp&upload_temp_key=$TEMP_KEY&original_filename=hero-v2.webp" \
-H "X-API-Key: $STEADYLINK_API_KEY"const result = await steadylink.replace(hero.assetId!, await fileFromPath("./hero-v2.webp"));
console.log(result.version, result.url); // 2 https://cdn.steadylink.io/a/3f2a9c1e-...new_body = Path("hero-v2.webp").read_bytes()
result = client.replace_file(bucket_id, "hero.webp", filename="hero-v2.webp", body=new_body)
print(result["version"]) # 2steadylink replace marketing:hero.webp ./hero-v2.webp
# Replaced hero.webp with hero-v2.webp. Now on revision 2.
# https://cdn.steadylink.io/a/3f2a9c1e-8b4d-4e7a-a1c2-5d6e7f809a1b{ "replaced": true, "version": 2 }The replacement call is addressed by bucket and key (the file's path inside the bucket). The JavaScript SDK and the CLI also accept the asset ID or the link and look up the bucket and key for you.
See the new version behind the same link#
Open the link again. Because it follows the current revision, it now serves hero-v2.webp, but there is one thing to know about timing:
- The stable link is cacheable for five minutes (
Cache-Control: public, max-age=300, stale-while-revalidate=3600). A browser or edge that fetched revision 1 recently can keep showing it until that copy expires, and may serve it once more while it refreshes in the background. - The new revision is also scanned for malware before it is served. A request that reaches SteadyLink before the scan finishes gets
423 Locked(pinned URLs included); retry after a moment.
To confirm the new bytes immediately, pin the revision with v. A pinned URL is a different cache entry, so nothing stale stands in the way:
curl -I "https://cdn.steadylink.io/a/$ASSET_ID?v=2"HTTP/2 200
content-type: image/webp
cache-control: public, max-age=31536000, immutable, stale-while-revalidate=604800, stale-if-error=604800, no-transformUse ?v= for checking, not as a cache buster in production. Any query parameter SteadyLink does not recognize, such as ?nocache=1, is rejected with 400 Unknown params, and a pinned URL never moves to later revisions. Revision 1 is still retained: ?v=1 serves it, and you can make it current again at any time (see Revisions).
Prefer the dashboard?#
Everything above can be done without code:
Create a bucket
Open Files and choose New bucket. Give it a clear name for the product, team, or campaign it belongs to. If you skip this, your first upload creates a bucket called My files.
Upload files
Choose Upload, select one or more files or drop them into the dialog, pick the destination bucket and folder, and choose who can open the link. You can close the dialog and keep working; progress moves to the activity panel.
Wait for the scan
Each file is uploaded, finalized, and then scanned for malware. A clean file is ready for delivery; a file that fails the scan is blocked and never served.
Copy the link
When the upload finishes, choose Copy link for a public file. Private files get Copy private link instead: a signed link that expires after seven days and can be revoked. You can create more signed links from the file's details later.
Replace it later
Open the file and choose Replace. The file gets a new version and its link is unchanged.
Next steps#
Batches, scanning states, failure modes, and every way to publish a new version.
Current and pinned revisions, caching, and what changes when you replace a file.
Resize, crop, and convert the same link with query parameters.
Share private files with signed, expiring, revocable links.